fix(anthropic): preserve interleaved thinking/tool_use block order on 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.
This commit is contained in:
RaumfahrerSpiffy
2026-06-10 20:45:16 -07:00
committed by Teknium
parent ad9012097b
commit aaccaada28
7 changed files with 344 additions and 7 deletions
+23
View File
@@ -1692,6 +1692,29 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
reasoning_content injection for Kimi/DeepSeek endpoints.
"""
content = m.get("content", "")
# Anthropic interleaved-thinking fast path: when this turn carries a
# verbatim, order-preserving block list (set by normalize_response only
# for turns that interleave SIGNED thinking with tool_use), replay it
# unchanged. Reconstructing from the parallel reasoning_details +
# tool_calls fields front-loads thinking and reorders signed blocks,
# which Anthropic rejects with HTTP 400 ("thinking ... blocks in the
# latest assistant message cannot be modified"). Block order — and thus
# each thinking block's signature — must survive verbatim. tool_use IDs
# are sanitized to match the tool_result IDs produced elsewhere; the
# downstream mcp_ prefixing pass handles tool names on these blocks.
ordered_blocks = m.get("anthropic_content_blocks")
if isinstance(ordered_blocks, list) and ordered_blocks:
replayed: List[Dict[str, Any]] = []
for b in ordered_blocks:
if not isinstance(b, dict):
continue
blk = copy.deepcopy(b)
if blk.get("type") == "tool_use" and "id" in blk:
blk["id"] = _sanitize_tool_id(blk.get("id", ""))
replayed.append(blk)
if replayed:
return {"role": "assistant", "content": replayed}
blocks = _extract_preserved_thinking_blocks(m)
if content:
if isinstance(content, list):