diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index d6f65707..2dc9ff65 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -54,6 +54,7 @@ import { CognitivePreparation, selectCognitiveDepth } from './cognitive.js'; import { detectEnvironment, type EnvironmentProfile } from './environment-profile.js'; import { ToolSelector } from './tool-selector.js'; import type { SkillRegistry } from './skills/types.js'; +import { findSkillsProvidingTool } from './skills/index.js'; import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { AsyncLocalStorage } from 'node:async_hooks'; @@ -5217,6 +5218,7 @@ export class Agent { category: s.category, hasInstructions: !!s.instructions, hasMcpTools: !!s.mcpServers && Object.keys(s.mcpServers).length > 0, + providesTools: s.providesTools, })); return JSON.stringify({ status: 'ok', @@ -5340,7 +5342,52 @@ export class Agent { } } - // 3. Not found as tool or skill + // 3. Check if a skill's providesTools includes this name (L2 progressive loading) + if (this.skillRegistry) { + const allManifests = this.skillRegistry.list(); + const matchingSkills = findSkillsProvidingTool(allManifests, name); + if (matchingSkills.length > 0) { + // Try to auto-activate the first matching skill + const skillName = matchingSkills[0]; + const skill = this.skillRegistry.get(skillName); + if (skill) { + let autoActivated = false; + if (skill.manifest.instructions) { + this.activatedSkillInstructions.set(skillName, skill.manifest.instructions); + autoActivated = true; + } + if (skill.manifest.mcpServers && this.skillMcpActivator) { + try { + const mcpTools = await this.skillMcpActivator(skillName, skill.manifest.mcpServers); + for (const tool of mcpTools) { + this.registerTool(tool); + this.activateTools([tool.name]); + } + if (mcpTools.length > 0) { + autoActivated = true; + skillToolNames.push(...mcpTools.map(t => t.name)); + } + } catch (err) { + log.warn('Failed to auto-activate skill MCP servers for tool request', { + agentId: this.id, skill: skillName, toolName: name, error: String(err), + }); + } + } + if (autoActivated) { + activated.push(`${name} (auto-activated skill "${skillName}")`); + log.info('Tool auto-activated via skill providesTools', { + agentId: this.id, toolName: name, skillName, + }); + continue; + } + } + // Skill found but couldn't auto-activate — add hint + unknown.push(name); + continue; + } + } + + // 4. Not found as tool or skill unknown.push(name); } @@ -5357,8 +5404,9 @@ export class Agent { } if (unknown.length > 0) { result.unknown = unknown; - result.hint = 'These names were not found as tools or skills. ' - + 'Use discover_tools({ mode: "list_skills" }) to see all available skills.'; + result.hint = 'These names were not found as activated tools or skills. ' + + 'Use discover_tools({ mode: "list_skills" }) to see all available skills ' + + 'and their provided tools.'; } return JSON.stringify(result); } diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts index 7096974e..25d81849 100644 --- a/packages/core/src/skills/index.ts +++ b/packages/core/src/skills/index.ts @@ -1,6 +1,18 @@ +/** Re-export types */ export type { SkillManifest, SkillMcpServerConfig, SkillInstance, SkillRegistry, SkillCategory, SkillToolDef } from './types.js'; export { InMemorySkillRegistry } from './registry.js'; +/** + * Search an array of SkillManifests for ones whose providesTools includes the given toolName. + * Returns the names of matching skills. + */ +export function findSkillsProvidingTool(manifests: SkillManifest[], toolName: string): string[] { + const lowerTool = toolName.toLowerCase(); + return manifests + .filter(m => m.providesTools?.some(t => t.toLowerCase() === lowerTool)) + .map(m => m.name); +} + import { homedir } from 'node:os'; import { join } from 'node:path'; import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; @@ -101,6 +113,7 @@ export function discoverSkillsInDir(dir: string): Array<{ manifest: SkillManifes mcpServers: resolveMcpServerPaths(pkg.skill?.mcpServers, skillDir), isolation: pkg.skill?.isolation, alwaysOn: pkg.skill?.alwaysOn ?? undefined, + providesTools: pkg.skill?.providesTools, sourcePath: skillDir, }; results.push({ manifest, path: skillDir, source: dir }); diff --git a/packages/core/src/skills/types.ts b/packages/core/src/skills/types.ts index 97e78caa..7b1b8d7d 100644 --- a/packages/core/src/skills/types.ts +++ b/packages/core/src/skills/types.ts @@ -34,6 +34,13 @@ export interface SkillManifest { builtIn?: boolean; /** If true, full instructions are auto-injected into every agent (not just listed as available) */ alwaysOn?: boolean; + /** Tool names this skill provides for L2 progressive loading. + * When an agent requests a tool not found in the active tool set, the system + * checks if any skill's providesTools includes that tool name. If so, it can + * auto-activate the skill to make the tool available. + * This avoids loading all skill MCP servers eagerly (L0) or loading all + * skill instructions into context (L1). */ + providesTools?: string[]; } export type SkillCategory = diff --git a/packages/core/test/skill-progressive-loading.test.ts b/packages/core/test/skill-progressive-loading.test.ts new file mode 100644 index 00000000..5d71b4f7 --- /dev/null +++ b/packages/core/test/skill-progressive-loading.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { InMemorySkillRegistry } from '../src/skills/registry.js'; +import { findSkillsProvidingTool } from '../src/skills/index.js'; +import type { SkillManifest, SkillInstance } from '../src/skills/types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeSkillManifest(overrides: Partial & { name: string }): SkillManifest { + return { + name: overrides.name, + version: overrides.version ?? '1.0.0', + description: overrides.description ?? `Skill ${overrides.name}`, + author: overrides.author ?? 'test', + category: overrides.category ?? 'development', + tags: overrides.tags ?? [], + instructions: overrides.instructions, + mcpServers: overrides.mcpServers, + providesTools: overrides.providesTools, + builtIn: overrides.builtIn, + alwaysOn: overrides.alwaysOn, + sourcePath: overrides.sourcePath, + }; +} + +function makeSkillInstance(overrides: Partial & { name: string }): SkillInstance { + return { manifest: makeSkillManifest(overrides) }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('SkillManifest — providesTools field', () => { + + it('can have a providesTools field with tool names', () => { + const manifest: SkillManifest = { + name: 'chrome-devtools', + version: '1.0.0', + description: 'Browser automation', + author: 'markus', + category: 'browser', + providesTools: ['browser_navigate', 'browser_click', 'browser_type'], + }; + expect(manifest.providesTools).toBeDefined(); + expect(manifest.providesTools!.length).toBe(3); + expect(manifest.providesTools!).toContain('browser_navigate'); + expect(manifest.providesTools!).toContain('browser_click'); + expect(manifest.providesTools!).toContain('browser_type'); + }); + + it('is optional — SkillManifest without providesTools is valid', () => { + const manifest: SkillManifest = { + name: 'my-skill', + version: '1.0.0', + description: 'A skill without providesTools', + author: 'test', + category: 'custom', + }; + expect(manifest.providesTools).toBeUndefined(); + }); + + it('can be an empty array', () => { + const manifest: SkillManifest = { + name: 'empty-skill', + version: '1.0.0', + description: 'Skill with empty providesTools', + author: 'test', + category: 'custom', + providesTools: [], + }; + expect(manifest.providesTools).toEqual([]); + }); +}); + +describe('findSkillsProvidingTool', () => { + + it('returns skill names when a match is found', () => { + const manifests: SkillManifest[] = [ + makeSkillManifest({ name: 'chrome', providesTools: ['browser_navigate', 'browser_click'] }), + makeSkillManifest({ name: 'node', providesTools: ['shell_execute'] }), + ]; + const result = findSkillsProvidingTool(manifests, 'browser_navigate'); + expect(result).toEqual(['chrome']); + }); + + it('returns empty array when no skill provides the tool', () => { + const manifests: SkillManifest[] = [ + makeSkillManifest({ name: 'chrome', providesTools: ['browser_navigate'] }), + ]; + const result = findSkillsProvidingTool(manifests, 'unknown_tool'); + expect(result).toEqual([]); + }); + + it('returns empty array when no skills have providesTools', () => { + const manifests: SkillManifest[] = [ + makeSkillManifest({ name: 'plain-skill' }), + ]; + const result = findSkillsProvidingTool(manifests, 'any_tool'); + expect(result).toEqual([]); + }); + + it('returns empty array when manifests array is empty', () => { + const result = findSkillsProvidingTool([], 'any_tool'); + expect(result).toEqual([]); + }); + + it('returns multiple skill names when multiple skills provide the same tool', () => { + const manifests: SkillManifest[] = [ + makeSkillManifest({ name: 'skill-a', providesTools: ['shared_tool'] }), + makeSkillManifest({ name: 'skill-b', providesTools: ['shared_tool', 'other_tool'] }), + makeSkillManifest({ name: 'skill-c', providesTools: ['different_tool'] }), + ]; + const result = findSkillsProvidingTool(manifests, 'shared_tool'); + expect(result).toHaveLength(2); + expect(result).toContain('skill-a'); + expect(result).toContain('skill-b'); + expect(result).not.toContain('skill-c'); + }); + + it('is case-insensitive (tool names are compared in lowercase)', () => { + const manifests: SkillManifest[] = [ + makeSkillManifest({ name: 'skill', providesTools: ['Browser_Navigate'] }), + ]; + // Both lowercase and mixed-case variants match because findSkillsProvidingTool + // normalizes both sides to lowercase + expect(findSkillsProvidingTool(manifests, 'browser_navigate')).toEqual(['skill']); + expect(findSkillsProvidingTool(manifests, 'Browser_Navigate')).toEqual(['skill']); + expect(findSkillsProvidingTool(manifests, 'BROWSER_NAVIGATE')).toEqual(['skill']); + }); +}); + +describe('findSkillsProvidingTool with InMemorySkillRegistry', () => { + + let registry: InMemorySkillRegistry; + + beforeEach(() => { + registry = new InMemorySkillRegistry(); + registry.register(makeSkillInstance({ + name: 'chrome-devtools', + providesTools: ['browser_navigate', 'browser_click', 'browser_snapshot'], + })); + registry.register(makeSkillInstance({ + name: 'shell-tools', + providesTools: ['shell_execute', 'shell_script'], + })); + }); + + it('finds tools across registered skills', () => { + const result = findSkillsProvidingTool(registry.list(), 'browser_navigate'); + expect(result).toEqual(['chrome-devtools']); + }); + + it('returns empty when tool is not provided by any registered skill', () => { + const result = findSkillsProvidingTool(registry.list(), 'file_read'); + expect(result).toEqual([]); + }); + + it('finds tool from second skill', () => { + const result = findSkillsProvidingTool(registry.list(), 'shell_execute'); + expect(result).toEqual(['shell-tools']); + }); +}); + +describe('SkillRegistry — providesTools stored and listed', () => { + + it('stores providesTools when registering a skill', () => { + const registry = new InMemorySkillRegistry(); + registry.register(makeSkillInstance({ + name: 'test-skill', + providesTools: ['tool_a', 'tool_b'], + })); + const manifests = registry.list(); + expect(manifests).toHaveLength(1); + expect(manifests[0]!.providesTools).toEqual(['tool_a', 'tool_b']); + }); + + it('includes providesTools in list() output', () => { + const registry = new InMemorySkillRegistry(); + registry.register(makeSkillInstance({ + name: 'skill-a', + providesTools: ['alpha'], + })); + registry.register(makeSkillInstance({ + name: 'skill-b', + providesTools: ['beta'], + })); + registry.register(makeSkillInstance({ + name: 'skill-c', + // no providesTools + })); + const manifests = registry.list(); + const a = manifests.find(m => m.name === 'skill-a'); + const b = manifests.find(m => m.name === 'skill-b'); + const c = manifests.find(m => m.name === 'skill-c'); + expect(a?.providesTools).toEqual(['alpha']); + expect(b?.providesTools).toEqual(['beta']); + expect(c?.providesTools).toBeUndefined(); + }); + + it('survives unregister and re-register', () => { + const registry = new InMemorySkillRegistry(); + const inst = makeSkillInstance({ name: 'temp', providesTools: ['temp_tool'] }); + registry.register(inst); + expect(findSkillsProvidingTool(registry.list(), 'temp_tool')).toEqual(['temp']); + + registry.unregister('temp'); + expect(findSkillsProvidingTool(registry.list(), 'temp_tool')).toEqual([]); + + registry.register(makeSkillInstance({ name: 'temp', providesTools: ['temp_tool'] })); + expect(findSkillsProvidingTool(registry.list(), 'temp_tool')).toEqual(['temp']); + }); +}); + +describe('chrome-devtools skill.json — providesTools present', () => { + + it('has the correct providesTools in the template', () => { + // This test validates that the chrome-devtools template skill.json + // includes providesTools for L2 progressive loading + const fs = require('node:fs'); + const path = require('node:path'); + const skillJsonPath = path.resolve( + __dirname, '..', '..', '..', 'templates', 'skills', 'chrome-devtools', 'skill.json', + ); + const content = JSON.parse(fs.readFileSync(skillJsonPath, 'utf-8')); + expect(content.skill.providesTools).toBeDefined(); + expect(Array.isArray(content.skill.providesTools)).toBe(true); + expect(content.skill.providesTools).toContain('browser_navigate'); + expect(content.skill.providesTools).toContain('browser_snapshot'); + expect(content.skill.providesTools).toContain('browser_click'); + expect(content.skill.providesTools).toContain('browser_type'); + expect(content.skill.providesTools).toContain('browser_evaluate'); + }); +}); diff --git a/packages/shared/src/types/package.ts b/packages/shared/src/types/package.ts index da5c68ba..009453f5 100644 --- a/packages/shared/src/types/package.ts +++ b/packages/shared/src/types/package.ts @@ -67,6 +67,11 @@ export interface SkillSection { isolation?: 'shared' | 'per-agent'; /** If true, instructions are auto-injected into every agent (not just available for discovery) */ alwaysOn?: boolean; + /** Tool names this skill provides for L2 progressive loading. + * When an agent requests a tool not found in the active set, the system + * checks if any skill's providesTools includes that tool name, and can + * auto-activate the skill to fulfill the request. */ + providesTools?: string[]; } // ─── Top-level manifest ───────────────────────────────────────────────────── @@ -195,6 +200,8 @@ export function buildManifest( requiredPermissions: (skillSection?.requiredPermissions ?? raw.requiredPermissions) as SkillSection['requiredPermissions'], mcpServers: (skillSection?.mcpServers ?? raw.mcpServers) as SkillSection['mcpServers'], isolation: (skillSection?.isolation ?? raw.isolation) as SkillSection['isolation'], + alwaysOn: (skillSection?.alwaysOn ?? raw.alwaysOn) as boolean | undefined, + providesTools: (skillSection?.providesTools ?? raw.providesTools) as string[] | undefined, }; // Pull version/author from raw if present if (!base.version || base.version === '1.0.0') { diff --git a/templates/skills/chrome-devtools/skill.json b/templates/skills/chrome-devtools/skill.json index 23426787..37e917a2 100644 --- a/templates/skills/chrome-devtools/skill.json +++ b/templates/skills/chrome-devtools/skill.json @@ -17,6 +17,7 @@ "skillFile": "SKILL.md", "requiredPermissions": ["browser", "network"], "isolation": "pooled", + "providesTools": ["browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_evaluate"], "mcpServers": { "chrome-devtools": { "command": "npx",