diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bdab5d4..72cf565 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -34,6 +34,10 @@ export { WebSearchTool, AskUserQuestionTool, ExitPlanModeTool, + makeToolSearchTool, + RegistryDeferredStore, + type DeferredToolEntry, + type DeferredToolStore, readTodos, TODO_FILE, parseDuckDuckGoHtml, diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index 219cd6e..00efa4e 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -15,5 +15,11 @@ export { WebSearchTool, parseDuckDuckGoHtml } from './web-search.js'; export type { SearchHit } from './web-search.js'; export { AskUserQuestionTool } from './ask-user.js'; export { ExitPlanModeTool } from './exit-plan.js'; +export { + makeToolSearchTool, + RegistryDeferredStore, + type DeferredToolEntry, + type DeferredToolStore, +} from './tool-search.js'; export { ToolRegistry, BUILTIN_TOOLS } from './registry.js'; export type { ToolDefinition, ToolContext, ToolResult, ToolHandler } from './types.js'; diff --git a/packages/core/src/tools/tool-search.test.ts b/packages/core/src/tools/tool-search.test.ts new file mode 100644 index 0000000..3ae4568 --- /dev/null +++ b/packages/core/src/tools/tool-search.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { ToolRegistry } from './registry.js'; +import { + makeToolSearchTool, + RegistryDeferredStore, + type DeferredToolEntry, +} from './tool-search.js'; +import type { ToolHandler } from '../types.js'; + +function fakeHandler(name: string, description = ''): ToolHandler { + return { + name, + definition: { name, description, inputSchema: { type: 'object' } }, + async execute() { + return { content: `ran ${name}` }; + }, + }; +} + +function entry(name: string, description: string): DeferredToolEntry { + return { + name, + description, + expand: () => fakeHandler(name, description), + }; +} + +describe('ToolSearch keyword query', () => { + it('returns sorted matches with a "select:" hint', async () => { + const reg = new ToolRegistry([]); + const store = new RegistryDeferredStore(reg, [ + entry('mcp__slack__send', 'Send a message to Slack'), + entry('mcp__gmail__draft', 'Draft a Gmail email'), + entry('mcp__notion__page', 'Create a Notion page'), + ]); + const search = makeToolSearchTool(store); + const r = await search.execute({ query: 'slack' }, { cwd: '/x' }); + expect(r.content).toContain('mcp__slack__send'); + expect(r.content).toContain('select:mcp__slack__send'); + }); + + it('returns "no matched" when nothing scores', async () => { + const reg = new ToolRegistry([]); + const store = new RegistryDeferredStore(reg, [entry('foo', 'bar')]); + const search = makeToolSearchTool(store); + const r = await search.execute({ query: 'zzz-no-such' }, { cwd: '/x' }); + expect(r.content).toMatch(/No deferred tools matched/); + }); + + it('caps results at max_results', async () => { + const reg = new ToolRegistry([]); + const entries: DeferredToolEntry[] = []; + for (let i = 0; i < 20; i++) entries.push(entry(`tool${i}`, 'common-word common')); + const store = new RegistryDeferredStore(reg, entries); + const search = makeToolSearchTool(store); + const r = await search.execute( + { query: 'common', max_results: 3 }, + { cwd: '/x' }, + ); + const data = r.data as { hits: unknown[] }; + expect(data.hits).toHaveLength(3); + }); +}); + +describe('ToolSearch select: query', () => { + it('loads named tools into the registry', async () => { + const reg = new ToolRegistry([]); + const store = new RegistryDeferredStore(reg, [ + entry('A', 'desc A'), + entry('B', 'desc B'), + ]); + const search = makeToolSearchTool(store); + const r = await search.execute({ query: 'select:A,B' }, { cwd: '/x' }); + expect(r.content).toMatch(/Loaded: A, B/); + expect(reg.get('A')).toBeDefined(); + expect(reg.get('B')).toBeDefined(); + }); + + it('reports missing tools without failing', async () => { + const reg = new ToolRegistry([]); + const store = new RegistryDeferredStore(reg, [entry('A', 'a')]); + const search = makeToolSearchTool(store); + const r = await search.execute({ query: 'select:A,DoesNotExist' }, { cwd: '/x' }); + expect(r.content).toMatch(/Loaded: A/); + expect(r.content).toMatch(/Not found: DoesNotExist/); + }); + + it('is idempotent — second select: doesnt double-register', async () => { + const reg = new ToolRegistry([]); + const store = new RegistryDeferredStore(reg, [entry('A', 'a')]); + const search = makeToolSearchTool(store); + await search.execute({ query: 'select:A' }, { cwd: '/x' }); + const r2 = await search.execute({ query: 'select:A' }, { cwd: '/x' }); + expect(r2.content).toMatch(/Loaded: A/); + }); + + it('errors on empty query', async () => { + const reg = new ToolRegistry([]); + const store = new RegistryDeferredStore(reg, []); + const search = makeToolSearchTool(store); + const r = await search.execute({ query: '' }, { cwd: '/x' }); + expect(r.isError).toBe(true); + }); +}); diff --git a/packages/core/src/tools/tool-search.ts b/packages/core/src/tools/tool-search.ts new file mode 100644 index 0000000..15e9c5c --- /dev/null +++ b/packages/core/src/tools/tool-search.ts @@ -0,0 +1,149 @@ +// ToolSearch tool — deferred-tool loading. Lets the agent discover and +// "expand" tools that aren't loaded by default (large MCP toolkits, computer- +// use tools, etc.) without bloating the system prompt with their full schema. +// +// Spec: docs/DEVELOPMENT_PLAN.md §3.15.6 +// +// Wire-up: +// · ToolRegistry tracks a `deferred` map of name → { description, expand() }. +// · Agent loop exposes ONLY the deferred-tool names (not schemas) until +// ToolSearch is called with `select:Name1,Name2,...`. +// · The tool returns the schemas and asks the registry to register them. + +import type { ToolContext, ToolHandler, ToolResult } from '../types.js'; + +export interface DeferredToolEntry { + name: string; + description: string; + /** Lazily produce the full ToolHandler when the tool is "expanded". */ + expand: () => Promise | ToolHandler; +} + +export interface DeferredToolStore { + /** Returns all deferred entries (for keyword search). */ + list(): DeferredToolEntry[]; + /** Expand and register an entry; idempotent on already-registered names. */ + expand(name: string): Promise; +} + +interface SearchInput { + query: string; + max_results?: number; +} + +const DEFAULT_MAX_RESULTS = 5; + +export function makeToolSearchTool(store: DeferredToolStore): ToolHandler { + return { + name: 'ToolSearch', + definition: { + name: 'ToolSearch', + description: + 'Find and load deferred tools by name or keyword. Use "select:Name1,Name2" to load tools by exact name; otherwise the query is matched as a fuzzy keyword search against tool names and descriptions. Once loaded, the tools become callable in subsequent turns.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'Either "select:" or a free-text query matching name+description.', + }, + max_results: { + type: 'number', + description: 'Cap on results for keyword queries (default 5).', + }, + }, + required: ['query'], + }, + }, + async execute(rawInput: Record, _ctx: ToolContext): Promise { + const input = rawInput as unknown as SearchInput; + if (!input?.query || typeof input.query !== 'string') { + return { content: 'Error: query is required (string).', isError: true }; + } + const max = Math.max(1, input.max_results ?? DEFAULT_MAX_RESULTS); + + if (input.query.startsWith('select:')) { + const names = input.query + .slice('select:'.length) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const loaded: string[] = []; + const missing: string[] = []; + for (const n of names) { + const h = await store.expand(n); + if (h) loaded.push(h.name); + else missing.push(n); + } + const lines: string[] = []; + if (loaded.length > 0) lines.push(`Loaded: ${loaded.join(', ')}`); + if (missing.length > 0) lines.push(`Not found: ${missing.join(', ')}`); + if (lines.length === 0) lines.push('No tools loaded.'); + return { content: lines.join('\n'), data: { loaded, missing } }; + } + + // Keyword search — rank by token overlap of name + description + const tokens = input.query + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length > 0); + const ranked = store + .list() + .map((e) => ({ entry: e, score: score(e, tokens) })) + .filter((r) => r.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, max); + if (ranked.length === 0) { + return { content: `No deferred tools matched "${input.query}".`, data: { hits: [] } }; + } + const lines = ranked.map( + (r) => `${r.entry.name} — ${r.entry.description.slice(0, 120)}`, + ); + lines.push(''); + lines.push(`Use \`select:${ranked.map((r) => r.entry.name).join(',')}\` to load.`); + return { + content: lines.join('\n'), + data: { hits: ranked.map((r) => ({ name: r.entry.name, score: r.score })) }, + }; + }, + }; +} + +function score(entry: DeferredToolEntry, tokens: string[]): number { + if (tokens.length === 0) return 0; + const text = `${entry.name} ${entry.description}`.toLowerCase(); + let s = 0; + for (const t of tokens) { + if (entry.name.toLowerCase() === t) s += 100; + else if (entry.name.toLowerCase().includes(t)) s += 10; + if (text.includes(t)) s += 1; + } + return s; +} + +/** + * Default DeferredToolStore backed by a ToolRegistry. Builds an internal map + * of name → entry on construction; expand() calls registry.register(). + */ +export class RegistryDeferredStore implements DeferredToolStore { + private readonly entries = new Map(); + constructor( + private readonly registry: { register: (h: ToolHandler) => void; get: (name: string) => ToolHandler | undefined }, + entries: DeferredToolEntry[], + ) { + for (const e of entries) this.entries.set(e.name, e); + } + list(): DeferredToolEntry[] { + return [...this.entries.values()]; + } + async expand(name: string): Promise { + const existing = this.registry.get(name); + if (existing) return existing; + const entry = this.entries.get(name); + if (!entry) return undefined; + const handler = await entry.expand(); + this.registry.register(handler); + return handler; + } +}