From 361aa3f8628cfb509c2b6bbae95c43776d9a7faa Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 11:26:21 -0700 Subject: [PATCH] Make tool-set churn visible between turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tools array is the head of the provider's cached prompt prefix, so any change to it re-prefills the whole request. Whether that actually happens in real sessions is unmeasured: it shows up only as a billing and latency spike a turn later, with nothing tying it back to a mount. Log a digest when the set changes. Hashed, not verbatim — MCP tool descriptions are arbitrary-length server-supplied text and do not belong in the log stream. This replaces an earlier attempt that sorted the array by name. That was wrong: advertisedTools already orders deterministically, so sorting added no stability, and an alphabetical insert can land at index 0 and invalidate more of the prefix than appending does. A gate run carrying it measured cache-hit rate down 3-8 points across all four eval tiers. CL-7868 --- src/agent/director.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/agent/director.ts b/src/agent/director.ts index a9f653a4a..956c41886 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -2,6 +2,7 @@ import { DefaultDirector, type ExtendedInferenceOptions, } from "@intx/inference"; +import { createHash } from "node:crypto"; import { getLogger } from "@intx/log"; import type { ReactorDirector, @@ -48,6 +49,25 @@ import { const logger = getLogger([LOG_NAMESPACE_ROOT, "agent", "director"]); +// 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 ANY change to that array — a mount, a +// description edit, or a pure reorder of an unchanged set — drops the next turn +// to 2-4%. Appending at the end is not cheaper than prepending: 4.5% versus +// 2.1%, both full misses. +// +// `advertisedTools` (src/agent/tool-search.ts) already keeps this array +// deterministic, so the array should only ever change when a genuine discovery +// grows it. This digest is here to prove that, because prefix churn is +// otherwise invisible — it shows up only as a billing and latency spike a turn +// later. Hashed rather than logged verbatim: MCP tool descriptions are +// arbitrary-length, server-supplied text and do not belong in the log stream. +// See CL-7868. +function toolSetDigest(tools: readonly ToolDefinition[]): string { + const shape = tools.map((t) => `${t.name}:${t.description ?? ""}`).join("|"); + return createHash("sha256").update(shape).digest("hex").slice(0, 12); +} + function isInternalRecoveryAbort( event: Extract, ): boolean { @@ -508,7 +528,11 @@ class ChatDirectorImpl extends DefaultDirector { } updateToolDefinitions(toolDefinitions: ToolDefinition[]): void { + const before = toolSetDigest(this._toolDefinitions); + const after = toolSetDigest(toolDefinitions); this._toolDefinitions = toolDefinitions; + if (before === after) return; + logger.debug`tool-set-changed count=${String(this._toolDefinitions.length)} before=${before} after=${after}`; } getTasks(): Task[] {