Skip to content

Commit 5396889

Browse files
committed
bedrock redacted
1 parent c9e8d31 commit 5396889

2 files changed

Lines changed: 123 additions & 11 deletions

File tree

crates/openfang-runtime/src/agent_loop.rs

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -158,22 +158,30 @@ fn build_assistant_message_preserving_thinking(
158158
response_blocks: &[ContentBlock],
159159
final_text: &str,
160160
) -> Message {
161-
let has_thinking = response_blocks
162-
.iter()
163-
.any(|b| matches!(b, ContentBlock::Thinking { .. }));
164-
if !has_thinking {
161+
// Key on either Thinking or RedactedThinking — Anthropic/Bedrock both
162+
// reject extended-thinking history that drops the redacted variant, so a
163+
// turn that contains only RedactedThinking must still be preserved.
164+
let has_reasoning = response_blocks.iter().any(|b| {
165+
matches!(
166+
b,
167+
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. }
168+
)
169+
});
170+
if !has_reasoning {
165171
return Message::assistant(final_text.to_string());
166172
}
167173

168-
// Preserve order: Thinking blocks first (in original order), then a
169-
// single Text block carrying `final_text`. Tool blocks aren't expected
170-
// here (StopReason::EndTurn path), but copy them through if present so
171-
// we don't drop information.
174+
// Preserve order: Thinking / RedactedThinking blocks first (in original
175+
// order), then a single Text block carrying `final_text`. Tool blocks
176+
// aren't expected here (StopReason::EndTurn path), but copy them through
177+
// if present so we don't drop information.
172178
let mut blocks: Vec<ContentBlock> = Vec::with_capacity(response_blocks.len() + 1);
173179
let mut emitted_text = false;
174180
for b in response_blocks {
175181
match b {
176-
ContentBlock::Thinking { .. } => blocks.push(b.clone()),
182+
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {
183+
blocks.push(b.clone())
184+
}
177185
ContentBlock::Text { .. } if !emitted_text => {
178186
blocks.push(ContentBlock::Text {
179187
text: final_text.to_string(),
@@ -3380,6 +3388,36 @@ mod tests {
33803388
assert_eq!(saved_text, Some(final_text));
33813389
}
33823390

3391+
/// Issue #1187 — a turn that contains only `RedactedThinking` (no
3392+
/// `Thinking` block) must still trigger the block-preserving path. The
3393+
/// previous gate keyed solely on `Thinking`, so redacted-only turns were
3394+
/// downgraded to plain text and the encrypted blob was lost on the next
3395+
/// request, which Anthropic/Bedrock reject.
3396+
#[test]
3397+
fn test_build_assistant_message_preserves_redacted_only() {
3398+
let response_blocks = vec![
3399+
ContentBlock::RedactedThinking {
3400+
data: "encrypted_only".to_string(),
3401+
},
3402+
ContentBlock::Text {
3403+
text: "Answer".to_string(),
3404+
provider_metadata: None,
3405+
},
3406+
];
3407+
let msg = build_assistant_message_preserving_thinking(&response_blocks, "Answer");
3408+
let blocks = match &msg.content {
3409+
MessageContent::Blocks(b) => b,
3410+
other => panic!("expected Blocks content for redacted-only turn, got {other:?}"),
3411+
};
3412+
let has_redacted = blocks
3413+
.iter()
3414+
.any(|b| matches!(b, ContentBlock::RedactedThinking { data } if data == "encrypted_only"));
3415+
assert!(
3416+
has_redacted,
3417+
"RedactedThinking-only turn must be preserved as Blocks"
3418+
);
3419+
}
3420+
33833421
#[test]
33843422
fn test_retry_constants() {
33853423
assert_eq!(MAX_RETRIES, 3);

crates/openfang-runtime/src/drivers/bedrock.rs

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,20 @@ enum BedrockContentBlock {
9292
#[serde(rename = "toolResult")]
9393
tool_result: BedrockToolResult,
9494
},
95+
// Bedrock Converse representation of Anthropic's `redacted_thinking`.
96+
// The encrypted blob is echoed back verbatim under
97+
// reasoningContent.redactedContent so Claude extended-thinking history
98+
// is not rejected on resubmission.
99+
ReasoningContent {
100+
#[serde(rename = "reasoningContent")]
101+
reasoning_content: BedrockReasoningContent,
102+
},
103+
}
104+
105+
#[derive(Debug, Serialize)]
106+
struct BedrockReasoningContent {
107+
#[serde(rename = "redactedContent")]
108+
redacted_content: String,
95109
}
96110

97111
#[derive(Debug, Serialize)]
@@ -299,10 +313,23 @@ fn convert_content_block(block: &ContentBlock) -> Option<BedrockContentBlock> {
299313
},
300314
},
301315
}),
302-
// Image, Thinking, RedactedThinking, and Unknown are not supported — silently drop
316+
// Echo redacted_thinking verbatim. Bedrock Converse rejects history
317+
// that drops these blocks on Claude extended-thinking models, mirroring
318+
// the anthropic.rs path. Drop empty blobs (e.g. interrupted stream).
319+
ContentBlock::RedactedThinking { data } => {
320+
if data.is_empty() {
321+
None
322+
} else {
323+
Some(BedrockContentBlock::ReasoningContent {
324+
reasoning_content: BedrockReasoningContent {
325+
redacted_content: data.clone(),
326+
},
327+
})
328+
}
329+
}
330+
// Image, Thinking, and Unknown are not supported — silently drop
303331
ContentBlock::Image { .. }
304332
| ContentBlock::Thinking { .. }
305-
| ContentBlock::RedactedThinking { .. }
306333
| ContentBlock::Unknown => None,
307334
}
308335
}
@@ -1126,6 +1153,53 @@ mod tests {
11261153
assert!(text_at_3 >= 1);
11271154
}
11281155

1156+
/// Issue #1187 — Bedrock Converse history must preserve
1157+
/// `redacted_thinking` blocks on Claude extended-thinking models.
1158+
/// A message containing only RedactedThinking must round-trip through
1159+
/// `convert_content_block` without being silently dropped, and the wire
1160+
/// format must use `reasoningContent.redactedContent`.
1161+
#[test]
1162+
fn test_bedrock_redacted_thinking_round_trip() {
1163+
let msg = Message::assistant_with_blocks(vec![ContentBlock::RedactedThinking {
1164+
data: "encrypted-blob-abc123".to_string(),
1165+
}]);
1166+
1167+
let bedrock_blocks = convert_message_content(&msg.content);
1168+
assert_eq!(
1169+
bedrock_blocks.len(),
1170+
1,
1171+
"RedactedThinking must survive convert_content_block"
1172+
);
1173+
match &bedrock_blocks[0] {
1174+
BedrockContentBlock::ReasoningContent { reasoning_content } => {
1175+
assert_eq!(reasoning_content.redacted_content, "encrypted-blob-abc123");
1176+
}
1177+
other => panic!("expected ReasoningContent block, got {other:?}"),
1178+
}
1179+
1180+
// Wire format check: serialized JSON must carry
1181+
// reasoningContent.redactedContent so Bedrock accepts the history.
1182+
let json = serde_json::to_value(&bedrock_blocks[0]).unwrap();
1183+
assert_eq!(
1184+
json["reasoningContent"]["redactedContent"],
1185+
"encrypted-blob-abc123"
1186+
);
1187+
}
1188+
1189+
/// Empty RedactedThinking blobs (interrupted stream) must be dropped on
1190+
/// outbound, matching the anthropic.rs behavior.
1191+
#[test]
1192+
fn test_bedrock_redacted_thinking_empty_dropped() {
1193+
let msg = Message::assistant_with_blocks(vec![ContentBlock::RedactedThinking {
1194+
data: String::new(),
1195+
}]);
1196+
let bedrock_blocks = convert_message_content(&msg.content);
1197+
assert!(
1198+
bedrock_blocks.is_empty(),
1199+
"empty redacted_thinking must be dropped"
1200+
);
1201+
}
1202+
11291203
#[test]
11301204
fn test_validate_tool_pairing_noop_on_correct() {
11311205
// already correct 2-for-2 → no change

0 commit comments

Comments
 (0)