From 5ade2e5056ed7c7016c9541bc29a8a8e92735a07 Mon Sep 17 00:00:00 2001 From: strands-agent <217235299+strands-agent@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:16:09 +0000 Subject: [PATCH 1/3] fix(bedrock): disable thinking mode when forcing tool_choice Fixes #1485 - Cannot use structured_output with thinking enabled. When BedrockModel has thinking mode enabled via additional_request_fields, and the event loop forces tool_choice (for structured_output retry), Bedrock's API returns an error because thinking is not allowed with forced tool use. This PR adds a helper method _get_additional_request_fields() that temporarily removes the thinking configuration when tool_choice is forcing tool use (not 'auto' and not None), while preserving all other additional request fields. - Adds _get_additional_request_fields() helper method - Preserves thinking for normal interactions (auto/None tool_choice) - Removes thinking only when forcing tool use - Preserves other additional_request_fields when thinking is removed - Adds comprehensive unit tests for all scenarios --- src/strands/models/bedrock.py | 34 ++++++-- tests/strands/models/test_bedrock_thinking.py | 81 +++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 tests/strands/models/test_bedrock_thinking.py diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index dfcd133c6..f1b089af9 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 (not auto and not None) + is_forcing_tool = tool_choice is not None and "auto" not 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..51bcc0711 --- /dev/null +++ b/tests/strands/models/test_bedrock_thinking.py @@ -0,0 +1,81 @@ +"""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", + }, + ) + + +class TestThinkingWithToolChoice: + """Tests for thinking mode behavior with different tool_choice values.""" + + def test_thinking_removed_when_forcing_tool_any(self, 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(self, 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(self, 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(self, 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(self, 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(self, 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(self): + """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"}} From 93dabb987a3aaa9de4df1a3a9f2a65b1c044476c Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Thu, 15 Jan 2026 15:14:23 -0500 Subject: [PATCH 2/3] style: remove class based test in favor of method based --- tests/strands/models/test_bedrock_thinking.py | 97 ++++++++++--------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/tests/strands/models/test_bedrock_thinking.py b/tests/strands/models/test_bedrock_thinking.py index 51bcc0711..10b53cb03 100644 --- a/tests/strands/models/test_bedrock_thinking.py +++ b/tests/strands/models/test_bedrock_thinking.py @@ -32,50 +32,53 @@ def model_with_thinking_and_other_fields(): ) -class TestThinkingWithToolChoice: - """Tests for thinking mode behavior with different tool_choice values.""" - - def test_thinking_removed_when_forcing_tool_any(self, 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(self, 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(self, 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(self, 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(self, 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(self, 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(self): - """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"}} +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"}} From 280a7da6391ff2ed83ab29fb8b2c5aa592f3e83c Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Thu, 15 Jan 2026 16:44:03 -0500 Subject: [PATCH 3/3] explicitly check for any or tool instead of 'not auto' --- src/strands/models/bedrock.py | 4 +-- tests_integ/models/test_model_bedrock.py | 37 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index f1b089af9..567a2e147 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -310,8 +310,8 @@ def _get_additional_request_fields(self, tool_choice: ToolChoice | None) -> dict if not additional_fields: return {} - # Check if tool_choice is forcing tool use (not auto and not None) - is_forcing_tool = tool_choice is not None and "auto" not in tool_choice + # 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 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 = [