diff --git a/docs/reference/transcript-hygiene.md b/docs/reference/transcript-hygiene.md index 7b777258bf61..c09e9f2fde4a 100644 --- a/docs/reference/transcript-hygiene.md +++ b/docs/reference/transcript-hygiene.md @@ -150,6 +150,13 @@ inter-session user turns that only have provenance metadata. - Turn validation (merge consecutive user turns to satisfy strict alternation). - Trailing assistant prefill turns are stripped from outgoing Anthropic Messages payloads when thinking is enabled, including Cloudflare AI Gateway routes. +- Pre-compaction assistant thinking signatures are stripped before provider + replay when a session has been compacted. Thinking signatures are + cryptographically bound to the conversation prefix at generation time; after + compaction the prefix changes (summarized content is replaced by a compaction + summary), so replaying the original signatures causes Anthropic to reject the + request with "Invalid signature in thinking block". The thinking text is + preserved as an unsigned block and is then handled by the rule below. - Thinking blocks with missing, empty, or blank replay signatures are stripped before provider conversion. If that empties an assistant turn, OpenClaw keeps turn shape with non-empty omitted-reasoning text. @@ -165,6 +172,9 @@ inter-session user turns that only have provenance metadata. repaired on disk before load. - Assistant stream-error turns that contain only blank text blocks are dropped from the in-memory replay copy instead of replaying an invalid blank block. +- Pre-compaction assistant thinking signatures are stripped before Converse + replay when a session has been compacted, for the same reason as Anthropic + above. - Claude thinking blocks with missing, empty, or blank replay signatures are stripped before Converse replay. If that empties an assistant turn, OpenClaw keeps turn shape with non-empty omitted-reasoning text. diff --git a/src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts b/src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts index 2c25db649ae2..47fe5d6b6488 100644 --- a/src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts +++ b/src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts @@ -33,6 +33,16 @@ function makeAssistant(text: string, timestamp: number) { }); } +function makeThinkingAssistant(text: string, thinkingSignature: string, timestamp: number) { + return makeAgentAssistantMessage({ + content: [ + { type: "thinking", thinking: "reasoning", thinkingSignature } as never, + { type: "text", text }, + ], + timestamp, + }); +} + function requireString(value: string | undefined, label: string): string { if (!value) { throw new Error(`expected ${label}`); @@ -508,3 +518,71 @@ describe("shouldRotateCompactionTranscript", () => { ).toBe(true); }); }); + +describe("rotateTranscriptAfterCompaction — thinking signature stripping", () => { + it("strips thinkingSignature from kept assistant messages in the successor file", async () => { + const dir = await createTmpDir(); + const manager = SessionManager.create(dir, dir); + + const oldUserId = manager.appendMessage({ + role: "user", + content: "old question", + timestamp: 1, + }); + manager.appendMessage(makeThinkingAssistant("old answer", "stale_sig_old", 2)); + const firstKeptId = manager.appendMessage({ + role: "user", + content: "kept question", + timestamp: 3, + }); + manager.appendMessage(makeThinkingAssistant("kept answer", "stale_sig_kept", 4)); + manager.appendCompaction("Summary of old work.", firstKeptId, 3000); + manager.appendMessage({ role: "user", content: "post question", timestamp: 5 }); + manager.appendMessage(makeThinkingAssistant("post answer", "fresh_sig", 6)); + + const sessionFile = requireString(manager.getSessionFile(), "source session file"); + const result = await rotateTranscriptAfterCompaction({ + sessionManager: manager, + sessionFile, + now: () => new Date("2026-06-04T00:00:00.000Z"), + }); + + expect(result.rotated).toBe(true); + const successor = SessionManager.open( + requireString(result.sessionFile, "successor session file"), + ); + + const entries = successor.getEntries(); + function getThinkingSignatureForTimestamp(ts: number): unknown { + for (const entry of entries) { + if (entry.type !== "message" || entry.message.role !== "assistant") { + continue; + } + if ((entry.message as { timestamp?: number }).timestamp !== ts) { + continue; + } + const content = (entry.message as { content?: unknown[] }).content ?? []; + for (const block of content) { + if ((block as { type?: unknown }).type === "thinking") { + return (block as { thinkingSignature?: unknown }).thinkingSignature; + } + } + } + return undefined; + } + + // Pre-compaction kept message (timestamp 4): signature stripped + expect(getThinkingSignatureForTimestamp(4)).toBeUndefined(); + // Post-compaction message (timestamp 6): signature preserved intact + expect(getThinkingSignatureForTimestamp(6)).toBe("fresh_sig"); + + // Old summarized messages should not appear + expect(entries.find((e) => e.id === oldUserId)).toBeUndefined(); + + // Context should remain coherent: compaction summary + kept + post-compaction + const contextText = JSON.stringify(successor.buildSessionContext().messages); + expect(contextText).toContain("kept question"); + expect(contextText).toContain("kept answer"); + expect(contextText).toContain("post answer"); + }); +}); diff --git a/src/agents/embedded-agent-runner/compaction-successor-transcript.ts b/src/agents/embedded-agent-runner/compaction-successor-transcript.ts index 912be8d80a60..de8b322fa294 100644 --- a/src/agents/embedded-agent-runner/compaction-successor-transcript.ts +++ b/src/agents/embedded-agent-runner/compaction-successor-transcript.ts @@ -8,6 +8,7 @@ import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { CompactionEntry, SessionEntry, SessionHeader } from "../sessions/index.js"; import { collectDuplicateUserMessageEntryIdsForCompaction } from "./compaction-duplicate-user-messages.js"; +import { stripThinkingSignaturesFromMessage } from "./thinking.js"; import { readTranscriptFileState, TranscriptFileState, @@ -116,15 +117,21 @@ function buildSuccessorEntries(params: { const compaction = branch[latestCompactionIndex] as CompactionEntry; const summarizedBranchIds = new Set(); + const preCompactionKeptBranchIds = new Set(); + let foundFirstKept = false; for (let index = 0; index < latestCompactionIndex; index += 1) { const entry = branch[index]; if (!entry) { continue; } if (compaction.firstKeptEntryId && entry.id === compaction.firstKeptEntryId) { - break; + foundFirstKept = true; + } + if (foundFirstKept) { + preCompactionKeptBranchIds.add(entry.id); + } else { + summarizedBranchIds.add(entry.id); } - summarizedBranchIds.add(entry.id); } const latestStateEntryIds = collectLatestStateEntryIds(branch.slice(0, latestCompactionIndex)); @@ -174,9 +181,17 @@ function buildSuccessorEntries(params: { parentId = entryById.get(parentId)?.parentId ?? null; } - keptEntries.push( - parentId === entry.parentId ? entry : ({ ...entry, parentId } as SessionEntry), - ); + const reparented = + parentId === entry.parentId ? entry : ({ ...entry, parentId } as SessionEntry); + // Strip thinking signatures only from pre-compaction kept entries. Pre-compaction + // signatures are bound to the original context prefix; the successor file has a different + // prefix so those signatures would cause Anthropic "Invalid signature in thinking block". + // Post-compaction entries were generated in the new context and have valid signatures. + const transformed = + reparented.type === "message" && preCompactionKeptBranchIds.has(reparented.id) + ? { ...reparented, message: stripThinkingSignaturesFromMessage(reparented.message) } + : reparented; + keptEntries.push(transformed); } return orderSuccessorEntries({ diff --git a/src/agents/embedded-agent-runner/replay-history.ts b/src/agents/embedded-agent-runner/replay-history.ts index 853655d45523..d64015b9a171 100644 --- a/src/agents/embedded-agent-runner/replay-history.ts +++ b/src/agents/embedded-agent-runner/replay-history.ts @@ -55,6 +55,7 @@ import { dropThinkingBlocks, shouldPreserveLatestAssistantThinking, stripInvalidThinkingSignatures, + stripStaleThinkingSignaturesForCompactionReplay, } from "./thinking.js"; const MODEL_SNAPSHOT_CUSTOM_TYPE = "model-snapshot"; @@ -734,16 +735,25 @@ export async function sanitizeSessionHistory(params: { const preserveLatestAssistantThinking = params.preserveLatestAssistantThinking ?? shouldPreserveLatestAssistantThinking(sanitizedImages); + // Strip thinking signatures that are stale due to compaction context changes before + // stripInvalidThinkingSignatures runs. Pre-compaction kept messages carry signatures + // bound to the original prefix; after compaction the prefix changes and Anthropic + // rejects them. Timestamp comparison with the latest compaction summary identifies + // the affected messages regardless of path (standard or truncateAfterCompaction). + const compactionStaleStripped = + signedThinkingProvider || policy.preserveSignatures + ? stripStaleThinkingSignaturesForCompactionReplay(sanitizedImages) + : sanitizedImages; // Some recovery paths supply a narrow policy with preserveSignatures disabled. // Native signed-thinking providers still cannot replay missing/blank // signatures once the assistant turn is no longer latest in the outbound // request. const validatedThinkingSignatures = signedThinkingProvider || policy.preserveSignatures - ? stripInvalidThinkingSignatures(sanitizedImages, { + ? stripInvalidThinkingSignatures(compactionStaleStripped, { preserveLatestAssistant: preserveLatestAssistantThinking, }) - : sanitizedImages; + : compactionStaleStripped; const droppedReasoning = policy.dropReasoningFromHistory ? dropReasoningFromHistory(validatedThinkingSignatures) : validatedThinkingSignatures; diff --git a/src/agents/embedded-agent-runner/thinking.test.ts b/src/agents/embedded-agent-runner/thinking.test.ts index 8a4cc9eba875..38fbc6e11789 100644 --- a/src/agents/embedded-agent-runner/thinking.test.ts +++ b/src/agents/embedded-agent-runner/thinking.test.ts @@ -12,6 +12,7 @@ import { isAssistantMessageWithContent, sanitizeThinkingForRecovery, stripInvalidThinkingSignatures, + stripStaleThinkingSignaturesForCompactionReplay, wrapAnthropicStreamWithRecovery, } from "./thinking.js"; @@ -863,3 +864,216 @@ describe("wrapAnthropicStreamWithRecovery", () => { expect(events).toHaveLength(2); }); }); + +describe("stripStaleThinkingSignaturesForCompactionReplay", () => { + it("returns the original reference when no compaction summary is present", () => { + const messages: AgentMessage[] = [ + castAgentMessage({ role: "user", content: "hello" }), + castAgentMessage({ + role: "assistant", + content: [{ type: "thinking", thinking: "think", thinkingSignature: "sig" }], + timestamp: 1000, + }), + ]; + expect(stripStaleThinkingSignaturesForCompactionReplay(messages)).toBe(messages); + }); + + it("strips thinking signatures from assistant messages at or before the compaction timestamp", () => { + const compactionSummary = castAgentMessage({ + role: "compactionSummary", + summary: "summary", + tokensBefore: 100, + timestamp: 2000, + }); + const preCompaction = castAgentMessage({ + role: "assistant", + content: [ + { type: "thinking", thinking: "old think", thinkingSignature: "stale_sig" }, + { type: "text", text: "old answer" }, + ], + timestamp: 1000, + }); + const postCompaction = castAgentMessage({ + role: "assistant", + content: [ + { type: "thinking", thinking: "new think", thinkingSignature: "fresh_sig" }, + { type: "text", text: "new answer" }, + ], + timestamp: 3000, + }); + const messages: AgentMessage[] = [ + compactionSummary, + preCompaction, + castAgentMessage({ role: "user", content: "q" }), + postCompaction, + ]; + + const result = stripStaleThinkingSignaturesForCompactionReplay(messages); + expect(result).not.toBe(messages); + + const pre = result[1] as AssistantMessage; + expect(pre.content).toEqual([ + { type: "thinking", thinking: "old think" }, + { type: "text", text: "old answer" }, + ]); + + const post = result[3] as AssistantMessage; + expect(post.content).toEqual([ + { type: "thinking", thinking: "new think", thinkingSignature: "fresh_sig" }, + { type: "text", text: "new answer" }, + ]); + }); + + it("strips thinkingSignature from a thinking-only pre-compaction message, leaving text for downstream handling", () => { + const messages: AgentMessage[] = [ + castAgentMessage({ + role: "compactionSummary", + summary: "s", + tokensBefore: 0, + timestamp: 2000, + }), + castAgentMessage({ + role: "assistant", + content: [{ type: "thinking", thinking: "hidden", thinkingSignature: "sig" }], + timestamp: 1000, + }), + ]; + const result = stripStaleThinkingSignaturesForCompactionReplay(messages); + const assistant = result[1] as AssistantMessage; + // Signature is stripped; thinking text is preserved. Downstream stripInvalidThinkingSignatures + // converts this unsigned thinking-only message to [assistant reasoning omitted]. + expect(assistant.content).toEqual([{ type: "thinking", thinking: "hidden" }]); + }); + + it("strips redacted_thinking data from pre-compaction messages", () => { + const messages: AgentMessage[] = [ + castAgentMessage({ + role: "compactionSummary", + summary: "s", + tokensBefore: 0, + timestamp: 2000, + }), + castAgentMessage({ + role: "assistant", + content: [ + { type: "redacted_thinking", data: "opaque_sig" }, + { type: "text", text: "visible" }, + ], + timestamp: 1500, + }), + ]; + const result = stripStaleThinkingSignaturesForCompactionReplay(messages); + const assistant = result[1] as AssistantMessage; + expect(assistant.content).toEqual([ + { type: "redacted_thinking" }, + { type: "text", text: "visible" }, + ]); + }); + + it("skips assistant messages with no parseable timestamp", () => { + const messages: AgentMessage[] = [ + castAgentMessage({ + role: "compactionSummary", + summary: "s", + tokensBefore: 0, + timestamp: 2000, + }), + castAgentMessage({ + role: "assistant", + content: [{ type: "thinking", thinking: "think", thinkingSignature: "sig" }], + }), + ]; + const result = stripStaleThinkingSignaturesForCompactionReplay(messages); + expect(result).toBe(messages); + }); + + it("uses the latest compaction summary timestamp when multiple summaries are present", () => { + const messages: AgentMessage[] = [ + castAgentMessage({ + role: "compactionSummary", + summary: "first", + tokensBefore: 0, + timestamp: 1000, + }), + castAgentMessage({ + role: "assistant", + content: [{ type: "thinking", thinking: "mid", thinkingSignature: "sig_mid" }], + timestamp: 1500, + }), + castAgentMessage({ + role: "compactionSummary", + summary: "second", + tokensBefore: 0, + timestamp: 2000, + }), + castAgentMessage({ + role: "assistant", + content: [{ type: "thinking", thinking: "after", thinkingSignature: "sig_after" }], + timestamp: 3000, + }), + ]; + const result = stripStaleThinkingSignaturesForCompactionReplay(messages); + // mid (timestamp 1500 < 2000): signature stripped + const mid = result[1] as AssistantMessage; + expect(mid.content).toEqual([{ type: "thinking", thinking: "mid" }]); + // after (timestamp 3000 > 2000): signature kept + const after = result[3] as AssistantMessage; + expect((after.content[0] as unknown as Record).thinkingSignature).toBe( + "sig_after", + ); + }); + + it("uses max compaction timestamp when summaries appear out of chronological order", () => { + // Two compaction summaries: ts=1500 appears first, ts=2000 appears later. + // latestCompactionTimestamp must be max(1500, 2000) = 2000, not 1500. + const messages: AgentMessage[] = [ + castAgentMessage({ + role: "compactionSummary", + summary: "earlier-in-array lower-timestamp", + tokensBefore: 0, + timestamp: 1500, + }), + castAgentMessage({ + role: "assistant", + content: [{ type: "thinking", thinking: "t1", thinkingSignature: "sig1" }], + timestamp: 1200, + }), + castAgentMessage({ + role: "compactionSummary", + summary: "later-in-array higher-timestamp", + tokensBefore: 0, + timestamp: 2000, + }), + castAgentMessage({ + role: "assistant", + content: [{ type: "thinking", thinking: "t2", thinkingSignature: "sig2" }], + timestamp: 1800, + }), + ]; + const result = stripStaleThinkingSignaturesForCompactionReplay(messages); + // Both messages have ts < 2000 so both should be stripped + const a1 = result[1] as AssistantMessage; + const a2 = result[3] as AssistantMessage; + expect((a1.content[0] as unknown as Record).thinkingSignature).toBeUndefined(); + expect((a2.content[0] as unknown as Record).thinkingSignature).toBeUndefined(); + }); + + it("preserves signatures on assistant messages at exactly the compaction timestamp", () => { + const messages: AgentMessage[] = [ + castAgentMessage({ + role: "compactionSummary", + summary: "s", + tokensBefore: 0, + timestamp: 2000, + }), + castAgentMessage({ + role: "assistant", + content: [{ type: "thinking", thinking: "exact", thinkingSignature: "exact_sig" }], + timestamp: 2000, + }), + ]; + const result = stripStaleThinkingSignaturesForCompactionReplay(messages); + // Same millisecond as compaction: treated as post-compaction; signature preserved + expect(result).toBe(messages); + }); +}); diff --git a/src/agents/embedded-agent-runner/thinking.ts b/src/agents/embedded-agent-runner/thinking.ts index a5721353a899..50c3202e3090 100644 --- a/src/agents/embedded-agent-runner/thinking.ts +++ b/src/agents/embedded-agent-runner/thinking.ts @@ -86,6 +86,133 @@ function buildOmittedAssistantReasoningContent(): AssistantContentBlock[] { return [{ type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT } as AssistantContentBlock]; } +function parseTimestampMs(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +} + +function stripSignatureFieldsFromThinkingBlock( + block: AssistantContentBlock, +): AssistantContentBlock { + const record = block as unknown as Record; + const stripped: Record = {}; + for (const key of Object.keys(record)) { + if (key === "thinkingSignature" || key === "signature" || key === "thought_signature") { + continue; + } + // data is the signature payload for redacted_thinking blocks + if (key === "data" && record.type === "redacted_thinking") { + continue; + } + stripped[key] = record[key]; + } + return stripped as unknown as AssistantContentBlock; +} + +/** + * Strip all thinking signature fields from a single assistant message. + * + * Removes thinkingSignature / signature / thought_signature from thinking blocks and + * data from redacted_thinking blocks. Thinking text is preserved. If the message + * becomes thinking-only with no signatures, the downstream stripInvalidThinkingSignatures + * will convert those unsigned blocks to placeholder text. + * + * Returns the original reference when nothing was stripped. + */ +export function stripThinkingSignaturesFromMessage(message: AgentMessage): AgentMessage { + if (!isAssistantMessageWithContent(message)) { + return message; + } + let changed = false; + const newContent: AssistantContentBlock[] = []; + for (const block of message.content) { + if (!isThinkingBlock(block)) { + newContent.push(block); + continue; + } + const record = block as unknown as Record; + const hasSignature = + record.thinkingSignature != null || + record.signature != null || + record.thought_signature != null || + (record.type === "redacted_thinking" && record.data != null); + if (!hasSignature) { + newContent.push(block); + continue; + } + newContent.push(stripSignatureFieldsFromThinkingBlock(block)); + changed = true; + } + if (!changed) { + return message; + } + return { ...message, content: newContent }; +} + +/** + * Strip thinking signatures from assistant messages that predate the latest compaction. + * + * Pre-compaction thinking signatures are cryptographically bound to the original context + * prefix. After compaction the prefix changes (summarized content is replaced by the + * compaction summary) so those signatures are stale and Anthropic rejects them with + * "Invalid signature in thinking block". The existing stripInvalidThinkingSignatures only + * catches absent/blank signatures; this function catches contextually stale ones identified + * by timestamp comparison with the latest compaction summary. + * + * Only strips from assistant messages whose timestamp is strictly before the latest + * compaction summary timestamp. Messages at or after that timestamp may have been generated + * in the new context and retain their signatures. Messages with no parseable timestamp are + * left unchanged. + * + * Returns the original array reference when nothing was changed. + */ +export function stripStaleThinkingSignaturesForCompactionReplay( + messages: AgentMessage[], +): AgentMessage[] { + let latestCompactionTimestamp: number | null = null; + for (const message of messages) { + if ((message as { role?: unknown }).role !== "compactionSummary") { + continue; + } + const ts = parseTimestampMs((message as { timestamp?: unknown }).timestamp); + if (ts !== null) { + latestCompactionTimestamp = + latestCompactionTimestamp === null ? ts : Math.max(latestCompactionTimestamp, ts); + } + } + if (latestCompactionTimestamp === null) { + return messages; + } + + let touched = false; + const out: AgentMessage[] = []; + for (const message of messages) { + if (!isAssistantMessageWithContent(message)) { + out.push(message); + continue; + } + const ts = parseTimestampMs((message as { timestamp?: unknown }).timestamp); + if (ts === null || ts >= latestCompactionTimestamp) { + out.push(message); + continue; + } + const stripped = stripThinkingSignaturesFromMessage(message); + if (stripped !== message) { + touched = true; + } + out.push(stripped); + } + return touched ? out : messages; +} + function hasReplayableThinkingSignature(block: AssistantContentBlock): boolean { if (!isThinkingBlock(block)) { return false;