From 71b9d575a0d66c03abc82e28335a3438668acc2c Mon Sep 17 00:00:00 2001 From: fre$h Date: Mon, 11 May 2026 19:11:08 -0400 Subject: [PATCH] fix(bedrock): handle ReasoningContent blocks gracefully (#8843) Signed-off-by: fresh3nough Signed-off-by: Douwe Osinga Co-authored-by: Douwe Osinga --- crates/goose/src/providers/formats/bedrock.rs | 315 +++++++++++++++++- 1 file changed, 306 insertions(+), 9 deletions(-) diff --git a/crates/goose/src/providers/formats/bedrock.rs b/crates/goose/src/providers/formats/bedrock.rs index 316395db76..23ed0c0698 100644 --- a/crates/goose/src/providers/formats/bedrock.rs +++ b/crates/goose/src/providers/formats/bedrock.rs @@ -55,13 +55,24 @@ pub fn to_bedrock_message_content(content: &MessageContent) -> Result { bedrock::ContentBlock::Image(to_bedrock_image(&image.data, &image.mime_type)?) } - MessageContent::Thinking(_) => { - // Thinking blocks are not supported in Bedrock - skip - bedrock::ContentBlock::Text("".to_string()) + MessageContent::Thinking(thinking) => { + let mut builder = bedrock::ReasoningTextBlock::builder().text(&thinking.thinking); + if !thinking.signature.is_empty() { + builder = builder.signature(&thinking.signature); + } + bedrock::ContentBlock::ReasoningContent(bedrock::ReasoningContentBlock::ReasoningText( + builder.build()?, + )) } - MessageContent::RedactedThinking(_) => { - // Redacted thinking blocks are not supported in Bedrock - skip - bedrock::ContentBlock::Text("".to_string()) + MessageContent::RedactedThinking(redacted) => { + match base64::prelude::BASE64_STANDARD.decode(&redacted.data) { + Ok(bytes) => bedrock::ContentBlock::ReasoningContent( + bedrock::ReasoningContentBlock::RedactedContent(aws_smithy_types::Blob::new( + bytes, + )), + ), + Err(_) => bedrock::ContentBlock::Text("".to_string()), + } } MessageContent::SystemNotification(_) => { bail!("SystemNotification should not get passed to the provider") @@ -334,14 +345,64 @@ pub fn from_bedrock_content_block(block: &bedrock::ContentBlock) -> Result { + from_bedrock_reasoning_content_block(reasoning)? + } bedrock::ContentBlock::CachePoint(_) => { - // Filtered upstream in from_bedrock_message bail!("CachePoint blocks should have been filtered out during message processing") } - _ => bail!("Unsupported content block type from Bedrock"), + _ => bail!( + "Unsupported Bedrock content block type: {}", + bedrock_content_block_kind(block) + ), }) } +fn from_bedrock_reasoning_content_block( + reasoning: &bedrock::ReasoningContentBlock, +) -> Result { + Ok(match reasoning { + bedrock::ReasoningContentBlock::ReasoningText(text_block) => { + let signature = text_block.signature.clone().unwrap_or_default(); + MessageContent::thinking(text_block.text.clone(), signature) + } + bedrock::ReasoningContentBlock::RedactedContent(blob) => { + let encoded = base64::prelude::BASE64_STANDARD.encode(blob.as_ref()); + MessageContent::redacted_thinking(encoded) + } + _ => bail!( + "Unsupported Bedrock reasoning content variant: {}", + bedrock_reasoning_content_block_kind(reasoning) + ), + }) +} + +fn bedrock_reasoning_content_block_kind(block: &bedrock::ReasoningContentBlock) -> &'static str { + match block { + bedrock::ReasoningContentBlock::ReasoningText(_) => "ReasoningText", + bedrock::ReasoningContentBlock::RedactedContent(_) => "RedactedContent", + _ => "Unknown", + } +} + +fn bedrock_content_block_kind(block: &bedrock::ContentBlock) -> &'static str { + match block { + bedrock::ContentBlock::Audio(_) => "Audio", + bedrock::ContentBlock::CachePoint(_) => "CachePoint", + bedrock::ContentBlock::CitationsContent(_) => "CitationsContent", + bedrock::ContentBlock::Document(_) => "Document", + bedrock::ContentBlock::GuardContent(_) => "GuardContent", + bedrock::ContentBlock::Image(_) => "Image", + bedrock::ContentBlock::ReasoningContent(_) => "ReasoningContent", + bedrock::ContentBlock::SearchResult(_) => "SearchResult", + bedrock::ContentBlock::Text(_) => "Text", + bedrock::ContentBlock::ToolResult(_) => "ToolResult", + bedrock::ContentBlock::ToolUse(_) => "ToolUse", + bedrock::ContentBlock::Video(_) => "Video", + _ => "Unknown", + } +} + pub fn from_bedrock_tool_result_content_block( content: &bedrock::ToolResultContentBlock, ) -> ToolResult { @@ -352,7 +413,7 @@ pub fn from_bedrock_tool_result_content_block( code: ErrorCode::INTERNAL_ERROR, message: Cow::from("Unsupported tool result from Bedrock".to_string()), data: None, - }) + }); } }) } @@ -552,6 +613,242 @@ mod tests { .contains("CachePoint blocks should have been filtered out")); } + #[test] + fn test_from_bedrock_content_block_reasoning_text() -> Result<()> { + let reasoning_text = bedrock::ReasoningTextBlock::builder() + .text("step-by-step reasoning") + .signature("sig-token") + .build()?; + let content_block = bedrock::ContentBlock::ReasoningContent( + bedrock::ReasoningContentBlock::ReasoningText(reasoning_text), + ); + + match from_bedrock_content_block(&content_block)? { + MessageContent::Thinking(thinking) => { + assert_eq!(thinking.thinking, "step-by-step reasoning"); + assert_eq!(thinking.signature, "sig-token"); + } + other => panic!("Expected Thinking content, got {:?}", other), + } + Ok(()) + } + + #[test] + fn test_from_bedrock_content_block_reasoning_text_without_signature() -> Result<()> { + let reasoning_text = bedrock::ReasoningTextBlock::builder() + .text("reasoning without signature") + .build()?; + let content_block = bedrock::ContentBlock::ReasoningContent( + bedrock::ReasoningContentBlock::ReasoningText(reasoning_text), + ); + + match from_bedrock_content_block(&content_block)? { + MessageContent::Thinking(thinking) => { + assert_eq!(thinking.thinking, "reasoning without signature"); + assert_eq!(thinking.signature, ""); + } + other => panic!("Expected Thinking content, got {:?}", other), + } + Ok(()) + } + + #[test] + fn test_from_bedrock_content_block_unsupported_type_errors() { + let image_block = bedrock::ImageBlock::builder() + .format(bedrock::ImageFormat::Png) + .source(bedrock::ImageSource::Bytes(aws_smithy_types::Blob::new( + Vec::new(), + ))) + .build() + .unwrap(); + let content_block = bedrock::ContentBlock::Image(image_block); + + let err = from_bedrock_content_block(&content_block) + .expect_err("unsupported variant should error"); + let msg = err.to_string(); + assert!( + msg.contains("Unsupported Bedrock content block type"), + "got: {msg}" + ); + assert!(msg.contains("Image"), "got: {msg}"); + } + + #[test] + fn test_from_bedrock_content_block_reasoning_redacted_content() -> Result<()> { + let raw = b"encrypted-reasoning-bytes"; + let blob = aws_smithy_types::Blob::new(raw.to_vec()); + let content_block = bedrock::ContentBlock::ReasoningContent( + bedrock::ReasoningContentBlock::RedactedContent(blob), + ); + + match from_bedrock_content_block(&content_block)? { + MessageContent::RedactedThinking(redacted) => { + let expected = base64::prelude::BASE64_STANDARD.encode(raw); + assert_eq!(redacted.data, expected); + } + other => panic!("Expected RedactedThinking content, got {:?}", other), + } + Ok(()) + } + + #[test] + fn test_to_bedrock_message_content_thinking() -> Result<()> { + let message_content = MessageContent::thinking("because of X", "sig-abc"); + let block = to_bedrock_message_content(&message_content)?; + + match block { + bedrock::ContentBlock::ReasoningContent( + bedrock::ReasoningContentBlock::ReasoningText(text_block), + ) => { + assert_eq!(text_block.text, "because of X"); + assert_eq!(text_block.signature.as_deref(), Some("sig-abc")); + } + other => panic!("Expected ReasoningContent::ReasoningText, got {:?}", other), + } + Ok(()) + } + + #[test] + fn test_to_bedrock_message_content_thinking_without_signature() -> Result<()> { + let message_content = MessageContent::thinking("silent reasoning", ""); + let block = to_bedrock_message_content(&message_content)?; + + match block { + bedrock::ContentBlock::ReasoningContent( + bedrock::ReasoningContentBlock::ReasoningText(text_block), + ) => { + assert_eq!(text_block.text, "silent reasoning"); + assert!(text_block.signature.is_none()); + } + other => panic!("Expected ReasoningContent::ReasoningText, got {:?}", other), + } + Ok(()) + } + + #[test] + fn test_to_bedrock_message_content_redacted_thinking() -> Result<()> { + let raw = b"encrypted-reasoning-bytes"; + let encoded = base64::prelude::BASE64_STANDARD.encode(raw); + let message_content = MessageContent::redacted_thinking(encoded); + + let block = to_bedrock_message_content(&message_content)?; + match block { + bedrock::ContentBlock::ReasoningContent( + bedrock::ReasoningContentBlock::RedactedContent(blob), + ) => { + assert_eq!(blob.as_ref(), raw); + } + other => panic!( + "Expected ReasoningContent::RedactedContent, got {:?}", + other + ), + } + Ok(()) + } + + #[test] + fn test_to_bedrock_message_content_redacted_thinking_opaque_payload() -> Result<()> { + let message_content = MessageContent::redacted_thinking("opaque_not_base64!@#".to_string()); + + let block = to_bedrock_message_content(&message_content)?; + match block { + bedrock::ContentBlock::Text(text) => assert_eq!(text, ""), + other => panic!( + "Expected fallback empty Text block for opaque payload, got {:?}", + other + ), + } + Ok(()) + } + + #[test] + fn test_bedrock_thinking_round_trip() -> Result<()> { + let original = + bedrock::ContentBlock::ReasoningContent(bedrock::ReasoningContentBlock::ReasoningText( + bedrock::ReasoningTextBlock::builder() + .text("chain of thought") + .signature("sig-xyz") + .build()?, + )); + + let message_content = from_bedrock_content_block(&original)?; + let round_tripped = to_bedrock_message_content(&message_content)?; + + match round_tripped { + bedrock::ContentBlock::ReasoningContent( + bedrock::ReasoningContentBlock::ReasoningText(text_block), + ) => { + assert_eq!(text_block.text, "chain of thought"); + assert_eq!(text_block.signature.as_deref(), Some("sig-xyz")); + } + other => panic!("Expected ReasoningContent::ReasoningText, got {:?}", other), + } + Ok(()) + } + + #[test] + fn test_bedrock_redacted_thinking_round_trip() -> Result<()> { + let raw = b"encrypted-reasoning-bytes"; + let original = bedrock::ContentBlock::ReasoningContent( + bedrock::ReasoningContentBlock::RedactedContent(aws_smithy_types::Blob::new( + raw.to_vec(), + )), + ); + + let message_content = from_bedrock_content_block(&original)?; + let round_tripped = to_bedrock_message_content(&message_content)?; + + match round_tripped { + bedrock::ContentBlock::ReasoningContent( + bedrock::ReasoningContentBlock::RedactedContent(blob), + ) => { + assert_eq!(blob.as_ref(), raw); + } + other => panic!( + "Expected ReasoningContent::RedactedContent, got {:?}", + other + ), + } + Ok(()) + } + + #[test] + fn test_from_bedrock_message_includes_reasoning_content() -> Result<()> { + use rmcp::model::Role; + + let reasoning_text = bedrock::ReasoningTextBlock::builder() + .text("thinking out loud") + .signature("sig") + .build()?; + + let bedrock_message = bedrock::Message::builder() + .role(bedrock::ConversationRole::Assistant) + .content(bedrock::ContentBlock::ReasoningContent( + bedrock::ReasoningContentBlock::ReasoningText(reasoning_text), + )) + .content(bedrock::ContentBlock::Text("final answer".to_string())) + .build() + .unwrap(); + + let message = from_bedrock_message(&bedrock_message)?; + + assert_eq!(message.role, Role::Assistant); + assert_eq!(message.content.len(), 2); + match &message.content[0] { + MessageContent::Thinking(thinking) => { + assert_eq!(thinking.thinking, "thinking out loud"); + assert_eq!(thinking.signature, "sig"); + } + other => panic!("Expected Thinking content, got {:?}", other), + } + match &message.content[1] { + MessageContent::Text(text) => assert_eq!(text.text, "final answer"), + other => panic!("Expected Text content, got {:?}", other), + } + + Ok(()) + } + #[test] fn test_from_bedrock_message_filters_cache_points() -> Result<()> { use rmcp::model::Role;