From ef86d8c95c2362f5404ae156cdba26cfffe3d323 Mon Sep 17 00:00:00 2001 From: martingarramon Date: Tue, 26 May 2026 17:30:01 -0300 Subject: [PATCH] fix(agents): preserve sessions_spawn transcript payloads (#82203) Remove the transcript redaction path for sessions_spawn arguments and inline attachments. OpenClaw transcripts are local trusted-operator state, and streamTo/resumeSessionId are runtime routing fields that must not be rewritten before replay or dispatch. Co-authored-by: Peter Steinberger --- .../pi-embedded-runner/run/attempt.test.ts | 164 ++++++++++++++- .../run/attempt.tool-call-normalization.ts | 65 ++++-- ...sion-transcript-repair.attachments.test.ts | 169 ++------------- src/agents/session-transcript-repair.test.ts | 121 +++++++++-- src/agents/session-transcript-repair.ts | 197 +++++++----------- src/agents/tool-call-id.ts | 48 +++-- src/agents/tool-call-shared.ts | 55 ----- src/agents/tools/sessions-spawn-tool.ts | 1 - 8 files changed, 438 insertions(+), 382 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 6b6f448b0520..e91e22a646fa 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -2321,7 +2321,109 @@ describe("wrapStreamFnSanitizeMalformedToolCalls", () => { ]); }); - it("drops signed thinking turns when replay would expose inline sessions_spawn attachments", async () => { + it("keeps signed thinking turns that reuse a mutable earlier tool id", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "call_1", + content: [{ type: "text", text: "mutable result" }], + }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "internal", thinkingSignature: "sig_1" }, + { type: "toolUse", id: "call_1", name: "read", input: {} }, + ], + }, + { + role: "toolResult", + toolCallId: "call_1", + content: [{ type: "text", text: "signed result" }], + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), { + validateAnthropicTurns: true, + preserveSignatures: true, + dropThinkingBlocks: false, + } as never); + const stream = wrapped( + { api: "anthropic-messages" } as never, + { messages } as never, + {} as never, + ) as FakeWrappedStream | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = firstBaseContext(baseFn); + expect(seenContext.messages).toBe(messages); + }); + + it("drops signed thinking reused ids when their real result is displaced", async () => { + const firstAssistant = { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + }; + const firstResult = { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "mutable result" }], + }; + const userMessage = { + role: "user", + content: [{ type: "text", text: "retry" }], + }; + const messages = [ + firstAssistant, + firstResult, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "internal", thinkingSignature: "sig_1" }, + { type: "toolUse", id: "call_1", name: "read", input: {} }, + ], + }, + userMessage, + { + role: "toolResult", + toolCallId: "call_1", + content: [{ type: "text", text: "signed result" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), { + validateAnthropicTurns: true, + preserveSignatures: true, + dropThinkingBlocks: false, + } as never); + const stream = wrapped( + { api: "anthropic-messages" } as never, + { messages } as never, + {} as never, + ) as FakeWrappedStream | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = firstBaseContext(baseFn); + expect(seenContext.messages).toEqual([firstAssistant, firstResult, userMessage]); + }); + + it("drops signed thinking turns with inline sessions_spawn attachments when the result is missing", async () => { const attachmentContent = "SIGNED_THINKING_INLINE_ATTACHMENT"; const messages = [ { @@ -2374,7 +2476,7 @@ describe("wrapStreamFnSanitizeMalformedToolCalls", () => { ]); }); - it("drops signed thinking turns when replay would expose non-content attachment payload fields", async () => { + it("drops signed thinking turns with non-content attachment payload fields when the result is missing", async () => { const attachmentContent = "SIGNED_THINKING_NESTED_ATTACHMENT"; const messages = [ { @@ -2433,6 +2535,60 @@ describe("wrapStreamFnSanitizeMalformedToolCalls", () => { ]); }); + it("keeps signed thinking turns with sessions_spawn attachments when the tool result is present", async () => { + const attachmentContent = "SIGNED_THINKING_PAIRED_ATTACHMENT"; + const messages = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "internal", thinkingSignature: "sig_1" }, + { + type: "toolUse", + id: "call_1", + name: "sessions_spawn", + input: { + task: "inspect attachment", + attachments: [{ name: "snapshot.txt", content: attachmentContent }], + }, + }, + ], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "sessions_spawn", + content: [{ type: "text", text: "done" }], + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls( + baseFn as never, + new Set(["sessions_spawn"]), + { + validateAnthropicTurns: true, + preserveSignatures: true, + dropThinkingBlocks: false, + } as never, + ); + const stream = wrapped( + { api: "anthropic-messages" } as never, + { messages } as never, + {} as never, + ) as FakeWrappedStream | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = firstBaseContext(baseFn); + expect(seenContext.messages).toBe(messages); + }); + it("keeps mutable thinking turns outside anthropic replay-only preservation", async () => { const messages = [ { @@ -3044,10 +3200,6 @@ describe("wrapStreamFnSanitizeMalformedToolCalls", () => { messages: Array<{ role?: string; content?: unknown[] }>; }; expect(seenContext.messages).toEqual([ - { - role: "assistant", - content: [{ type: "text", text: "[tool calls omitted]" }], - }, { role: "user", content: [{ type: "text", text: "retry" }], diff --git a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts b/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts index 089a8d502f7e..03782c0b6223 100644 --- a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts +++ b/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts @@ -7,10 +7,10 @@ import { validateAnthropicTurns, validateGeminiTurns } from "../../pi-embedded-h import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js"; import { extractToolCallsFromAssistant, + extractToolResultIds, sanitizeToolCallIdsForCloudCodeAssist, type ToolCallIdMode, } from "../../tool-call-id.js"; -import { hasUnredactedSessionsSpawnAttachments } from "../../tool-call-shared.js"; import { normalizeToolName } from "../../tool-policy.js"; import { shouldAllowProviderOwnedThinkingReplay } from "../../transcript-policy.js"; import type { TranscriptPolicy } from "../../transcript-policy.js"; @@ -263,12 +263,7 @@ function isReplaySafeThinkingTurn(content: unknown[], allowedToolNames?: Set; displaced: boolean } { + const ids = new Set(); + let sawNonToolResult = false; + let displaced = false; + for (let nextIndex = index + 1; nextIndex < messages.length; nextIndex += 1) { + const message = messages[nextIndex]; + if (!message || typeof message !== "object") { + sawNonToolResult = true; + continue; + } + if (message.role === "assistant" && assistantTurnHasReplayToolCall(message)) { + break; + } + if (message.role === "toolResult") { + const resultIds = extractToolResultIds(message); + for (const id of resultIds) { + ids.add(id); + } + displaced ||= resultIds.length > 0 && sawNonToolResult; + continue; + } + sawNonToolResult = true; + } + return { ids, displaced }; +} + function replayToolCallNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } @@ -326,9 +350,11 @@ function sanitizeReplayToolCallInputs( let changed = false; let droppedAssistantMessages = 0; const out: AgentMessage[] = []; - const claimedReplaySafeToolCallIds = new Set(); + const preservedThinkingToolCallIds = new Set(); + const priorToolCallIds = new Set(); - for (const message of messages) { + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; if (!message || typeof message !== "object" || message.role !== "assistant") { out.push(message); continue; @@ -343,13 +369,21 @@ function sanitizeReplayToolCallInputs( message.content.some((block) => isReplayToolCallBlock(block)) ) { const replaySafeToolCalls = extractToolCallsFromAssistant(message); + const followingToolResults = collectFollowingToolResults(messages, index); if ( isReplaySafeThinkingTurn(message.content, allowedToolNames) && - replaySafeToolCalls.every((toolCall) => !claimedReplaySafeToolCallIds.has(toolCall.id)) + replaySafeToolCalls.every( + (toolCall) => + !preservedThinkingToolCallIds.has(toolCall.id) && + (!followingToolResults.displaced || !priorToolCallIds.has(toolCall.id)) && + followingToolResults.ids.has(toolCall.id), + ) ) { for (const toolCall of replaySafeToolCalls) { - claimedReplaySafeToolCallIds.add(toolCall.id); + preservedThinkingToolCallIds.add(toolCall.id); + priorToolCallIds.add(toolCall.id); } + changed ||= followingToolResults.displaced; out.push(message); } else { changed = true; @@ -394,13 +428,20 @@ function sanitizeReplayToolCallInputs( if (messageChanged) { changed = true; if (nextContent.length > 0) { - out.push({ ...message, content: nextContent }); + const nextMessage = { ...message, content: nextContent }; + for (const toolCall of extractToolCallsFromAssistant(nextMessage)) { + priorToolCallIds.add(toolCall.id); + } + out.push(nextMessage); } else { droppedAssistantMessages += 1; } continue; } + for (const toolCall of extractToolCallsFromAssistant(message)) { + priorToolCallIds.add(toolCall.id); + } out.push(message); } diff --git a/src/agents/session-transcript-repair.attachments.test.ts b/src/agents/session-transcript-repair.attachments.test.ts index 16318fcfa55b..864e1b277bcf 100644 --- a/src/agents/session-transcript-repair.attachments.test.ts +++ b/src/agents/session-transcript-repair.attachments.test.ts @@ -27,39 +27,18 @@ function mkSessionsSpawnToolCall(content: string): AgentMessage { }); } -describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => { - it("replaces attachments[].content with __OPENCLAW_REDACTED__", () => { - const secret = "SUPER_SECRET_SHOULD_NOT_PERSIST"; // pragma: allowlist secret - const input = [mkSessionsSpawnToolCall(secret)]; +describe("sanitizeToolCallInputs preserves sessions_spawn payloads", () => { + it("keeps attachment content in transcript-owned tool calls", () => { + const content = "LOCAL_ATTACHMENT_CONTENT"; + const input = [mkSessionsSpawnToolCall(content)]; const out = sanitizeToolCallInputs(input); - expect(out).toStrictEqual([ - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call_1", - name: "sessions_spawn", - arguments: { - task: "do thing", - attachments: [ - { - name: "README.md", - encoding: "utf8", - content: "__OPENCLAW_REDACTED__", - }, - ], - }, - }, - ], - timestamp: 0, - }, - ]); - expect(JSON.stringify(out)).not.toContain(secret); + + expect(out).toStrictEqual(input); + expect(JSON.stringify(out)).toContain(content); }); - it("redacts attachments content from tool input payloads too", () => { - const secret = "INPUT_SECRET_SHOULD_NOT_PERSIST"; // pragma: allowlist secret + it("keeps attachment content from tool input payloads too", () => { + const content = "INPUT_ATTACHMENT_CONTENT"; const input = castAgentMessages([ { role: "assistant", @@ -70,7 +49,7 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => { name: "sessions_spawn", input: { task: "do thing", - attachments: [{ name: "x.txt", content: secret }], + attachments: [{ name: "x.txt", content }], }, }, ], @@ -78,32 +57,12 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => { ]); const out = sanitizeToolCallInputs(input); - expect(out).toStrictEqual([ - { - role: "assistant", - content: [ - { - type: "toolUse", - id: "call_2", - name: "sessions_spawn", - input: { - task: "do thing", - attachments: [ - { - name: "x.txt", - content: "__OPENCLAW_REDACTED__", - }, - ], - }, - }, - ], - }, - ]); - expect(JSON.stringify(out)).not.toContain(secret); + expect(out).toStrictEqual(input); + expect(JSON.stringify(out)).toContain(content); }); - it("replaces non-content attachment payload fields with a minimal redacted stub", () => { - const secret = "NESTED_ATTACHMENT_SECRET"; // pragma: allowlist secret + it("keeps non-content attachment payload fields unchanged", () => { + const nestedValue = "NESTED_ATTACHMENT_VALUE"; const input = castAgentMessages([ { role: "assistant", @@ -119,8 +78,8 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => { name: "payload.json", mimeType: "application/json", encoding: "utf8", - data: secret, - nested: { secret }, + data: nestedValue, + nested: { value: nestedValue }, }, ], }, @@ -130,26 +89,11 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => { ]); const out = sanitizeToolCallInputs(input); - const msg = out[0] as { content?: unknown[] }; - const tool = (msg.content?.[0] ?? null) as { - input?: { attachments?: unknown[] }; - arguments?: { attachments?: unknown[] }; - } | null; - const attachment = (tool?.input?.attachments?.[0] ?? - tool?.arguments?.attachments?.[0] ?? - null) as Record | null; - expect(attachment).toEqual({ - name: "payload.json", - mimeType: "application/json", - encoding: "utf8", - content: "__OPENCLAW_REDACTED__", - }); - expect(JSON.stringify(out)).not.toContain(secret); + expect(out).toStrictEqual(input); + expect(JSON.stringify(out)).toContain(nestedValue); }); - it("redacts ACP-only routing fields from arguments and input payloads", () => { - const argumentResumeSessionId = "ACP_ARGUMENT_SESSION_ID_SHOULD_NOT_PERSIST"; // pragma: allowlist secret - const inputResumeSessionId = "ACP_INPUT_SESSION_ID_SHOULD_NOT_PERSIST"; // pragma: allowlist secret + it("keeps ACP routing fields unchanged", () => { const input = castAgentMessages([ { role: "assistant", @@ -160,7 +104,7 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => { name: "sessions_spawn", arguments: { task: "do thing", - resumeSessionId: argumentResumeSessionId, + resumeSessionId: "argument-session", streamTo: "parent", }, }, @@ -170,7 +114,7 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => { name: "sessions_spawn", input: { task: "do other thing", - resumeSessionId: inputResumeSessionId, + resumeSessionId: "input-session", streamTo: "parent", }, }, @@ -179,75 +123,6 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => { ]); const out = sanitizeToolCallInputs(input); - expect(out).toStrictEqual([ - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call_4", - name: "sessions_spawn", - arguments: { - task: "do thing", - resumeSessionId: "__OPENCLAW_REDACTED__", - streamTo: "__OPENCLAW_REDACTED__", - }, - }, - { - type: "toolUse", - id: "call_5", - name: "sessions_spawn", - input: { - task: "do other thing", - resumeSessionId: "__OPENCLAW_REDACTED__", - streamTo: "__OPENCLAW_REDACTED__", - }, - }, - ], - }, - ]); - expect(JSON.stringify(out)).not.toContain(argumentResumeSessionId); - expect(JSON.stringify(out)).not.toContain(inputResumeSessionId); - }); - - it("redacts ACP-only routing fields with non-string payloads", () => { - const nestedResumeSessionId = "ACP_NESTED_SESSION_ID_SHOULD_NOT_PERSIST"; // pragma: allowlist secret - const input = castAgentMessages([ - { - role: "assistant", - content: [ - { - type: "toolUse", - id: "call_6", - name: "sessions_spawn", - input: { - task: "do nested thing", - resumeSessionId: { value: nestedResumeSessionId }, - streamTo: ["parent"], - }, - }, - ], - }, - ]); - - const out = sanitizeToolCallInputs(input); - expect(out).toStrictEqual([ - { - role: "assistant", - content: [ - { - type: "toolUse", - id: "call_6", - name: "sessions_spawn", - input: { - task: "do nested thing", - resumeSessionId: "__OPENCLAW_REDACTED__", - streamTo: "__OPENCLAW_REDACTED__", - }, - }, - ], - }, - ]); - expect(JSON.stringify(out)).not.toContain(nestedResumeSessionId); + expect(out).toStrictEqual(input); }); }); diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index 51bdf986b6ad..d5509d3c9eeb 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -595,11 +595,94 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => { allowProviderOwnedThinkingReplay: true, }); - expect(out).toEqual([input[0]]); + expect(out).toEqual([]); }); - it("drops signed-thinking assistant turns that would require attachment redaction", () => { - const secret = "SIGNED_THINKING_ATTACHMENT_SECRET"; // pragma: allowlist secret + it("preserves signed-thinking turns that reuse a mutable earlier tool id", () => { + const input = castAgentMessages([ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_shared", name: "read", arguments: { path: "a" } }], + }, + { + role: "toolResult", + toolCallId: "call_shared", + content: [{ type: "text", text: "mutable result" }], + }, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "Signed replay can keep its provider-owned id.", + thinkingSignature: "sig_later", + }, + { type: "toolUse", id: "call_shared", name: "read", input: { path: "b" } }, + ], + }, + { + role: "toolResult", + toolCallId: "stale_call", + toolUseId: "call_shared", + content: [{ type: "text", text: "signed result" }], + }, + ]); + + const out = sanitizeToolCallInputs(input, { + allowedToolNames: ["read"], + allowProviderOwnedThinkingReplay: true, + }); + + expect(out).toBe(input); + }); + + it("drops signed-thinking reused ids when their real result is displaced", () => { + const firstAssistant = { + role: "assistant", + content: [{ type: "toolCall", id: "call_shared", name: "read", arguments: { path: "a" } }], + } as const; + const firstResult = { + role: "toolResult", + toolCallId: "call_shared", + content: [{ type: "text", text: "mutable result" }], + } as const; + const userMessage = { + role: "user", + content: [{ type: "text", text: "interstitial" }], + } as const; + const displacedResult = { + role: "toolResult", + toolCallId: "call_shared", + content: [{ type: "text", text: "signed result" }], + } as const; + const input = castAgentMessages([ + firstAssistant, + firstResult, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "Signed replay has a displaced result.", + thinkingSignature: "sig_later", + }, + { type: "toolUse", id: "call_shared", name: "read", input: { path: "b" } }, + ], + }, + userMessage, + displacedResult, + ]); + + const out = sanitizeToolCallInputs(input, { + allowedToolNames: ["read"], + allowProviderOwnedThinkingReplay: true, + }); + + expect(out).toEqual([firstAssistant, firstResult, userMessage, displacedResult]); + }); + + it("drops signed-thinking assistant turns with sessions_spawn attachments when the result is missing", () => { + const content = "SIGNED_THINKING_ATTACHMENT_CONTENT"; const input = castAgentMessages([ { role: "assistant", @@ -615,7 +698,7 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => { name: "sessions_spawn", input: { task: "inspect attachment", - attachments: [{ name: "snapshot.txt", content: secret }], + attachments: [{ name: "snapshot.txt", content }], }, }, ], @@ -627,19 +710,20 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => { allowProviderOwnedThinkingReplay: true, }); - expect(out).toStrictEqual([]); - expect(JSON.stringify(out)).not.toContain(secret); + expect(out).toEqual([]); + expect(JSON.stringify(out)).not.toContain(content); }); - it("keeps signed-thinking assistant turns when sessions_spawn attachments are already redacted", () => { + it("keeps signed-thinking assistant turns with sessions_spawn attachments when the result is present", () => { + const content = "SIGNED_THINKING_ATTACHMENT_CONTENT"; const input = castAgentMessages([ { role: "assistant", content: [ { type: "thinking", - thinking: "Let me replay the helper turn.", - thinkingSignature: "sig_spawn_safe", + thinking: "Let me spawn a helper.", + thinkingSignature: "sig_spawn", }, { type: "toolUse", @@ -647,17 +731,17 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => { name: "sessions_spawn", input: { task: "inspect attachment", - attachments: [ - { - name: "snapshot.txt", - mimeType: "text/plain", - content: "__OPENCLAW_REDACTED__", - }, - ], + attachments: [{ name: "snapshot.txt", content }], }, }, ], }, + { + role: "toolResult", + toolCallId: "call_spawn", + toolName: "sessions_spawn", + content: [{ type: "text", text: "done" }], + }, ]); const out = sanitizeToolCallInputs(input, { @@ -666,6 +750,7 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => { }); expect(out).toEqual(input); + expect(JSON.stringify(out)).toContain(content); }); it("keeps generic thinking turns mutable when immutable preservation is disabled", () => { @@ -768,7 +853,7 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => { expect((toolCalls[0] ?? {}).input).toEqual({ task: "hello" }); }); - it("redacts sessions_spawn attachments for mixed-case and padded tool names", () => { + it("preserves sessions_spawn attachments for mixed-case and padded tool names", () => { const input = castAgentMessages([ { role: "assistant", @@ -793,7 +878,7 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => { expect((toolCalls[0] ?? {}).name).toBe("SESSIONS_SPAWN"); const inputObj = (toolCalls[0]?.input ?? {}) as Record; const attachments = (inputObj.attachments ?? []) as Array>; - expect(attachments[0]?.content).toBe("__OPENCLAW_REDACTED__"); + expect(attachments[0]?.content).toBe("SECRET"); }); it("preserves other block properties when trimming tool names", () => { const toolCalls = sanitizeAssistantToolCalls([ diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index 52da8bbf34dd..8beb203acb35 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -1,18 +1,15 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { hasNonEmptyString as hasNonEmptyStringField, - normalizeLowercaseStringOrEmpty, normalizeOptionalString, readStringValue, } from "../shared/string-coerce.js"; -import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js"; import { - REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT, - SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS, - isAllowedToolCallName, - isRedactedSessionsSpawnAttachment, - normalizeAllowedToolNames, -} from "./tool-call-shared.js"; + extractToolCallsFromAssistant, + extractToolResultId, + extractToolResultIds, +} from "./tool-call-id.js"; +import { isAllowedToolCallName, normalizeAllowedToolNames } from "./tool-call-shared.js"; type RawToolCallBlock = { type?: unknown; @@ -70,102 +67,23 @@ function hasToolCallId(block: RawToolCallBlock): boolean { ); } -function redactSessionsSpawnAttachmentsArgs(value: unknown): unknown { - if (!value || typeof value !== "object") { - return value; - } - const rec = value as Record; - const raw = rec.attachments; - if (!Array.isArray(raw)) { - return value; - } - let changed = false; - const next = raw.map((item) => { - if (isRedactedSessionsSpawnAttachment(item)) { - return item; - } - changed = true; - return redactSessionsSpawnAttachment(item); - }); - if (!changed) { - return value; - } - return { ...rec, attachments: next }; -} - -function redactSessionsSpawnAcpArgs(value: unknown): unknown { - if (!value || typeof value !== "object") { - return value; - } - const rec = value as Record; - const next = { ...rec }; - let changed = false; - - for (const key of ["resumeSessionId", "streamTo"] as const) { - if (Object.hasOwn(rec, key)) { - next[key] = REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT; - changed = true; - } - } - - return changed ? next : value; -} - -function redactSessionsSpawnArgs(value: unknown): unknown { - return redactSessionsSpawnAcpArgs(redactSessionsSpawnAttachmentsArgs(value)); -} - -function redactSessionsSpawnAttachment(item: unknown): Record { - const next: Record = { - content: REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT, - }; - if (!item || typeof item !== "object") { - return next; - } - const attachment = item as Record; - for (const key of SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS) { - const value = attachment[key]; - if (typeof value === "string" && value.trim().length > 0) { - next[key] = value; - } - } - return next; -} - function sanitizeToolCallBlock(block: RawToolCallBlock): RawToolCallBlock { + // This repair path normalizes replay shape only. Tool payloads are local + // trusted-operator transcript state per SECURITY.md, so do not redact or + // rewrite sessions_spawn arguments here. const rawName = readStringValue(block.name); const trimmedName = rawName?.trim(); const hasTrimmedName = typeof trimmedName === "string" && trimmedName.length > 0; const normalizedName = hasTrimmedName ? trimmedName : undefined; const nameChanged = hasTrimmedName && rawName !== trimmedName; - const isSessionsSpawn = normalizeLowercaseStringOrEmpty(normalizedName) === "sessions_spawn"; - - if (!isSessionsSpawn) { - if (!nameChanged) { - return block; - } - return { ...(block as Record), name: normalizedName } as RawToolCallBlock; - } - - // Redact sensitive sessions_spawn payload fields from persisted transcripts. - // Apply redaction to both `.arguments` and `.input` properties since block structures can vary. - const nextArgs = redactSessionsSpawnArgs(block.arguments); - const nextInput = redactSessionsSpawnArgs(block.input); - if (nextArgs === block.arguments && nextInput === block.input && !nameChanged) { + if (!nameChanged) { return block; } - const next = { ...(block as Record) }; if (nameChanged && normalizedName) { next.name = normalizedName; } - if (nextArgs !== block.arguments || Object.hasOwn(block, "arguments")) { - next.arguments = nextArgs; - } - if (nextInput !== block.input || Object.hasOwn(block, "input")) { - next.input = nextInput; - } return next as RawToolCallBlock; } @@ -298,6 +216,35 @@ export function stripToolResultDetails(messages: AgentMessage[]): AgentMessage[] return touched ? out : messages; } +function collectFollowingToolResults( + messages: AgentMessage[], + index: number, +): { ids: Set; displaced: boolean } { + const ids = new Set(); + let sawNonToolResult = false; + let displaced = false; + for (let nextIndex = index + 1; nextIndex < messages.length; nextIndex += 1) { + const message = messages[nextIndex]; + if (!message || typeof message !== "object") { + sawNonToolResult = true; + continue; + } + if (message.role === "assistant" && assistantHasToolCalls(message)) { + break; + } + if (message.role === "toolResult") { + const resultIds = extractToolResultIds(message); + for (const id of resultIds) { + ids.add(id); + } + displaced ||= resultIds.length > 0 && sawNonToolResult; + continue; + } + sawNonToolResult = true; + } + return { ids, displaced }; +} + function repairToolCallInputs( messages: AgentMessage[], options?: ToolCallInputRepairOptions, @@ -308,9 +255,11 @@ function repairToolCallInputs( const out: AgentMessage[] = []; const allowedToolNames = normalizeAllowedToolNames(options?.allowedToolNames); const allowProviderOwnedThinkingReplay = options?.allowProviderOwnedThinkingReplay === true; - const claimedReplaySafeToolCallIds = new Set(); + const preservedThinkingToolCallIds = new Set(); + const priorToolCallIds = new Set(); - for (const msg of messages) { + for (let index = 0; index < messages.length; index += 1) { + const msg = messages[index]; if (!msg || typeof msg !== "object") { out.push(msg); continue; @@ -328,16 +277,24 @@ function repairToolCallInputs( ) { // Signed Anthropic thinking blocks must remain byte-for-byte stable on // replay. Preserve the turn only if every sibling tool call is already - // valid and requires no redaction or normalization. Otherwise drop the + // valid and already has a real tool result. Otherwise drop the // whole assistant turn rather than mutating provider-owned content. const replaySafeToolCalls = extractToolCallsFromAssistant(msg); + const followingToolResults = collectFollowingToolResults(messages, index); if ( isReplaySafeThinkingAssistantTurn(msg.content, allowedToolNames) && - replaySafeToolCalls.every((toolCall) => !claimedReplaySafeToolCallIds.has(toolCall.id)) + replaySafeToolCalls.every( + (toolCall) => + !preservedThinkingToolCallIds.has(toolCall.id) && + (!followingToolResults.displaced || !priorToolCallIds.has(toolCall.id)) && + followingToolResults.ids.has(toolCall.id), + ) ) { for (const toolCall of replaySafeToolCalls) { - claimedReplaySafeToolCallIds.add(toolCall.id); + preservedThinkingToolCallIds.add(toolCall.id); + priorToolCallIds.add(toolCall.id); } + changed ||= followingToolResults.displaced; out.push(msg); } else { droppedToolCalls += countRawToolCallBlocks(msg.content); @@ -366,35 +323,12 @@ function repairToolCallInputs( } if (isRawToolCallBlock(block)) { if (RAW_TOOL_CALL_BLOCK_TYPES.has((block as { type?: string }).type ?? "")) { - // Only sanitize (redact) sessions_spawn blocks; all others are passed through - // unchanged to preserve provider-specific shapes (e.g. toolUse.input for Anthropic). - const blockName = - typeof (block as { name?: unknown }).name === "string" - ? (block as { name: string }).name.trim() - : undefined; - if (normalizeLowercaseStringOrEmpty(blockName) === "sessions_spawn") { - const sanitized = sanitizeToolCallBlock(block); - if (sanitized !== block) { - changed = true; - messageChanged = true; - } - nextContent.push(sanitized as typeof block); - } else { - if (typeof (block as { name?: unknown }).name === "string") { - const rawName = (block as { name: string }).name; - const trimmedName = rawName.trim(); - if (rawName !== trimmedName && trimmedName) { - const renamed = { ...(block as object), name: trimmedName } as typeof block; - nextContent.push(renamed); - changed = true; - messageChanged = true; - } else { - nextContent.push(block); - } - } else { - nextContent.push(block); - } + const sanitized = sanitizeToolCallBlock(block); + if (sanitized !== block) { + changed = true; + messageChanged = true; } + nextContent.push(sanitized as typeof block); continue; } } else { @@ -408,15 +342,26 @@ function repairToolCallInputs( changed = true; continue; } - out.push({ ...msg, content: nextContent }); + const nextMessage = { ...msg, content: nextContent }; + for (const toolCall of extractToolCallsFromAssistant(nextMessage)) { + priorToolCallIds.add(toolCall.id); + } + out.push(nextMessage); continue; } if (messageChanged) { - out.push({ ...msg, content: nextContent }); + const nextMessage = { ...msg, content: nextContent }; + for (const toolCall of extractToolCallsFromAssistant(nextMessage)) { + priorToolCallIds.add(toolCall.id); + } + out.push(nextMessage); continue; } + for (const toolCall of extractToolCallsFromAssistant(msg)) { + priorToolCallIds.add(toolCall.id); + } out.push(msg); } diff --git a/src/agents/tool-call-id.ts b/src/agents/tool-call-id.ts index 133b26b25f4e..50794126a075 100644 --- a/src/agents/tool-call-id.ts +++ b/src/agents/tool-call-id.ts @@ -1,10 +1,6 @@ import { createHash } from "node:crypto"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { - hasUnredactedSessionsSpawnAttachments, - isAllowedToolCallName, - normalizeAllowedToolNames, -} from "./tool-call-shared.js"; +import { isAllowedToolCallName, normalizeAllowedToolNames } from "./tool-call-shared.js"; export type ToolCallIdMode = "strict" | "strict9"; const NATIVE_ANTHROPIC_TOOL_USE_ID_RE = /^toolu_[A-Za-z0-9_]+$/; @@ -90,15 +86,36 @@ export function extractToolCallsFromAssistant( export function extractToolResultId( msg: Extract, ): string | null { - const toolCallId = (msg as { toolCallId?: unknown }).toolCallId; - if (typeof toolCallId === "string" && toolCallId) { - return toolCallId; + return extractToolResultIds(msg)[0] ?? null; +} + +export function extractToolResultIds(msg: Extract): string[] { + const ids: string[] = []; + const record = msg as { + toolCallId?: unknown; + toolUseId?: unknown; + tool_call_id?: unknown; + tool_use_id?: unknown; + callId?: unknown; + call_id?: unknown; + }; + for (const value of [ + record.toolCallId, + record.toolUseId, + record.tool_call_id, + record.tool_use_id, + record.callId, + record.call_id, + ]) { + if (typeof value !== "string") { + continue; + } + const id = value.trim(); + if (id && !ids.includes(id)) { + ids.push(id); + } } - const toolUseId = (msg as { toolUseId?: unknown }).toolUseId; - if (typeof toolUseId === "string" && toolUseId) { - return toolUseId; - } - return null; + return ids; } function isThinkingLikeBlock(block: unknown): boolean { @@ -119,10 +136,7 @@ function hasToolCallInput(block: ReplaySafeToolCallBlock): boolean { function toolCallNeedsReplayMutation(block: ReplaySafeToolCallBlock): boolean { const rawName = typeof block.name === "string" ? block.name : undefined; const trimmedName = rawName?.trim(); - if (rawName && rawName !== trimmedName) { - return true; - } - return hasUnredactedSessionsSpawnAttachments(block); + return !!rawName && rawName !== trimmedName; } function isReplaySafeThinkingAssistantMessage( diff --git a/src/agents/tool-call-shared.ts b/src/agents/tool-call-shared.ts index bae14bfa5eb0..1c397e7f14a8 100644 --- a/src/agents/tool-call-shared.ts +++ b/src/agents/tool-call-shared.ts @@ -3,9 +3,6 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; const TOOL_CALL_NAME_MAX_CHARS = 64; const TOOL_CALL_NAME_RE = /^[A-Za-z0-9_:.-]+$/; -export const REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT = "__OPENCLAW_REDACTED__"; -export const SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS = ["name", "encoding", "mimeType"] as const; - export function normalizeAllowedToolNames(allowedToolNames?: Iterable): Set | null { if (!allowedToolNames) { return null; @@ -43,55 +40,3 @@ export function isAllowedToolCallName( } return allowedToolNames.has(normalizeLowercaseStringOrEmpty(trimmed)); } - -export function isRedactedSessionsSpawnAttachment(item: unknown): boolean { - if (!item || typeof item !== "object") { - return false; - } - const attachment = item as Record; - if (attachment.content !== REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT) { - return false; - } - for (const key of Object.keys(attachment)) { - if (key === "content") { - continue; - } - if (!(SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS as readonly string[]).includes(key)) { - return false; - } - if (typeof attachment[key] !== "string" || attachment[key].trim().length === 0) { - return false; - } - } - return true; -} - -type SessionsSpawnAttachmentToolCallBlock = { - name?: unknown; - input?: unknown; - arguments?: unknown; -}; - -export function hasUnredactedSessionsSpawnAttachments( - block: SessionsSpawnAttachmentToolCallBlock, -): boolean { - const rawName = typeof block.name === "string" ? block.name.trim() : ""; - if (normalizeLowercaseStringOrEmpty(rawName) !== "sessions_spawn") { - return false; - } - for (const payload of [block.arguments, block.input]) { - if (!payload || typeof payload !== "object") { - continue; - } - const attachments = (payload as { attachments?: unknown }).attachments; - if (!Array.isArray(attachments)) { - continue; - } - for (const attachment of attachments) { - if (!isRedactedSessionsSpawnAttachment(attachment)) { - return true; - } - } - } - return false; -} diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 5eb76e21ae34..a0fb89387d36 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -196,7 +196,6 @@ function createSessionsSpawnToolSchema(params: { ), // Inline attachments (snapshot-by-value). - // NOTE: Attachment contents are redacted from transcript persistence by sanitizeToolCallInputs. attachments: Type.Optional( Type.Array( Type.Object({