Skip to content

Commit fdbfba0

Browse files
committed
feat(tools): promote discovered tools only on execute
Search returns cards only. A call declares that one name, then dispatches.
1 parent 065075f commit fdbfba0

13 files changed

Lines changed: 490 additions & 140 deletions

‎src/agent/tool-search.test.ts‎

Lines changed: 242 additions & 18 deletions
Large diffs are not rendered by default.

‎src/agent/tool-search.ts‎

Lines changed: 59 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import { advertisedToolName, projectToolDefinition } from "./tool-aliases.js";
1515
import { canonicalToolName } from "./canonical-tool-name.js";
1616

1717
// Tools whose full schema is always advertised to the model. Everything else is
18-
// registered but discovered on demand via tool_search, which promotes matches
19-
// onto the call gate and the next infer's wire set. Shared by the system prompt
18+
// registered but discovered on demand via tool_search, which returns name +
19+
// description cards only. A found tool's schema joins the wire when the model
20+
// actually calls it (promote-on-execute). Shared by the system prompt
2021
// and the advertised-set gate so the two never drift.
2122
//
2223
// `present` is deliberately absent: most sessions never render a view, and at
@@ -202,9 +203,9 @@ export function advertisedTools(
202203
}
203204

204205
// Tracks which non-built-in tool names the session has activated (via
205-
// tool_search matches, or a director-side trigger like the lsp hint), in
206-
// first-activation order. Backed by a Set, so re-activating an already-active
207-
// name is a no-op — it neither reorders nor duplicates the entry.
206+
// promote-on-execute of a called name, or a director-side trigger like the
207+
// lsp hint), in first-activation order. Backed by a Set, so re-activating an
208+
// already-active name is a no-op — it neither reorders nor duplicates the entry.
208209
export interface ActivatedToolTracker {
209210
// Adds any new names and returns whether the set actually changed.
210211
activate(names: readonly string[]): boolean;
@@ -243,14 +244,19 @@ export function createActivatedToolTracker(): ActivatedToolTracker {
243244
export const toolSearchDefinition: ToolDefinition = {
244245
name: "tool_search",
245246
description:
246-
"Discover callable tools by capability. Most tools — MCP servers, present, and other integrations — are not on the wire until this search promotes them onto the next inference. Core tools (read, bash, web_fetch, web_search, spawn_agent, …) are already on the wire — do not tool_search for them. wait_agents is mounted on exec-primary runs only, so it is not on the wire elsewhere and this search cannot promote it there. Call this with a short description of what you need (e.g. 'issue tracker', 'render layout', 'granola notes') to get a ranked handful of matching names and short descriptions. Matched tools join the next inference tool list — call them on the next turn, not from this result.",
247+
"Discover callable tools by capability. Most tools — MCP servers, present, and other integrations — are found here as name + description cards; their schemas join the wire when you actually call them. Core tools (read, bash, web_fetch, web_search, spawn_agent, …) are already on the wire — do not tool_search for them. wait_agents is mounted on exec-primary runs only, so it is not on the wire elsewhere and this search cannot surface it there. Call this with a short description of what you need (e.g. 'issue tracker', 'render layout', 'granola notes') to get a ranked handful of matching names and short descriptions (default 5, overridable via limit, max 20). Search does not add schemas to the tool list — call a returned name to use it.",
247248
inputSchema: {
248249
type: "object",
249250
properties: {
250251
query: {
251252
type: "string",
252253
description: "A short description of the capability you need.",
253254
},
255+
limit: {
256+
type: "number",
257+
description:
258+
"Max matches to return (default 5, hard cap 20). Override when you need a wider set.",
259+
},
254260
},
255261
required: ["query"],
256262
},
@@ -263,16 +269,17 @@ export interface ToolIndex {
263269

264270
// Rank registered tools against name + description via the shared lexical
265271
// scorer. Already-advertised tools are always callable so they never rank;
266-
// the allow list (when set) keeps search from promoting outside it.
272+
// the allow list (when set) keeps search from listing tools outside it.
267273
export const TOOL_SEARCH_MAX_RESULTS = 5;
274+
export const TOOL_SEARCH_LIMIT_MAX = 20;
268275
export const TOOL_SEARCH_SCORE_RATIO = 0.75;
269276
export const TOOL_SEARCH_DESC_MAX = 160;
270277

271278
export function createToolIndex(
272279
getDefs: () => readonly ToolDefinition[],
273280
advertisedNames: readonly string[] = ADVERTISED_TOOL_NAMES,
274281
// Closed allow list (exec director overlays): when set, the index only
275-
// surfaces allowed tools so search cannot promote outside the allow.
282+
// surfaces allowed tools so search cannot list names outside the allow.
276283
allow?: readonly string[] | undefined,
277284
): ToolIndex {
278285
const score = (
@@ -314,10 +321,8 @@ export function createToolIndex(
314321
}
315322

316323
export interface ToolSearchDeps {
317-
search: (query: string) => string[];
324+
search: (query: string, limit?: number) => string[];
318325
lookup: (name: string) => ToolDefinition | undefined;
319-
// Promote matches onto the call gate and the next infer's wire set.
320-
promote: (names: string[]) => void;
321326
// Resolves to the remaining in-flight MCP handshake count after waiting up
322327
// to `timeoutMs`. The toolset bounds its own wait; the handler re-races
323328
// below so even a stuck dependency can never hang the call. Omitted callers
@@ -347,7 +352,39 @@ export const TOOL_SEARCH_PENDING_WAIT_MS = 1_000;
347352
// out-of-band authorization.
348353
export const TOOL_SEARCH_RECONNECT_WAIT_MS = 500;
349354

350-
const ToolSearchArgs = type({ query: "string" });
355+
const ToolSearchArgs = type({
356+
query: "string",
357+
"limit?": "number",
358+
});
359+
360+
function resolveSearchLimit(value: number | undefined): number {
361+
if (value === undefined || !Number.isFinite(value)) {
362+
return TOOL_SEARCH_MAX_RESULTS;
363+
}
364+
const n = Math.trunc(value);
365+
if (n < 1) return TOOL_SEARCH_MAX_RESULTS;
366+
return Math.min(n, TOOL_SEARCH_LIMIT_MAX);
367+
}
368+
369+
function parseToolSearchArgs(
370+
rawArgs: Record<string, unknown>,
371+
): { error: string } | { query: string; limit: number } {
372+
const parsed = ToolSearchArgs(rawArgs);
373+
const queryRaw =
374+
parsed instanceof type.errors ? rawArgs.query : parsed.query;
375+
if (typeof queryRaw !== "string") {
376+
return { error: "Error: tool_search requires query (string)." };
377+
}
378+
const query = queryRaw.trim();
379+
if (query.length === 0) {
380+
return { error: "Error: tool_search requires a non-empty query." };
381+
}
382+
const limit =
383+
parsed instanceof type.errors
384+
? TOOL_SEARCH_MAX_RESULTS
385+
: resolveSearchLimit(parsed.limit);
386+
return { query, limit };
387+
}
351388

352389
function capDescription(text: string, max = TOOL_SEARCH_DESC_MAX): string {
353390
const oneLine = text.replace(/\s+/g, " ").trim();
@@ -384,14 +421,10 @@ export function createToolSearchTool(deps: ToolSearchDeps): AgentTool {
384421
return stringTool({
385422
definition: toolSearchDefinition,
386423
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {
387-
const parsed = ToolSearchArgs(rawArgs);
388-
if (parsed instanceof type.errors) {
389-
return "Error: tool_search requires query (string).";
390-
}
391-
const query = parsed.query.trim();
392-
if (query.length === 0)
393-
return "Error: tool_search requires a non-empty query.";
394-
let names = deps.search(query);
424+
const parsed = parseToolSearchArgs(rawArgs);
425+
if ("error" in parsed) return parsed.error;
426+
const { query, limit } = parsed;
427+
let names = deps.search(query, limit);
395428
if (names.length === 0 && deps.awaitPendingConnections !== undefined) {
396429
// Tier 1 — miss while connectors start up: wait briefly, then
397430
// re-search so late-mounting tools land. The race bounds even a stuck
@@ -400,7 +433,7 @@ export function createToolSearchTool(deps: ToolSearchDeps): AgentTool {
400433
deps.awaitPendingConnections,
401434
deps.pendingWaitMs ?? TOOL_SEARCH_PENDING_WAIT_MS,
402435
);
403-
names = deps.search(query);
436+
names = deps.search(query, limit);
404437
if (
405438
names.length === 0 &&
406439
(stillPending ?? 1) > 0 &&
@@ -414,7 +447,7 @@ export function createToolSearchTool(deps: ToolSearchDeps): AgentTool {
414447
deps.awaitPendingConnections,
415448
TOOL_SEARCH_RECONNECT_WAIT_MS,
416449
);
417-
names = deps.search(query);
450+
names = deps.search(query, limit);
418451
}
419452
if (names.length === 0 && (stillPending ?? 1) > 0) {
420453
const detail =
@@ -429,14 +462,13 @@ export function createToolSearchTool(deps: ToolSearchDeps): AgentTool {
429462
if (names.length === 0) {
430463
return `No tools matched "${query}". Try different keywords describing the capability.`;
431464
}
432-
// Matches open on the call gate and join the next infer's wire set
433-
// (promoter flushes at activate). The card is names + capped
434-
// descriptions only — full schemas ride the next tools array.
435-
deps.promote(names);
465+
// Cards only — name + capped description. Search does not open the call
466+
// gate or grow the tools array; promote-on-execute declares a name when
467+
// the model actually calls it.
436468
const blocks = names.map((name) =>
437469
renderToolCard(deps.lookup(name), name),
438470
);
439-
return `Promoted onto the next inference tool list. Call them on the next turn:\n\n${blocks.join("\n")}`;
471+
return `Matching tools — call a listed name to use it:\n\n${blocks.join("\n")}`;
440472
},
441473
});
442474
}

‎src/agent/tools.ts‎

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ export interface AgentToolsetArgs {
291291
/**
292292
* Closed allow list (exec director overlays). tool_search is mounted only
293293
* when the allow includes it, and the search index only surfaces allowed
294-
* tools so search cannot promote outside the allow. Omit for the product
294+
* tools so search cannot list names outside the allow. Omit for the product
295295
* default (tool_search mounted, index over the live registry).
296296
*/
297297
toolSearchAllow?: readonly string[];
@@ -378,8 +378,9 @@ export interface AgentToolset {
378378
// Catalog unshadow can change local → global/none without rebuilding the
379379
// toolset; connectOne reads this on every late connect.
380380
setMcpServersSource: (source: "local" | "global" | "none") => void;
381-
// Wire the callback the `tool_search` tool invokes to make matched tools
382-
// advertised. Set by the runner once the director + reload loop exist.
381+
// Wire the session promoter used for promote-on-execute (a registered
382+
// undeclared call declares that one name, then dispatches). Set by the
383+
// runner once the director + reload loop exist.
383384
setToolPromoter: (promote: (names: string[]) => void) => void;
384385
// Session-start skill snapshot shared with the prompt listing.
385386
skills: SkillSummary[];
@@ -782,11 +783,9 @@ export async function createAgentToolset(
782783
}),
783784
];
784785

785-
// tool_search ranks over the live runner (set just below) and promotes matches
786-
// through a holder the runner wires up once its advertise/reload loop exists.
787-
const promoter: { promote: (names: string[]) => void } = {
788-
promote: () => undefined,
789-
};
786+
// tool_search ranks over the live runner (set just below). Matches stay
787+
// cards-only; the session promoter (wired once advertise/reload exists)
788+
// declares a name when the model actually calls it.
790789
const runnerHolder: { current?: DynamicToolRunner } = {};
791790
const toolIndex = createToolIndex(
792791
() => runnerHolder.current?.currentDefinitions() ?? [],
@@ -795,19 +794,18 @@ export async function createAgentToolset(
795794
);
796795
// Closed exec allow lists omit tool_search itself (leaf posture); when the
797796
// allow excludes it the tool is never mounted, so there is nothing to
798-
// search with and nothing the promoter can activate.
797+
// search with.
799798
if (
800799
args.toolSearchAllow === undefined ||
801800
args.toolSearchAllow.includes(toolSearchDefinition.name)
802801
) {
803802
baseTools.push(
804803
createToolSearchTool({
805-
search: (query) => toolIndex.search(query),
804+
search: (query, limit) => toolIndex.search(query, limit),
806805
lookup: (name) =>
807806
runnerHolder.current
808807
?.currentDefinitions()
809808
.find((d) => d.name === name),
810-
promote: (names) => promoter.promote(names),
811809
// Misses wait briefly for in-flight MCP handshakes (bounded, so hung
812810
// OAuth cannot hang the call) and re-search before answering. Reads the
813811
// connection map live — declared below, populated by the time any
@@ -1608,7 +1606,7 @@ export async function createAgentToolset(
16081606
mcpServersSource = source;
16091607
},
16101608
setToolPromoter: (promote) => {
1611-
promoter.promote = promote;
1609+
dynamicRunner.setOnUndeclaredCall((name) => promote([name]));
16121610
},
16131611
skills,
16141612
...(fleetRecords !== undefined ? { fleetRecords } : {}),

‎src/exec/runner.test.ts‎

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -672,12 +672,12 @@ describe("exec tool call gate and promoter", () => {
672672
flushPromotions();
673673
},
674674
});
675+
runner.setOnUndeclaredCall((name) => promote([name]));
675676
const search = createToolSearchTool({
676677
search: (query) =>
677678
createToolIndex(() => runner.currentDefinitions()).search(query),
678679
lookup: (name) =>
679680
runner.currentDefinitions().find((d) => d.name === name),
680-
promote,
681681
});
682682
return {
683683
runner,
@@ -698,41 +698,57 @@ describe("exec tool call gate and promoter", () => {
698698
);
699699
}
700700

701-
test("tool_search then MCP dispatch with the gate on", async () => {
701+
test("search does not change the advertised set", async () => {
702702
const { runner, search, persistCount, computeAdvertised } =
703703
wireExecDiscovery();
704-
const blocked = await dispatch(runner, "mcp__linear__save_issue");
705-
expect(blocked.isError).toBe(true);
706-
expect(blocked.content).toContain("tool_search");
704+
const before = computeAdvertised(runner.currentDefinitions()).map(
705+
(d) => d.name,
706+
);
707+
expect(before).not.toContain("mcp__linear__save_issue");
707708

708709
if (search.kind !== "string") throw new Error("expected string tool");
709710
await search.handler({ query: "linear" }, new AbortController().signal);
710-
expect(persistCount()).toBe(1);
711-
712-
const allowed = await dispatch(runner, "mcp__linear__save_issue");
713-
expect(allowed.content).toBe("saved");
714-
expect(allowed.isError).toBeUndefined();
711+
expect(persistCount()).toBe(0);
715712
expect(
716713
computeAdvertised(runner.currentDefinitions()).map((d) => d.name),
717-
).toContain("mcp__linear__save_issue");
714+
).toEqual(before);
718715
});
719716

720-
test("present and plugin names pass the gate after tool_search promote", async () => {
721-
const { runner, search } = wireExecDiscovery();
722-
expect((await dispatch(runner, "present")).isError).toBe(true);
723-
expect((await dispatch(runner, "plugin__notes__save")).isError).toBe(true);
724-
717+
test("a subsequent call to a searched-but-not-yet-declared name promotes only that name", async () => {
718+
const { runner, search, computeAdvertised } = wireExecDiscovery();
725719
if (search.kind !== "string") throw new Error("expected string tool");
720+
await search.handler({ query: "linear" }, new AbortController().signal);
726721
await search.handler(
727722
{ query: "render layout" },
728723
new AbortController().signal,
729724
);
730-
await search.handler(
731-
{ query: "granola notes" },
732-
new AbortController().signal,
725+
const afterSearch = computeAdvertised(runner.currentDefinitions()).map(
726+
(d) => d.name,
727+
);
728+
expect(afterSearch).not.toContain("mcp__linear__save_issue");
729+
expect(afterSearch).not.toContain("present");
730+
731+
const allowed = await dispatch(runner, "mcp__linear__save_issue");
732+
expect(allowed.content).toBe("saved");
733+
expect(allowed.isError).toBeUndefined();
734+
const names = computeAdvertised(runner.currentDefinitions()).map(
735+
(d) => d.name,
733736
);
737+
expect(names).toContain("mcp__linear__save_issue");
738+
expect(names).not.toContain("present");
739+
expect(names).not.toContain("plugin__notes__save");
740+
});
734741

742+
test("present and plugin names promote only the called name", async () => {
743+
const { runner, computeAdvertised } = wireExecDiscovery();
735744
expect((await dispatch(runner, "present")).content).toBe("view");
745+
expect(
746+
computeAdvertised(runner.currentDefinitions()).map((d) => d.name),
747+
).toContain("present");
748+
expect(
749+
computeAdvertised(runner.currentDefinitions()).map((d) => d.name),
750+
).not.toContain("plugin__notes__save");
751+
736752
expect((await dispatch(runner, "plugin__notes__save")).content).toBe(
737753
"noted",
738754
);

‎src/exec/runner.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
813813
computeAdvertised,
814814
isAdvertised,
815815
flushPromotions,
816+
pruneIdlePromotions,
816817
} = createAdvertisedToolset({
817818
sessionMode,
818819
toolAvailability,
@@ -870,9 +871,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
870871
),
871872
telemetry: liveTelemetry,
872873
onFolded: () => {
873-
// Fold restarts the cached prefix, so catch promotions still
874-
// pending. Search already flushed names onto the next infer.
875-
if (flushPromotions()) {
874+
// Fold restarts the cached prefix — drop idle execute-promoted
875+
// schemas rather than carrying them forever.
876+
if (pruneIdlePromotions()) {
876877
directorHolder.instance?.updateToolDefinitions(
877878
computeAdvertised(
878879
agentToolset.dynamicRunner.currentDefinitions(),
@@ -895,10 +896,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
895896
evidenceArchiveHolder,
896897
});
897898

898-
// tool_search starts as a no-op promoter; without this, the call gate
899-
// refuses MCP/present/plugin names the result just told the model to
900-
// invoke. Under a closed overlay the promoter only activates allowed
901-
// names, so outside-allow tools can never become advertised or callable.
899+
// Promote-on-execute: an undeclared registered call declares that one
900+
// name, then dispatches. Under a closed overlay the promoter only
901+
// activates allowed names, so outside-allow tools stay refused.
902902
const commitPromotedWire = (): void => {
903903
if (flushPromotions()) {
904904
directorHolder.instance?.updateToolDefinitions(

0 commit comments

Comments
 (0)