Skip to content

Commit 131fcf8

Browse files
oratisclaude
andauthored
feat(core): M3c-rest — ToolSearch (deferred tool loading) (#24)
· makeToolSearchTool(store) — agent calls with `select:<csv>` to load by name, or free-text to keyword-search; returns ranked matches. · RegistryDeferredStore — default impl that registers expanded handlers into a ToolRegistry. Idempotent on re-expand. Tests: +7 (3 keyword + 4 select); core 349→356, total 396→403. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6a58ffd commit 131fcf8

4 files changed

Lines changed: 263 additions & 0 deletions

File tree

packages/core/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export {
3434
WebSearchTool,
3535
AskUserQuestionTool,
3636
ExitPlanModeTool,
37+
makeToolSearchTool,
38+
RegistryDeferredStore,
39+
type DeferredToolEntry,
40+
type DeferredToolStore,
3741
readTodos,
3842
TODO_FILE,
3943
parseDuckDuckGoHtml,

packages/core/src/tools/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,11 @@ export { WebSearchTool, parseDuckDuckGoHtml } from './web-search.js';
1515
export type { SearchHit } from './web-search.js';
1616
export { AskUserQuestionTool } from './ask-user.js';
1717
export { ExitPlanModeTool } from './exit-plan.js';
18+
export {
19+
makeToolSearchTool,
20+
RegistryDeferredStore,
21+
type DeferredToolEntry,
22+
type DeferredToolStore,
23+
} from './tool-search.js';
1824
export { ToolRegistry, BUILTIN_TOOLS } from './registry.js';
1925
export type { ToolDefinition, ToolContext, ToolResult, ToolHandler } from './types.js';
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { ToolRegistry } from './registry.js';
3+
import {
4+
makeToolSearchTool,
5+
RegistryDeferredStore,
6+
type DeferredToolEntry,
7+
} from './tool-search.js';
8+
import type { ToolHandler } from '../types.js';
9+
10+
function fakeHandler(name: string, description = ''): ToolHandler {
11+
return {
12+
name,
13+
definition: { name, description, inputSchema: { type: 'object' } },
14+
async execute() {
15+
return { content: `ran ${name}` };
16+
},
17+
};
18+
}
19+
20+
function entry(name: string, description: string): DeferredToolEntry {
21+
return {
22+
name,
23+
description,
24+
expand: () => fakeHandler(name, description),
25+
};
26+
}
27+
28+
describe('ToolSearch keyword query', () => {
29+
it('returns sorted matches with a "select:" hint', async () => {
30+
const reg = new ToolRegistry([]);
31+
const store = new RegistryDeferredStore(reg, [
32+
entry('mcp__slack__send', 'Send a message to Slack'),
33+
entry('mcp__gmail__draft', 'Draft a Gmail email'),
34+
entry('mcp__notion__page', 'Create a Notion page'),
35+
]);
36+
const search = makeToolSearchTool(store);
37+
const r = await search.execute({ query: 'slack' }, { cwd: '/x' });
38+
expect(r.content).toContain('mcp__slack__send');
39+
expect(r.content).toContain('select:mcp__slack__send');
40+
});
41+
42+
it('returns "no matched" when nothing scores', async () => {
43+
const reg = new ToolRegistry([]);
44+
const store = new RegistryDeferredStore(reg, [entry('foo', 'bar')]);
45+
const search = makeToolSearchTool(store);
46+
const r = await search.execute({ query: 'zzz-no-such' }, { cwd: '/x' });
47+
expect(r.content).toMatch(/No deferred tools matched/);
48+
});
49+
50+
it('caps results at max_results', async () => {
51+
const reg = new ToolRegistry([]);
52+
const entries: DeferredToolEntry[] = [];
53+
for (let i = 0; i < 20; i++) entries.push(entry(`tool${i}`, 'common-word common'));
54+
const store = new RegistryDeferredStore(reg, entries);
55+
const search = makeToolSearchTool(store);
56+
const r = await search.execute(
57+
{ query: 'common', max_results: 3 },
58+
{ cwd: '/x' },
59+
);
60+
const data = r.data as { hits: unknown[] };
61+
expect(data.hits).toHaveLength(3);
62+
});
63+
});
64+
65+
describe('ToolSearch select: query', () => {
66+
it('loads named tools into the registry', async () => {
67+
const reg = new ToolRegistry([]);
68+
const store = new RegistryDeferredStore(reg, [
69+
entry('A', 'desc A'),
70+
entry('B', 'desc B'),
71+
]);
72+
const search = makeToolSearchTool(store);
73+
const r = await search.execute({ query: 'select:A,B' }, { cwd: '/x' });
74+
expect(r.content).toMatch(/Loaded: A, B/);
75+
expect(reg.get('A')).toBeDefined();
76+
expect(reg.get('B')).toBeDefined();
77+
});
78+
79+
it('reports missing tools without failing', async () => {
80+
const reg = new ToolRegistry([]);
81+
const store = new RegistryDeferredStore(reg, [entry('A', 'a')]);
82+
const search = makeToolSearchTool(store);
83+
const r = await search.execute({ query: 'select:A,DoesNotExist' }, { cwd: '/x' });
84+
expect(r.content).toMatch(/Loaded: A/);
85+
expect(r.content).toMatch(/Not found: DoesNotExist/);
86+
});
87+
88+
it('is idempotent — second select: doesnt double-register', async () => {
89+
const reg = new ToolRegistry([]);
90+
const store = new RegistryDeferredStore(reg, [entry('A', 'a')]);
91+
const search = makeToolSearchTool(store);
92+
await search.execute({ query: 'select:A' }, { cwd: '/x' });
93+
const r2 = await search.execute({ query: 'select:A' }, { cwd: '/x' });
94+
expect(r2.content).toMatch(/Loaded: A/);
95+
});
96+
97+
it('errors on empty query', async () => {
98+
const reg = new ToolRegistry([]);
99+
const store = new RegistryDeferredStore(reg, []);
100+
const search = makeToolSearchTool(store);
101+
const r = await search.execute({ query: '' }, { cwd: '/x' });
102+
expect(r.isError).toBe(true);
103+
});
104+
});
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// ToolSearch tool — deferred-tool loading. Lets the agent discover and
2+
// "expand" tools that aren't loaded by default (large MCP toolkits, computer-
3+
// use tools, etc.) without bloating the system prompt with their full schema.
4+
//
5+
// Spec: docs/DEVELOPMENT_PLAN.md §3.15.6
6+
//
7+
// Wire-up:
8+
// · ToolRegistry tracks a `deferred` map of name → { description, expand() }.
9+
// · Agent loop exposes ONLY the deferred-tool names (not schemas) until
10+
// ToolSearch is called with `select:Name1,Name2,...`.
11+
// · The tool returns the schemas and asks the registry to register them.
12+
13+
import type { ToolContext, ToolHandler, ToolResult } from '../types.js';
14+
15+
export interface DeferredToolEntry {
16+
name: string;
17+
description: string;
18+
/** Lazily produce the full ToolHandler when the tool is "expanded". */
19+
expand: () => Promise<ToolHandler> | ToolHandler;
20+
}
21+
22+
export interface DeferredToolStore {
23+
/** Returns all deferred entries (for keyword search). */
24+
list(): DeferredToolEntry[];
25+
/** Expand and register an entry; idempotent on already-registered names. */
26+
expand(name: string): Promise<ToolHandler | undefined>;
27+
}
28+
29+
interface SearchInput {
30+
query: string;
31+
max_results?: number;
32+
}
33+
34+
const DEFAULT_MAX_RESULTS = 5;
35+
36+
export function makeToolSearchTool(store: DeferredToolStore): ToolHandler {
37+
return {
38+
name: 'ToolSearch',
39+
definition: {
40+
name: 'ToolSearch',
41+
description:
42+
'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.',
43+
inputSchema: {
44+
type: 'object',
45+
properties: {
46+
query: {
47+
type: 'string',
48+
description:
49+
'Either "select:<csv of tool names>" or a free-text query matching name+description.',
50+
},
51+
max_results: {
52+
type: 'number',
53+
description: 'Cap on results for keyword queries (default 5).',
54+
},
55+
},
56+
required: ['query'],
57+
},
58+
},
59+
async execute(rawInput: Record<string, unknown>, _ctx: ToolContext): Promise<ToolResult> {
60+
const input = rawInput as unknown as SearchInput;
61+
if (!input?.query || typeof input.query !== 'string') {
62+
return { content: 'Error: query is required (string).', isError: true };
63+
}
64+
const max = Math.max(1, input.max_results ?? DEFAULT_MAX_RESULTS);
65+
66+
if (input.query.startsWith('select:')) {
67+
const names = input.query
68+
.slice('select:'.length)
69+
.split(',')
70+
.map((s) => s.trim())
71+
.filter(Boolean);
72+
const loaded: string[] = [];
73+
const missing: string[] = [];
74+
for (const n of names) {
75+
const h = await store.expand(n);
76+
if (h) loaded.push(h.name);
77+
else missing.push(n);
78+
}
79+
const lines: string[] = [];
80+
if (loaded.length > 0) lines.push(`Loaded: ${loaded.join(', ')}`);
81+
if (missing.length > 0) lines.push(`Not found: ${missing.join(', ')}`);
82+
if (lines.length === 0) lines.push('No tools loaded.');
83+
return { content: lines.join('\n'), data: { loaded, missing } };
84+
}
85+
86+
// Keyword search — rank by token overlap of name + description
87+
const tokens = input.query
88+
.toLowerCase()
89+
.split(/\s+/)
90+
.filter((t) => t.length > 0);
91+
const ranked = store
92+
.list()
93+
.map((e) => ({ entry: e, score: score(e, tokens) }))
94+
.filter((r) => r.score > 0)
95+
.sort((a, b) => b.score - a.score)
96+
.slice(0, max);
97+
if (ranked.length === 0) {
98+
return { content: `No deferred tools matched "${input.query}".`, data: { hits: [] } };
99+
}
100+
const lines = ranked.map(
101+
(r) => `${r.entry.name}${r.entry.description.slice(0, 120)}`,
102+
);
103+
lines.push('');
104+
lines.push(`Use \`select:${ranked.map((r) => r.entry.name).join(',')}\` to load.`);
105+
return {
106+
content: lines.join('\n'),
107+
data: { hits: ranked.map((r) => ({ name: r.entry.name, score: r.score })) },
108+
};
109+
},
110+
};
111+
}
112+
113+
function score(entry: DeferredToolEntry, tokens: string[]): number {
114+
if (tokens.length === 0) return 0;
115+
const text = `${entry.name} ${entry.description}`.toLowerCase();
116+
let s = 0;
117+
for (const t of tokens) {
118+
if (entry.name.toLowerCase() === t) s += 100;
119+
else if (entry.name.toLowerCase().includes(t)) s += 10;
120+
if (text.includes(t)) s += 1;
121+
}
122+
return s;
123+
}
124+
125+
/**
126+
* Default DeferredToolStore backed by a ToolRegistry. Builds an internal map
127+
* of name → entry on construction; expand() calls registry.register().
128+
*/
129+
export class RegistryDeferredStore implements DeferredToolStore {
130+
private readonly entries = new Map<string, DeferredToolEntry>();
131+
constructor(
132+
private readonly registry: { register: (h: ToolHandler) => void; get: (name: string) => ToolHandler | undefined },
133+
entries: DeferredToolEntry[],
134+
) {
135+
for (const e of entries) this.entries.set(e.name, e);
136+
}
137+
list(): DeferredToolEntry[] {
138+
return [...this.entries.values()];
139+
}
140+
async expand(name: string): Promise<ToolHandler | undefined> {
141+
const existing = this.registry.get(name);
142+
if (existing) return existing;
143+
const entry = this.entries.get(name);
144+
if (!entry) return undefined;
145+
const handler = await entry.expand();
146+
this.registry.register(handler);
147+
return handler;
148+
}
149+
}

0 commit comments

Comments
 (0)