mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(agents): strip stale compaction thinking signatures before Anthropic replay (#90163)
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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>();
|
||||
const preCompactionKeptBranchIds = new Set<string>();
|
||||
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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>).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<string, unknown>).thinkingSignature).toBeUndefined();
|
||||
expect((a2.content[0] as unknown as Record<string, unknown>).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
const stripped: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user