mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
Agent Skills: Implement Autonomous Activation Tool & Context Injection (#15725)
This commit is contained in:
@@ -24,6 +24,7 @@ import { ReadFileTool } from '../tools/read-file.js';
|
|||||||
import { GrepTool } from '../tools/grep.js';
|
import { GrepTool } from '../tools/grep.js';
|
||||||
import { canUseRipgrep, RipGrepTool } from '../tools/ripGrep.js';
|
import { canUseRipgrep, RipGrepTool } from '../tools/ripGrep.js';
|
||||||
import { GlobTool } from '../tools/glob.js';
|
import { GlobTool } from '../tools/glob.js';
|
||||||
|
import { ActivateSkillTool } from '../tools/activate-skill.js';
|
||||||
import { EditTool } from '../tools/edit.js';
|
import { EditTool } from '../tools/edit.js';
|
||||||
import { SmartEditTool } from '../tools/smart-edit.js';
|
import { SmartEditTool } from '../tools/smart-edit.js';
|
||||||
import { ShellTool } from '../tools/shell.js';
|
import { ShellTool } from '../tools/shell.js';
|
||||||
@@ -737,6 +738,13 @@ export class Config {
|
|||||||
if (this.skillsSupport) {
|
if (this.skillsSupport) {
|
||||||
await this.getSkillManager().discoverSkills(this.storage);
|
await this.getSkillManager().discoverSkills(this.storage);
|
||||||
this.getSkillManager().setDisabledSkills(this.disabledSkills);
|
this.getSkillManager().setDisabledSkills(this.disabledSkills);
|
||||||
|
|
||||||
|
// Re-register ActivateSkillTool to update its schema with the discovered enabled skill enums
|
||||||
|
if (this.getSkillManager().getSkills().length > 0) {
|
||||||
|
this.getToolRegistry().registerTool(
|
||||||
|
new ActivateSkillTool(this, this.messageBus),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize hook system if enabled
|
// Initialize hook system if enabled
|
||||||
@@ -1690,6 +1698,7 @@ export class Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerCoreTool(GlobTool, this);
|
registerCoreTool(GlobTool, this);
|
||||||
|
registerCoreTool(ActivateSkillTool, this);
|
||||||
if (this.getUseSmartEdit()) {
|
if (this.getUseSmartEdit()) {
|
||||||
registerCoreTool(SmartEditTool, this);
|
registerCoreTool(SmartEditTool, this);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ toolName = "write_file"
|
|||||||
decision = "ask_user"
|
decision = "ask_user"
|
||||||
priority = 10
|
priority = 10
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
toolName = "activate_skill"
|
||||||
|
decision = "ask_user"
|
||||||
|
priority = 10
|
||||||
|
|
||||||
[[rule]]
|
[[rule]]
|
||||||
toolName = "write_file"
|
toolName = "write_file"
|
||||||
decision = "allow"
|
decision = "allow"
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ActivateSkillTool } from './activate-skill.js';
|
||||||
|
import type { Config } from '../config/config.js';
|
||||||
|
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||||
|
|
||||||
|
vi.mock('../utils/getFolderStructure.js', () => ({
|
||||||
|
getFolderStructure: vi.fn().mockResolvedValue('Mock folder structure'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ActivateSkillTool', () => {
|
||||||
|
let mockConfig: Config;
|
||||||
|
let tool: ActivateSkillTool;
|
||||||
|
const mockMessageBus = {
|
||||||
|
publish: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
unsubscribe: vi.fn(),
|
||||||
|
} as unknown as MessageBus;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const skills = [
|
||||||
|
{
|
||||||
|
name: 'test-skill',
|
||||||
|
description: 'A test skill',
|
||||||
|
location: '/path/to/test-skill/SKILL.md',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockConfig = {
|
||||||
|
getSkillManager: vi.fn().mockReturnValue({
|
||||||
|
getSkills: vi.fn().mockReturnValue(skills),
|
||||||
|
getAllSkills: vi.fn().mockReturnValue(skills),
|
||||||
|
getSkill: vi.fn().mockImplementation((name: string) => {
|
||||||
|
if (name === 'test-skill') {
|
||||||
|
return {
|
||||||
|
name: 'test-skill',
|
||||||
|
description: 'A test skill',
|
||||||
|
location: '/path/to/test-skill/SKILL.md',
|
||||||
|
body: 'Skill instructions content.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
activateSkill: vi.fn(),
|
||||||
|
}),
|
||||||
|
} as unknown as Config;
|
||||||
|
tool = new ActivateSkillTool(mockConfig, mockMessageBus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return enhanced description', () => {
|
||||||
|
const params = { name: 'test-skill' };
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
expect(invocation.getDescription()).toBe('"test-skill": A test skill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return enhanced confirmation details', async () => {
|
||||||
|
const params = { name: 'test-skill' };
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
const details = await (
|
||||||
|
invocation as unknown as {
|
||||||
|
getConfirmationDetails: (signal: AbortSignal) => Promise<{
|
||||||
|
prompt: string;
|
||||||
|
title: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
).getConfirmationDetails(new AbortController().signal);
|
||||||
|
|
||||||
|
expect(details.title).toBe('Activate Skill: test-skill');
|
||||||
|
expect(details.prompt).toContain('enable the specialized agent skill');
|
||||||
|
expect(details.prompt).toContain('A test skill');
|
||||||
|
expect(details.prompt).toContain('Mock folder structure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should activate a valid skill and return its content in XML tags', async () => {
|
||||||
|
const params = { name: 'test-skill' };
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
|
||||||
|
expect(mockConfig.getSkillManager().activateSkill).toHaveBeenCalledWith(
|
||||||
|
'test-skill',
|
||||||
|
);
|
||||||
|
expect(result.llmContent).toContain('<ACTIVATED_SKILL name="test-skill">');
|
||||||
|
expect(result.llmContent).toContain('<INSTRUCTIONS>');
|
||||||
|
expect(result.llmContent).toContain('Skill instructions content.');
|
||||||
|
expect(result.llmContent).toContain('</INSTRUCTIONS>');
|
||||||
|
expect(result.llmContent).toContain('<AVAILABLE_RESOURCES>');
|
||||||
|
expect(result.llmContent).toContain('Mock folder structure');
|
||||||
|
expect(result.llmContent).toContain('</AVAILABLE_RESOURCES>');
|
||||||
|
expect(result.llmContent).toContain('</ACTIVATED_SKILL>');
|
||||||
|
expect(result.returnDisplay).toContain('Skill **test-skill** activated');
|
||||||
|
expect(result.returnDisplay).toContain('Mock folder structure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if skill is not in enum', async () => {
|
||||||
|
const params = { name: 'non-existent' };
|
||||||
|
expect(() => tool.build(params as { name: string })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an error if skill content cannot be read', async () => {
|
||||||
|
vi.mocked(mockConfig.getSkillManager().getSkill).mockReturnValue(null);
|
||||||
|
const params = { name: 'test-skill' };
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
|
||||||
|
expect(result.llmContent).toContain('Error: Skill "test-skill" not found.');
|
||||||
|
expect(mockConfig.getSkillManager().activateSkill).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate that name is provided', () => {
|
||||||
|
expect(() =>
|
||||||
|
tool.build({ name: '' } as unknown as { name: string }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { getFolderStructure } from '../utils/getFolderStructure.js';
|
||||||
|
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||||
|
import type {
|
||||||
|
ToolResult,
|
||||||
|
ToolCallConfirmationDetails,
|
||||||
|
ToolInvocation,
|
||||||
|
ToolConfirmationOutcome,
|
||||||
|
} from './tools.js';
|
||||||
|
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||||
|
import type { Config } from '../config/config.js';
|
||||||
|
import { ACTIVATE_SKILL_TOOL_NAME } from './tool-names.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters for the ActivateSkill tool
|
||||||
|
*/
|
||||||
|
export interface ActivateSkillToolParams {
|
||||||
|
/**
|
||||||
|
* The name of the skill to activate
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ActivateSkillToolInvocation extends BaseToolInvocation<
|
||||||
|
ActivateSkillToolParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
private cachedFolderStructure: string | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
params: ActivateSkillToolParams,
|
||||||
|
messageBus: MessageBus | undefined,
|
||||||
|
_toolName?: string,
|
||||||
|
_toolDisplayName?: string,
|
||||||
|
) {
|
||||||
|
super(params, messageBus, _toolName, _toolDisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
const skillName = this.params.name;
|
||||||
|
const skill = this.config.getSkillManager().getSkill(skillName);
|
||||||
|
if (skill) {
|
||||||
|
return `"${skillName}": ${skill.description}`;
|
||||||
|
}
|
||||||
|
return `"${skillName}" (⚠️ unknown skill)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrFetchFolderStructure(
|
||||||
|
skillLocation: string,
|
||||||
|
): Promise<string> {
|
||||||
|
if (this.cachedFolderStructure === undefined) {
|
||||||
|
this.cachedFolderStructure = await getFolderStructure(
|
||||||
|
path.dirname(skillLocation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.cachedFolderStructure;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async getConfirmationDetails(
|
||||||
|
_abortSignal: AbortSignal,
|
||||||
|
): Promise<ToolCallConfirmationDetails | false> {
|
||||||
|
if (!this.messageBus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillName = this.params.name;
|
||||||
|
const skill = this.config.getSkillManager().getSkill(skillName);
|
||||||
|
|
||||||
|
if (!skill) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderStructure = await this.getOrFetchFolderStructure(
|
||||||
|
skill.location,
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||||
|
type: 'info',
|
||||||
|
title: `Activate Skill: ${skillName}`,
|
||||||
|
prompt: `You are about to enable the specialized agent skill **${skillName}**.
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
${skill.description}
|
||||||
|
|
||||||
|
**Resources to be shared with the model:**
|
||||||
|
${folderStructure}`,
|
||||||
|
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||||
|
await this.publishPolicyUpdate(outcome);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return confirmationDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||||
|
const skillName = this.params.name;
|
||||||
|
const skillManager = this.config.getSkillManager();
|
||||||
|
const skill = skillManager.getSkill(skillName);
|
||||||
|
|
||||||
|
if (!skill) {
|
||||||
|
const skills = skillManager.getSkills();
|
||||||
|
return {
|
||||||
|
llmContent: `Error: Skill "${skillName}" not found. Available skills are: ${skills.map((s) => s.name).join(', ')}`,
|
||||||
|
returnDisplay: `Skill "${skillName}" not found.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
skillManager.activateSkill(skillName);
|
||||||
|
|
||||||
|
const folderStructure = await this.getOrFetchFolderStructure(
|
||||||
|
skill.location,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
llmContent: `<ACTIVATED_SKILL name="${skillName}">
|
||||||
|
<INSTRUCTIONS>
|
||||||
|
${skill.body}
|
||||||
|
</INSTRUCTIONS>
|
||||||
|
|
||||||
|
<AVAILABLE_RESOURCES>
|
||||||
|
${folderStructure}
|
||||||
|
</AVAILABLE_RESOURCES>
|
||||||
|
</ACTIVATED_SKILL>`,
|
||||||
|
returnDisplay: `Skill **${skillName}** activated. Resources loaded from \`${path.dirname(skill.location)}\`:\n\n${folderStructure}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of the ActivateSkill tool logic
|
||||||
|
*/
|
||||||
|
export class ActivateSkillTool extends BaseDeclarativeTool<
|
||||||
|
ActivateSkillToolParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
static readonly Name = ACTIVATE_SKILL_TOOL_NAME;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
messageBus?: MessageBus,
|
||||||
|
) {
|
||||||
|
const skills = config.getSkillManager().getSkills();
|
||||||
|
const skillNames = skills.map((s) => s.name);
|
||||||
|
|
||||||
|
let schema: z.ZodTypeAny;
|
||||||
|
if (skillNames.length === 0) {
|
||||||
|
schema = z.object({
|
||||||
|
name: z.string().describe('No skills are currently available.'),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
schema = z.object({
|
||||||
|
name: z
|
||||||
|
.enum(skillNames as [string, ...string[]])
|
||||||
|
.describe('The name of the skill to activate.'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
super(
|
||||||
|
ActivateSkillTool.Name,
|
||||||
|
'Activate Skill',
|
||||||
|
"Activates a specialized agent skill by name. Returns the skill's instructions wrapped in `<ACTIVATED_SKILL>` tags. These provide specialized guidance for the current task. Use this when you identify a task that matches a skill's description.",
|
||||||
|
Kind.Other,
|
||||||
|
zodToJsonSchema(schema),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
messageBus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createInvocation(
|
||||||
|
params: ActivateSkillToolParams,
|
||||||
|
messageBus?: MessageBus,
|
||||||
|
_toolName?: string,
|
||||||
|
_toolDisplayName?: string,
|
||||||
|
): ToolInvocation<ActivateSkillToolParams, ToolResult> {
|
||||||
|
return new ActivateSkillToolInvocation(
|
||||||
|
this.config,
|
||||||
|
params,
|
||||||
|
messageBus,
|
||||||
|
_toolName,
|
||||||
|
_toolDisplayName ?? 'Activate Skill',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export const READ_FILE_TOOL_NAME = 'read_file';
|
|||||||
export const LS_TOOL_NAME = 'list_directory';
|
export const LS_TOOL_NAME = 'list_directory';
|
||||||
export const MEMORY_TOOL_NAME = 'save_memory';
|
export const MEMORY_TOOL_NAME = 'save_memory';
|
||||||
export const GET_INTERNAL_DOCS_TOOL_NAME = 'get_internal_docs';
|
export const GET_INTERNAL_DOCS_TOOL_NAME = 'get_internal_docs';
|
||||||
|
export const ACTIVATE_SKILL_TOOL_NAME = 'activate_skill';
|
||||||
export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]);
|
export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]);
|
||||||
export const DELEGATE_TO_AGENT_TOOL_NAME = 'delegate_to_agent';
|
export const DELEGATE_TO_AGENT_TOOL_NAME = 'delegate_to_agent';
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export const ALL_BUILTIN_TOOL_NAMES = [
|
|||||||
READ_FILE_TOOL_NAME,
|
READ_FILE_TOOL_NAME,
|
||||||
LS_TOOL_NAME,
|
LS_TOOL_NAME,
|
||||||
MEMORY_TOOL_NAME,
|
MEMORY_TOOL_NAME,
|
||||||
|
ACTIVATE_SKILL_TOOL_NAME,
|
||||||
DELEGATE_TO_AGENT_TOOL_NAME,
|
DELEGATE_TO_AGENT_TOOL_NAME,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ import { debugLogger } from './debugLogger.js';
|
|||||||
|
|
||||||
const MAX_ITEMS = 200;
|
const MAX_ITEMS = 200;
|
||||||
const TRUNCATION_INDICATOR = '...';
|
const TRUNCATION_INDICATOR = '...';
|
||||||
const DEFAULT_IGNORED_FOLDERS = new Set(['node_modules', '.git', 'dist']);
|
const DEFAULT_IGNORED_FOLDERS = new Set([
|
||||||
|
'node_modules',
|
||||||
|
'.git',
|
||||||
|
'dist',
|
||||||
|
'__pycache__',
|
||||||
|
]);
|
||||||
|
|
||||||
// --- Interfaces ---
|
// --- Interfaces ---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user