From 39351a5759bfdc6a41526306ec04f4b1921a22c5 Mon Sep 17 00:00:00 2001 From: aleksmeshr Date: Mon, 8 Jun 2026 16:00:47 +0200 Subject: [PATCH 1/2] fix(rig-bedrock): surface empty Bedrock responses as ResponseError with stop_reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, when Bedrock returned an assistant message with zero content blocks (e.g. guardrail intervention, content filter, max_tokens exhaustion during extended thinking, or model glitch), rig-bedrock raised `CompletionError::RequestError(EmptyListError)` deep inside the `OneOrMany::many` conversion in `message.rs`. This is misleading on two counts: 1. The `RequestError` variant is documented as "Error building the completion request" — it is for outgoing request construction failures, not response parsing. 2. The bare `EmptyListError` carries no context about *why* the response was empty, making it impossible for callers to branch (retry on max_tokens vs. fail fast on guardrails/content filter). Changes: - Short-circuit empty assistant content in `TryFrom` with a `ResponseError` that includes `stop_reason` context. - Re-label `OneOrMany::many` failures in `TryFrom` from `RequestError` to `ResponseError`. - Add `Display` impl for `StopReason` so the converse stop reason can be formatted into error messages (`max_tokens`, `guardrail_intervened`, `content_filtered`, `end_turn`, etc.). This lets downstream code distinguish recoverable cases (e.g. retry with doubled `max_tokens` when `stop_reason=max_tokens`) from terminal cases (guardrails, content filter) instead of substring-matching on `"empty vector"` in the error message. --- .../src/types/assistant_content.rs | 39 +++++++++++++------ .../rig-bedrock/src/types/converse_output.rs | 14 +++++++ crates/rig-bedrock/src/types/message.rs | 19 +++++++-- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/crates/rig-bedrock/src/types/assistant_content.rs b/crates/rig-bedrock/src/types/assistant_content.rs index f7b6ae7bd..52cd13657 100644 --- a/crates/rig-bedrock/src/types/assistant_content.rs +++ b/crates/rig-bedrock/src/types/assistant_content.rs @@ -85,27 +85,42 @@ impl TryFrom for completion::CompletionResponse Result { - let message: RigMessage = value + let stop_reason = value.0.stop_reason.clone(); + + let aws_message = value .to_owned() .0 .output - .ok_or(CompletionError::ProviderError( - "Model didn't return any output".into(), - ))? + .ok_or_else(|| { + CompletionError::ResponseError(format!( + "Model returned no output (stop_reason={stop_reason})" + )) + })? .as_message() .map_err(|_| { - CompletionError::ProviderError( - "Failed to extract message from converse output".into(), - ) + CompletionError::ResponseError(format!( + "Failed to extract message from converse output (stop_reason={stop_reason})" + )) })? - .to_owned() - .try_into()?; + .to_owned(); + + // Short-circuit empty content blocks with a descriptive ResponseError so + // callers can distinguish guardrail/content-filter/max-tokens cases from + // a generic "RequestError(EmptyListError)" raised deeper in the + // OneOrMany conversion. + if aws_message.content.is_empty() { + return Err(CompletionError::ResponseError(format!( + "No assistance content returned (stop_reason={stop_reason})" + ))); + } + + let message: RigMessage = aws_message.try_into()?; let choice = match message.0 { completion::Message::Assistant { content, .. } => Ok(content), - _ => Err(CompletionError::ResponseError( - "Response contained no message or tool call (empty)".to_owned(), - )), + _ => Err(CompletionError::ResponseError(format!( + "Response contained no message or tool call (stop_reason={stop_reason})" + ))), }?; let usage = value.0.usage().map(normalize_usage).unwrap_or_default(); diff --git a/crates/rig-bedrock/src/types/converse_output.rs b/crates/rig-bedrock/src/types/converse_output.rs index a24c2181e..1a3ac74f5 100644 --- a/crates/rig-bedrock/src/types/converse_output.rs +++ b/crates/rig-bedrock/src/types/converse_output.rs @@ -82,6 +82,20 @@ pub enum StopReason { /// the crate. /// /// This is not intended to be used directly. +impl fmt::Display for StopReason { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + StopReason::ContentFiltered => write!(f, "content_filtered"), + StopReason::EndTurn => write!(f, "end_turn"), + StopReason::GuardrailIntervened => write!(f, "guardrail_intervened"), + StopReason::MaxTokens => write!(f, "max_tokens"), + StopReason::StopSequence => write!(f, "stop_sequence"), + StopReason::ToolUse => write!(f, "tool_use"), + StopReason::Unknown(value) => write!(f, "unknown({value})"), + } + } +} + #[derive(Clone, Eq, Ord, PartialEq, PartialOrd, Debug, Hash, Serialize, Deserialize)] pub struct UnknownVariantValue(pub(crate) String); diff --git a/crates/rig-bedrock/src/types/message.rs b/crates/rig-bedrock/src/types/message.rs index d97ed28b7..00b4fe04d 100644 --- a/crates/rig-bedrock/src/types/message.rs +++ b/crates/rig-bedrock/src/types/message.rs @@ -64,8 +64,16 @@ impl TryFrom for RigMessage { .map(|rig_assistant_content| rig_assistant_content.0) .collect::>(); - let content = OneOrMany::many(assistant_content) - .map_err(|e| CompletionError::RequestError(Box::new(e)))?; + // Response-side failure: Bedrock returned an assistant message + // with zero content blocks. Surface as ResponseError; the + // `RequestError` variant is reserved for outgoing request + // construction failures. + let content = OneOrMany::many(assistant_content).map_err(|_| { + CompletionError::ResponseError( + "Bedrock returned an assistant message with no content blocks" + .to_owned(), + ) + })?; Ok(RigMessage(Message::Assistant { content, id: None })) } @@ -79,8 +87,11 @@ impl TryFrom for RigMessage { .map(|user_content| user_content.0) .collect::>(); - let content = OneOrMany::many(user_content) - .map_err(|e| CompletionError::RequestError(Box::new(e)))?; + let content = OneOrMany::many(user_content).map_err(|_| { + CompletionError::ResponseError( + "Bedrock returned a user message with no content blocks".to_owned(), + ) + })?; Ok(RigMessage(Message::User { content })) } _ => Err(CompletionError::ProviderError( From f0bf8f85a3d6c4733ee6dd2ad2083056a3658a5d Mon Sep 17 00:00:00 2001 From: aleksmeshr Date: Tue, 9 Jun 2026 17:23:32 +0200 Subject: [PATCH 2/2] feat(rig-bedrock): synthesize empty text choice for end_turn with empty content When Bedrock returns an assistant message with zero content blocks AND `stop_reason=end_turn`, treat it as a benign empty response by synthesizing a single empty `AssistantContent::Text("")` block, so callers receive a well-formed `CompletionResponse` instead of an error. Other stop reasons (max_tokens, guardrail_intervened, content_filtered, stop_sequence, tool_use, unknown) still return a `ResponseError` carrying the stop_reason context so callers can branch (e.g. retry on max_tokens, fail fast on guardrails). Adds three unit tests covering end_turn, max_tokens, and guardrail empty-content scenarios. --- .../src/types/assistant_content.rs | 96 +++++++++++++++++-- 1 file changed, 88 insertions(+), 8 deletions(-) diff --git a/crates/rig-bedrock/src/types/assistant_content.rs b/crates/rig-bedrock/src/types/assistant_content.rs index 52cd13657..682c1f5c7 100644 --- a/crates/rig-bedrock/src/types/assistant_content.rs +++ b/crates/rig-bedrock/src/types/assistant_content.rs @@ -1,15 +1,17 @@ use aws_sdk_bedrockruntime::types as aws_bedrock; use rig_core::{ + OneOrMany, completion::CompletionError, message::{AssistantContent, Text}, }; + use serde::{Deserialize, Serialize}; use crate::types::message::RigMessage; use super::{ - converse_output::{ContentBlock, InternalConverseOutput, TokenUsage}, + converse_output::{ContentBlock, InternalConverseOutput, StopReason, TokenUsage}, json::AwsDocument, }; use rig_core::completion::{self, GetTokenUsage}; @@ -104,14 +106,29 @@ impl TryFrom for completion::CompletionResponse { + let choice = OneOrMany::one(AssistantContent::Text(Text::new(""))); + let usage = value.0.usage().map(normalize_usage).unwrap_or_default(); + Ok(completion::CompletionResponse { + choice, + usage, + raw_response: value, + message_id: None, + }) + } + _ => Err(CompletionError::ResponseError(format!( + "No assistance content returned (stop_reason={stop_reason})" + ))), + }; } let message: RigMessage = aws_message.try_into()?; @@ -384,6 +401,69 @@ mod tests { ); } + fn make_output_with_content_and_stop_reason( + content: Vec, + stop_reason: aws_bedrock::StopReason, + ) -> AwsConverseOutput { + let message = aws_bedrock::Message::builder() + .role(aws_bedrock::ConversationRole::Assistant) + .set_content(Some(content)) + .build() + .unwrap(); + let builder = aws_sdk_bedrockruntime::operation::converse::ConverseOutput::builder() + .output(aws_bedrock::ConverseOutput::Message(message)) + .stop_reason(stop_reason); + let internal: InternalConverseOutput = builder.build().unwrap().try_into().unwrap(); + AwsConverseOutput(internal) + } + + #[test] + fn empty_content_with_end_turn_yields_empty_text_choice() { + let output = make_output_with_content_and_stop_reason( + vec![], + aws_bedrock::StopReason::EndTurn, + ); + let completion: completion::CompletionResponse = output + .try_into() + .expect("end_turn with empty content should not error"); + assert_eq!( + completion.choice, + OneOrMany::one(AssistantContent::Text("".into())) + ); + } + + #[test] + fn empty_content_with_max_tokens_yields_response_error() { + let output = make_output_with_content_and_stop_reason( + vec![], + aws_bedrock::StopReason::MaxTokens, + ); + let result: Result, _> = + output.try_into(); + let Err(err) = result else { + panic!("max_tokens with empty content should error"); + }; + let msg = err.to_string(); + assert!( + msg.contains("max_tokens"), + "error should mention stop_reason: {msg}" + ); + } + + #[test] + fn empty_content_with_guardrail_yields_response_error() { + let output = make_output_with_content_and_stop_reason( + vec![], + aws_bedrock::StopReason::GuardrailIntervened, + ); + let result: Result, _> = + output.try_into(); + let Err(err) = result else { + panic!("guardrail with empty content should error"); + }; + assert!(err.to_string().contains("guardrail_intervened")); + } + #[test] fn aws_converse_output_preserves_parallel_tool_calls_in_completion_response() { let content = vec![