Skip to content

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

Merged
teknium1 merged 5 commits into
mainfrom
hermes/hermes-f33b112f
Jun 11, 2026
Merged

fix(anthropic): preserve interleaved thinking/tool_use block order on replay (#17861)#43943
teknium1 merged 5 commits into
mainfrom
hermes/hermes-f33b112f

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Interleaved-thinking turns (signed thinking blocks mixed with tool_use, Claude 4.6+/Opus agentic work) now replay to Anthropic in their original block order, eliminating the HTTP 400 "thinking ... blocks in the latest assistant message cannot be modified" at its source (salvages #35586, the structural fix for #17861).

Root cause: normalize_response split the turn into parallel reasoning_details + tool_calls lists, discarding cross-type order; _convert_assistant_message rebuilt it as [all thinking][text][all tool_use]. Anthropic signs each thinking block against the content preceding it at its position, so the reorder invalidated signatures and 400'd.

Changes

Validation

result
Targeted tests (block-order, output-field-leak, adapter, classifier) 331 passed
Live E2E, direct Anthropic, multi-tool turns with extended thinking (sonnet-4-6 + opus-4-6, terminal toolset) ordered channel captured ([thinking, text, tool_use, tool_use]), multi-turn replay clean, completed=True
Replay conversion audit on live history latest turn: exact order + signatures intact; older turns: thinking stripped, text/tool_use order preserved; zero forbidden extra fields on the wire
Fallback shape (channel absent, simulating disk reload) pinned by new test — reconstruction still produces a valid turn

Supersedes #20997 (same idea, but replays unsanitized SDK dicts → "Extra inputs are not permitted" 400, and keeps thinking on all turns) and #26959 (verbatim pass would revert the orphan-strip demotion guard from 64628ea and break unsigned-block handling on Kimi-synthesized paths).

Infographic

Interleaved thinking block order preserved

Spaceman-Spiffy and others added 5 commits June 10, 2026 20:00
… 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.
…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.
…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, #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 #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 #36087 and #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>
…olumn)

Drop the hermes_state.py column + persistence plumbing from the salvaged
interleaved-thinking fix. The ordered-block channel covers the failure
window in-memory (turn replayed within the live conversation loop). A
session reloaded from disk after a crash falls back to reconstruction;
if that replay 400s, the thinking-signature recovery (#43667) strips
reasoning_details and retries — one degraded call in a rare resume path
instead of a schema column. Replaces the DB-roundtrip test with a
fallback-shape test.
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-f33b112f vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 10663 on HEAD, 10658 on base (🆕 +5)

🆕 New issues (3):

Rule Count
unresolved-import 2
not-subscriptable 1
First entries
tests/agent/test_anthropic_thinking_block_order.py:36: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/agent/test_anthropic_output_field_leak.py:16: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/agent/test_anthropic_output_field_leak.py:58: [not-subscriptable] not-subscriptable: Cannot subscript object of type `None` with no `__getitem__` method

✅ Fixed issues: none

Unchanged: 5566 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@liuhao1024

Copy link
Copy Markdown
Contributor

Verification review — reviewed the interleaved-thinking replay mechanism and credential-redaction re-sourcing.

This is a well-structured defense-in-depth approach to a real Anthropic SDK bug (HTTP 400 on replay when output-only fields like text.parsed_output leak into request input):

Architecture:

  1. normalize_response captures sanitized ordered blocks at capture time (first layer)
  2. _sanitize_replay_block strips output-only fields at replay time (second layer)
  3. _convert_content_part_to_anthropic handles the content-list path (third layer)

Whitelist approach in _sanitize_replay_block is the right call — unknown block types are dropped rather than risk another "Extra inputs are not permitted" error. Per-type whitelists (text: text+citations; thinking: thinking+signature; tool_use: id+name+input) are narrow and correct.

Credential re-sourcing: The redacted_input_by_id map correctly re-sources tool_use inputs from tool_calls[].function.arguments (which IS redacted at storage time via build_assistant_message) rather than from the raw API response block. This prevents replaying un-redacted credentials that a model may have inlined into a tool call. The _sanitize_tool_id key normalization ensures stable matching.

Activation guard: The ordered-blocks channel only activates when both signed thinking AND tool_use are present — the exact condition where parallel list reconstruction fails. Pure-text or thinking-then-tools turns use the existing code path untouched.

CI: all 20 checks green.

1 similar comment
@liuhao1024

Copy link
Copy Markdown
Contributor

Verification review — reviewed the interleaved-thinking replay mechanism and credential-redaction re-sourcing.

This is a well-structured defense-in-depth approach to a real Anthropic SDK bug (HTTP 400 on replay when output-only fields like text.parsed_output leak into request input):

Architecture:

  1. normalize_response captures sanitized ordered blocks at capture time (first layer)
  2. _sanitize_replay_block strips output-only fields at replay time (second layer)
  3. _convert_content_part_to_anthropic handles the content-list path (third layer)

Whitelist approach in _sanitize_replay_block is the right call — unknown block types are dropped rather than risk another "Extra inputs are not permitted" error. Per-type whitelists (text: text+citations; thinking: thinking+signature; tool_use: id+name+input) are narrow and correct.

Credential re-sourcing: The redacted_input_by_id map correctly re-sources tool_use inputs from tool_calls[].function.arguments (which IS redacted at storage time via build_assistant_message) rather than from the raw API response block. This prevents replaying un-redacted credentials that a model may have inlined into a tool call. The _sanitize_tool_id key normalization ensures stable matching.

Activation guard: The ordered-blocks channel only activates when both signed thinking AND tool_use are present — the exact condition where parallel list reconstruction fails. Pure-text or thinking-then-tools turns use the existing code path untouched.

CI: all 20 checks green.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants