Skip to content

Anthropic thinking blocks captured without their signature — any tool call on Opus 4.6 wedges the session #821

Description

@chrismurrph

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions