Skip to content

Commit a351e8b

Browse files
openperfsallyom
authored andcommitted
fix(agents): strip stale compaction thinking signatures before Anthropic replay
Pre-compaction assistant messages carry thinkingSignature values bound to the original conversation prefix. After compaction the prefix changes (summarized content is replaced by the compaction summary), so Anthropic rejects those signatures with "Invalid signature in thinking block", permanently stalling the session through gateway restarts. stripInvalidThinkingSignatures only catches absent/blank signatures; this adds stripStaleThinkingSignaturesForCompactionReplay (thinking.ts) which identifies pre-compaction assistant messages by timestamp comparison against the latest compaction summary and strips their signature fields. Called in sanitizeSessionHistory (replay-history.ts) before stripInvalidThinkingSignatures for all signed-thinking providers (Anthropic, Bedrock, Vertex). Also fixes buildSuccessorEntries (compaction-successor-transcript.ts) to strip only pre-compaction kept entries when writing the rotation successor JSONL; uses strict < timestamp boundary so same-instant post-compaction messages are not affected. Docs: update transcript-hygiene.md Anthropic and Bedrock sections. Tests: 8 new cases for stripStaleThinkingSignaturesForCompactionReplay; 1 new case for buildSuccessorEntries verifying pre/post-compaction signature boundary. Fixes #90108
1 parent a9224f6 commit a351e8b

6 files changed

Lines changed: 461 additions & 7 deletions

File tree

docs/reference/transcript-hygiene.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,13 @@ inter-session user turns that only have provenance metadata.
150150
- Turn validation (merge consecutive user turns to satisfy strict alternation).
151151
- Trailing assistant prefill turns are stripped from outgoing Anthropic Messages
152152
payloads when thinking is enabled, including Cloudflare AI Gateway routes.
153+
- Pre-compaction assistant thinking signatures are stripped before provider
154+
replay when a session has been compacted. Thinking signatures are
155+
cryptographically bound to the conversation prefix at generation time; after
156+
compaction the prefix changes (summarized content is replaced by a compaction
157+
summary), so replaying the original signatures causes Anthropic to reject the
158+
request with "Invalid signature in thinking block". The thinking text is
159+
preserved as an unsigned block and is then handled by the rule below.
153160
- Thinking blocks with missing, empty, or blank replay signatures are stripped
154161
before provider conversion. If that empties an assistant turn, OpenClaw keeps
155162
turn shape with non-empty omitted-reasoning text.
@@ -165,6 +172,9 @@ inter-session user turns that only have provenance metadata.
165172
repaired on disk before load.
166173
- Assistant stream-error turns that contain only blank text blocks are dropped
167174
from the in-memory replay copy instead of replaying an invalid blank block.
175+
- Pre-compaction assistant thinking signatures are stripped before Converse
176+
replay when a session has been compacted, for the same reason as Anthropic
177+
above.
168178
- Claude thinking blocks with missing, empty, or blank replay signatures are
169179
stripped before Converse replay. If that empties an assistant turn, OpenClaw
170180
keeps turn shape with non-empty omitted-reasoning text.

src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ function makeAssistant(text: string, timestamp: number) {
3333
});
3434
}
3535

36+
function makeThinkingAssistant(text: string, thinkingSignature: string, timestamp: number) {
37+
return makeAgentAssistantMessage({
38+
content: [
39+
{ type: "thinking", thinking: "reasoning", thinkingSignature } as never,
40+
{ type: "text", text },
41+
],
42+
timestamp,
43+
});
44+
}
45+
3646
function requireString(value: string | undefined, label: string): string {
3747
if (!value) {
3848
throw new Error(`expected ${label}`);
@@ -508,3 +518,71 @@ describe("shouldRotateCompactionTranscript", () => {
508518
).toBe(true);
509519
});
510520
});
521+
522+
describe("rotateTranscriptAfterCompaction — thinking signature stripping", () => {
523+
it("strips thinkingSignature from kept assistant messages in the successor file", async () => {
524+
const dir = await createTmpDir();
525+
const manager = SessionManager.create(dir, dir);
526+
527+
const oldUserId = manager.appendMessage({
528+
role: "user",
529+
content: "old question",
530+
timestamp: 1,
531+
});
532+
manager.appendMessage(makeThinkingAssistant("old answer", "stale_sig_old", 2));
533+
const firstKeptId = manager.appendMessage({
534+
role: "user",
535+
content: "kept question",
536+
timestamp: 3,
537+
});
538+
manager.appendMessage(makeThinkingAssistant("kept answer", "stale_sig_kept", 4));
539+
manager.appendCompaction("Summary of old work.", firstKeptId, 3000);
540+
manager.appendMessage({ role: "user", content: "post question", timestamp: 5 });
541+
manager.appendMessage(makeThinkingAssistant("post answer", "fresh_sig", 6));
542+
543+
const sessionFile = requireString(manager.getSessionFile(), "source session file");
544+
const result = await rotateTranscriptAfterCompaction({
545+
sessionManager: manager,
546+
sessionFile,
547+
now: () => new Date("2026-06-04T00:00:00.000Z"),
548+
});
549+
550+
expect(result.rotated).toBe(true);
551+
const successor = SessionManager.open(
552+
requireString(result.sessionFile, "successor session file"),
553+
);
554+
555+
const entries = successor.getEntries();
556+
function getThinkingSignatureForTimestamp(ts: number): unknown {
557+
for (const entry of entries) {
558+
if (entry.type !== "message" || entry.message.role !== "assistant") {
559+
continue;
560+
}
561+
if ((entry.message as { timestamp?: number }).timestamp !== ts) {
562+
continue;
563+
}
564+
const content = (entry.message as { content?: unknown[] }).content ?? [];
565+
for (const block of content) {
566+
if ((block as { type?: unknown }).type === "thinking") {
567+
return (block as { thinkingSignature?: unknown }).thinkingSignature;
568+
}
569+
}
570+
}
571+
return undefined;
572+
}
573+
574+
// Pre-compaction kept message (timestamp 4): signature stripped
575+
expect(getThinkingSignatureForTimestamp(4)).toBeUndefined();
576+
// Post-compaction message (timestamp 6): signature preserved intact
577+
expect(getThinkingSignatureForTimestamp(6)).toBe("fresh_sig");
578+
579+
// Old summarized messages should not appear
580+
expect(entries.find((e) => e.id === oldUserId)).toBeUndefined();
581+
582+
// Context should remain coherent: compaction summary + kept + post-compaction
583+
const contextText = JSON.stringify(successor.buildSessionContext().messages);
584+
expect(contextText).toContain("kept question");
585+
expect(contextText).toContain("kept answer");
586+
expect(contextText).toContain("post answer");
587+
});
588+
});

src/agents/embedded-agent-runner/compaction-successor-transcript.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js";
88
import type { OpenClawConfig } from "../../config/types.openclaw.js";
99
import type { CompactionEntry, SessionEntry, SessionHeader } from "../sessions/index.js";
1010
import { collectDuplicateUserMessageEntryIdsForCompaction } from "./compaction-duplicate-user-messages.js";
11+
import { stripThinkingSignaturesFromMessage } from "./thinking.js";
1112
import {
1213
readTranscriptFileState,
1314
TranscriptFileState,
@@ -116,15 +117,21 @@ function buildSuccessorEntries(params: {
116117
const compaction = branch[latestCompactionIndex] as CompactionEntry;
117118

118119
const summarizedBranchIds = new Set<string>();
120+
const preCompactionKeptBranchIds = new Set<string>();
121+
let foundFirstKept = false;
119122
for (let index = 0; index < latestCompactionIndex; index += 1) {
120123
const entry = branch[index];
121124
if (!entry) {
122125
continue;
123126
}
124127
if (compaction.firstKeptEntryId && entry.id === compaction.firstKeptEntryId) {
125-
break;
128+
foundFirstKept = true;
129+
}
130+
if (foundFirstKept) {
131+
preCompactionKeptBranchIds.add(entry.id);
132+
} else {
133+
summarizedBranchIds.add(entry.id);
126134
}
127-
summarizedBranchIds.add(entry.id);
128135
}
129136

130137
const latestStateEntryIds = collectLatestStateEntryIds(branch.slice(0, latestCompactionIndex));
@@ -174,9 +181,17 @@ function buildSuccessorEntries(params: {
174181
parentId = entryById.get(parentId)?.parentId ?? null;
175182
}
176183

177-
keptEntries.push(
178-
parentId === entry.parentId ? entry : ({ ...entry, parentId } as SessionEntry),
179-
);
184+
const reparented =
185+
parentId === entry.parentId ? entry : ({ ...entry, parentId } as SessionEntry);
186+
// Strip thinking signatures only from pre-compaction kept entries. Pre-compaction
187+
// signatures are bound to the original context prefix; the successor file has a different
188+
// prefix so those signatures would cause Anthropic "Invalid signature in thinking block".
189+
// Post-compaction entries were generated in the new context and have valid signatures.
190+
const transformed =
191+
reparented.type === "message" && preCompactionKeptBranchIds.has(reparented.id)
192+
? { ...reparented, message: stripThinkingSignaturesFromMessage(reparented.message) }
193+
: reparented;
194+
keptEntries.push(transformed);
180195
}
181196

182197
return orderSuccessorEntries({

src/agents/embedded-agent-runner/replay-history.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
dropThinkingBlocks,
5656
shouldPreserveLatestAssistantThinking,
5757
stripInvalidThinkingSignatures,
58+
stripStaleThinkingSignaturesForCompactionReplay,
5859
} from "./thinking.js";
5960

6061
const MODEL_SNAPSHOT_CUSTOM_TYPE = "model-snapshot";
@@ -734,16 +735,25 @@ export async function sanitizeSessionHistory(params: {
734735
const preserveLatestAssistantThinking =
735736
params.preserveLatestAssistantThinking ??
736737
shouldPreserveLatestAssistantThinking(sanitizedImages);
738+
// Strip thinking signatures that are stale due to compaction context changes before
739+
// stripInvalidThinkingSignatures runs. Pre-compaction kept messages carry signatures
740+
// bound to the original prefix; after compaction the prefix changes and Anthropic
741+
// rejects them. Timestamp comparison with the latest compaction summary identifies
742+
// the affected messages regardless of path (standard or truncateAfterCompaction).
743+
const compactionStaleStripped =
744+
signedThinkingProvider || policy.preserveSignatures
745+
? stripStaleThinkingSignaturesForCompactionReplay(sanitizedImages)
746+
: sanitizedImages;
737747
// Some recovery paths supply a narrow policy with preserveSignatures disabled.
738748
// Native signed-thinking providers still cannot replay missing/blank
739749
// signatures once the assistant turn is no longer latest in the outbound
740750
// request.
741751
const validatedThinkingSignatures =
742752
signedThinkingProvider || policy.preserveSignatures
743-
? stripInvalidThinkingSignatures(sanitizedImages, {
753+
? stripInvalidThinkingSignatures(compactionStaleStripped, {
744754
preserveLatestAssistant: preserveLatestAssistantThinking,
745755
})
746-
: sanitizedImages;
756+
: compactionStaleStripped;
747757
const droppedReasoning = policy.dropReasoningFromHistory
748758
? dropReasoningFromHistory(validatedThinkingSignatures)
749759
: validatedThinkingSignatures;

0 commit comments

Comments
 (0)