Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/strands/models/bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand Down
135 changes: 135 additions & 0 deletions tests/strands/models/test_bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"