Skip to content

Echo Anthropic thinking-block signatures on replay (#821) - #823

Merged
yogthos merged 1 commit into
dirge-code:mainfrom
chrismurrph:fix/821-thinking-signature
Aug 26, 2026
Merged

Echo Anthropic thinking-block signatures on replay (#821)#823
yogthos merged 1 commit into
dirge-code:mainfrom
chrismurrph:fix/821-thinking-signature

Conversation

@chrismurrph

Copy link
Copy Markdown
Contributor

Fixes #821.

Problem

Anthropic mints a cryptographic signature on every thinking block and requires it echoed back verbatim on replay. dirge stored only the text, so the first replay — the tool-use continuation request — failed:

400 invalid_request_error
messages.3.content.0.thinking.signature: Field required

Reproduced 100% with any Anthropic entry at reasoning-on plus a single tool call. In an interactive session the bad block replays on every subsequent prompt, wedging the session.

Cause

Three places, all losing the same value:

  • ContentBlock::Thinking { text: String } had nowhere to store it.
  • Capture discarded it: ReasoningContent::Text { text, .. } => Some(text.clone()) — the .. is the signature.
  • Replay rebuilt it unsigned: Reasoning::new(text), which hardcodes signature: None.

rig 0.41 already carries the field and offers Reasoning::new_with_signature, so no upstream change was needed.

Fix

ContentBlock::Thinking gains two optional fields, both #[serde(default, skip_serializing_if = "Option::is_none")]:

  • signature — the provider-issued signature, extracted at capture from the trailing complete Reasoning block (both dirge-zf35 fold arms adopt it, guarded so a captured signature is never clobbered by a later None).
  • signature_model (signatureModel on the wire) — the model that minted it, stamped in the factory, which is the layer that knows the model identity.

Replay is governed by a policy computed once per request:

  • non-Anthropic providers → unchanged, Reasoning::new(text), byte-identical to before;
  • Anthropic → attach via new_with_signature iff a signature is present and signatureModel equals this request's model and reasoning is enabled this turn; otherwise drop the block (the rest of the message survives, mirroring the existing openai path).

Why the model has to be recorded

provider_name / model_name at the factory are not sufficient: serialized history carries no model identity, and /model, escalation and subagents switch models in-process. A signature replayed to a different model is rejected with Invalid signature in thinking block — so a naive always-attach fix trades this bug for a different 400. Recording the minting model makes the decision exact.

Dropping is safe in every case that reaches it: attach happens whenever the model matches and reasoning is on, which is precisely the tool-use continuation case, so a same-model continuation never loses its thinking block. The remaining paths are an unsigned legacy block (previously a guaranteed 400), a foreign model's signature (likewise), or reasoning off — where the echo isn't required at all.

Back-compat

default + skip_serializing_if means legacy {"type":"thinking","text":"..."} deserializes, and a block with both fields None re-serializes to exactly that byte shape, so existing saved sessions round-trip unchanged. Pinned in both directions by legacy_thinking_block_json_round_trips_unchanged.

Tests (9 new)

Legacy round-trip pin (both directions); signature captured from the complete-block path and from both fold arms; replay attaches on model match; replay drops for a foreign model, an unknown model, reasoning-off, and unsigned; non-Anthropic replay byte-identical; the stamp touches only signed, unstamped blocks.

Full suite 5414 passed, 0 failed, 1 ignored. clippy --all-targets -- -D warnings clean, fmt --check clean.

End-to-end, with a negative control

Same config and prompt, Opus 4.6 at effort: medium, tool call in the loop:

  • unpatched upstream/main binary400 ... messages.3.content.0.thinking.signature: Field required
  • patched binary, run 1 → completes normally
  • patched binary, run 2 → completes normally

The control confirms the repro was live in those exact runs (thinking engaged, block present at content.0), and the patched runs completing with thinking on and a tool_use in flight means the block was attached with a valid signature — a dropped block would have produced a different 400, so this is not passing by dropping.

Known limitations / out of scope

  • Session persistence flattens thinking blocks. The messages table stores content TEXT, and the resume path constructs signature: None, so cross-process --session reuse carries no signatures; those blocks are dropped from Anthropic replays. Safe (they previously 400'd), but it means a resumed session loses its reasoning context. No schema change made here.
  • Aliased escalation/review routes. provider::build passes the config entry alias as provider_name for those stream fns and canonicalizes only openai, so an Anthropic escalation route configured under an alias falls to the unsigned path and still 400s exactly as before. Fixing that means canonicalizing at the dispatch site — separate change.
  • Gemini sessions now store rig-provided thought signatures in session JSON. No wire change: attach is gated to Anthropic.
  • Built and tested with --no-default-features --features no-plugin (no janet toolchain on this host — Build fails on Linux #712), so the mechanical field additions in plugin_hooks_tests.rs are verified only by CI with default features.
  • Overlaps Set max_tokens on non-reasoning Anthropic requests (#816) #822 in rig_stream_factory.rs but in disjoint regions (~40 lines apart); a rebase should be clean or trivial whichever merges first.

Anthropic signs every extended-thinking block and schema-rejects a
replayed block without the signature (400: thinking.signature: Field
required), so the first in-process replay — the tool-use continuation —
failed on every reasoning-on turn that called a tool.

- ContentBlock::Thinking gains optional signature + signatureModel
  fields; both default/skip so legacy session JSON round-trips
  byte-identically (pinned by test).
- rig_stream captures the signature from the complete Reasoning event,
  including through the dirge-zf35 delta fold (replace and append arms),
  never clobbering a captured signature with None.
- The stream factory stamps the minting model onto signed blocks (the
  capture layer does not know the model identity).
- Replay attaches the signature only for the anthropic provider when
  the request's model matches the minting model and reasoning is on
  this turn; otherwise the block is dropped (unsigned or foreign-signed
  blocks are both rejected). Every other provider keeps the unsigned
  echo byte-identical to before.
@yogthos

yogthos commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

awesome thanks!

@yogthos
yogthos merged commit 232260d into dirge-code:main Aug 26, 2026
15 checks passed
yogthos pushed a commit that referenced this pull request Sep 2, 2026
Same defect as #821, one field over — the gap #823 disclosed when it
scoped itself to Anthropic thinking blocks.

Gemini mints a `thought_signature` on every `functionCall` part and
rejects a replayed call without one ("Function call is missing a
thought_signature in functionCall parts"), so the first tool-using turn
in any Gemini session failed on the replay. rig hands the value over on
capture (`gemini/completion.rs:489`) and sends it straight back out again
(`impl From<message::ToolCall> for Part`); dirge discarded it at capture
and hardcoded `signature: None` at replay, so the round trip existed end
to end and only dirge broke it.

Mirrors #823's mechanism, which was built for exactly this:

  1. `ContentBlock::ToolCall` gains `signature` / `signatureModel`, both
     skipped when absent, so a call without one serializes byte-identically
     to the legacy shape and saved sessions round-trip unchanged.
  2. Capture keeps `ToolCall.signature`; the factory stamps the minting
     model, the same pass #821 added for thinking blocks, now covering
     both block kinds.
  3. Replay attaches the signature when the request's model is the one
     that minted it. `signature_model` stops being a single-provider guard
     and becomes the cross-provider one it was built to be.

Missing-signature case: a tool call cannot be dropped alone — its paired
`tool_result` would be orphaned, which every backend rejects — so the
pair is dropped together and the turn is logged with the count. That
costs one step of history against a turn that could not be sent at all;
sending it unsigned is the bug, and refusing the turn would hard-block
every pre-existing Gemini session.

Every other provider keeps today's unsigned replay, byte-identical on the
wire.
yogthos pushed a commit that referenced this pull request Sep 2, 2026
Same defect as #821, one field over — the gap #823 disclosed when it
scoped itself to Anthropic thinking blocks.

Gemini mints a `thought_signature` on every `functionCall` part and
rejects a replayed call without one ("Function call is missing a
thought_signature in functionCall parts"), so the first tool-using turn
in any Gemini session failed on the replay. rig hands the value over on
capture (`gemini/completion.rs:489`) and sends it straight back out again
(`impl From<message::ToolCall> for Part`); dirge discarded it at capture
and hardcoded `signature: None` at replay, so the round trip existed end
to end and only dirge broke it.

Mirrors #823's mechanism, which was built for exactly this:

  1. `ContentBlock::ToolCall` gains `signature` / `signatureModel`, both
     skipped when absent, so a call without one serializes byte-identically
     to the legacy shape and saved sessions round-trip unchanged.
  2. Capture keeps `ToolCall.signature`; the factory stamps the minting
     model, the same pass #821 added for thinking blocks, now covering
     both block kinds.
  3. Replay attaches the signature when the request's model is the one
     that minted it. `signature_model` stops being a single-provider guard
     and becomes the cross-provider one it was built to be.

Missing-signature case: a tool call cannot be dropped alone — its paired
`tool_result` would be orphaned, which every backend rejects — so the
pair is dropped together and the turn is logged with the count. That
costs one step of history against a turn that could not be sent at all;
sending it unsigned is the bug, and refusing the turn would hard-block
every pre-existing Gemini session.

Every other provider keeps today's unsigned replay, byte-identical on the
wire.
yogthos added a commit that referenced this pull request Sep 2, 2026
Same defect as #821, one field over — the gap #823 disclosed when it
scoped itself to Anthropic thinking blocks.

Gemini mints a `thought_signature` on every `functionCall` part and
rejects a replayed call without one ("Function call is missing a
thought_signature in functionCall parts"), so the first tool-using turn
in any Gemini session failed on the replay. rig hands the value over on
capture (`gemini/completion.rs:489`) and sends it straight back out again
(`impl From<message::ToolCall> for Part`); dirge discarded it at capture
and hardcoded `signature: None` at replay, so the round trip existed end
to end and only dirge broke it.

Mirrors #823's mechanism, which was built for exactly this:

  1. `ContentBlock::ToolCall` gains `signature` / `signatureModel`, both
     skipped when absent, so a call without one serializes byte-identically
     to the legacy shape and saved sessions round-trip unchanged.
  2. Capture keeps `ToolCall.signature`; the factory stamps the minting
     model, the same pass #821 added for thinking blocks, now covering
     both block kinds.
  3. Replay attaches the signature when the request's model is the one
     that minted it. `signature_model` stops being a single-provider guard
     and becomes the cross-provider one it was built to be.

Missing-signature case: a tool call cannot be dropped alone — its paired
`tool_result` would be orphaned, which every backend rejects — so the
pair is dropped together and the turn is logged with the count. That
costs one step of history against a turn that could not be sent at all;
sending it unsigned is the bug, and refusing the turn would hard-block
every pre-existing Gemini session.

Every other provider keeps today's unsigned replay, byte-identical on the
wire.

Co-authored-by: Yogthos <yogthos@gmail.com>
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.

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

2 participants