Skip to content

Commit 232260d

Browse files
authored
Echo Anthropic thinking-block signatures on replay (GH #821) (#823)
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.
1 parent ebdeb78 commit 232260d

11 files changed

Lines changed: 572 additions & 22 deletions

File tree

src/agent/agent_loop/bridge.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ impl EventBridge {
447447
.content
448448
.iter()
449449
.filter_map(|b| match b {
450-
ContentBlock::Thinking { text } => Some(text.as_str()),
450+
ContentBlock::Thinking { text, .. } => Some(text.as_str()),
451451
_ => None,
452452
})
453453
.collect::<Vec<_>>()

src/agent/agent_loop/bridge_tests.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ fn assistant_with_thinking(s: &str) -> AssistantMessage {
2020
AssistantMessage::new(
2121
vec![ContentBlock::Thinking {
2222
text: s.to_string(),
23+
signature: None,
24+
signature_model: None,
2325
}],
2426
StopReason::Stop,
2527
)
@@ -287,6 +289,8 @@ fn text_and_reasoning_tracked_independently() {
287289
message: AssistantMessage::new(
288290
vec![ContentBlock::Thinking {
289291
text: "thinking".to_string(),
292+
signature: None,
293+
signature_model: None,
290294
}],
291295
StopReason::Stop,
292296
),
@@ -298,6 +302,8 @@ fn text_and_reasoning_tracked_independently() {
298302
vec![
299303
ContentBlock::Thinking {
300304
text: "thinking".to_string(),
305+
signature: None,
306+
signature_model: None,
301307
},
302308
ContentBlock::Text {
303309
text: "answer".to_string(),

src/agent/agent_loop/call_syntax.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -990,13 +990,15 @@ mod tests {
990990
let msg = super::super::message::AssistantMessage::new(
991991
vec![ContentBlock::Thinking {
992992
text: thought.to_string(),
993+
signature: None,
994+
signature_model: None,
993995
}],
994996
super::super::message::StopReason::ToolUse,
995997
);
996998
let out = absorb_text_calls(&msg, &[call("scav-1", "bash")], &tools(&["bash"]));
997999
match out.content.as_slice() {
9981000
[
999-
ContentBlock::Thinking { text },
1001+
ContentBlock::Thinking { text, .. },
10001002
ContentBlock::ToolCall { .. },
10011003
] => {
10021004
assert_eq!(text, thought)

src/agent/agent_loop/integration.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,11 @@ pub fn rig_message_to_loop_messages(m: rig::completion::Message) -> Vec<LoopMess
278278
})
279279
.collect::<Vec<_>>()
280280
.join("\n");
281-
blocks.push(ContentBlock::Thinking { text });
281+
blocks.push(ContentBlock::Thinking {
282+
text,
283+
signature: None,
284+
signature_model: None,
285+
});
282286
}
283287
AssistantContent::Image(_) => {}
284288
}

src/agent/agent_loop/message.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,30 @@ pub enum ContentBlock {
5858
},
5959
Thinking {
6060
text: String,
61+
/// Provider-issued cryptographic signature over the thinking text
62+
/// (GH #821). Anthropic mints one per thinking block and requires
63+
/// it echoed back verbatim when the block is replayed — a thinking
64+
/// block without it is schema-rejected (`signature: Field
65+
/// required`). `None` for providers that don't sign reasoning and
66+
/// for blocks captured before this field existed; both serde
67+
/// attributes keep the serialized shape byte-identical to the
68+
/// legacy `{type, text}` form in that case, so existing saved
69+
/// sessions round-trip unchanged.
70+
#[serde(default, skip_serializing_if = "Option::is_none")]
71+
signature: Option<String>,
72+
/// The model that minted `signature` (GH #821). A signature is
73+
/// only valid for the model that produced it — replaying it to a
74+
/// different model is rejected (`Invalid signature in thinking
75+
/// block`) — and dirge can switch models mid-session (`/model`,
76+
/// escalation, subagents), so the replay path attaches the
77+
/// signature only when this matches the request's model, and
78+
/// drops the block otherwise.
79+
#[serde(
80+
default,
81+
skip_serializing_if = "Option::is_none",
82+
rename = "signatureModel"
83+
)]
84+
signature_model: Option<String>,
6185
},
6286
ToolCall {
6387
id: String,
@@ -778,6 +802,54 @@ impl LoopEvent {
778802
mod tests {
779803
use super::*;
780804

805+
/// GH #821 back-compat pin: `ContentBlock` is serde-serialized into
806+
/// session storage, so the `Thinking` variant's new optional
807+
/// signature fields must (a) deserialize legacy JSON that has no
808+
/// such keys, and (b) serialize back to EXACTLY that legacy shape
809+
/// when they are `None` — otherwise every saved session written
810+
/// before (or without) the fields breaks on upgrade.
811+
#[test]
812+
fn legacy_thinking_block_json_round_trips_unchanged() {
813+
let legacy = r#"{"type":"thinking","text":"pondering"}"#;
814+
let block: ContentBlock = serde_json::from_str(legacy).expect("legacy JSON must parse");
815+
assert_eq!(
816+
block,
817+
ContentBlock::Thinking {
818+
text: "pondering".to_string(),
819+
signature: None,
820+
signature_model: None,
821+
}
822+
);
823+
let reserialized = serde_json::to_string(&block).expect("must serialize");
824+
assert_eq!(
825+
reserialized, legacy,
826+
"None signature fields must be omitted so the wire shape is byte-identical to pre-#821"
827+
);
828+
}
829+
830+
/// GH #821: a signed block round-trips both new fields (camelCase
831+
/// `signatureModel`, matching the transcript key convention).
832+
#[test]
833+
fn signed_thinking_block_round_trips_signature_fields() {
834+
let block = ContentBlock::Thinking {
835+
text: "pondering".to_string(),
836+
signature: Some("sig-abc".to_string()),
837+
signature_model: Some("claude-opus-4-6".to_string()),
838+
};
839+
let json = serde_json::to_value(&block).expect("must serialize");
840+
assert_eq!(
841+
json,
842+
serde_json::json!({
843+
"type": "thinking",
844+
"text": "pondering",
845+
"signature": "sig-abc",
846+
"signatureModel": "claude-opus-4-6",
847+
})
848+
);
849+
let back: ContentBlock = serde_json::from_value(json).expect("must deserialize");
850+
assert_eq!(back, block);
851+
}
852+
781853
/// `DeltaPhase::is_content()` is the single source of truth for
782854
/// "has downstream output already been emitted?" — it gates both
783855
/// the retry wrapper (`retry.rs`) and the billing-fallback safety

src/agent/agent_loop/rig_stream.rs

Lines changed: 141 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ where
360360
let over_budget = reasoning_meter.record(&reasoning);
361361
match current_thinking_idx {
362362
Some(idx) => {
363-
if let Some(ContentBlock::Thinking { text }) =
363+
if let Some(ContentBlock::Thinking { text, .. }) =
364364
partial.content.get_mut(idx)
365365
{
366366
text.push_str(&reasoning);
@@ -372,7 +372,11 @@ where
372372
}
373373
None => {
374374
current_thinking_idx = Some(partial.content.len());
375-
partial.content.push(ContentBlock::Thinking { text: reasoning });
375+
partial.content.push(ContentBlock::Thinking {
376+
text: reasoning,
377+
signature: None,
378+
signature_model: None,
379+
});
376380
current_text_idx = None;
377381
yield StreamEvent::Delta {
378382
partial: partial.clone(),
@@ -420,6 +424,19 @@ where
420424
})
421425
.collect::<Vec<_>>()
422426
.join("\n");
427+
// GH #821: capture the provider-issued signature the
428+
// complete block carries (Anthropic sends it via a
429+
// `signature_delta` that rig folds into the final
430+
// `ReasoningContent::Text`). It must be echoed back
431+
// verbatim when the block is replayed, so dropping it
432+
// here — the old `Text { text, .. }` did — 400s the
433+
// first tool-use continuation with reasoning on.
434+
let signature: Option<String> = r.content.iter().find_map(|c| match c {
435+
rig::completion::message::ReasoningContent::Text {
436+
signature, ..
437+
} => signature.clone(),
438+
_ => None,
439+
});
423440
// dirge-zf35: mirror the H-7 ToolCall dedupe. Some
424441
// providers stream `ReasoningDelta`s and THEN send a
425442
// complete `Reasoning` for the same content. If a
@@ -444,17 +461,32 @@ where
444461
Some(ContentBlock::Thinking { .. })
445462
) =>
446463
{
447-
if let Some(ContentBlock::Thinking { text: acc }) =
448-
partial.content.get_mut(idx)
464+
if let Some(ContentBlock::Thinking {
465+
text: acc,
466+
signature: acc_signature,
467+
..
468+
}) = partial.content.get_mut(idx)
449469
{
450470
if acc.is_empty() || text.starts_with(acc.as_str()) {
451471
*acc = text;
452472
} else {
453473
acc.push_str(&text);
454474
}
475+
// GH #821: the fold must carry the signature
476+
// too — in the Anthropic flow the text arrives
477+
// as deltas and the signature ONLY on this
478+
// complete block. Never clobber a previously
479+
// captured signature with `None`.
480+
if signature.is_some() {
481+
*acc_signature = signature;
482+
}
455483
}
456484
}
457-
_ => partial.content.push(ContentBlock::Thinking { text }),
485+
_ => partial.content.push(ContentBlock::Thinking {
486+
text,
487+
signature,
488+
signature_model: None,
489+
}),
458490
}
459491
current_thinking_idx = None;
460492
current_text_idx = None;
@@ -1153,7 +1185,7 @@ mod tests {
11531185
.content
11541186
.iter()
11551187
.filter_map(|b| match b {
1156-
ContentBlock::Thinking { text } => Some(text.clone()),
1188+
ContentBlock::Thinking { text, .. } => Some(text.clone()),
11571189
_ => None,
11581190
})
11591191
.collect();
@@ -1207,7 +1239,7 @@ mod tests {
12071239
);
12081240
match events.last().unwrap() {
12091241
StreamEvent::Done { message, .. } => {
1210-
if let ContentBlock::Thinking { text } = &message.content[0] {
1242+
if let ContentBlock::Thinking { text, .. } = &message.content[0] {
12111243
assert_eq!(text, "Let me think about this");
12121244
} else {
12131245
panic!("expected thinking");
@@ -1263,7 +1295,7 @@ mod tests {
12631295
.content
12641296
.iter()
12651297
.filter_map(|b| match b {
1266-
ContentBlock::Thinking { text } => Some(text),
1298+
ContentBlock::Thinking { text, .. } => Some(text),
12671299
_ => None,
12681300
})
12691301
.collect();
@@ -1278,6 +1310,103 @@ mod tests {
12781310
}
12791311
}
12801312

1313+
/// GH #821: a complete reasoning block's signature must be captured,
1314+
/// not discarded — Anthropic requires it echoed back verbatim when
1315+
/// the block is replayed (the tool-use continuation 400s without it).
1316+
#[tokio::test]
1317+
async fn complete_reasoning_signature_is_captured() {
1318+
let raw = raw_stream(vec![Ok(StreamedAssistantContent::Reasoning(
1319+
Reasoning::new_with_signature("All thinking", Some("sig-821".to_string())),
1320+
))]);
1321+
let events = drain(wrap_streamed_assistant(raw, None, None)).await;
1322+
match events.last().unwrap() {
1323+
StreamEvent::Done { message, .. } => match &message.content[0] {
1324+
ContentBlock::Thinking {
1325+
text, signature, ..
1326+
} => {
1327+
assert_eq!(text, "All thinking");
1328+
assert_eq!(signature.as_deref(), Some("sig-821"));
1329+
}
1330+
other => panic!("expected thinking, got {other:?}"),
1331+
},
1332+
_ => panic!("expected Done last"),
1333+
}
1334+
}
1335+
1336+
/// GH #821 + dirge-zf35: in the Anthropic flow the text arrives as
1337+
/// `ReasoningDelta`s and the signature ONLY on the trailing complete
1338+
/// `Reasoning`. The fold that merges the complete block into the open
1339+
/// delta-built block must carry the signature onto that block.
1340+
#[tokio::test]
1341+
async fn signature_survives_the_delta_fold() {
1342+
let raw = raw_stream(vec![
1343+
Ok(StreamedAssistantContent::ReasoningDelta {
1344+
id: None,
1345+
reasoning: "Let me think".to_string(),
1346+
}),
1347+
Ok(StreamedAssistantContent::ReasoningDelta {
1348+
id: None,
1349+
reasoning: " about this".to_string(),
1350+
}),
1351+
Ok(StreamedAssistantContent::Reasoning(
1352+
Reasoning::new_with_signature(
1353+
"Let me think about this",
1354+
Some("sig-821".to_string()),
1355+
),
1356+
)),
1357+
]);
1358+
let events = drain(wrap_streamed_assistant(raw, None, None)).await;
1359+
match events.last().unwrap() {
1360+
StreamEvent::Done { message, .. } => {
1361+
let blocks: Vec<_> = message
1362+
.content
1363+
.iter()
1364+
.filter_map(|b| match b {
1365+
ContentBlock::Thinking {
1366+
text, signature, ..
1367+
} => Some((text.as_str(), signature.as_deref())),
1368+
_ => None,
1369+
})
1370+
.collect();
1371+
assert_eq!(
1372+
blocks,
1373+
vec![("Let me think about this", Some("sig-821"))],
1374+
"fold must keep one block AND adopt the complete block's signature"
1375+
);
1376+
}
1377+
_ => panic!("expected Done last"),
1378+
}
1379+
}
1380+
1381+
/// GH #821, Gemini shape: when the complete `Reasoning` is only the
1382+
/// trailing chunk (append path of the dirge-zf35 fold), its signature
1383+
/// must still land on the accumulated block.
1384+
#[tokio::test]
1385+
async fn signature_survives_the_append_fold() {
1386+
let raw = raw_stream(vec![
1387+
Ok(StreamedAssistantContent::ReasoningDelta {
1388+
id: None,
1389+
reasoning: "chunk A".to_string(),
1390+
}),
1391+
Ok(StreamedAssistantContent::Reasoning(
1392+
Reasoning::new_with_signature("chunk Z", Some("sig-tail".to_string())),
1393+
)),
1394+
]);
1395+
let events = drain(wrap_streamed_assistant(raw, None, None)).await;
1396+
match events.last().unwrap() {
1397+
StreamEvent::Done { message, .. } => match &message.content[0] {
1398+
ContentBlock::Thinking {
1399+
text, signature, ..
1400+
} => {
1401+
assert_eq!(text, "chunk Achunk Z");
1402+
assert_eq!(signature.as_deref(), Some("sig-tail"));
1403+
}
1404+
other => panic!("expected thinking, got {other:?}"),
1405+
},
1406+
_ => panic!("expected Done last"),
1407+
}
1408+
}
1409+
12811410
/// Gemini shape: non-signature thought parts stream as
12821411
/// `ReasoningDelta`s, and the complete `Reasoning` event carries
12831412
/// only the FINAL chunk (the thought_signature-bearing part), not
@@ -1305,7 +1434,7 @@ mod tests {
13051434
.content
13061435
.iter()
13071436
.filter_map(|b| match b {
1308-
ContentBlock::Thinking { text } => Some(text),
1437+
ContentBlock::Thinking { text, .. } => Some(text),
13091438
_ => None,
13101439
})
13111440
.collect();
@@ -1344,7 +1473,7 @@ mod tests {
13441473
.content
13451474
.iter()
13461475
.filter_map(|b| match b {
1347-
ContentBlock::Thinking { text } => Some(text),
1476+
ContentBlock::Thinking { text, .. } => Some(text),
13481477
_ => None,
13491478
})
13501479
.collect();
@@ -1413,7 +1542,7 @@ mod tests {
14131542
.content
14141543
.iter()
14151544
.filter_map(|b| match b {
1416-
ContentBlock::Thinking { text } => Some(text.as_str()),
1545+
ContentBlock::Thinking { text, .. } => Some(text.as_str()),
14171546
_ => None,
14181547
})
14191548
.collect::<Vec<_>>()
@@ -1835,7 +1964,7 @@ mod tests {
18351964
));
18361965
assert!(matches!(
18371966
&final_msg.content[1],
1838-
ContentBlock::Thinking { text } if text == "thinking"
1967+
ContentBlock::Thinking { text, .. } if text == "thinking"
18391968
));
18401969
assert!(matches!(
18411970
&final_msg.content[2],

0 commit comments

Comments
 (0)