mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-17 17:41:24 -07:00
feat(sdk): implement support for custom skills (#19031)
This commit is contained in:
@@ -17,12 +17,15 @@ import {
|
||||
scheduleAgentTools,
|
||||
getAuthTypeFromEnv,
|
||||
type ToolRegistry,
|
||||
loadSkillsFromDir,
|
||||
ActivateSkillTool,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { type Tool, SdkTool } from './tool.js';
|
||||
import { SdkAgentFilesystem } from './fs.js';
|
||||
import { SdkAgentShell } from './shell.js';
|
||||
import type { SessionContext } from './types.js';
|
||||
import type { SkillReference } from './skills.js';
|
||||
|
||||
export type SystemInstructions =
|
||||
| string
|
||||
@@ -32,6 +35,7 @@ export interface GeminiCliAgentOptions {
|
||||
instructions: SystemInstructions;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tools?: Array<Tool<any>>;
|
||||
skills?: SkillReference[];
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
debug?: boolean;
|
||||
@@ -40,9 +44,10 @@ export interface GeminiCliAgentOptions {
|
||||
}
|
||||
|
||||
export class GeminiCliAgent {
|
||||
private config: Config;
|
||||
private readonly config: Config;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private tools: Array<Tool<any>>;
|
||||
private readonly tools: Array<Tool<any>>;
|
||||
private readonly skillRefs: SkillReference[];
|
||||
private readonly instructions: SystemInstructions;
|
||||
private instructionsLoaded = false;
|
||||
|
||||
@@ -50,6 +55,7 @@ export class GeminiCliAgent {
|
||||
this.instructions = options.instructions;
|
||||
const cwd = options.cwd || process.cwd();
|
||||
this.tools = options.tools || [];
|
||||
this.skillRefs = options.skills || [];
|
||||
|
||||
const initialMemory =
|
||||
typeof this.instructions === 'string' ? this.instructions : '';
|
||||
@@ -67,6 +73,8 @@ export class GeminiCliAgent {
|
||||
extensionsEnabled: false,
|
||||
recordResponses: options.recordResponses,
|
||||
fakeResponses: options.fakeResponses,
|
||||
skillsSupport: true,
|
||||
adminSkillsEnabled: true,
|
||||
};
|
||||
|
||||
this.config = new Config(configParams);
|
||||
@@ -83,6 +91,45 @@ export class GeminiCliAgent {
|
||||
await this.config.refreshAuth(authType);
|
||||
await this.config.initialize();
|
||||
|
||||
// Load additional skills from options
|
||||
if (this.skillRefs.length > 0) {
|
||||
const skillManager = this.config.getSkillManager();
|
||||
|
||||
const loadPromises = this.skillRefs.map(async (ref) => {
|
||||
try {
|
||||
if (ref.type === 'dir') {
|
||||
return await loadSkillsFromDir(ref.path);
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to load skills from ${ref.path}:`, e);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const loadedSkills = (await Promise.all(loadPromises)).flat();
|
||||
|
||||
if (loadedSkills.length > 0) {
|
||||
skillManager.addSkills(loadedSkills);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-register ActivateSkillTool if we have skills (either built-in/workspace or manually loaded)
|
||||
// This is required because ActivateSkillTool captures the set of available skills at construction time.
|
||||
const skillManager = this.config.getSkillManager();
|
||||
if (skillManager.getSkills().length > 0) {
|
||||
const registry = this.config.getToolRegistry();
|
||||
const toolName = ActivateSkillTool.Name;
|
||||
// Config.initialize already registers it, but we might have added more skills.
|
||||
// Re-registering updates the schema with new skills.
|
||||
if (registry.getTool(toolName)) {
|
||||
registry.unregisterTool(toolName);
|
||||
}
|
||||
registry.registerTool(
|
||||
new ActivateSkillTool(this.config, this.config.getMessageBus()),
|
||||
);
|
||||
}
|
||||
|
||||
// Register tools now that registry exists
|
||||
const registry = this.config.getToolRegistry();
|
||||
const messageBus = this.config.getMessageBus();
|
||||
|
||||
@@ -6,4 +6,5 @@
|
||||
|
||||
export * from './agent.js';
|
||||
export * from './tool.js';
|
||||
export * from './skills.js';
|
||||
export * from './types.js';
|
||||
|
||||
91
packages/sdk/src/skills.integration.test.ts
Normal file
91
packages/sdk/src/skills.integration.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { GeminiCliAgent } from './agent.js';
|
||||
import { skillDir } from './skills.js';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Set this to true locally when you need to update snapshots
|
||||
const RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true';
|
||||
|
||||
const getGoldenPath = (name: string) =>
|
||||
path.resolve(__dirname, '../test-data', `${name}.json`);
|
||||
|
||||
const SKILL_DIR = path.resolve(__dirname, '../test-data/skills/pirate-skill');
|
||||
const SKILL_ROOT = path.resolve(__dirname, '../test-data/skills');
|
||||
|
||||
describe('GeminiCliAgent Skills Integration', () => {
|
||||
it('loads and activates a skill from a directory', async () => {
|
||||
const goldenFile = getGoldenPath('skill-dir-success');
|
||||
|
||||
const agent = new GeminiCliAgent({
|
||||
instructions: 'You are a helpful assistant.',
|
||||
skills: [skillDir(SKILL_DIR)],
|
||||
// If recording, use real model + record path.
|
||||
// If testing, use auto model + fake path.
|
||||
model: RECORD_MODE ? 'gemini-2.0-flash' : undefined,
|
||||
recordResponses: RECORD_MODE ? goldenFile : undefined,
|
||||
fakeResponses: RECORD_MODE ? undefined : goldenFile,
|
||||
});
|
||||
|
||||
// 1. Ask to activate the skill
|
||||
const events = [];
|
||||
// The prompt explicitly asks to activate the skill by name
|
||||
const stream = agent.sendStream(
|
||||
'Activate the pirate-skill and then tell me a joke.',
|
||||
);
|
||||
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const textEvents = events.filter((e) => e.type === 'content');
|
||||
const responseText = textEvents
|
||||
.map((e) => (typeof e.value === 'string' ? e.value : ''))
|
||||
.join('');
|
||||
|
||||
// Expect pirate speak
|
||||
expect(responseText.toLowerCase()).toContain('arrr');
|
||||
}, 60000);
|
||||
|
||||
it('loads and activates a skill from a root', async () => {
|
||||
const goldenFile = getGoldenPath('skill-root-success');
|
||||
|
||||
const agent = new GeminiCliAgent({
|
||||
instructions: 'You are a helpful assistant.',
|
||||
skills: [skillDir(SKILL_ROOT)],
|
||||
// If recording, use real model + record path.
|
||||
// If testing, use auto model + fake path.
|
||||
model: RECORD_MODE ? 'gemini-2.0-flash' : undefined,
|
||||
recordResponses: RECORD_MODE ? goldenFile : undefined,
|
||||
fakeResponses: RECORD_MODE ? undefined : goldenFile,
|
||||
});
|
||||
|
||||
// 1. Ask to activate the skill
|
||||
const events = [];
|
||||
const stream = agent.sendStream(
|
||||
'Activate the pirate-skill and confirm it is active.',
|
||||
);
|
||||
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const textEvents = events.filter((e) => e.type === 'content');
|
||||
const responseText = textEvents
|
||||
.map((e) => (typeof e.value === 'string' ? e.value : ''))
|
||||
.join('');
|
||||
|
||||
// Expect confirmation or pirate speak
|
||||
expect(responseText.toLowerCase()).toContain('arrr');
|
||||
}, 60000);
|
||||
});
|
||||
16
packages/sdk/src/skills.ts
Normal file
16
packages/sdk/src/skills.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export type SkillReference = { type: 'dir'; path: string };
|
||||
|
||||
/**
|
||||
* Reference a directory containing skills.
|
||||
*
|
||||
* @param path Path to the skill directory
|
||||
*/
|
||||
export function skillDir(path: string): SkillReference {
|
||||
return { type: 'dir', path };
|
||||
}
|
||||
Reference in New Issue
Block a user