Skip to content

Commit 77b566a

Browse files
committed
fix(agent): put tool_search promotions on the next infer
Holding promoted names off the wire until compact left the model unable to emit them. Search cards now rank a small N of names with capped descriptions so the card cannot say call them now.
1 parent 445886d commit 77b566a

17 files changed

Lines changed: 242 additions & 280 deletions

‎docs/MCP.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,10 @@ global or local `exa` entry disables or overrides it as described above.
9292
Tools from connected servers are not advertised to the model up front; they are
9393
registered for dispatch as soon as the server connects (including later in the
9494
same turn) and surfaced on demand through dynamic tool discovery
95-
(`tool_search`). Names activated via `tool_search` persist in the session's
96-
`run.json` and are re-advertised on resume and after rebuilds.
95+
(`tool_search`). A match is a ranked handful of names plus capped
96+
descriptions — not full input schemas. Names activated via `tool_search`
97+
join the next inference tool list (no wait for compact), persist in the
98+
session's `run.json`, and are re-advertised on resume and after rebuilds.
9799

98100
For integrations a project calls constantly, `pinnedTools` in local
99101
`.corbits/settings.json` keeps those names on the wire permanently — no

‎src/agent/lexical-rank.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,23 @@ export function scoreLexical(
4545
// search_agents, and skill_search. Per-surface scoring (and the agent
4646
// description bonus) stays at each call site; only the cut is shared. The
4747
// sort is score-descending and stable, so tied scores keep catalog order
48-
// identically on every surface.
48+
// identically on every surface. `minRatio` (0–1) drops scores below
49+
// top * minRatio before the limit slice, so a specific query keeps the
50+
// peak cluster instead of filling the N cap with weak cousins.
4951
export function rankAndCut<T>(
5052
items: readonly T[],
5153
scoreItem: (item: T) => number,
5254
limit: number,
55+
minRatio?: number,
5356
): T[] {
54-
return items
57+
const ranked = items
5558
.map((item) => ({ item, score: scoreItem(item) }))
5659
.filter((entry) => entry.score > 0)
57-
.sort((a, b) => b.score - a.score)
60+
.sort((a, b) => b.score - a.score);
61+
const top = ranked[0]?.score ?? 0;
62+
const floor = minRatio === undefined || top === 0 ? 0 : top * minRatio;
63+
return ranked
64+
.filter((entry) => entry.score >= floor)
5865
.slice(0, limit)
5966
.map((entry) => entry.item);
6067
}

‎src/agent/search-scorer-parity.test.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,18 @@ async function skillOrder(query: string): Promise<string[]> {
6565
describe("search scorer parity", () => {
6666
test("tool, skill, and agent search rank one catalog the same way", async () => {
6767
const expected = ["granola-notes", "mygranolahoard", "notebook"];
68-
expect(createToolIndex(() => tools, []).search(QUERY)).toEqual(expected);
69-
expect(await skillOrder(QUERY)).toEqual(expected);
70-
expect(
71-
createAgentIndex(() => agents)
72-
.search(QUERY)
73-
.map((profile) => profile.id),
74-
).toEqual(expected);
68+
const skillHits = await skillOrder(QUERY);
69+
const agentHits = createAgentIndex(() => agents)
70+
.search(QUERY)
71+
.map((profile) => profile.id);
72+
expect(skillHits).toEqual(expected);
73+
expect(agentHits).toEqual(expected);
74+
// tool_search uses the same ranker, then keeps only the peak cluster
75+
// so a specific query does not fill the N cap with weak cousins.
76+
const toolHits = createToolIndex(() => tools, []).search(QUERY);
77+
expect(toolHits[0]).toBe(expected[0]);
78+
expect(expected.slice(0, toolHits.length)).toEqual(toolHits);
79+
expect(toolHits.length).toBeGreaterThan(0);
80+
expect(toolHits.length).toBeLessThanOrEqual(expected.length);
7581
});
7682
});

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

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,45 @@ const defs: ToolDefinition[] = [
5656

5757
const index = createToolIndex(() => defs);
5858

59+
function mcpDef(
60+
name: string,
61+
description: string,
62+
extraSchema?: Record<string, unknown>,
63+
): ToolDefinition {
64+
return {
65+
name,
66+
description,
67+
inputSchema: {
68+
type: "object",
69+
properties: extraSchema ?? {},
70+
required: [],
71+
},
72+
};
73+
}
74+
75+
const linearCatalog: ToolDefinition[] = [
76+
mcpDef("mcp__linear__list_issues", "List issues in Linear"),
77+
mcpDef("mcp__linear__get_issue", "Get a Linear issue"),
78+
mcpDef("mcp__linear__save_issue", "Save a Linear issue"),
79+
mcpDef("mcp__linear__create_issue", "Create a Linear issue"),
80+
mcpDef("mcp__linear__delete_issue", "Delete a Linear issue"),
81+
mcpDef("mcp__linear__update_issue", "Update a Linear issue"),
82+
mcpDef("mcp__linear__archive_issue", "Archive a Linear issue"),
83+
mcpDef("mcp__linear__assign_issue", "Assign a Linear issue"),
84+
mcpDef("mcp__linear__label_issue", "Label a Linear issue"),
85+
mcpDef("mcp__linear__list_comments", "List comments on a Linear issue"),
86+
mcpDef("mcp__linear__save_comment", "Save a comment on a Linear issue"),
87+
mcpDef("mcp__linear__list_projects", "List Linear projects"),
88+
mcpDef("mcp__github__create_issue", "Create a GitHub issue"),
89+
mcpDef(
90+
"mcp__linear__verbose_issue",
91+
`${"A very long Linear issue description. ".repeat(20)}end-marker`,
92+
{ title: { type: "string" }, teamId: { type: "string" } },
93+
),
94+
];
95+
96+
const linearIndex = createToolIndex(() => linearCatalog);
97+
5998
describe("createToolIndex", () => {
6099
test("ranks a name-token match above a description-only match", () => {
61100
const results = index.search("present");
@@ -235,6 +274,29 @@ describe("createToolIndex", () => {
235274
expect(allowed.search("pages")).toContain("present");
236275
expect(allowed.search("linear")).not.toContain("mcp__linear__create_issue");
237276
});
277+
278+
test("ranks a save-issue query above list-issue tools", () => {
279+
const ranked = linearIndex.search("linear issue save");
280+
expect(ranked[0]).toBe("mcp__linear__save_issue");
281+
const listPos = ranked.indexOf("mcp__linear__list_issues");
282+
if (listPos !== -1) {
283+
expect(ranked.indexOf("mcp__linear__save_issue")).toBeLessThan(listPos);
284+
}
285+
});
286+
287+
test("caps a broad linear-issue query to a handful of top matches", () => {
288+
const ranked = linearIndex.search("linear issue");
289+
expect(ranked.length).toBeGreaterThan(1);
290+
expect(ranked.length).toBeLessThanOrEqual(5);
291+
expect(ranked.every((name) => name.includes("linear"))).toBe(true);
292+
});
293+
294+
test("a specific save query returns one or two tools, not the whole family", () => {
295+
const ranked = linearIndex.search("linear issue save");
296+
expect(ranked).toContain("mcp__linear__save_issue");
297+
expect(ranked.length).toBeGreaterThanOrEqual(1);
298+
expect(ranked.length).toBeLessThanOrEqual(2);
299+
});
238300
});
239301

240302
function call(
@@ -268,21 +330,26 @@ describe("createToolSearchTool", () => {
268330
expect(out).toContain("layout");
269331
});
270332

271-
test("surfaces a matched tool's input schema so the model can shape arguments", async () => {
333+
test("returns name and capped description without a full input schema", async () => {
272334
const tool = createToolSearchTool({
273-
search: (q) => index.search(q),
274-
lookup: (name) => defs.find((d) => d.name === name),
335+
search: (q) => linearIndex.search(q),
336+
lookup: (name) => linearCatalog.find((d) => d.name === name),
275337
promote: () => undefined,
276338
});
277-
const out = await call(tool, { query: "issue tracker" });
278-
expect(out).toContain("mcp__linear__create_issue");
279-
// Parameter names and the required list must appear — this is the whole
280-
// point: MCP tools are never in the wire tools array up front, so their
281-
// schema reaches the model through the tool_search result this same turn,
282-
// ahead of the promoted definition landing on the next infer call.
283-
expect(out).toContain("title");
284-
expect(out).toContain("teamId");
285-
expect(out).toContain("required");
339+
const out = await call(tool, { query: "linear issue" });
340+
expect(out).toContain("mcp__linear__");
341+
expect(out).not.toMatch(/input schema/i);
342+
expect(out).not.toContain("teamId");
343+
expect(out).not.toContain("end-marker");
344+
const descLines = out
345+
.split("\n")
346+
.filter((line) => line.startsWith("- mcp__"));
347+
expect(descLines.length).toBeGreaterThan(0);
348+
for (const line of descLines) {
349+
expect(line.length).toBeLessThanOrEqual(200);
350+
}
351+
expect(out).not.toMatch(/call them now/i);
352+
expect(out).toMatch(/next (turn|infer)/i);
286353
});
287354

288355
test("rejects an empty query", async () => {

‎src/agent/tool-search.ts‎

Lines changed: 26 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,8 @@ import { sessionModeEnablesSubAgents } from "../config/session-mode.js";
1414

1515
// Tools whose full schema is always advertised to the model. Everything else is
1616
// registered but discovered on demand via tool_search, which promotes matches
17-
// onto the call gate at once; the full schema joins the wire set at the next
18-
// cache-safe boundary. Shared by the system prompt and
19-
// the advertised-set gate so the two never drift.
17+
// onto the call gate and the next infer's wire set. Shared by the system prompt
18+
// and the advertised-set gate so the two never drift.
2019
//
2120
// `present` is deliberately absent: most sessions never render a view, and at
2221
// 2,793 chars it is the second-largest schema on the wire. It stays off the
@@ -149,10 +148,11 @@ export const ADVERTISED_TOOL_NAMES: readonly string[] = [
149148

150149
// Project the live tool registry onto the advertised set: the fixed built-in
151150
// prefix (its order never changes — this is what keeps the provider cache
152-
// prefix stable) followed by wire-committed tools (MCP or otherwise) in
153-
// first-commit order. The wire array is byte-stable turn to turn: callers pass
154-
// only names committed via flushPromotions at a cache-safe boundary, never the
155-
// live activation list, so a mid-session discovery cannot append here.
151+
// prefix stable across no-discovery turns) followed by wire-committed tools
152+
// (MCP or otherwise) in first-commit order. Callers pass names committed via
153+
// flushPromotions (on promote, and at cache-safe boundaries), never the live
154+
// activation list before a flush, so a mid-handler discovery cannot append
155+
// here until the promoter commits it.
156156
// `activated` is expected to already be deduped/ordered (see
157157
// `createActivatedToolTracker`), but names are deduped again here defensively
158158
// so a caller passing raw matches still can't reorder or duplicate an entry.
@@ -217,7 +217,7 @@ export function createActivatedToolTracker(): ActivatedToolTracker {
217217
export const toolSearchDefinition: ToolDefinition = {
218218
name: "tool_search",
219219
description:
220-
"Discover callable tools by capability. Most tools — MCP servers, present, and other integrations — are not callable until this search promotes them. Core tools (read_file, run_shell, 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 matching tools' names, descriptions, and input schemas. Matched tools are promoted and callable on return — invoke them directly, no separate load step.",
220+
"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_file, run_shell, 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.",
221221
inputSchema: {
222222
type: "object",
223223
properties: {
@@ -238,6 +238,10 @@ export interface ToolIndex {
238238
// Rank registered tools against name + description via the shared lexical
239239
// scorer. Already-advertised tools are always callable so they never rank;
240240
// the allow list (when set) keeps search from promoting outside it.
241+
export const TOOL_SEARCH_MAX_RESULTS = 5;
242+
export const TOOL_SEARCH_SCORE_RATIO = 0.75;
243+
export const TOOL_SEARCH_DESC_MAX = 160;
244+
241245
export function createToolIndex(
242246
getDefs: () => readonly ToolDefinition[],
243247
advertisedNames: readonly string[] = ADVERTISED_TOOL_NAMES,
@@ -257,7 +261,7 @@ export function createToolIndex(
257261
);
258262

259263
return {
260-
search(query: string, limit = 8): string[] {
264+
search(query: string, limit = TOOL_SEARCH_MAX_RESULTS): string[] {
261265
const rawQuery = query.toLowerCase().trim();
262266
const queryTokens = tokenizeLexical(query);
263267
if (queryTokens.length === 0) return [];
@@ -268,6 +272,7 @@ export function createToolIndex(
268272
candidates,
269273
(def) => score(def, queryTokens, rawQuery),
270274
limit,
275+
TOOL_SEARCH_SCORE_RATIO,
271276
).map((def) => def.name);
272277
},
273278
};
@@ -276,9 +281,7 @@ export function createToolIndex(
276281
export interface ToolSearchDeps {
277282
search: (query: string) => string[];
278283
lookup: (name: string) => ToolDefinition | undefined;
279-
// Promote matches onto the call gate so the model can invoke them this turn
280-
// from the result card's schema. Wire declaration follows at the next
281-
// cache-safe boundary (compaction fold), never mid-thread.
284+
// Promote matches onto the call gate and the next infer's wire set.
282285
promote: (names: string[]) => void;
283286
// Resolves to the remaining in-flight MCP handshake count after waiting up
284287
// to `timeoutMs`. The toolset bounds its own wait; the handler re-races
@@ -311,22 +314,15 @@ export const TOOL_SEARCH_RECONNECT_WAIT_MS = 500;
311314

312315
const ToolSearchArgs = type({ query: "string" });
313316

314-
// Render one discovered tool as name, description, and pretty-printed input
315-
// schema. The schema is the load-bearing addition: MCP and other unadvertised
316-
// tools never appear in the wire tools array, so this is the model's only view
317-
// of their parameter names, types, and required fields.
318-
function renderToolCard(def: ToolDefinition | undefined, name: string): string {
319-
if (def === undefined) return `- ${name}`;
320-
const header = `- ${def.name}: ${def.description ?? ""}`;
321-
const schema = JSON.stringify(def.inputSchema ?? {}, null, 2);
322-
return `${header}\n input schema:\n${indent(schema, " ")}`;
317+
function capDescription(text: string, max = TOOL_SEARCH_DESC_MAX): string {
318+
const oneLine = text.replace(/\s+/g, " ").trim();
319+
if (oneLine.length <= max) return oneLine;
320+
return `${oneLine.slice(0, max - 1)}…`;
323321
}
324322

325-
function indent(text: string, pad: string): string {
326-
return text
327-
.split("\n")
328-
.map((line) => `${pad}${line}`)
329-
.join("\n");
323+
function renderToolCard(def: ToolDefinition | undefined, name: string): string {
324+
if (def === undefined) return `- ${name}`;
325+
return `- ${def.name}: ${capDescription(def.description ?? "")}`;
330326
}
331327

332328
// Race the dependency's pending-count wait against a bound, so a
@@ -398,17 +394,14 @@ export function createToolSearchTool(deps: ToolSearchDeps): AgentTool {
398394
if (names.length === 0) {
399395
return `No tools matched "${query}". Try different keywords describing the capability.`;
400396
}
401-
// Matches open on the call gate at once; the full schema joins the wire
402-
// declarations at the next cache-safe boundary (compaction fold), never
403-
// mid-thread, so the provider's cached prefix stays byte-stable. The
404-
// tool result below still carries name, description, AND input schema
405-
// so the model can shape arguments and call this same turn, before the
406-
// promoted definition is declared on the wire.
397+
// Matches open on the call gate and join the next infer's wire set
398+
// (promoter flushes at activate). The card is names + capped
399+
// descriptions only — full schemas ride the next tools array.
407400
deps.promote(names);
408401
const blocks = names.map((name) =>
409402
renderToolCard(deps.lookup(name), name),
410403
);
411-
return `These tools are available — you can call them now:\n\n${blocks.join("\n\n")}`;
404+
return `Promoted onto the next inference tool list. Call them on the next turn:\n\n${blocks.join("\n")}`;
412405
},
413406
});
414407
}

0 commit comments

Comments
 (0)