Skip to content

Commit 5357a1a

Browse files
committed
perf(agent): memoize toolSetDigest per director, not per module
The digest is only ever consumed by ChatDirectorImpl.updateToolDefinitions, so a module-level WeakMap keyed by array identity is heavier than the call pattern needs. Move the memoization onto the director as a last-digest field: the incoming set is hashed once when installed and the previous set's digest is reused from the field on the next update. toolSetDigest becomes a pure function with identical digest values; the proof test now spies through updateToolDefinitions to show one hash per installed tool set per director.
1 parent dbaf3e9 commit 5357a1a

2 files changed

Lines changed: 42 additions & 30 deletions

File tree

‎src/agent/director.ts‎

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,25 +60,19 @@ const logger = getLogger([LOG_NAMESPACE_ROOT, "agent", "director"]);
6060
// digest stays deterministic — it only moves when discovery grows the set.
6161
// Hashed: MCP descriptions are arbitrary server text, not log material.
6262
//
63-
// Memoized per array identity: the director owns stable tool arrays and swaps
64-
// them wholesale (constructor, updateToolDefinitions) without in-place
65-
// mutation, so the same reference always means the same content and a new
66-
// reference (tools changed) misses the WeakMap and recomputes. A WeakMap also
67-
// avoids retaining arrays the director has dropped.
68-
const toolSetDigestCache = new WeakMap<readonly ToolDefinition[], string>();
69-
63+
// Pure by design: the memoization lives on the director (_toolSetDigest), the
64+
// only production caller, so the module keeps no per-array state. Callers swap
65+
// tool arrays wholesale (constructor, updateToolDefinitions) without in-place
66+
// mutation; mutating an installed array in place would leave the director's
67+
// cached digest stale.
7068
export function toolSetDigest(tools: readonly ToolDefinition[]): string {
71-
const cached = toolSetDigestCache.get(tools);
72-
if (cached !== undefined) return cached;
7369
const shape = tools
7470
.map(
7571
(t) =>
7672
`${t.name}:${t.description ?? ""}:${JSON.stringify(t.inputSchema ?? null)}`,
7773
)
7874
.join("|");
79-
const digest = createHash("sha256").update(shape).digest("hex").slice(0, 12);
80-
toolSetDigestCache.set(tools, digest);
81-
return digest;
75+
return createHash("sha256").update(shape).digest("hex").slice(0, 12);
8276
}
8377

8478
function isInternalRecoveryAbort(
@@ -487,6 +481,13 @@ class ChatDirectorImpl extends DefaultDirector {
487481
private readonly askOperatorCalls = new Set<string>();
488482
private readonly _systemPrompt: string;
489483
private _toolDefinitions: ToolDefinition[];
484+
// Digest of the installed _toolDefinitions, stored when the array is set so
485+
// updateToolDefinitions can compare an incoming set without re-hashing the
486+
// current one. One slot per director — the digest is only consumed by the
487+
// next update on this instance, never by shared/static context (all
488+
// production call sites are here). Stale only if a caller mutates the
489+
// installed array in place; callers swap arrays wholesale instead.
490+
private _toolSetDigest: string | undefined;
490491
private inactivityTimeoutMs: number | undefined;
491492
private totalTimeoutMs: number | undefined;
492493
// Host-owned live object, attached via setWorkflowCoordinator — never a
@@ -711,9 +712,13 @@ class ChatDirectorImpl extends DefaultDirector {
711712
}
712713

713714
updateToolDefinitions(toolDefinitions: ToolDefinition[]): void {
714-
const before = toolSetDigest(this._toolDefinitions);
715+
// The stored digest covers the current set (installed by the constructor
716+
// or a prior update); only the very first update has to hash it, and the
717+
// fallback runs once per director.
718+
const before = this._toolSetDigest ?? toolSetDigest(this._toolDefinitions);
715719
const after = toolSetDigest(toolDefinitions);
716720
this._toolDefinitions = toolDefinitions;
721+
this._toolSetDigest = after;
717722
if (before === after) return;
718723
logger.debug`tool-set-changed count=${String(this._toolDefinitions.length)} before=${before} after=${after}`;
719724
}

‎src/agent/tool-set-digest.test.ts‎

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { describe, expect, test } from "bun:test";
22
import { withMockedModule } from "../../testkit/mock-module.js";
33

4-
// Spy on createHash to prove toolSetDigest memoizes: repeated digest calls
5-
// with the same tool array must run the sha256 exactly once, and a new array
6-
// (tools changed) must run it again. The mock delegates to the real
7-
// implementation (captured before mocking) so behavior is unchanged;
8-
// hashCalls only measures the delta around each assertion.
4+
// Spy on createHash to prove toolSetDigest memoizes per director: installing
5+
// a tool set runs the sha256 exactly once for that set, and comparing a later
6+
// update against the stored per-director digest must not re-hash the previous
7+
// set. The mock delegates to the real implementation (captured before mocking)
8+
// so behavior is unchanged; hashCalls only measures the delta around each
9+
// assertion.
910
let hashCalls = 0;
1011
await withMockedModule<typeof import("node:crypto")>(
1112
import.meta.resolve("node:crypto"),
@@ -19,7 +20,7 @@ await withMockedModule<typeof import("node:crypto")>(
1920
);
2021

2122
// Dynamic import so director.ts resolves node:crypto through the mock above.
22-
const { toolSetDigest } = await import("./director.js");
23+
const { createChatDirector } = await import("./director.js");
2324

2425
const base = {
2526
name: "read_file",
@@ -28,26 +29,32 @@ const base = {
2829
};
2930

3031
describe("toolSetDigest memoization", () => {
31-
test("hashes once per tool set across repeated digest calls", () => {
32+
test("hashes each installed tool set once per director", () => {
33+
const director = createChatDirector("system", [], {});
3234
const tools = [{ ...base }];
35+
const changed = [{ ...base, description: "changed" }];
36+
37+
// Warm-up installs `tools`; its digest is stored on the director.
38+
director.updateToolDefinitions(tools);
3339
hashCalls = 0;
34-
const first = toolSetDigest(tools);
35-
const second = toolSetDigest(tools);
36-
const third = toolSetDigest(tools);
37-
expect(second).toBe(first);
38-
expect(third).toBe(first);
40+
// The previous set's digest comes from the per-director field (no re-hash);
41+
// only the incoming set is hashed.
42+
director.updateToolDefinitions(changed);
3943
expect(hashCalls).toBe(1);
4044
});
4145

4246
test("recomputes when a new tool set arrives", () => {
47+
const director = createChatDirector("system", [], {});
4348
const tools = [{ ...base }];
4449
const changed = [{ ...base, description: "changed" }];
50+
51+
director.updateToolDefinitions(tools);
52+
director.updateToolDefinitions(changed);
4553
hashCalls = 0;
46-
const digest = toolSetDigest(tools);
54+
// A fresh reference (even with identical content) misses the stored slot
55+
// and re-runs the hash; the digest gates the tool-set-changed log, so
56+
// identical content still logs nothing.
57+
director.updateToolDefinitions([{ ...base }]);
4758
expect(hashCalls).toBe(1);
48-
const changedDigest = toolSetDigest(changed);
49-
expect(hashCalls).toBe(2);
50-
expect(changedDigest).not.toBe(toolSetDigest(tools));
51-
expect(digest).toBe(toolSetDigest(tools));
5259
});
5360
});

0 commit comments

Comments
 (0)