Skip to content

Commit f4256a4

Browse files
Merge pull request #1027 from corbitsdev/cl-7868-any-mid-session-tool-set-change-costs-a-full-prompt-cache
Hold newly promoted tools off the wire until a cache-safe boundary
2 parents d139517 + cb5db51 commit f4256a4

13 files changed

Lines changed: 429 additions & 115 deletions

src/agent/director.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
ReactorInboundEvent,
66
ReactorState,
77
} from "@intx/types/runtime";
8-
import { createChatDirector } from "./director.js";
8+
import { createChatDirector, toolSetDigest } from "./director.js";
99

1010
const mockState: ReactorState = { turns: [] } as unknown as ReactorState;
1111

@@ -100,6 +100,36 @@ async function runToolOnlyStreak(
100100
return last;
101101
}
102102

103+
describe("toolSetDigest", () => {
104+
const base = {
105+
name: "read_file",
106+
description: "read a file",
107+
inputSchema: { type: "object" },
108+
};
109+
110+
test("identical sets share a digest", () => {
111+
expect(toolSetDigest([{ ...base }])).toBe(toolSetDigest([{ ...base }]));
112+
});
113+
114+
// The digest gates the tool-set-changed log line, and the serialized tools
115+
// array is the head of the provider's cached prompt prefix — an
116+
// inputSchema-only change reshapes the wire bytes, so it must move the
117+
// digest or the cache bust goes unlogged.
118+
test("an inputSchema-only change alters the digest", () => {
119+
const before = [{ ...base }];
120+
const after = [
121+
{
122+
...base,
123+
inputSchema: {
124+
type: "object",
125+
properties: { path: { type: "string" } },
126+
},
127+
},
128+
];
129+
expect(toolSetDigest(after)).not.toBe(toolSetDigest(before));
130+
});
131+
});
132+
103133
describe("ChatDirector tool-only loop protection", () => {
104134
const providerlessPolicy = { providerName: "test-provider" };
105135

src/agent/director.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
DefaultDirector,
33
type ExtendedInferenceOptions,
44
} from "@intx/inference";
5+
import { createHash } from "node:crypto";
56
import { getLogger } from "@intx/log";
67
import type {
78
ReactorDirector,
@@ -48,6 +49,29 @@ import {
4849

4950
const logger = getLogger([LOG_NAMESPACE_ROOT, "agent", "director"]);
5051

52+
// The serialized `tools` array is the head of the provider's cached prompt
53+
// prefix, ahead of the system prompt. Measured on OpenCode Go Responses, a warm
54+
// session holds 99.3% cached and ANY change to that array — a mount, a
55+
// description edit, or a pure reorder of an unchanged set — drops the next turn
56+
// to 2-4%. Appending at the end is not cheaper than prepending: 4.5% versus
57+
// 2.1%, both full misses.
58+
//
59+
// `advertisedTools` (src/agent/tool-search.ts) already keeps this array
60+
// deterministic, so the array should only ever change when a genuine discovery
61+
// grows it. This digest is here to prove that, because prefix churn is
62+
// otherwise invisible — it shows up only as a billing and latency spike a turn
63+
// later. Hashed rather than logged verbatim: MCP tool descriptions are
64+
// arbitrary-length, server-supplied text and do not belong in the log stream.
65+
export function toolSetDigest(tools: readonly ToolDefinition[]): string {
66+
const shape = tools
67+
.map(
68+
(t) =>
69+
`${t.name}:${t.description ?? ""}:${JSON.stringify(t.inputSchema ?? null)}`,
70+
)
71+
.join("|");
72+
return createHash("sha256").update(shape).digest("hex").slice(0, 12);
73+
}
74+
5175
function isInternalRecoveryAbort(
5276
event: Extract<ReactorInboundEvent, { type: "inference.error" }>,
5377
): boolean {
@@ -521,7 +545,11 @@ class ChatDirectorImpl extends DefaultDirector {
521545
}
522546

523547
updateToolDefinitions(toolDefinitions: ToolDefinition[]): void {
548+
const before = toolSetDigest(this._toolDefinitions);
549+
const after = toolSetDigest(toolDefinitions);
524550
this._toolDefinitions = toolDefinitions;
551+
if (before === after) return;
552+
logger.debug`tool-set-changed count=${String(this._toolDefinitions.length)} before=${before} after=${after}`;
525553
}
526554

527555
getTasks(): Task[] {

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: 122 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
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";
4+
import { createAdvertisedToolset } from "./session/assemble-runtime.js";
85
import { createPermissionGate } from "./permission/gate.js";
96
import {
107
COMPACTOR_KEEP_RECENT_TURNS,
@@ -1145,6 +1142,93 @@ describe("updateToolDefinitions rewrites infer tools", () => {
11451142
expect(JSON.stringify(after)).toBe(JSON.stringify(before));
11461143
});
11471144

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 () => {
1152+
const linearTool = {
1153+
name: "mcp__linear__list_issues",
1154+
description: "list issues",
1155+
inputSchema: { type: "object", properties: {}, required: [] },
1156+
};
1157+
const toolset = await createAgentToolset({
1158+
cwd: process.cwd(),
1159+
permissionGate: createPermissionGate({
1160+
approvals: [],
1161+
interactive: false,
1162+
skipPermissions: true,
1163+
reactorGated: false,
1164+
}),
1165+
onOperatorGate: async () => ({ kind: "cancel" }),
1166+
});
1167+
toolset.dynamicRunner.addTools([
1168+
{ kind: "string", definition: linearTool, handler: async () => "ok" },
1169+
]);
1170+
1171+
const advertised = createAdvertisedToolset({
1172+
sessionMode: "orchestrator",
1173+
toolAvailability: { languageServerAvailable: false },
1174+
getProvider: () => ({ providerName: "openai", model: "gpt-5" }),
1175+
});
1176+
const director = createChatDirector(
1177+
"base-prompt",
1178+
advertised.computeAdvertised(toolset.dynamicRunner.currentDefinitions()),
1179+
{ onTasksChange: () => undefined },
1180+
);
1181+
1182+
const before = await firstInferTools(
1183+
director,
1184+
makeMessageReceivedEvent("hello"),
1185+
);
1186+
1187+
// tool_search matched a genuinely new tool: the runner opens the call
1188+
// gate (activation); the per-turn wire recompute deliberately ignores it.
1189+
expect(advertised.activated.activate(["mcp__linear__list_issues"])).toBe(
1190+
true,
1191+
);
1192+
director.updateToolDefinitions(
1193+
advertised.computeAdvertised(toolset.dynamicRunner.currentDefinitions()),
1194+
);
1195+
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.
1199+
const after = await firstInferTools(
1200+
director,
1201+
makeMessageReceivedEvent("continue"),
1202+
);
1203+
expect(JSON.stringify(after)).toBe(JSON.stringify(before));
1204+
expect(advertised.isAdvertised("mcp__linear__list_issues")).toBe(true);
1205+
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+
1229+
await toolset.dispose();
1230+
});
1231+
11481232
// submit_output is always on the wire so a workflow going active never grows
11491233
// the array and busts the provider cache prefix.
11501234
test("submit_output is advertised even with no active workflow", async () => {
@@ -1165,12 +1249,13 @@ describe("updateToolDefinitions rewrites infer tools", () => {
11651249
expect(inferToolNames(inferAction)).toContain("submit_output");
11661250
});
11671251

1168-
// End-to-end: tool_search matches an MCP tool, the runner's promote wiring
1169-
// (mirrored here via createActivatedToolTracker + updateToolDefinitions) grows
1170-
// the wire set once with the tool's full definition, and it then holds steady.
1171-
// On a strict provider, a model can only call a tool
1172-
// that was actually declared on the wire, so promotion must land here.
1173-
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 () => {
11741259
const linearTool = {
11751260
name: "mcp__linear__list_issues",
11761261
description: "list issues",
@@ -1190,10 +1275,14 @@ describe("updateToolDefinitions rewrites infer tools", () => {
11901275
{ kind: "string", definition: linearTool, handler: async () => "ok" },
11911276
]);
11921277

1193-
const activated = createActivatedToolTracker();
1278+
const advertised = createAdvertisedToolset({
1279+
sessionMode: "orchestrator",
1280+
toolAvailability: { languageServerAvailable: false },
1281+
getProvider: () => ({ providerName: "openai", model: "gpt-5" }),
1282+
});
11941283
const computeAdvertised = (
11951284
all: ReturnType<typeof toolset.dynamicRunner.currentDefinitions>,
1196-
) => advertisedTools(all, activated.list());
1285+
) => advertised.computeAdvertised(all);
11971286
const director = createChatDirector(
11981287
"base-prompt",
11991288
computeAdvertised(toolset.dynamicRunner.currentDefinitions()),
@@ -1209,17 +1298,34 @@ describe("updateToolDefinitions rewrites infer tools", () => {
12091298
expect(beforeNames).not.toContain("mcp__linear__list_issues");
12101299
const beforeJson = JSON.stringify(before);
12111300

1212-
// Simulate the runner's promoteTools: tool_search matched this tool, so it
1213-
// is activated and the director's tool set is updated for the next infer.
1214-
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"]);
12151304
director.updateToolDefinitions(
12161305
computeAdvertised(toolset.dynamicRunner.currentDefinitions()),
12171306
);
12181307

1219-
const after = await firstInferTools(
1308+
const gated = await firstInferTools(
12201309
director,
12211310
makeMessageReceivedEvent("continue"),
12221311
);
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+
);
12231329
const afterTools = after as { name: string }[];
12241330
const afterNames = afterTools.map((t) => t.name);
12251331
expect(afterNames).toContain("mcp__linear__list_issues");

0 commit comments

Comments
 (0)