Anthropic thinking blocks are captured without their signature — any tool call on Opus 4.6 wedges the session
Symptom
dirge 0.25.0. First turn succeeds and makes a tool call; the continuation request fails:
Error: HttpError: Invalid status code 400 Bad Request with message:
{"type":"error","error":{"type":"invalid_request_error",
"message":"messages.3.content.0.thinking.signature: Field required"},
"request_id":"req_011CePS8sDoLQ5RT555Sn7nC"}
In an interactive session the offending assistant message replays on every subsequent
prompt, so the session is wedged — the same failure shape as the empty-text-block wedge
described in assistant_empty_text_block_is_dropped.
Reproduction (100%, 1 run)
Provider entry:
{ "provider_type": "anthropic", "model": "claude-opus-4-6", "auth": "claude-code", "effort": "medium" }
echo hello > sample.txt
dirge -p --session repro1 --provider medium --accept-all --no-skills \
"Read sample.txt in the current directory, then tell me how many characters it contains. Think carefully."
--trace shows turn 1 completing the read tool call, then the continuation turn failing:
{"kind": "turn_start"}
{"kind": "tool_end", "tool": "read", "error": false}
{"kind": "turn_end", "error": null}
{"kind": "turn_start"}
{"kind": "turn_end", "error": "HttpError: Invalid status code 400 ... thinking.signature: Field required"}
Any tool call does it — the continuation request is the first replay of the thinking block.
Cause
Anthropic returns thinking blocks with a cryptographic signature and requires it echoed
back verbatim on replay. dirge's content type has nowhere to put one —
src/agent/agent_loop/message.rs:
pub enum ContentBlock {
Text { text: String },
Thinking { text: String }, // <-- no signature field
ToolCall { id: String, name: String, arguments: Value },
}
Capture discards it. src/agent/agent_loop/rig_stream.rs (~411) keeps only the text out of
rig's reasoning payload:
rig::completion::message::ReasoningContent::Text { text, .. } => Some(text.clone()),
// ^^ signature discarded here
The delta path likewise pushes ContentBlock::Thinking { text: reasoning } (~375).
Replay then builds an unsigned block — src/agent/agent_loop/rig_stream_factory.rs (~824):
"thinking" => {
if !include_reasoning { return None; }
let text = obj.get("text").and_then(|t| t.as_str())?;
Some(AssistantContent::Reasoning(Reasoning::new(text)))
}
and Anthropic is not exempt from reasoning echo (~789):
fn provider_rejects_reasoning_echo(provider_name: Option<&str>) -> bool {
matches!(provider_name, Some(p) if p.eq_ignore_ascii_case("openai"))
}
Scope — what does and does not trigger it
Two conditions must hold together, which is probably why this hasn't been reported:
- Thinking text must actually be captured. Confirmed on
claude-opus-4-6. On
claude-opus-5 and claude-fable-5 I could not reproduce it (5 attempts, tools and
multi-turn): those models default to display: "omitted", so thinking text comes back
empty and no block appears to be captured. That looks like accident rather than
protection — the moment a signed block with text is captured on those models, the same
replay path applies.
- The replay must be in-process. Cross-process
dirge -p runs sharing a --session
do not reproduce it, even on Opus 4.6 — the messages table stores content TEXT, so
persistence appears to flatten thinking blocks away. Tool-use continuations and
interactive multi-turn sessions replay the in-memory block and do fail.
effort: "off" is only a partial workaround
src/provider/adapter.rs:353 maps Anthropic to DisableWire::None, so off omits the
thinking key rather than sending thinking: {type: "disabled"}. On Opus 4.6 / Sonnet 4.6
omitting it means no thinking, so off does avoid the bug there. It would not help on
claude-opus-5 (thinking is on by default when the parameter is absent) or
claude-fable-5 (always on, and {type: "disabled"} is rejected with a 400) — so it is not
a general fix if condition 1 above ever opens on those models.
Suggested fix
Carry the signature end to end: add an optional signature to ContentBlock::Thinking,
populate it from ReasoningContent::Text { .. } at capture, and pass it through on replay.
Rig appears to carry it already — hence the .. in the match above, and the
thought_signature comment in the same function — so this may need no rig change.
Please don't fix it by adding anthropic to provider_rejects_reasoning_echo: on
thinking-enabled models the preceding thinking block is required for tool-use continuation,
so dropping it trades this 400 for broken agentic turns.
Open question
Does the Anthropic API still accept thinking: {type: "enabled", budget_tokens: N} on
Claude 5 model ids? EffortWire::AnthropicBudget sends that shape and the 0.24.1 fix
("Requests carrying an Anthropic thinking budget now set max_tokens") assumes it is
accepted, but Anthropic's current docs list budget_tokens as removed on
Opus 5 / Fable 5 / Sonnet 5 / Opus 4.8 / 4.7, returning 400.
Anthropic thinking blocks are captured without their
signature— any tool call on Opus 4.6 wedges the sessionSymptom
dirge 0.25.0. First turn succeeds and makes a tool call; the continuation request fails:
In an interactive session the offending assistant message replays on every subsequent
prompt, so the session is wedged — the same failure shape as the empty-text-block wedge
described in
assistant_empty_text_block_is_dropped.Reproduction (100%, 1 run)
Provider entry:
{ "provider_type": "anthropic", "model": "claude-opus-4-6", "auth": "claude-code", "effort": "medium" }--traceshows turn 1 completing thereadtool call, then the continuation turn failing:{"kind": "turn_start"} {"kind": "tool_end", "tool": "read", "error": false} {"kind": "turn_end", "error": null} {"kind": "turn_start"} {"kind": "turn_end", "error": "HttpError: Invalid status code 400 ... thinking.signature: Field required"}Any tool call does it — the continuation request is the first replay of the thinking block.
Cause
Anthropic returns thinking blocks with a cryptographic
signatureand requires it echoedback verbatim on replay. dirge's content type has nowhere to put one —
src/agent/agent_loop/message.rs:Capture discards it.
src/agent/agent_loop/rig_stream.rs(~411) keeps only the text out ofrig's reasoning payload:
The delta path likewise pushes
ContentBlock::Thinking { text: reasoning }(~375).Replay then builds an unsigned block —
src/agent/agent_loop/rig_stream_factory.rs(~824):and Anthropic is not exempt from reasoning echo (~789):
Scope — what does and does not trigger it
Two conditions must hold together, which is probably why this hasn't been reported:
claude-opus-4-6. Onclaude-opus-5andclaude-fable-5I could not reproduce it (5 attempts, tools andmulti-turn): those models default to
display: "omitted", so thinking text comes backempty and no block appears to be captured. That looks like accident rather than
protection — the moment a signed block with text is captured on those models, the same
replay path applies.
dirge -pruns sharing a--sessiondo not reproduce it, even on Opus 4.6 — the
messagestable storescontent TEXT, sopersistence appears to flatten thinking blocks away. Tool-use continuations and
interactive multi-turn sessions replay the in-memory block and do fail.
effort: "off"is only a partial workaroundsrc/provider/adapter.rs:353maps Anthropic toDisableWire::None, sooffomits thethinkingkey rather than sendingthinking: {type: "disabled"}. On Opus 4.6 / Sonnet 4.6omitting it means no thinking, so
offdoes avoid the bug there. It would not help onclaude-opus-5(thinking is on by default when the parameter is absent) orclaude-fable-5(always on, and{type: "disabled"}is rejected with a 400) — so it is nota general fix if condition 1 above ever opens on those models.
Suggested fix
Carry the signature end to end: add an optional
signaturetoContentBlock::Thinking,populate it from
ReasoningContent::Text { .. }at capture, and pass it through on replay.Rig appears to carry it already — hence the
..in the match above, and thethought_signaturecomment in the same function — so this may need no rig change.Please don't fix it by adding
anthropictoprovider_rejects_reasoning_echo: onthinking-enabled models the preceding thinking block is required for tool-use continuation,
so dropping it trades this 400 for broken agentic turns.
Open question
Does the Anthropic API still accept
thinking: {type: "enabled", budget_tokens: N}onClaude 5 model ids?
EffortWire::AnthropicBudgetsends that shape and the 0.24.1 fix("Requests carrying an Anthropic thinking budget now set
max_tokens") assumes it isaccepted, but Anthropic's current docs list
budget_tokensas removed onOpus 5 / Fable 5 / Sonnet 5 / Opus 4.8 / 4.7, returning 400.