fix(agents): land #38935 from @MumuTW

Co-authored-by: MumuTW <MumuTW@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-03-07 18:55:49 +00:00
parent 231c1fa37a
commit 5effa6043e
3 changed files with 54 additions and 12 deletions

View File

@@ -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

View File

@@ -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<string, unknown> = { role: "user", content: "hello" };
const child: Record<string, unknown> = { 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<string, unknown>;
expect(event.messageCount).toBe(1);
expect(event.messageFingerprints).toHaveLength(1);
});
});

View File

@@ -104,7 +104,7 @@ function getWriter(filePath: string): CacheTraceWriter {
return getQueuedFileWriter(writers, filePath);
}
function stableStringify(value: unknown): string {
function stableStringify(value: unknown, seen: WeakSet<object> = 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<string, unknown>;
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(",")}}`;
}