From c33d2a98198ce6b8588986100cab6b6c77d4a9b2 Mon Sep 17 00:00:00 2001 From: giulio-leone Date: Fri, 13 Mar 2026 03:44:14 +0100 Subject: [PATCH] fix: convert JSON content blocks to text for Amazon Nova models in tool results Nova models (Nova Pro, Nova Lite, Nova Micro) hallucinate when tool results contain JSON content blocks ({'json': {...}}). They produce incoherent responses, fabricate information, role-play both sides of conversations, and hallucinate fake XML tool calls. Claude models handle JSON blocks correctly. This fix adds model-aware JSON-to-text conversion in _format_request_message_content() that automatically serializes JSON content blocks to their text representation (json.dumps) when the model is an Amazon Nova variant. Non-Nova models continue to receive JSON blocks as-is. The detection follows the existing pattern used for tool result status handling (_MODELS_INCLUDE_STATUS / _should_include_tool_result_status), adding a parallel _MODELS_CONVERT_JSON_TO_TEXT list and _should_convert_json_to_text() method. Closes #1095 --- src/strands/models/bedrock.py | 21 ++++- tests/strands/models/test_bedrock.py | 135 +++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index bab4031ed..8aedefc56 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -52,6 +52,11 @@ "anthropic.claude", ] +# Models that hallucinate when receiving JSON content blocks in tool results +_MODELS_CONVERT_JSON_TO_TEXT = [ + "amazon.nova", +] + T = TypeVar("T", bound=BaseModel) DEFAULT_READ_TIMEOUT = 120 @@ -486,6 +491,15 @@ def _should_include_tool_result_status(self) -> bool: else: # "auto" return any(model in self.config["model_id"] for model in _MODELS_INCLUDE_STATUS) + def _should_convert_json_to_text(self) -> bool: + """Determine whether JSON content blocks in tool results should be converted to text. + + Some models (e.g., Amazon Nova) hallucinate when tool results contain JSON content + blocks. Converting them to their text representation avoids this issue. + """ + model_id = self.config.get("model_id", "").lower() + return any(model in model_id for model in _MODELS_CONVERT_JSON_TO_TEXT) + def _handle_location(self, location: SourceLocation) -> dict[str, Any] | None: """Convert location content block to Bedrock format if its an S3Location.""" if location["type"] == "s3": @@ -598,8 +612,11 @@ def _format_request_message_content(self, content: ContentBlock) -> dict[str, An formatted_content: list[dict[str, Any]] = [] for tool_result_content in tool_result["content"]: if "json" in tool_result_content: - # Handle json field since not in ContentBlock but valid in ToolResultContent - formatted_content.append({"json": tool_result_content["json"]}) + if self._should_convert_json_to_text(): + formatted_content.append({"text": json.dumps(tool_result_content["json"])}) + else: + # Handle json field since not in ContentBlock but valid in ToolResultContent + formatted_content.append({"json": tool_result_content["json"]}) else: formatted_message_content = self._format_request_message_content( cast(ContentBlock, tool_result_content) diff --git a/tests/strands/models/test_bedrock.py b/tests/strands/models/test_bedrock.py index 89c4df70d..ec5823c8c 100644 --- a/tests/strands/models/test_bedrock.py +++ b/tests/strands/models/test_bedrock.py @@ -2856,3 +2856,138 @@ def test_guardrail_latest_message_disabled_does_not_wrap(model): assert "text" in formatted assert "guardContent" not in formatted + + +def test_nova_model_converts_json_to_text_in_tool_result(bedrock_client): + """Nova models should convert JSON content blocks to text in tool results.""" + model = BedrockModel(model_id="us.amazon.nova-pro-v1:0") + messages = [ + { + "role": "user", + "content": [ + { + "toolResult": { + "content": [{"json": {"key": "value", "number": 42}}], + "toolUseId": "tool123", + } + } + ], + } + ] + + formatted_request = model._format_request(messages) + tool_result = formatted_request["messages"][0]["content"][0]["toolResult"] + + assert len(tool_result["content"]) == 1 + assert "text" in tool_result["content"][0] + assert "json" not in tool_result["content"][0] + assert tool_result["content"][0]["text"] == '{"key": "value", "number": 42}' + + +def test_nova_model_converts_mixed_json_and_text_in_tool_result(bedrock_client): + """Nova models should convert JSON blocks while preserving text blocks.""" + model = BedrockModel(model_id="amazon.nova-lite-v1:0") + messages = [ + { + "role": "user", + "content": [ + { + "toolResult": { + "content": [ + {"text": "Some text output"}, + {"json": {"status": "ok"}}, + ], + "toolUseId": "tool456", + } + } + ], + } + ] + + formatted_request = model._format_request(messages) + tool_result = formatted_request["messages"][0]["content"][0]["toolResult"] + + assert len(tool_result["content"]) == 2 + assert tool_result["content"][0] == {"text": "Some text output"} + assert tool_result["content"][1] == {"text": '{"status": "ok"}'} + + +def test_claude_model_preserves_json_in_tool_result(bedrock_client): + """Claude models should preserve JSON content blocks as-is.""" + model = BedrockModel(model_id="us.anthropic.claude-sonnet-4-20250514-v1:0") + messages = [ + { + "role": "user", + "content": [ + { + "toolResult": { + "content": [{"json": {"key": "value"}}], + "toolUseId": "tool789", + } + } + ], + } + ] + + formatted_request = model._format_request(messages) + tool_result = formatted_request["messages"][0]["content"][0]["toolResult"] + + assert len(tool_result["content"]) == 1 + assert "json" in tool_result["content"][0] + assert tool_result["content"][0]["json"] == {"key": "value"} + + +def test_nova_model_handles_nested_json_in_tool_result(bedrock_client): + """Nova models should handle deeply nested JSON structures.""" + model = BedrockModel(model_id="us.amazon.nova-pro-v1:0") + nested_json = { + "results": [ + {"id": 1, "data": {"nested": True}}, + {"id": 2, "data": {"nested": False}}, + ], + "metadata": {"total": 2}, + } + messages = [ + { + "role": "user", + "content": [ + { + "toolResult": { + "content": [{"json": nested_json}], + "toolUseId": "tool_nested", + } + } + ], + } + ] + + formatted_request = model._format_request(messages) + tool_result = formatted_request["messages"][0]["content"][0]["toolResult"] + + assert "text" in tool_result["content"][0] + import json + + parsed = json.loads(tool_result["content"][0]["text"]) + assert parsed == nested_json + + +def test_should_convert_json_to_text_nova_variants(bedrock_client): + """All Nova model ID variants should trigger JSON-to-text conversion.""" + nova_ids = [ + "amazon.nova-pro-v1:0", + "us.amazon.nova-pro-v1:0", + "amazon.nova-lite-v1:0", + "amazon.nova-micro-v1:0", + ] + for model_id in nova_ids: + model = BedrockModel(model_id=model_id) + assert model._should_convert_json_to_text(), f"{model_id} should convert JSON to text" + + non_nova_ids = [ + "us.anthropic.claude-sonnet-4-20250514-v1:0", + "amazon.titan-text-v1", + "us.meta.llama3-1-70b-instruct-v1:0", + ] + for model_id in non_nova_ids: + model = BedrockModel(model_id=model_id) + assert not model._should_convert_json_to_text(), f"{model_id} should NOT convert JSON to text"