Skip to content

fix: boot-time patch for Anthropic thinking block signature corruption#44650

Open
rikisann wants to merge 1 commit intoopenclaw:mainfrom
rikisann:fix/anthropic-thinking-block-signature
Open

fix: boot-time patch for Anthropic thinking block signature corruption#44650
rikisann wants to merge 1 commit intoopenclaw:mainfrom
rikisann:fix/anthropic-thinking-block-signature

Conversation

@rikisann
Copy link
Copy Markdown

Problem

Extended thinking sessions consistently fail after several turns with:

LLM request rejected: messages.N.content.1: thinking or redacted_thinking blocks
in the latest assistant message cannot be modified.

Two independent bugs in @mariozechner/pi-ai combine to cause this:

Bug 1: Thinking block accumulation (transform-messages.js)

transformMessages() preserves all thinking blocks across the entire conversation history. Anthropic only validates the latest assistant message's thinking blocks — older ones serve no purpose but:

  • Waste context window tokens (thinking blocks can be 2-10K tokens each)
  • Risk corruption during context compaction (compaction may truncate or modify them)
  • Increase the surface area for Bug 2

Bug 2: Signature invalidation via sanitization (anthropic.js)

convertMessages() runs sanitizeSurrogates() on thinking text before sending it back to the API. This function strips lone surrogates and other characters, modifying the text that Anthropic's cryptographic signature was computed over. The signature check fails, and the API rejects the request.

Fix

Two boot-time patches applied before main module load:

Patch 1 (transform-messages.js): Strip thinking blocks from all assistant messages except the latest one. Anthropic only validates the latest — older blocks are dead weight.

Patch 2 (anthropic.js): Skip sanitizeSurrogates() on thinking text, preserving it byte-for-byte so the signature remains valid.

Both patches are applied atomically (temp file + rename) with graceful handling for read-only filesystems and already-patched installations.

Relationship to #27965

PR #27965 by @chocothunder5013 addresses Bug 2 (sanitization) with a similar boot-time approach. This PR is a superset — it fixes both bugs:

Fix #27965 This PR
Skip sanitizeSurrogates on thinking text
Strip old thinking blocks (prevent accumulation)
Context window savings
Idempotent (safe to run multiple times)
Read-only filesystem handling

Testing

Tested on a production OpenClaw deployment with thinking=high across dozens of multi-turn sessions over 12+ hours. Before the fix: sessions crashed within 5-10 turns. After: no thinking block errors observed.

Files Changed

  • openclaw.mjs — boot-time patch function applied before main module load

Fixes #25347
Fixes #25194
Supersedes #27965

Two bugs in @mariozechner/pi-ai cause Anthropic API rejections in
multi-turn conversations with extended thinking enabled:

1. transform-messages.js keeps ALL thinking blocks across the entire
   conversation history. Older blocks accumulate, waste context tokens,
   and can be corrupted by compaction.

2. anthropic.js runs sanitizeSurrogates() on thinking text, which can
   strip characters and invalidate the cryptographic signature.

This patch applies two fixes at boot time:
- Strip thinking blocks from all non-latest assistant messages
- Preserve thinking text byte-for-byte for the latest assistant message

Fixes openclaw#25347
Fixes openclaw#25194
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66aa9a8b27

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

},
{
label: "strip old thinking blocks (early return)",
find: /const transformedContent = assistantMsg\.content\.flatMap\(\(block\) => \{\s*if \(block\.type === "thinking"\) \{/,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make transform-messages patch idempotent

applyAnthropicThinkingPatch() runs on every CLI start, but this regex still matches the already-patched transform-messages.js content, so each run injects another const isLatestAssistant = ... and another if (!isLatestAssistant) line. On the second run this produces duplicate const declarations in the patched dependency, which can fail module parsing the next time the Anthropic transform path is loaded.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR adds a boot-time patching function to openclaw.mjs that directly modifies two files inside @mariozechner/pi-ai/dist/ before the main module is loaded, aiming to fix thinking-block accumulation and signature corruption in multi-turn Anthropic extended-thinking sessions. The approach and intent are sound, but there is a critical idempotency bug in the second sub-patch for transform-messages.js that will corrupt the target file on every boot after the first.

Key changes:

  • New applyAnthropicThinkingPatch() function that atomically patches @mariozechner/pi-ai source files at startup.
  • Patch 1 strips thinking blocks from all non-latest assistant messages via two sequential string/regex replacements in transform-messages.js.
  • Patch 2 skips sanitizeSurrogates() on thinking text in anthropic.js to preserve Anthropic's cryptographic signature.

Issues found:

  • Critical: The second sub-patch in Patch 1 ("strip old thinking blocks (early return)") uses a regex that continues to match the already-patched code — \s* covers the newline between flatMap((block) => { and if (block.type === "thinking") {, both of which survive the patch unchanged. On the second boot the replacement is applied again, producing a duplicate const isLatestAssistant declaration (a hard SyntaxError) and a second early-return guard. From that point @mariozechner/pi-ai fails to load entirely, which is a worse regression than the original bug.
  • Minor: "node:module" is imported twice; the two imports should be consolidated into one.

Confidence Score: 1/5

  • Not safe to merge — the patch corrupts the target module file on every boot after the first, causing a hard SyntaxError and making the application unloadable.
  • The second sub-patch regex (/const transformedContent = assistantMsg\.content\.flatMap\(\(block\) => \{\s*if \(block\.type === "thinking"\) \{/) still matches the already-patched code because \s* covers the newline + indentation that separates flatMap((block) => { from if (block.type === "thinking") {, and both landmarks remain unchanged after patching. This breaks the idempotency guarantee: on the second boot the patch is applied again, doubling the const isLatestAssistant declaration (SyntaxError) and the early-return guard, rendering the upstream module unloadable. The issue must be fixed (e.g., via a negative lookahead on isLatestAssistant) before this can be safely merged.
  • openclaw.mjs lines 102–111 (second sub-patch of Patch 1)
Prompt To Fix All With AI
This is a comment left during a code review.
Path: openclaw.mjs
Line: 102-111

Comment:
**Second sub-patch regex matches already-patched code — breaks on every boot after the first**

The `find` regex will still match the patched file, breaking the idempotency guarantee and corrupting `transform-messages.js` on every subsequent start.

After the first successful run, the code in `transform-messages.js` becomes:

```js
const isLatestAssistant = msgIndex === lastAssistantIndex;
            const transformedContent = assistantMsg.content.flatMap((block) => {
                if (block.type === "thinking") {
                    if (!isLatestAssistant) { return []; }
```

On the **second boot**, the regex `/const transformedContent = assistantMsg\.content\.flatMap\(\(block\) => \{\s*if \(block\.type === "thinking"\) \{/` still matches because:
1. `const transformedContent = assistantMsg.content.flatMap((block) => {` is still present in the file.
2. `\s*` happily matches the newline + leading spaces that follow.
3. `if (block.type === "thinking") {` is still there.

The replacement is applied a second time, producing a duplicate `const isLatestAssistant` variable declarationa hard `SyntaxError` — and a duplicate `if (!isLatestAssistant) { return []; }` guard. From that point on, `@mariozechner/pi-ai` fails to load entirely, which is a worse outcome than the original bug.

The fix is to make the `find` pattern unique to the **un-patched** form of the code — e.g., include a fragment that is present in the original but absent after patching. The simplest approach is to anchor the regex on the line that immediately **follows** `if (block.type === "thinking") {` in the original file (whatever appears there before `isLatestAssistant` is inserted), or to add a negative lookahead for `isLatestAssistant`:

```js
find: /const transformedContent = assistantMsg\.content\.flatMap\(\(block\) => \{\s*if \(block\.type === "thinking"\) \{(?!\s*if \(!isLatestAssistant\))/,
```

This ensures the regex does not match once the early-return guard has already been inserted.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: openclaw.mjs
Line: 5-6

Comment:
**Duplicate import from `node:module`**

`"node:module"` is imported twice — once for the default export `module` (line 5, pre-existing) and once for the named export `createRequire` (line 6, new). These can be consolidated into a single import statement:

```suggestion
import module, { createRequire } from "node:module";
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 66aa9a8

Comment on lines +102 to +111
{
label: "strip old thinking blocks (early return)",
find: /const transformedContent = assistantMsg\.content\.flatMap\(\(block\) => \{\s*if \(block\.type === "thinking"\) \{/,
replace: [
"const isLatestAssistant = msgIndex === lastAssistantIndex;",
" const transformedContent = assistantMsg.content.flatMap((block) => {",
' if (block.type === "thinking") {',
" if (!isLatestAssistant) { return []; }",
].join("\n"),
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second sub-patch regex matches already-patched code — breaks on every boot after the first

The find regex will still match the patched file, breaking the idempotency guarantee and corrupting transform-messages.js on every subsequent start.

After the first successful run, the code in transform-messages.js becomes:

const isLatestAssistant = msgIndex === lastAssistantIndex;
            const transformedContent = assistantMsg.content.flatMap((block) => {
                if (block.type === "thinking") {
                    if (!isLatestAssistant) { return []; }

On the second boot, the regex /const transformedContent = assistantMsg\.content\.flatMap\(\(block\) => \{\s*if \(block\.type === "thinking"\) \{/ still matches because:

  1. const transformedContent = assistantMsg.content.flatMap((block) => { is still present in the file.
  2. \s* happily matches the newline + leading spaces that follow.
  3. if (block.type === "thinking") { is still there.

The replacement is applied a second time, producing a duplicate const isLatestAssistant variable declaration — a hard SyntaxError — and a duplicate if (!isLatestAssistant) { return []; } guard. From that point on, @mariozechner/pi-ai fails to load entirely, which is a worse outcome than the original bug.

The fix is to make the find pattern unique to the un-patched form of the code — e.g., include a fragment that is present in the original but absent after patching. The simplest approach is to anchor the regex on the line that immediately follows if (block.type === "thinking") { in the original file (whatever appears there before isLatestAssistant is inserted), or to add a negative lookahead for isLatestAssistant:

find: /const transformedContent = assistantMsg\.content\.flatMap\(\(block\) => \{\s*if \(block\.type === "thinking"\) \{(?!\s*if \(!isLatestAssistant\))/,

This ensures the regex does not match once the early-return guard has already been inserted.

Prompt To Fix With AI
This is a comment left during a code review.
Path: openclaw.mjs
Line: 102-111

Comment:
**Second sub-patch regex matches already-patched code — breaks on every boot after the first**

The `find` regex will still match the patched file, breaking the idempotency guarantee and corrupting `transform-messages.js` on every subsequent start.

After the first successful run, the code in `transform-messages.js` becomes:

```js
const isLatestAssistant = msgIndex === lastAssistantIndex;
            const transformedContent = assistantMsg.content.flatMap((block) => {
                if (block.type === "thinking") {
                    if (!isLatestAssistant) { return []; }
```

On the **second boot**, the regex `/const transformedContent = assistantMsg\.content\.flatMap\(\(block\) => \{\s*if \(block\.type === "thinking"\) \{/` still matches because:
1. `const transformedContent = assistantMsg.content.flatMap((block) => {` is still present in the file.
2. `\s*` happily matches the newline + leading spaces that follow.
3. `if (block.type === "thinking") {` is still there.

The replacement is applied a second time, producing a duplicate `const isLatestAssistant` variable declarationa hard `SyntaxError` — and a duplicate `if (!isLatestAssistant) { return []; }` guard. From that point on, `@mariozechner/pi-ai` fails to load entirely, which is a worse outcome than the original bug.

The fix is to make the `find` pattern unique to the **un-patched** form of the code — e.g., include a fragment that is present in the original but absent after patching. The simplest approach is to anchor the regex on the line that immediately **follows** `if (block.type === "thinking") {` in the original file (whatever appears there before `isLatestAssistant` is inserted), or to add a negative lookahead for `isLatestAssistant`:

```js
find: /const transformedContent = assistantMsg\.content\.flatMap\(\(block\) => \{\s*if \(block\.type === "thinking"\) \{(?!\s*if \(!isLatestAssistant\))/,
```

This ensures the regex does not match once the early-return guard has already been inserted.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 5 to +6
import module from "node:module";
import { createRequire } from "node:module";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate import from node:module

"node:module" is imported twice — once for the default export module (line 5, pre-existing) and once for the named export createRequire (line 6, new). These can be consolidated into a single import statement:

Suggested change
import module from "node:module";
import { createRequire } from "node:module";
import module, { createRequire } from "node:module";
Prompt To Fix With AI
This is a comment left during a code review.
Path: openclaw.mjs
Line: 5-6

Comment:
**Duplicate import from `node:module`**

`"node:module"` is imported twice — once for the default export `module` (line 5, pre-existing) and once for the named export `createRequire` (line 6, new). These can be consolidated into a single import statement:

```suggestion
import module, { createRequire } from "node:module";
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor

@xiaoyaner0201 xiaoyaner0201 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Duplicate PR Group: Anthropic Thinking Block Compaction

This PR is one of 7 open PRs addressing the same issue: Anthropic thinking/redacted_thinking blocks from older assistant messages cause API errors during context compaction.

Related PRs

PR Author Date Approach Preserves latest redacted_thinking Compaction path Tests Clean diff
#24261 @Vaibhavee89 02-23 Guard functions + logging N/A (no actual strip) Detect only Medium
#25381 @Nipurn123 02-24 Modify dropThinkingBlocks skip latest Good ❌ (bundles sendDice)
#27142 @slatem 02-26 Extend dropThinkingBlocks to Anthropic ❌ (strips all) Limited
#39919 @taw0002 03-08 New strip function in sanitizeSessionHistory Good
#39940 @liangruochong44-ui 03-08 Policy flag for Anthropic ❌ (strips all) Medium ❌ (bundles cron/SQLite/UI)
#43783 @coletoncodes 03-12 New stripThinkingFromNonLatestAssistant + compact path Best
#44650 @rikisann 03-13 Runtime monkey-patch of node_modules None ⚠️ Fragile approach

Analysis

After reading all 7 diffs:

#43783 is the strongest candidate. It:

  1. Creates a standalone stripThinkingFromNonLatestAssistant() without changing dropThinkingBlocks semantics (preserves Copilot behavior)
  2. Integrates via existing policy.preserveSignatures flag (proper transcript policy mechanism)
  3. Only PR that also fixes the compaction path (compact.ts) — others only fix sanitizeSessionHistory
  4. Handles both thinking and redacted_thinking block types
  5. Clean diff with comprehensive tests (toolResult interleaving, empty blocks, reference equality)

#39919 is a close second (same core function, clean diff, good tests) but misses the compaction path.

Notable discovery from #44650: sanitizeSurrogates may corrupt thinking block signatures — worth investigating separately even though the monkey-patch approach is not viable.

Not recommended: #27142 and #39940 strip thinking from ALL messages including latest (breaks multi-turn quality). #25381 and #39940 bundle unrelated changes.

Similarities identified via embedding-based clustering (cosine > 0.82). Maintainers: #43783 appears ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants