Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export {
WebSearchTool,
AskUserQuestionTool,
ExitPlanModeTool,
makeToolSearchTool,
RegistryDeferredStore,
type DeferredToolEntry,
type DeferredToolStore,
readTodos,
TODO_FILE,
parseDuckDuckGoHtml,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
104 changes: 104 additions & 0 deletions packages/core/src/tools/tool-search.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
149 changes: 149 additions & 0 deletions packages/core/src/tools/tool-search.ts
Original file line number Diff line number Diff line change
@@ -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> | 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<ToolHandler | undefined>;
}

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:<csv of tool names>" 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<string, unknown>, _ctx: ToolContext): Promise<ToolResult> {
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<string, DeferredToolEntry>();
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<ToolHandler | undefined> {
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;
}
}
Loading