From 5effa6043e4860c36cffd1e23c9c76c17a323fc4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 7 Mar 2026 18:55:49 +0000 Subject: [PATCH] fix(agents): land #38935 from @MumuTW Co-authored-by: MumuTW --- CHANGELOG.md | 1 + src/agents/cache-trace.test.ts | 31 +++++++++++++++++++++++++++++++ src/agents/cache-trace.ts | 34 ++++++++++++++++++++++------------ 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd8888ac..11aec656d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -249,6 +249,7 @@ Docs: https://docs.openclaw.ai - Models/auth token prompts: guard cancelled manual token prompts so `Symbol(clack:cancel)` values cannot be persisted into auth profiles; adds regression coverage for cancelled `models auth paste-token`. (#38951) Thanks @MumuTW. - Gateway/loopback announce URLs: treat `http://` and `https://` aliases with the same loopback/private-network policy as websocket URLs so loopback cron announce delivery no longer fails secure URL validation. (#39064) Thanks @Narcooo. - Models/default provider fallback: when the hardcoded default provider is removed from `models.providers`, resolve defaults from configured providers instead of reporting stale removed-provider defaults in status output. (#38947) Thanks @davidemanuelDEV. +- Agents/cache-trace stability: guard stable stringify against circular references in trace payloads so near-limit payloads no longer crash with `Maximum call stack size exceeded`; adds regression coverage. (#38935) Thanks @MumuTW. ## 2026.3.2 diff --git a/src/agents/cache-trace.test.ts b/src/agents/cache-trace.test.ts index be49e93a3..28a8d9d28 100644 --- a/src/agents/cache-trace.test.ts +++ b/src/agents/cache-trace.test.ts @@ -144,4 +144,35 @@ describe("createCacheTrace", () => { expect(source.bytes).toBe(6); expect(source.sha256).toBe(crypto.createHash("sha256").update("U0VDUkVU").digest("hex")); }); + + it("handles circular references in messages without stack overflow", () => { + const lines: string[] = []; + const trace = createCacheTrace({ + cfg: { + diagnostics: { + cacheTrace: { + enabled: true, + }, + }, + }, + env: {}, + writer: { + filePath: "memory", + write: (line) => lines.push(line), + }, + }); + + const parent: Record = { role: "user", content: "hello" }; + const child: Record = { ref: parent }; + parent.child = child; // circular reference + + trace?.recordStage("prompt:images", { + messages: [parent] as unknown as [], + }); + + expect(lines.length).toBe(1); + const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record; + expect(event.messageCount).toBe(1); + expect(event.messageFingerprints).toHaveLength(1); + }); }); diff --git a/src/agents/cache-trace.ts b/src/agents/cache-trace.ts index 46d515792..c3125c074 100644 --- a/src/agents/cache-trace.ts +++ b/src/agents/cache-trace.ts @@ -104,7 +104,7 @@ function getWriter(filePath: string): CacheTraceWriter { return getQueuedFileWriter(writers, filePath); } -function stableStringify(value: unknown): string { +function stableStringify(value: unknown, seen: WeakSet = new WeakSet()): string { if (value === null || value === undefined) { return String(value); } @@ -117,30 +117,40 @@ function stableStringify(value: unknown): string { if (typeof value !== "object") { return JSON.stringify(value) ?? "null"; } + if (seen.has(value)) { + return JSON.stringify("[Circular]"); + } + seen.add(value); if (value instanceof Error) { - return stableStringify({ - name: value.name, - message: value.message, - stack: value.stack, - }); + return stableStringify( + { + name: value.name, + message: value.message, + stack: value.stack, + }, + seen, + ); } if (value instanceof Uint8Array) { - return stableStringify({ - type: "Uint8Array", - data: Buffer.from(value).toString("base64"), - }); + return stableStringify( + { + type: "Uint8Array", + data: Buffer.from(value).toString("base64"), + }, + seen, + ); } if (Array.isArray(value)) { const serializedEntries: string[] = []; for (const entry of value) { - serializedEntries.push(stableStringify(entry)); + serializedEntries.push(stableStringify(entry, seen)); } return `[${serializedEntries.join(",")}]`; } const record = value as Record; const serializedFields: string[] = []; for (const key of Object.keys(record).toSorted()) { - serializedFields.push(`${JSON.stringify(key)}:${stableStringify(record[key])}`); + serializedFields.push(`${JSON.stringify(key)}:${stableStringify(record[key], seen)}`); } return `{${serializedFields.join(",")}}`; }