From 958284dc249186e9ce0e285122de8e181b87a902 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Fri, 2 Jan 2026 11:15:06 -0800 Subject: [PATCH] Agent Skills: Implement Autonomous Activation Tool & Context Injection (#15725) --- packages/core/src/config/config.ts | 9 + packages/core/src/policy/policies/write.toml | 5 + .../core/src/tools/activate-skill.test.ts | 118 +++++++++++ packages/core/src/tools/activate-skill.ts | 192 ++++++++++++++++++ packages/core/src/tools/tool-names.ts | 2 + packages/core/src/utils/getFolderStructure.ts | 7 +- 6 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/tools/activate-skill.test.ts create mode 100644 packages/core/src/tools/activate-skill.ts diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 74037c1f7c..8fee660edd 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -24,6 +24,7 @@ import { ReadFileTool } from '../tools/read-file.js'; import { GrepTool } from '../tools/grep.js'; import { canUseRipgrep, RipGrepTool } from '../tools/ripGrep.js'; import { GlobTool } from '../tools/glob.js'; +import { ActivateSkillTool } from '../tools/activate-skill.js'; import { EditTool } from '../tools/edit.js'; import { SmartEditTool } from '../tools/smart-edit.js'; import { ShellTool } from '../tools/shell.js'; @@ -737,6 +738,13 @@ export class Config { if (this.skillsSupport) { await this.getSkillManager().discoverSkills(this.storage); 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 @@ -1690,6 +1698,7 @@ export class Config { } registerCoreTool(GlobTool, this); + registerCoreTool(ActivateSkillTool, this); if (this.getUseSmartEdit()) { registerCoreTool(SmartEditTool, this); } else { diff --git a/packages/core/src/policy/policies/write.toml b/packages/core/src/policy/policies/write.toml index 09387b59c1..991424cebc 100644 --- a/packages/core/src/policy/policies/write.toml +++ b/packages/core/src/policy/policies/write.toml @@ -56,6 +56,11 @@ toolName = "write_file" decision = "ask_user" priority = 10 +[[rule]] +toolName = "activate_skill" +decision = "ask_user" +priority = 10 + [[rule]] toolName = "write_file" decision = "allow" diff --git a/packages/core/src/tools/activate-skill.test.ts b/packages/core/src/tools/activate-skill.test.ts new file mode 100644 index 0000000000..80f4dc6885 --- /dev/null +++ b/packages/core/src/tools/activate-skill.test.ts @@ -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(''); + expect(result.llmContent).toContain(''); + expect(result.llmContent).toContain('Skill instructions content.'); + expect(result.llmContent).toContain(''); + expect(result.llmContent).toContain(''); + expect(result.llmContent).toContain('Mock folder structure'); + expect(result.llmContent).toContain(''); + expect(result.llmContent).toContain(''); + 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(); + }); +}); diff --git a/packages/core/src/tools/activate-skill.ts b/packages/core/src/tools/activate-skill.ts new file mode 100644 index 0000000000..afea50316c --- /dev/null +++ b/packages/core/src/tools/activate-skill.ts @@ -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 { + if (this.cachedFolderStructure === undefined) { + this.cachedFolderStructure = await getFolderStructure( + path.dirname(skillLocation), + ); + } + return this.cachedFolderStructure; + } + + protected override async getConfirmationDetails( + _abortSignal: AbortSignal, + ): Promise { + 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 { + 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: ` + + ${skill.body} + + + + ${folderStructure} + +`, + 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 `` 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 { + return new ActivateSkillToolInvocation( + this.config, + params, + messageBus, + _toolName, + _toolDisplayName ?? 'Activate Skill', + ); + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index db2967405e..41e4be8dec 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -21,6 +21,7 @@ export const READ_FILE_TOOL_NAME = 'read_file'; export const LS_TOOL_NAME = 'list_directory'; export const MEMORY_TOOL_NAME = 'save_memory'; 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 DELEGATE_TO_AGENT_TOOL_NAME = 'delegate_to_agent'; @@ -43,6 +44,7 @@ export const ALL_BUILTIN_TOOL_NAMES = [ READ_FILE_TOOL_NAME, LS_TOOL_NAME, MEMORY_TOOL_NAME, + ACTIVATE_SKILL_TOOL_NAME, DELEGATE_TO_AGENT_TOOL_NAME, ] as const; diff --git a/packages/core/src/utils/getFolderStructure.ts b/packages/core/src/utils/getFolderStructure.ts index 0544f4655d..8f871e1283 100644 --- a/packages/core/src/utils/getFolderStructure.ts +++ b/packages/core/src/utils/getFolderStructure.ts @@ -18,7 +18,12 @@ import { debugLogger } from './debugLogger.js'; const MAX_ITEMS = 200; 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 ---