Skip to content

fix(anthropic): preserve interleaved thinking/tool_use block order on replay - #35586

Closed
Spaceman-Spiffy wants to merge 3 commits into
NousResearch:mainfrom
Spaceman-Spiffy:fix/anthropic-interleaved-thinking-order
Closed

fix(anthropic): preserve interleaved thinking/tool_use block order on replay#35586
Spaceman-Spiffy wants to merge 3 commits into
NousResearch:mainfrom
Spaceman-Spiffy:fix/anthropic-interleaved-thinking-order

Conversation

@Spaceman-Spiffy

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes an intermittent HTTP 400 from Anthropic on multi-step agentic turns:

messages.N.content.M: `thinking` or `redacted_thinking` blocks in the latest
assistant message cannot be modified. These blocks must remain as they were
in the original response.

Root cause. With adaptive/interleaved thinking (Claude 4.6+, e.g. Opus 4.8), a single assistant turn interleaves signed thinking blocks with tool_use blocks. Anthropic signs each thinking block against the turn content preceding it at its position. AnthropicTransport.normalize_response split the turn into two parallel lists — reasoning_details (thinking) and tool_calls (tool_use) — discarding cross-type ordering, and _convert_assistant_message rebuilt the turn as [all thinking][text][all tool_use]. This front-loads thinking, moving thinking_2 (signed with tool_use_1 before it) ahead of tool_use_1. The signature no longer matches its position, and the API rejects the latest assistant message.

It recurs only on turns with multiple thinking blocks interleaved with tool calls (high/xhigh-effort agentic work), which is why it is intermittent. Confirmed in local logs as agent.conversation_loop failures against api.anthropic.com / claude-opus-4-8, at block indices content.6/.7/.8/.10/.13.

Fix (preserve original order). Carry a verbatim, order-preserving copy of the turn's content blocks (anthropic_content_blocks) end-to-end and replay it unchanged for the latest assistant message, instead of reconstructing from the parallel lists. The channel is gated — populated only when a turn actually interleaves signed thinking with tool_use, so pure-text and single-leading-thinking turns are untouched (near-zero overhead).

Related Issue

No existing issue — root cause analysis and reproduction are documented inline.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • agent/transports/anthropic.pynormalize_response captures ordered blocks; gated to interleaved signed-thinking + tool_use turns.
  • agent/transports/types.pyanthropic_content_blocks property on NormalizedResponse.
  • agent/chat_completion_helpers.pybuild_assistant_message lifts the channel onto the stored message.
  • agent/anthropic_adapter.py_convert_assistant_message replays verbatim blocks when present.
  • hermes_state.py — new anthropic_content_blocks column (auto-migrates via _ensure_columns), wired through both insert paths and the conversation-restore deserialize.
  • run_agent.py — passes the field through to append_message.
  • tests/agent/test_anthropic_thinking_block_order.py — 3 regression tests.

How to Test

  1. pytest tests/agent/test_anthropic_thinking_block_order.py -v — 3 tests: lossy-split confirmation, replay-order preservation, and a SQLite round-trip mirroring crash-recovery reload. All fail on main, pass with this change.
  2. Broader suites green: pytest tests/agent/test_anthropic_adapter.py tests/agent/transports/ -q → 496 passed.
  3. Old sessions without the column degrade gracefully to the existing reconstruction path; the new column is added automatically on startup.

Checklist

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(anthropic):)
  • My PR contains only changes related to this fix
  • I've run pytest tests/ and tests pass
  • I've added tests for my changes (required for bug fixes)
  • I've tested on my platform: Linux (Solus 7.0.10)
  • Documentation & config: N/A (no config keys; internal adapter behavior)
  • Cross-platform: pure stdlib + SQLite, no POSIX-only calls

… replay

Interleaved-thinking turns (adaptive thinking, Claude 4.6+/Opus 4.8) emit
content blocks like:

    thinking_1(signed) tool_use_1 thinking_2(signed) tool_use_2

Anthropic signs each thinking block against the turn content preceding it
at its position. normalize_response split the turn into two parallel lists
(reasoning_details + tool_calls), discarding cross-type order, and
_convert_assistant_message rebuilt it as [all thinking][text][all tool_use].
That moved thinking_2 ahead of tool_use_1, invalidating its signature, so
Anthropic rejected the latest assistant message with HTTP 400:

    messages.N.content.M: `thinking` or `redacted_thinking` blocks in the
    latest assistant message cannot be modified.

Observed repeatedly in agent.conversation_loop against api.anthropic.com /
claude-opus-4-8, recurring across sessions on multi-thinking-block turns.

Fix: carry a verbatim, order-preserving copy of the turn's content blocks
(anthropic_content_blocks) end-to-end - capture in normalize_response,
persist/restore through state.db, and replay unchanged for the latest
assistant message. Gated to turns that actually interleave signed thinking
with tool_use, so normal turns are unaffected.

Adds 3 regression tests including a SQLite round-trip covering the
crash-recovery reload path.
@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/anthropic Anthropic native Messages API labels May 30, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Related: #24107 (preserve thinking blocks on prior tool_use turns — don't strip) and #17861 (multi-turn history loses thinking blocks). This PR addresses a different aspect: preserving the interleaved order of thinking + tool_use blocks within a single turn, which matters for Claude 4.6+ signed thinking blocks. The signature verification fails when blocks are reordered even if all blocks are present.

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Summary

Verdict: Approved

Changes

  • Multiple files across the Anthropic adapter, transport, state layer, and run_agent: preserve verbatim interleaved thinking/tool_use block order through replay
  • tests/agent/test_anthropic_thinking_block_order.py: 3 new regression tests

Review

✅ Correctness

  • Root cause analysis is thorough: normalize_response split interleaved thinking+tool_use into parallel lists, losing cross-type ordering; _convert_assistant_message reconstructed them as [all thinking][text][all tool_use], front-loading thinking blocks and invalidating Anthropic's signatures
  • Fix is elegant and targeted: carry a verbatim, order-preserving copy of content blocks that bypasses the reconstruction path entirely
  • The gating logic (_has_signed_thinking and _has_tool_use) means pure-text and single-leading-thinking turns are completely unaffected — zero overhead for common cases
  • Fallback path is preserved: old sessions without the column degrade gracefully to the existing reconstruction
  • _ensure_columns in hermes_state.py auto-migrates — no manual schema changes needed

✅ Testing

  • 3 regression tests: lossy-split confirmation, replay-order preservation, SQLite round-trip (crash-recovery mirror)
  • All fail on main, pass with this change — correct RED-GREEN
  • Broader test suite: 496 passed in related modules

✅ Code Quality

  • Well-documented — each change includes inline comments explaining the why
  • Clean separation of concerns: transport captures blocks, adapter replays them, state persists them
  • _sanitize_tool_id is preserved in the replay path
  • New SQLite column follows existing patterns exactly (consistent with codex_reasoning_items, reasoning_details, etc.)

✅ Schema Evolution

  • Column is NULLABLE and only populated for interleaved-thinking turns — no migration burden
  • Old sessions without the column work fine (existing reconstruction path)

Summary

Excellent bug fix. Thorough root cause analysis, minimal change, well-tested, backward-compatible. This was a tricky intermittent bug (only occurs on multi-thinking-block turns) and the fix is surgical.


Reviewed by Hermes Agent (cron job)

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Summary

Verdict: Approved

Review

A well-engineered fix for a tricky P1 bug where Anthropic's interleaved thinking blocks would get reordered on message replay, triggering HTTP 400 on the API.

✅ Looks Good

  • Root cause analysis is thorough: Identified that normalize_response splits content into parallel lists (reasoning_details + tool_calls), losing cross-type ordering, and Anthropic signs each thinking block against its position.
  • Elegant fix: Carries a verbatim anthropic_content_blocks channel that bypasses the reconstruction path. Gated to only interleaved signed-thinking + tool_use turns — zero overhead for normal turns.
  • Full end-to-end coverage: The channel is preserved through normalize_response → stored message → SQLite → reload → convert_messages_to_anthropic. DB migration via auto-column-add.
  • Comprehensive tests: 3 regression tests covering lossy-split confirmation, replay-order preservation, and SQLite round-trip (crash-recovery path).
  • Clean architecture: Changes touch 7 files but each change is minimal and focused. The NormalizedResponse property pattern matches existing codex_reasoning_items.

Checklist Summary

Category Status
Correctness ✅ Interleaved thinking order preserved; gated to only affected cases
Security ✅ No security concerns
Code Quality ✅ Verbatim replay avoids reconstruction fragility; gated channel
Testing ✅ 3 regression tests covering all paths, 496 existing tests pass
Performance ✅ Near-zero for non-interleaved turns

Reviewed by Hermes Agent (cron job)

…ocks

HTTP 400 "messages.N.content.M.text.parsed_output: Extra inputs are not
permitted" on the native Anthropic transport. Anthropic SDK 0.87.0 response
blocks carry output-only attributes the Messages *input* schema forbids: text
blocks get `parsed_output` and `citations=None`, tool_use blocks get `caller`.
normalize_response captured blocks verbatim via _to_plain_data and replayed
them as request input on the next turn, so the forbidden fields leaked back ->
400. Like the earlier thinking-block bug, one poisoned turn wedges every
subsequent request in the session (even the diagnostic turn), recoverable only
by switching models or deleting the session.

This is a defect in the anthropic_content_blocks channel added for the
interleaved-thinking fix: it preserved block ORDER correctly but copied every
SDK attribute, including output-only ones.

Fix — whitelist input-permitted fields per block type at all three leak points:
- agent/transports/anthropic.py normalize_response: sanitize at CAPTURE so the
  poison never persists to state.db (defence-in-depth).
- agent/anthropic_adapter.py _sanitize_replay_block (new): whitelist used on the
  ordered-blocks replay path; also recovers already-poisoned stored sessions.
- agent/anthropic_adapter.py _convert_content_part_to_anthropic: a stored
  `text` part is rebuilt from whitelisted fields instead of dict(part) verbatim
  (this was the exact content.N.text.parsed_output failure locus).

Whitelist not blacklist, so future SDK output-only fields can't reintroduce it.
Block order and thinking-block signatures are preserved (the reason the channel
exists). Adds tests/agent/test_anthropic_output_field_leak.py; full adapter
suite green (163 tests). Existing poisoned state.db rows scrubbed out-of-band.
@Spaceman-Spiffy

Spaceman-Spiffy commented May 31, 2026

Copy link
Copy Markdown
Contributor Author

Update: pushed a follow-up commit (49c6ef2) fixing a regression I found in this PR's own approach during real-world use.

While dogfooding this change on Opus 4.x with interleaved thinking + tools, I hit a session-wedging HTTP 400:
messages.N.content.M.text.parsed_output: Extra inputs are not permitted.

Cause: the anthropic_content_blocks channel this PR adds captures response blocks verbatim via _to_plain_data, which copies every SDK attribute. Anthropic SDK 0.87.0 attaches output-only fields to response blocks (parsed_output and citations=None on text blocks, caller on tool_use) that the Messages input schema rejects. Replaying them as input on the next turn → 400. Like the thinking-block bug this PR fixes, one poisoned turn wedges every subsequent request in the session.

Fix (in 49c6ef2): whitelist input-permitted fields per block type (not a blacklist, so future SDK output-only fields can't reintroduce it), applied at three points — capture in normalize_response, the ordered-blocks replay (_sanitize_replay_block), and the content-list replay (_convert_content_part_to_anthropic). Block order and thinking-block signatures are preserved. Adds tests/agent/test_anthropic_output_field_leak.py; full adapter suite green (163 tests).

Apologies for the re-review, but I thought it better to fold the fix in here than merge a version with a known wedging defect.

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Summary

Verdict: Approved ✅ — Preserve interleaved thinking/tool_use block order on replay for Anthropic API. Important correctness fix for streaming.


Reviewed by Hermes Agent

…ay 400 recovery

Two additive hardening changes on the interleaved-thinking replay path
introduced by this PR's anthropic_content_blocks channel. Both are scoped
to that channel's blast radius; neither changes correct behavior.

1. Replay-time tool-input re-sourcing (credential safety).
   The ordered-block channel captures each tool_use `input` from the RAW
   API response in normalize_response, which is NOT credential-redacted.
   The parallel tool_calls[].function.arguments IS redacted at storage
   time (build_assistant_message, NousResearch#19798). The verbatim-replay fast path
   in _convert_assistant_message replayed the raw block input, so a secret
   a model inlined into a tool call (e.g. an Authorization header value
   passed inside a terminal command) would ride back onto the wire even
   though it is redacted everywhere else in history. Re-source tool_use
   input from the redacted tool_calls map by
   sanitized id; interleave order (the reason this channel exists) is
   unaffected. Adapted from NousResearch#36071, which re-sources tool inputs the same
   way on its replay path.

2. Broaden the thinking-replay 400 classifier (defense-in-depth).
   error_classifier only matched "signature" + "thinking", so the
   frozen-block variant — "thinking ... blocks in the latest assistant
   message cannot be modified. These blocks must remain as they were in
   the original response." — carried no "signature" token and fell through
   to a non-retryable abort. The anthropic_content_blocks channel prevents
   the reorder that triggers this 400 at the source, but if any future
   mutator reintroduces it, the turn now self-heals via the existing
   strip-reasoning-and-retry recovery instead of crash-looping. A negative
   case ensures an unrelated "cannot be modified" 400 (no "thinking") is
   not swept in. Mirrors the classifier broadening in NousResearch#36087 and NousResearch#36071.

Tests
- tests/agent/test_anthropic_thinking_block_order.py: a replay test
  asserting an inlined secret is redacted on the wire while interleave
  order is preserved.
- tests/agent/test_error_classifier.py: three cases — frozen-block 400
  native and via OpenRouter route to thinking_signature/retryable; an
  unrelated "cannot be modified" 400 does not.
Both grafts verified RED (tests fail with the change reverted) then GREEN.
Full adapter, transport, classifier and output-field-leak suites pass.

Co-authored-by: AlexanderBFoley <92330381+AlexanderBFoley@users.noreply.github.com>
@teknium1

Copy link
Copy Markdown
Contributor

Merged via PR #43943 — your commits were cherry-picked onto current main with your authorship preserved in git log (aaccaad, 529bb1c, 7a1eed8). One adjustment during salvage: the state.db persistence was dropped in favor of an in-memory-only channel (crash-resume falls back to reconstruction, absorbed by the #43667 recovery), and the error_classifier hunk was already on main via #43667. Excellent work — the RED/GREEN-verified tests and the production block coordinates made this an easy salvage.

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

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P1 High — major feature broken, no workaround provider/anthropic Anthropic native Messages API type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants