Skip to content

Commit 8f4ecd0

Browse files
committed
perf(director): pin tool wire order so discovery order cannot bust the cache
The serialized tools array is the head of the provider's cached prompt prefix, ahead of the system prompt. Measured on OpenCode Go Responses, a warm session holds 99.3% cached and a pure reorder of an unchanged set drops it to 2.1% — the same full miss a real mount costs. Discovery order is not stable across turns (MCP connect order, plugin load order, map iteration), so the wire order is now sorted by name. updateToolDefinitions logs a digest when the set actually changes, so prefix churn is countable instead of showing up only as a billing spike. Drops the assertion that a newly discovered tool must land at the end of the array. Appending was treated as the cache-preserving position; it is not — append measures 4.5% cached against prepend's 2.1%, both full misses. CL-7868
1 parent d9ec82c commit 8f4ecd0

2 files changed

Lines changed: 92 additions & 12 deletions

File tree

‎src/agent/director.ts‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,22 @@ import {
4848

4949
const logger = getLogger([LOG_NAMESPACE_ROOT, "agent", "director"]);
5050

51+
// The serialized `tools` array is the head of the provider's cached prompt
52+
// prefix, ahead of the system prompt. Any byte-level change to it — a mount, a
53+
// description edit, or a pure reorder of an unchanged set — invalidates the
54+
// whole prefix for that turn. Measured on OpenCode Go Responses: 99.3% cached
55+
// falling to 2.1% on a reorder alone. Tool discovery order is not stable across
56+
// turns (MCP connect order, plugin load order, map iteration), so the wire
57+
// order is pinned by name. See CL-7868.
58+
function stableToolOrder(tools: readonly ToolDefinition[]): ToolDefinition[] {
59+
return [...tools].sort((a, b) => a.name.localeCompare(b.name));
60+
}
61+
62+
/** Identity of a serialized tool set: name and description, in wire order. */
63+
function toolSetDigest(tools: readonly ToolDefinition[]): string {
64+
return tools.map((t) => `${t.name}:${t.description ?? ""}`).join("|");
65+
}
66+
5167
function isInternalRecoveryAbort(
5268
event: Extract<ReactorInboundEvent, { type: "inference.error" }>,
5369
): boolean {
@@ -482,9 +498,10 @@ class ChatDirectorImpl extends DefaultDirector {
482498
toolDefinitions: ToolDefinition[],
483499
options: ChatDirectorImplOptions,
484500
) {
485-
super(systemPrompt, toolDefinitions, {});
501+
const orderedTools = stableToolOrder(toolDefinitions);
502+
super(systemPrompt, orderedTools, {});
486503
this._systemPrompt = systemPrompt;
487-
this._toolDefinitions = toolDefinitions;
504+
this._toolDefinitions = orderedTools;
488505
this.inactivityTimeoutMs = options.inactivityTimeoutMs;
489506
this.totalTimeoutMs = options.totalTimeoutMs;
490507
this.taskClassifier = options.taskClassifier;
@@ -494,7 +511,7 @@ class ChatDirectorImpl extends DefaultDirector {
494511
this.compaction = createCompactionGovernor(
495512
options.requestContinuation,
496513
systemPrompt,
497-
toolDefinitions,
514+
orderedTools,
498515
);
499516
this.modelFamilyPolicy =
500517
options.modelFamilyPolicy ??
@@ -508,7 +525,14 @@ class ChatDirectorImpl extends DefaultDirector {
508525
}
509526

510527
updateToolDefinitions(toolDefinitions: ToolDefinition[]): void {
511-
this._toolDefinitions = toolDefinitions;
528+
const ordered = stableToolOrder(toolDefinitions);
529+
const before = toolSetDigest(this._toolDefinitions);
530+
const after = toolSetDigest(ordered);
531+
this._toolDefinitions = ordered;
532+
if (before === after) return;
533+
// Prefix churn is otherwise silent — it shows up only as a billing and
534+
// latency spike on the next turn. Name it so it can be counted.
535+
logger.debug`tool-set-changed count=${String(this._toolDefinitions.length)} before=${before} after=${after}`;
512536
}
513537

514538
getTasks(): Task[] {
@@ -592,7 +616,7 @@ class ChatDirectorImpl extends DefaultDirector {
592616
(t) => t.name === submitOutputDefinition.name,
593617
)
594618
? this._toolDefinitions
595-
: [...this._toolDefinitions, submitOutputDefinition];
619+
: stableToolOrder([...this._toolDefinitions, submitOutputDefinition]);
596620

597621
const directive = active
598622
? (this.workflowCoordinator?.directive() ?? null)

‎src/director.test.ts‎

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,18 +1223,22 @@ describe("updateToolDefinitions rewrites infer tools", () => {
12231223
const afterTools = after as { name: string }[];
12241224
const afterNames = afterTools.map((t) => t.name);
12251225
expect(afterNames).toContain("mcp__linear__list_issues");
1226-
// submit_output rides along separately (see withCurrentTools), appended
1227-
// after computeAdvertised's result every turn — strip it before comparing
1228-
// the fixed built-in prefix, which must survive untouched ahead of the
1229-
// newly appended MCP tool.
1226+
// submit_output rides along separately (see withCurrentTools). Strip both
1227+
// it and the new tool: the rest of the set must be unchanged, and in the
1228+
// same relative order.
1229+
//
1230+
// Where the new tool lands is deliberately NOT asserted. Appending at the
1231+
// end used to be treated as the cache-preserving position; it is not.
1232+
// Measured on OpenCode Go Responses, appending a tool drops the hit rate
1233+
// to 4.5% and prepending to 2.1% — both are a full prefix miss, because
1234+
// the tools array is hashed as a unit ahead of the system prompt. Wire
1235+
// order is therefore sorted by name for stability, not positioned for
1236+
// cache survival. See CL-7868.
12301237
const beforePrefix = beforeNames.filter((n) => n !== "submit_output");
12311238
const afterPrefix = afterNames.filter(
12321239
(n) => n !== "submit_output" && n !== "mcp__linear__list_issues",
12331240
);
12341241
expect(afterPrefix).toEqual(beforePrefix);
1235-
expect(afterNames.indexOf("mcp__linear__list_issues")).toBe(
1236-
beforePrefix.length,
1237-
);
12381242

12391243
// A further turn with no new discovery stays byte-identical to `after`.
12401244
const stable = await firstInferTools(
@@ -1661,3 +1665,55 @@ describe("chatDirector spacer echo", () => {
16611665
expect(exhausted.some((a) => a.type === "reply")).toBe(true);
16621666
});
16631667
});
1668+
1669+
// The serialized tools array is the head of the provider's cached prompt
1670+
// prefix. A reorder of an unchanged set costs the same full miss as a real
1671+
// mount — measured at 99.3% cached falling to 2.1% on OpenCode Go Responses
1672+
// (CL-7868) — so discovery order must never reach the wire.
1673+
describe("tool wire order", () => {
1674+
const tool = (name: string) => ({
1675+
name,
1676+
description: `The ${name} tool`,
1677+
inputSchema: { type: "object" as const, properties: {} },
1678+
});
1679+
1680+
async function wireTools(
1681+
director: ReturnType<typeof createChatDirector>,
1682+
): Promise<string[]> {
1683+
const actions = actionsArray(
1684+
await director.decide(
1685+
makeToolErrorEvent("c", "denied by approver: never touch /etc"),
1686+
mockState,
1687+
mockCapabilities,
1688+
),
1689+
);
1690+
const infer = actions.find((a) => a.type === "infer");
1691+
const options = (infer as { options?: ExtendedInferenceOptions }).options;
1692+
return (options?.tools ?? []).map((t) => t.name);
1693+
}
1694+
1695+
test("two discovery orders of one tool set produce the same wire order", async () => {
1696+
const forward = createChatDirector(
1697+
"sys",
1698+
[tool("read_file"), tool("grep"), tool("run_shell")],
1699+
{ onTasksChange: () => undefined },
1700+
);
1701+
const reverse = createChatDirector(
1702+
"sys",
1703+
[tool("run_shell"), tool("grep"), tool("read_file")],
1704+
{ onTasksChange: () => undefined },
1705+
);
1706+
expect(await wireTools(forward)).toEqual(await wireTools(reverse));
1707+
});
1708+
1709+
test("a re-mount in a different order does not change the wire order", async () => {
1710+
const director = createChatDirector(
1711+
"sys",
1712+
[tool("read_file"), tool("grep")],
1713+
{ onTasksChange: () => undefined },
1714+
);
1715+
const before = await wireTools(director);
1716+
director.updateToolDefinitions([tool("grep"), tool("read_file")]);
1717+
expect(await wireTools(director)).toEqual(before);
1718+
});
1719+
});

0 commit comments

Comments
 (0)