diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index bab4031ed..4291de648 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -410,6 +410,12 @@ def _format_bedrock_messages(self, messages: Messages) -> list[dict[str, Any]]: filtered_unknown_members = False dropped_deepseek_reasoning_content = False + # Check if extended thinking is enabled — Bedrock requires every assistant message + # to start with a reasoningContent block when thinking is active. + additional_fields = self.config.get("additional_request_fields") or {} + thinking_config = additional_fields.get("thinking", {}) + thinking_enabled = thinking_config.get("type") == "enabled" + # Pre-compute the index of the last user message containing text or image content. # This ensures guardContent wrapping is maintained across tool execution cycles, where # the final message in the list is a toolResult (role=user) rather than text/image content. @@ -446,6 +452,22 @@ def _format_bedrock_messages(self, messages: Messages) -> list[dict[str, Any]]: cleaned_content.append(formatted_content) + # When thinking is enabled, ensure assistant messages start with a reasoningContent block. + # Bedrock requires every assistant message to begin with a thinking block when thinking + # is enabled. Custom session managers or external history may strip or reorder these + # blocks, causing ValidationException. We defensively fix ordering and inject a minimal + # redactedContent placeholder when thinking blocks are missing entirely. + if message["role"] == "assistant" and thinking_enabled and cleaned_content: + has_reasoning = any("reasoningContent" in cb for cb in cleaned_content) + if has_reasoning: + # Ensure reasoningContent blocks come first (Bedrock requirement) + reasoning_blocks = [cb for cb in cleaned_content if "reasoningContent" in cb] + other_blocks = [cb for cb in cleaned_content if "reasoningContent" not in cb] + cleaned_content = reasoning_blocks + other_blocks + else: + # Inject a minimal redactedContent placeholder at the start + cleaned_content.insert(0, {"reasoningContent": {"redactedContent": b"redacted"}}) + # Create new message with cleaned content (skip if empty) if cleaned_content: cleaned_messages.append({"content": cleaned_content, "role": message["role"]}) diff --git a/tests/strands/models/test_bedrock.py b/tests/strands/models/test_bedrock.py index 89c4df70d..f4e48d65c 100644 --- a/tests/strands/models/test_bedrock.py +++ b/tests/strands/models/test_bedrock.py @@ -2856,3 +2856,176 @@ def test_guardrail_latest_message_disabled_does_not_wrap(model): assert "text" in formatted assert "guardContent" not in formatted + + +# --- Tests for thinking-enabled multi-turn reasoningContent handling --- + + +def test_format_request_injects_reasoning_when_thinking_enabled_and_missing(bedrock_client, model_id): + """When thinking is enabled, assistant messages without reasoningContent get a redactedContent placeholder.""" + model = BedrockModel( + model_id=model_id, + additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}}, + ) + + messages = [ + {"role": "user", "content": [{"text": "Hello"}]}, + { + "role": "assistant", + "content": [ + {"text": "I'll check."}, + {"toolUse": {"toolUseId": "t1", "name": "lookup", "input": {}}}, + ], + }, + {"role": "user", "content": [{"toolResult": {"toolUseId": "t1", "content": [{"text": "result"}]}}]}, + ] + + request = model._format_request(messages) + assistant_msg = request["messages"][1] + assert assistant_msg["role"] == "assistant" + + # The first content block must be a reasoningContent placeholder + first_block = assistant_msg["content"][0] + assert "reasoningContent" in first_block + assert "redactedContent" in first_block["reasoningContent"] + + # Original content blocks should follow + assert "text" in assistant_msg["content"][1] + assert "toolUse" in assistant_msg["content"][2] + + +def test_format_request_no_injection_when_thinking_disabled(bedrock_client, model_id): + """When thinking is not enabled, assistant messages are not modified.""" + model = BedrockModel(model_id=model_id) + + messages = [ + {"role": "user", "content": [{"text": "Hello"}]}, + { + "role": "assistant", + "content": [ + {"text": "Response."}, + {"toolUse": {"toolUseId": "t1", "name": "lookup", "input": {}}}, + ], + }, + ] + + request = model._format_request(messages) + assistant_msg = request["messages"][1] + + # No reasoningContent should be injected + assert not any("reasoningContent" in cb for cb in assistant_msg["content"]) + assert "text" in assistant_msg["content"][0] + assert "toolUse" in assistant_msg["content"][1] + + +def test_format_request_preserves_existing_reasoning_when_thinking_enabled(bedrock_client, model_id): + """When thinking is enabled and reasoningContent already exists, it should be preserved (not duplicated).""" + model = BedrockModel( + model_id=model_id, + additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}}, + ) + + messages = [ + {"role": "user", "content": [{"text": "Hello"}]}, + { + "role": "assistant", + "content": [ + {"reasoningContent": {"reasoningText": {"text": "Let me think...", "signature": "sig123"}}}, + {"text": "Here's my response."}, + ], + }, + ] + + request = model._format_request(messages) + assistant_msg = request["messages"][1] + + # reasoningContent should be first and preserved (not duplicated) + assert len(assistant_msg["content"]) == 2 + assert "reasoningContent" in assistant_msg["content"][0] + assert assistant_msg["content"][0]["reasoningContent"]["reasoningText"]["text"] == "Let me think..." + assert "text" in assistant_msg["content"][1] + + +def test_format_request_reorders_reasoning_to_first_when_thinking_enabled(bedrock_client, model_id): + """When thinking is enabled and reasoningContent is not the first block, it should be moved to front.""" + model = BedrockModel( + model_id=model_id, + additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}}, + ) + + messages = [ + {"role": "user", "content": [{"text": "Hello"}]}, + { + "role": "assistant", + "content": [ + {"text": "Response first."}, + {"reasoningContent": {"reasoningText": {"text": "Thinking after text", "signature": "s1"}}}, + {"toolUse": {"toolUseId": "t1", "name": "test", "input": {}}}, + ], + }, + ] + + request = model._format_request(messages) + assistant_msg = request["messages"][1] + + # reasoningContent must be first + assert "reasoningContent" in assistant_msg["content"][0] + # Followed by non-reasoning blocks + assert "text" in assistant_msg["content"][1] + assert "toolUse" in assistant_msg["content"][2] + + +def test_format_request_multi_turn_thinking_session_reload(bedrock_client, model_id): + """Simulate a session reload where assistant messages lost reasoningContent blocks.""" + model = BedrockModel( + model_id=model_id, + additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}}, + ) + + # Simulate messages loaded from a session manager that stripped reasoningContent + messages = [ + {"role": "user", "content": [{"text": "What is the status of zone X?"}]}, + { + "role": "assistant", + "content": [ + {"text": "I'll check the status."}, + {"toolUse": {"toolUseId": "tool_123", "name": "zoneStatus", "input": {"zone": "X"}}}, + ], + }, + { + "role": "user", + "content": [{"toolResult": {"toolUseId": "tool_123", "content": [{"text": "Zone X is active"}]}}], + }, + { + "role": "assistant", + "content": [{"text": "Zone X is currently active."}], + }, + {"role": "user", "content": [{"text": "Now tell me about zone Y"}]}, + ] + + request = model._format_request(messages) + + # Both assistant messages should now have reasoningContent as first block + for msg in request["messages"]: + if msg["role"] == "assistant": + first_block = msg["content"][0] + assert "reasoningContent" in first_block, ( + f"Assistant message missing reasoningContent: {msg['content']}" + ) + + +def test_format_request_does_not_inject_reasoning_for_user_messages(bedrock_client, model_id): + """User messages should never get reasoningContent injected.""" + model = BedrockModel( + model_id=model_id, + additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}}, + ) + + messages = [ + {"role": "user", "content": [{"text": "Hello"}]}, + ] + + request = model._format_request(messages) + user_msg = request["messages"][0] + + assert not any("reasoningContent" in cb for cb in user_msg["content"])