diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index dfcd133c6..567a2e147 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -255,11 +255,7 @@ def _format_request( if tool_specs else {} ), - **( - {"additionalModelRequestFields": self.config["additional_request_fields"]} - if self.config.get("additional_request_fields") - else {} - ), + **(self._get_additional_request_fields(tool_choice)), **( {"additionalModelResponseFieldPaths": self.config["additional_response_field_paths"]} if self.config.get("additional_response_field_paths") @@ -298,6 +294,34 @@ def _format_request( ), } + def _get_additional_request_fields(self, tool_choice: ToolChoice | None) -> dict[str, Any]: + """Get additional request fields, removing thinking if tool_choice forces tool use. + + Bedrock's API does not allow thinking mode when tool_choice forces tool use. + When forcing a tool (e.g., for structured_output retry), we temporarily disable thinking. + + Args: + tool_choice: The tool choice configuration. + + Returns: + A dict containing additionalModelRequestFields if configured, or empty dict. + """ + additional_fields = self.config.get("additional_request_fields") + if not additional_fields: + return {} + + # Check if tool_choice is forcing tool use ("any" or specific "tool") + is_forcing_tool = tool_choice is not None and ("any" in tool_choice or "tool" in tool_choice) + + if is_forcing_tool and "thinking" in additional_fields: + # Create a copy without the thinking key + fields_without_thinking = {k: v for k, v in additional_fields.items() if k != "thinking"} + if fields_without_thinking: + return {"additionalModelRequestFields": fields_without_thinking} + return {} + + return {"additionalModelRequestFields": additional_fields} + def _format_bedrock_messages(self, messages: Messages) -> list[dict[str, Any]]: """Format messages for Bedrock API compatibility. diff --git a/tests/strands/models/test_bedrock_thinking.py b/tests/strands/models/test_bedrock_thinking.py new file mode 100644 index 000000000..10b53cb03 --- /dev/null +++ b/tests/strands/models/test_bedrock_thinking.py @@ -0,0 +1,84 @@ +"""Tests for thinking mode behavior in BedrockModel.""" + +import pytest + +from strands.models.bedrock import BedrockModel + + +@pytest.fixture +def model_with_thinking(): + """Create a BedrockModel with thinking enabled.""" + return BedrockModel( + model_id="anthropic.claude-sonnet-4-20250514-v1:0", + additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 5000}}, + ) + + +@pytest.fixture +def model_without_thinking(): + """Create a BedrockModel without thinking.""" + return BedrockModel(model_id="anthropic.claude-sonnet-4-20250514-v1:0") + + +@pytest.fixture +def model_with_thinking_and_other_fields(): + """Create a BedrockModel with thinking and other additional fields.""" + return BedrockModel( + model_id="anthropic.claude-sonnet-4-20250514-v1:0", + additional_request_fields={ + "thinking": {"type": "enabled", "budget_tokens": 5000}, + "some_other_field": "value", + }, + ) + + +def test_thinking_removed_when_forcing_tool_any(model_with_thinking): + """Thinking should be removed when tool_choice forces tool use with 'any'.""" + tool_choice = {"any": {}} + result = model_with_thinking._get_additional_request_fields(tool_choice) + assert result == {} # thinking removed, no other fields + + +def test_thinking_removed_when_forcing_specific_tool(model_with_thinking): + """Thinking should be removed when tool_choice forces a specific tool.""" + tool_choice = {"tool": {"name": "structured_output_tool"}} + result = model_with_thinking._get_additional_request_fields(tool_choice) + assert result == {} # thinking removed, no other fields + + +def test_thinking_preserved_with_auto_tool_choice(model_with_thinking): + """Thinking should be preserved when tool_choice is 'auto'.""" + tool_choice = {"auto": {}} + result = model_with_thinking._get_additional_request_fields(tool_choice) + assert result == {"additionalModelRequestFields": {"thinking": {"type": "enabled", "budget_tokens": 5000}}} + + +def test_thinking_preserved_with_none_tool_choice(model_with_thinking): + """Thinking should be preserved when tool_choice is None.""" + result = model_with_thinking._get_additional_request_fields(None) + assert result == {"additionalModelRequestFields": {"thinking": {"type": "enabled", "budget_tokens": 5000}}} + + +def test_other_fields_preserved_when_thinking_removed(model_with_thinking_and_other_fields): + """Other additional fields should be preserved when thinking is removed.""" + tool_choice = {"any": {}} + result = model_with_thinking_and_other_fields._get_additional_request_fields(tool_choice) + assert result == {"additionalModelRequestFields": {"some_other_field": "value"}} + + +def test_no_fields_when_model_has_no_additional_fields(model_without_thinking): + """Should return empty dict when model has no additional_request_fields.""" + tool_choice = {"any": {}} + result = model_without_thinking._get_additional_request_fields(tool_choice) + assert result == {} + + +def test_fields_preserved_when_no_thinking_and_forcing_tool(): + """Additional fields without thinking should be preserved when forcing tool.""" + model = BedrockModel( + model_id="anthropic.claude-sonnet-4-20250514-v1:0", + additional_request_fields={"some_field": "value"}, + ) + tool_choice = {"any": {}} + result = model._get_additional_request_fields(tool_choice) + assert result == {"additionalModelRequestFields": {"some_field": "value"}} diff --git a/tests_integ/models/test_model_bedrock.py b/tests_integ/models/test_model_bedrock.py index b31f23663..0b3aa7b47 100644 --- a/tests_integ/models/test_model_bedrock.py +++ b/tests_integ/models/test_model_bedrock.py @@ -275,6 +275,43 @@ def test_redacted_content_handling(): assert isinstance(result.message["content"][0]["reasoningContent"]["redactedContent"], bytes) +def test_reasoning_content_in_messages_with_thinking_disabled(): + """Test that messages with reasoningContent are accepted when thinking is explicitly disabled.""" + # First, get a real reasoning response with thinking enabled + thinking_model = BedrockModel( + model_id="us.anthropic.claude-sonnet-4-20250514-v1:0", + additional_request_fields={ + "thinking": { + "type": "enabled", + "budget_tokens": 1024, + } + }, + ) + agent_with_thinking = Agent(model=thinking_model) + result_with_thinking = agent_with_thinking("What is 2+2?") + + # Verify we got reasoning content + assert "reasoningContent" in result_with_thinking.message["content"][0] + + # Now create a model with thinking disabled and use the messages from the thinking session + disabled_model = BedrockModel( + model_id="us.anthropic.claude-sonnet-4-20250514-v1:0", + additional_request_fields={ + "thinking": { + "type": "disabled", + } + }, + ) + + # Use the conversation history that includes reasoning content + messages = agent_with_thinking.messages + + agent_disabled = Agent(model=disabled_model, messages=messages) + result = agent_disabled("What about 3+3?") + + assert result.stop_reason == "end_turn" + + def test_multi_prompt_system_content(): """Test multi-prompt system content blocks.""" system_prompt_content = [