mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
fix(bedrock): handle ReasoningContent blocks gracefully (#8843)
Signed-off-by: fresh3nough <anonwurcod@proton.me> Signed-off-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -55,13 +55,24 @@ pub fn to_bedrock_message_content(content: &MessageContent) -> Result<bedrock::C
|
||||
MessageContent::Image(image) => {
|
||||
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<Messa
|
||||
.map(rmcp::model::CallToolResult::success)
|
||||
},
|
||||
),
|
||||
bedrock::ContentBlock::ReasoningContent(reasoning) => {
|
||||
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<MessageContent> {
|
||||
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<Content> {
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user