feat(sdk): implement support for custom skills (#19031)

This commit is contained in:
Michael Bleigh
2026-02-13 18:09:31 -08:00
committed by GitHub
parent 9fc7b56793
commit a129dbcdd4
10 changed files with 202 additions and 29 deletions
+1
View File
@@ -158,6 +158,7 @@ export * from './tools/read-many-files.js';
export * from './tools/mcp-client.js'; export * from './tools/mcp-client.js';
export * from './tools/mcp-tool.js'; export * from './tools/mcp-tool.js';
export * from './tools/write-todos.js'; export * from './tools/write-todos.js';
export * from './tools/activate-skill.js';
export * from './tools/ask-user.js'; export * from './tools/ask-user.js';
// MCP OAuth // MCP OAuth
+7
View File
@@ -107,6 +107,13 @@ export class SkillManager {
this.addSkillsWithPrecedence(builtinSkills); this.addSkillsWithPrecedence(builtinSkills);
} }
/**
* Adds skills to the manager programmatically.
*/
addSkills(skills: SkillDefinition[]): void {
this.addSkillsWithPrecedence(skills);
}
private addSkillsWithPrecedence(newSkills: SkillDefinition[]): void { private addSkillsWithPrecedence(newSkills: SkillDefinition[]): void {
const skillMap = new Map<string, SkillDefinition>( const skillMap = new Map<string, SkillDefinition>(
this.skills.map((s) => [s.name, s]), this.skills.map((s) => [s.name, s]),
+28 -27
View File
@@ -141,33 +141,34 @@ Validation (these are probably hardest to validate):
## `Custom Skills` ## `Custom Skills`
> **Status:** Not Implemented. > **Status:** Implemented. `skillDir` helper and `GeminiCliAgent` support
> loading skills from filesystem.
Custom skills can be referenced by individual directories or by "skill roots" Custom skills can be referenced by individual directories or by "skill roots"
(directories containing many skills). (directories containing many skills).
```ts ### Directory Structure
import { GeminiCliAgent, skillDir, skillRoot } from '@google/gemini-cli-sdk';
const agent = new GeminiCliAgent({ ```
skills: [skillDir('/path/to/single/skill'), skillRoot('/path/to/skills/dir')], skill-dir/
}); SKILL.md (Metadata and instructions)
tools/ (Optional directory for tools)
my-tool.js
``` ```
**NOTE:** I would like to support fully in-memory skills (including reference ### Usage
files); however, it seems like that would currently require a pretty significant
refactor so we'll focus on filesystem skills for now. In an ideal future state,
we could do something like:
```ts ```typescript
import { GeminiCliAgent, skill } from '@google/gemini-cli-sdk'; import { GeminiCliAgent, skillDir } from '@google/gemini-sdk';
const mySkill = skill({ const agent = new GeminiCliAgent({
name: 'my-skill', instructions: 'You are a helpful assistant.',
description: 'description of when my skill should be used', skills: [
content: 'This is the SKILL.md content', // Load a single skill from a directory
// it can also be a function skillDir('./my-skill'),
content: (ctx) => `This is dynamic content.`, // Load all skills found in subdirectories of this root
skillDir('./skills-collection'),
],
}); });
``` ```
@@ -318,16 +319,16 @@ export interface AgentShellProcess {
Based on the current implementation status, we can proceed with: Based on the current implementation status, we can proceed with:
## Feature 2: Custom Skills Support ## Feature 3: Custom Hooks Support
Implement support for loading and registering custom skills. This involves Implement support for loading and registering custom hooks. This involves adding
adding a `skills` option to `GeminiCliAgentOptions` and implementing the logic a `hooks` option to `GeminiCliAgentOptions`.
to read skill definitions from directories.
**Tasks:** **Tasks:**
1. Add `skills` option to `GeminiCliAgentOptions`. 1. Define `Hook` interface and helper functions.
2. Implement `skillDir` and `skillRoot` helpers to load skills from the 2. Add `hooks` option to `GeminiCliAgentOptions`.
filesystem. 3. Implement hook registration logic in `GeminiCliAgent`.
3. Update `GeminiCliAgent` to register loaded skills with the internal tool
registry. IMPORTANT: Hook signatures should be strongly typed all the way through. You'll
need to create a mapping of the string event name to the request/response types.
+49 -2
View File
@@ -17,12 +17,15 @@ import {
scheduleAgentTools, scheduleAgentTools,
getAuthTypeFromEnv, getAuthTypeFromEnv,
type ToolRegistry, type ToolRegistry,
loadSkillsFromDir,
ActivateSkillTool,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { type Tool, SdkTool } from './tool.js'; import { type Tool, SdkTool } from './tool.js';
import { SdkAgentFilesystem } from './fs.js'; import { SdkAgentFilesystem } from './fs.js';
import { SdkAgentShell } from './shell.js'; import { SdkAgentShell } from './shell.js';
import type { SessionContext } from './types.js'; import type { SessionContext } from './types.js';
import type { SkillReference } from './skills.js';
export type SystemInstructions = export type SystemInstructions =
| string | string
@@ -32,6 +35,7 @@ export interface GeminiCliAgentOptions {
instructions: SystemInstructions; instructions: SystemInstructions;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
tools?: Array<Tool<any>>; tools?: Array<Tool<any>>;
skills?: SkillReference[];
model?: string; model?: string;
cwd?: string; cwd?: string;
debug?: boolean; debug?: boolean;
@@ -40,9 +44,10 @@ export interface GeminiCliAgentOptions {
} }
export class GeminiCliAgent { export class GeminiCliAgent {
private config: Config; private readonly config: Config;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 readonly instructions: SystemInstructions;
private instructionsLoaded = false; private instructionsLoaded = false;
@@ -50,6 +55,7 @@ export class GeminiCliAgent {
this.instructions = options.instructions; this.instructions = options.instructions;
const cwd = options.cwd || process.cwd(); const cwd = options.cwd || process.cwd();
this.tools = options.tools || []; this.tools = options.tools || [];
this.skillRefs = options.skills || [];
const initialMemory = const initialMemory =
typeof this.instructions === 'string' ? this.instructions : ''; typeof this.instructions === 'string' ? this.instructions : '';
@@ -67,6 +73,8 @@ export class GeminiCliAgent {
extensionsEnabled: false, extensionsEnabled: false,
recordResponses: options.recordResponses, recordResponses: options.recordResponses,
fakeResponses: options.fakeResponses, fakeResponses: options.fakeResponses,
skillsSupport: true,
adminSkillsEnabled: true,
}; };
this.config = new Config(configParams); this.config = new Config(configParams);
@@ -83,6 +91,45 @@ export class GeminiCliAgent {
await this.config.refreshAuth(authType); await this.config.refreshAuth(authType);
await this.config.initialize(); 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 // Register tools now that registry exists
const registry = this.config.getToolRegistry(); const registry = this.config.getToolRegistry();
const messageBus = this.config.getMessageBus(); const messageBus = this.config.getMessageBus();
+1
View File
@@ -6,4 +6,5 @@
export * from './agent.js'; export * from './agent.js';
export * from './tool.js'; export * from './tool.js';
export * from './skills.js';
export * from './types.js'; export * from './types.js';
@@ -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
View 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 };
}
@@ -0,0 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7160,"candidatesTokenCount":8,"totalTokenCount":7168,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7160}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Arrr, why"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10048,"totalTokenCount":10048,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10048}]}},{"candidates":[{"content":{"parts":[{"text":" don't pirates play poker? Because they always have a hidden ace up their sleeves"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10048,"totalTokenCount":10048,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10048}]}},{"candidates":[{"content":{"parts":[{"text":"!\\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7284,"candidatesTokenCount":23,"totalTokenCount":7307,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7284}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":23}]}}]}
@@ -0,0 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7170,"candidatesTokenCount":8,"totalTokenCount":7178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7170}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10058,"totalTokenCount":10058,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10058}]}},{"candidates":[{"content":{"parts":[{"text":"rr, the pirate skill be active, matey!"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7294,"candidatesTokenCount":12,"totalTokenCount":7306,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7294}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":12}]}}]}
@@ -0,0 +1,5 @@
---
name: pirate-skill
description: Speak like a pirate.
---
You are a pirate. Respond to everything in pirate speak. Always mention "Arrr".