Skip to content

Commit 7ca090b

Browse files
committed
perf(tools): hold promoted tools off the wire until a safe boundary
Activating a tool mid-session used to grow the advertised set on the next turn, re-prefilling the provider's cached prompt prefix. Promotion now opens only the call gate, so the model can invoke the tool at once, while the wire set stays byte-identical until rotation, compaction, or session start commits it.
1 parent 9fdbc4f commit 7ca090b

11 files changed

Lines changed: 280 additions & 123 deletions

File tree

‎src/agent/director.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ const logger = getLogger([LOG_NAMESPACE_ROOT, "agent", "director"]);
6262
// otherwise invisible — it shows up only as a billing and latency spike a turn
6363
// later. Hashed rather than logged verbatim: MCP tool descriptions are
6464
// arbitrary-length, server-supplied text and do not belong in the log stream.
65-
// See CL-7868.
6665
function toolSetDigest(tools: readonly ToolDefinition[]): string {
6766
const shape = tools.map((t) => `${t.name}:${t.description ?? ""}`).join("|");
6867
return createHash("sha256").update(shape).digest("hex").slice(0, 12);

‎src/agent/tool-search.ts‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { sessionModeEnablesSubAgents } from "../config/session-mode.js";
88

99
// Tools whose full schema is always advertised to the model. Everything else is
1010
// registered but discovered on demand via tool_search, which promotes matches
11-
// onto the advertised set and the call gate. Shared by the system prompt and
11+
// onto the call gate at once; the full schema joins the wire set at the next
12+
// cache-safe boundary. Shared by the system prompt and
1213
// the advertised-set gate so the two never drift.
1314
//
1415
// `present` is deliberately absent: most sessions never render a view, and at
@@ -142,10 +143,11 @@ export const ADVERTISED_TOOL_NAMES: readonly string[] = [
142143

143144
// Project the live tool registry onto the advertised set: the fixed built-in
144145
// prefix (its order never changes — this is what keeps the provider cache
145-
// prefix stable) followed by session-activated tools (MCP or otherwise) in
146-
// first-activation order. The wire array is byte-stable turn to turn until a
147-
// discovery appends a new name, at which point it grows once and then holds
148-
// steady again. `activated` is expected to already be deduped/ordered (see
146+
// prefix stable) followed by wire-committed tools (MCP or otherwise) in
147+
// first-commit order. The wire array is byte-stable turn to turn: callers pass
148+
// only names committed via flushPromotions at a cache-safe boundary, never the
149+
// live activation list, so a mid-session discovery cannot append here.
150+
// `activated` is expected to already be deduped/ordered (see
149151
// `createActivatedToolTracker`), but names are deduped again here defensively
150152
// so a caller passing raw matches still can't reorder or duplicate an entry.
151153
export function advertisedTools(
@@ -209,7 +211,7 @@ export function createActivatedToolTracker(): ActivatedToolTracker {
209211
export const toolSearchDefinition: ToolDefinition = {
210212
name: "tool_search",
211213
description:
212-
"Discover callable tools by capability. Most tools — MCP servers, present, and other integrations — are not advertised until this search promotes them onto the wire. 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.",
214+
"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.",
213215
inputSchema: {
214216
type: "object",
215217
properties: {
@@ -283,9 +285,9 @@ export function createToolIndex(
283285
export interface ToolSearchDeps {
284286
search: (query: string) => string[];
285287
lookup: (name: string) => ToolDefinition | undefined;
286-
// Promote matches onto the advertised set and the call gate so the model can
287-
// invoke them this turn. The next inference also declares them on the wire
288-
// for strict providers.
288+
// Promote matches onto the call gate so the model can invoke them this turn
289+
// from the result card's schema. Wire declaration follows at the next
290+
// cache-safe boundary (compaction fold), never mid-thread.
289291
promote: (names: string[]) => void;
290292
// Resolves to the remaining in-flight MCP handshake count after waiting up
291293
// to `timeoutMs`. The toolset bounds its own wait; the handler re-races
@@ -374,12 +376,12 @@ export function createToolSearchTool(deps: ToolSearchDeps): AgentTool {
374376
if (names.length === 0) {
375377
return `No tools matched "${query}". Try different keywords describing the capability.`;
376378
}
377-
// Matches are promoted into the advertised set so the next inference
378-
// declares them on the wire — required for strict providers (e.g. the grok
379-
// Responses API) where a model cannot call a tool that was never declared.
380-
// The tool result below still carries name, description, AND input schema
381-
// so the model can shape arguments this same turn, before the promoted
382-
// definition round-trips through the next infer call.
379+
// Matches open on the call gate at once; the full schema joins the wire
380+
// declarations at the next cache-safe boundary (compaction fold), never
381+
// mid-thread, so the provider's cached prefix stays byte-stable. The
382+
// tool result below still carries name, description, AND input schema
383+
// so the model can shape arguments and call this same turn, before the
384+
// promoted definition is declared on the wire.
383385
deps.promote(names);
384386
const blocks = names.map((name) =>
385387
renderToolCard(deps.lookup(name), name),

‎src/director.test.ts‎

Lines changed: 71 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import { describe, test, expect } from "bun:test";
22
import { createChatDirector, askOperatorDefinition } from "./agent/director.js";
33
import { createAgentToolset } from "./agent/tools.js";
4-
import {
5-
advertisedTools,
6-
createActivatedToolTracker,
7-
} from "./agent/tool-search.js";
84
import { createAdvertisedToolset } from "./session/assemble-runtime.js";
95
import { createPermissionGate } from "./permission/gate.js";
106
import {
@@ -1146,12 +1142,13 @@ describe("updateToolDefinitions rewrites infer tools", () => {
11461142
expect(JSON.stringify(after)).toBe(JSON.stringify(before));
11471143
});
11481144

1149-
// CL-7868: the provider cache is a prefix cache keyed on the tools array,
1150-
// so a tool_search turn that promotes a genuinely new tool must not reshape
1151-
// the wire set mid-session. The call gate opens (the model invokes the tool
1152-
// from the search result's schema) while the advertised array holds steady
1153-
// until the next cache-safe boundary.
1154-
test("a tool_search turn promoting a genuinely new tool leaves the wire byte-identical", async () => {
1145+
// CL-7868 (direction A): the provider cache is a prefix cache keyed on the
1146+
// tools array, so a tool_search turn that promotes a genuinely new tool must
1147+
// not reshape the wire set mid-session. The call gate opens (the model
1148+
// invokes the tool from the search result's schema) while the advertised
1149+
// array holds steady; the growth event lands at the next cache-safe
1150+
// boundary (compaction fold), appended after the untouched fixed prefix.
1151+
test("a tool_search turn promoting a genuinely new tool leaves the wire byte-identical until the fold commits it", async () => {
11551152
const linearTool = {
11561153
name: "mcp__linear__list_issues",
11571154
description: "list issues",
@@ -1188,19 +1185,47 @@ describe("updateToolDefinitions rewrites infer tools", () => {
11881185
);
11891186

11901187
// tool_search matched a genuinely new tool: the runner opens the call
1191-
// gate and refreshes the director exactly as it does today.
1192-
advertised.activated.activate(["mcp__linear__list_issues"]);
1188+
// gate (activation); the per-turn wire recompute deliberately ignores it.
1189+
expect(advertised.activated.activate(["mcp__linear__list_issues"])).toBe(
1190+
true,
1191+
);
11931192
director.updateToolDefinitions(
11941193
advertised.computeAdvertised(toolset.dynamicRunner.currentDefinitions()),
11951194
);
11961195

1196+
// Mid-session the wire is byte-identical — the hot prefix never grows —
1197+
// while the gate is open so the model can invoke the match from the
1198+
// result card's schema.
11971199
const after = await firstInferTools(
11981200
director,
11991201
makeMessageReceivedEvent("continue"),
12001202
);
12011203
expect(JSON.stringify(after)).toBe(JSON.stringify(before));
12021204
expect(advertised.isAdvertised("mcp__linear__list_issues")).toBe(true);
12031205

1206+
// Cache-safe boundary (compaction fold): the pending promotion commits and
1207+
// the next turn declares it after the untouched fixed prefix.
1208+
expect(advertised.flushPromotions()).toBe(true);
1209+
director.updateToolDefinitions(
1210+
advertised.computeAdvertised(toolset.dynamicRunner.currentDefinitions()),
1211+
);
1212+
const folded = await firstInferTools(
1213+
director,
1214+
makeMessageReceivedEvent("after fold"),
1215+
);
1216+
const foldedNames = (folded as { name: string }[]).map((t) => t.name);
1217+
expect(foldedNames).toContain("mcp__linear__list_issues");
1218+
const beforeNames = (before as { name: string }[])
1219+
.map((t) => t.name)
1220+
.filter((n) => n !== "submit_output");
1221+
const foldedPrefix = foldedNames.filter(
1222+
(n) => n !== "submit_output" && n !== "mcp__linear__list_issues",
1223+
);
1224+
expect(foldedPrefix).toEqual(beforeNames);
1225+
expect(foldedNames.indexOf("mcp__linear__list_issues")).toBe(
1226+
beforeNames.length,
1227+
);
1228+
12041229
await toolset.dispose();
12051230
});
12061231

@@ -1224,12 +1249,13 @@ describe("updateToolDefinitions rewrites infer tools", () => {
12241249
expect(inferToolNames(inferAction)).toContain("submit_output");
12251250
});
12261251

1227-
// End-to-end: tool_search matches an MCP tool, the runner's promote wiring
1228-
// (mirrored here via createActivatedToolTracker + updateToolDefinitions) grows
1229-
// the wire set once with the tool's full definition, and it then holds steady.
1230-
// On a strict provider, a model can only call a tool
1231-
// that was actually declared on the wire, so promotion must land here.
1232-
test("a tool_search match is on the wire on the next turn, then the array holds stable", async () => {
1252+
// End-to-end: tool_search matches an MCP tool; the runner's gate-only promote
1253+
// wiring (mirrored here via createAdvertisedToolset + updateToolDefinitions)
1254+
// keeps it off the wire the next turn — the hot prefix stays byte-identical
1255+
// while the gate lets the model call it from the result card. The fold
1256+
// commits the pending promotion with the tool's full definition, appended
1257+
// after the untouched fixed prefix, and the array then holds steady.
1258+
test("a tool_search match stays off the wire until the fold, then holds stable", async () => {
12331259
const linearTool = {
12341260
name: "mcp__linear__list_issues",
12351261
description: "list issues",
@@ -1249,10 +1275,14 @@ describe("updateToolDefinitions rewrites infer tools", () => {
12491275
{ kind: "string", definition: linearTool, handler: async () => "ok" },
12501276
]);
12511277

1252-
const activated = createActivatedToolTracker();
1278+
const advertised = createAdvertisedToolset({
1279+
sessionMode: "orchestrator",
1280+
toolAvailability: { languageServerAvailable: false },
1281+
getProvider: () => ({ providerName: "openai", model: "gpt-5" }),
1282+
});
12531283
const computeAdvertised = (
12541284
all: ReturnType<typeof toolset.dynamicRunner.currentDefinitions>,
1255-
) => advertisedTools(all, activated.list());
1285+
) => advertised.computeAdvertised(all);
12561286
const director = createChatDirector(
12571287
"base-prompt",
12581288
computeAdvertised(toolset.dynamicRunner.currentDefinitions()),
@@ -1268,17 +1298,34 @@ describe("updateToolDefinitions rewrites infer tools", () => {
12681298
expect(beforeNames).not.toContain("mcp__linear__list_issues");
12691299
const beforeJson = JSON.stringify(before);
12701300

1271-
// Simulate the runner's promoteTools: tool_search matched this tool, so it
1272-
// is activated and the director's tool set is updated for the next infer.
1273-
activated.activate(["mcp__linear__list_issues"]);
1301+
// Simulate the runner's gate-only promoter plus its boundary wire push:
1302+
// the gate opens but the wire recompute is byte-identical until the fold.
1303+
advertised.activated.activate(["mcp__linear__list_issues"]);
12741304
director.updateToolDefinitions(
12751305
computeAdvertised(toolset.dynamicRunner.currentDefinitions()),
12761306
);
12771307

1278-
const after = await firstInferTools(
1308+
const gated = await firstInferTools(
12791309
director,
12801310
makeMessageReceivedEvent("continue"),
12811311
);
1312+
expect((gated as { name: string }[]).map((t) => t.name)).not.toContain(
1313+
"mcp__linear__list_issues",
1314+
);
1315+
expect(JSON.stringify(gated)).toBe(beforeJson);
1316+
1317+
// Simulate the compaction fold: commit the pending promotion, push the
1318+
// refreshed set, and the tool is declared — appended after the fixed
1319+
// built-in prefix, which survives untouched ahead of it.
1320+
advertised.flushPromotions();
1321+
director.updateToolDefinitions(
1322+
computeAdvertised(toolset.dynamicRunner.currentDefinitions()),
1323+
);
1324+
1325+
const after = await firstInferTools(
1326+
director,
1327+
makeMessageReceivedEvent("after fold"),
1328+
);
12821329
const afterTools = after as { name: string }[];
12831330
const afterNames = afterTools.map((t) => t.name);
12841331
expect(afterNames).toContain("mcp__linear__list_issues");

‎src/exec/runner.test.ts‎

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,15 @@ describe("exec director allowlist", () => {
6363
test("promote cannot make an outside-allow tool callable under explorer", () => {
6464
const overlay = resolveExecDirectorOverlay("explorer");
6565
expect(isExecOverlayToolAllowed(overlay, OUTSIDE_ALLOW)).toBe(false);
66-
const { activated, computeAdvertised, isAdvertised } =
67-
createAdvertisedToolset({
68-
sessionMode: "orchestrator",
69-
toolAvailability: { languageServerAvailable: true },
70-
getProvider: () => ({ providerName: "test", model: "test-model" }),
71-
builtInPrefix: overlay.advertisedAllow,
72-
});
66+
const { activated, isAdvertised } = createAdvertisedToolset({
67+
sessionMode: "orchestrator",
68+
toolAvailability: { languageServerAvailable: true },
69+
getProvider: () => ({ providerName: "test", model: "test-model" }),
70+
builtInPrefix: overlay.advertisedAllow,
71+
});
7372
const promote = createExecToolPromoter({
7473
activate: (names) => activated.activate(names),
7574
isAllowed: (name) => isExecOverlayToolAllowed(overlay, name),
76-
currentDefinitions: () => [],
77-
computeAdvertised,
78-
updateDirectorTools: () => undefined,
7975
});
8076
promote([OUTSIDE_ALLOW]);
8177
expect(activated.has(OUTSIDE_ALLOW)).toBe(false);
@@ -85,28 +81,39 @@ describe("exec director allowlist", () => {
8581
).toBe(false);
8682
});
8783

88-
test("the promoter gates allow itself — a raw activate caller gets no bypass", () => {
84+
test("the promoter gates allow itself and leaves the wire set for the fold", () => {
8985
const overlay = resolveExecDirectorOverlay("explorer");
90-
const { activated, computeAdvertised } = createAdvertisedToolset({
91-
sessionMode: "orchestrator",
92-
toolAvailability: { languageServerAvailable: true },
93-
getProvider: () => ({ providerName: "test", model: "test-model" }),
94-
builtInPrefix: overlay.advertisedAllow,
95-
});
96-
let advertisedCount = 0;
86+
const { activated, computeAdvertised, flushPromotions } =
87+
createAdvertisedToolset({
88+
sessionMode: "orchestrator",
89+
toolAvailability: { languageServerAvailable: true },
90+
getProvider: () => ({ providerName: "test", model: "test-model" }),
91+
builtInPrefix: overlay.advertisedAllow,
92+
});
9793
const promote = createExecToolPromoter({
9894
activate: (names) => activated.activate(names),
9995
isAllowed: (name) => isExecOverlayToolAllowed(overlay, name),
100-
currentDefinitions: () => [],
101-
computeAdvertised,
102-
updateDirectorTools: () => {
103-
advertisedCount += 1;
104-
},
10596
});
97+
const registry = [
98+
{
99+
name: "read_file",
100+
description: "read a file",
101+
inputSchema: { type: "object", properties: {} },
102+
},
103+
];
104+
const before = JSON.stringify(computeAdvertised(registry));
105+
// A raw activate caller gets no bypass: outside-allow names never open the
106+
// gate, while the allowed match opens it at once.
106107
promote([OUTSIDE_ALLOW, "read_file"]);
107108
expect(activated.has(OUTSIDE_ALLOW)).toBe(false);
108109
expect(activated.has("read_file")).toBe(true);
109-
expect(advertisedCount).toBe(1);
110+
// Gate-only: the wire recompute ignores the fresh activation, so the
111+
// provider's cached prefix holds until the fold commits it.
112+
expect(JSON.stringify(computeAdvertised(registry))).toBe(before);
113+
expect(flushPromotions()).toBe(true);
114+
expect(computeAdvertised(registry).map((d) => d.name)).toContain(
115+
"read_file",
116+
);
110117
});
111118

112119
test("skywalker overlay leaves every tool allowed", () => {

‎src/exec/runner.ts‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ import type {
4848
ContextStore,
4949
InferenceSource,
5050
InboundMessage,
51-
ToolDefinition,
5251
} from "@intx/types/runtime";
5352
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
5453
import { loadAgentProfiles } from "../agent/profiles.js";
@@ -403,14 +402,14 @@ export function createExecToolCallGate(
403402
export function createExecToolPromoter(args: {
404403
activate: (names: readonly string[]) => boolean;
405404
isAllowed: (name: string) => boolean;
406-
currentDefinitions: () => readonly ToolDefinition[];
407-
computeAdvertised: (all: readonly ToolDefinition[]) => ToolDefinition[];
408-
updateDirectorTools: (defs: ToolDefinition[]) => void;
409405
persist?: () => void;
410406
}): (names: string[]) => void {
411407
return (names) => {
408+
// Gate-only, like the TUI promoteTools: activation lets the model invoke
409+
// the match from the result card's schema at once, while the schema joins
410+
// the wire set at the next cache-safe boundary (compaction fold) so the
411+
// provider's cached prefix never grows mid-thread (CL-7868).
412412
if (!args.activate(names.filter((name) => args.isAllowed(name)))) return;
413-
args.updateDirectorTools(args.computeAdvertised(args.currentDefinitions()));
414413
args.persist?.();
415414
};
416415
}
@@ -818,6 +817,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
818817
activated: activatedToolNames,
819818
computeAdvertised,
820819
isAdvertised,
820+
flushPromotions,
821821
} = createAdvertisedToolset({
822822
sessionMode,
823823
toolAvailability,
@@ -879,6 +879,17 @@ export async function runExec(config: Config): Promise<ExecResult> {
879879
return tools.length > 0 ? { activatedTools: tools } : undefined;
880880
},
881881
telemetry: liveTelemetry,
882+
onFolded: () => {
883+
// A fold restarts the provider's cached prefix anyway: commit
884+
// mid-session promotions so the next turn declares them.
885+
if (flushPromotions()) {
886+
directorHolder.instance?.updateToolDefinitions(
887+
computeAdvertised(
888+
agentToolset.dynamicRunner.currentDefinitions(),
889+
),
890+
);
891+
}
892+
},
882893
}),
883894
onBuilt: (agent, storage) => {
884895
currentAgent = agent;
@@ -895,11 +906,6 @@ export async function runExec(config: Config): Promise<ExecResult> {
895906
createExecToolPromoter({
896907
activate: (names) => activatedToolNames.activate(names),
897908
isAllowed: (name) => isExecOverlayToolAllowed(overlay, name),
898-
currentDefinitions: () =>
899-
agentToolset.dynamicRunner.currentDefinitions(),
900-
computeAdvertised,
901-
updateDirectorTools: (defs) =>
902-
directorHolder.instance?.updateToolDefinitions(defs),
903909
persist: () => {
904910
void persist("running");
905911
},

0 commit comments

Comments
 (0)