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
20 changes: 13 additions & 7 deletions src/strands/models/bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,16 @@ def _format_bedrock_messages(self, messages: Messages) -> list[dict[str, Any]]:
filtered_unknown_members = False
dropped_deepseek_reasoning_content = False

guardrail_latest_message = self.config.get("guardrail_latest_message", False)
# Pre-compute the index of the last user message containing text or image content
# so that guardContent wrapping is maintained even when the final message is a toolResult.
last_user_text_idx = None
if self.config.get("guardrail_latest_message", False):
for ridx, msg in reversed(list(enumerate(messages))):
if msg["role"] == "user" and any(
"text" in cb or "image" in cb for cb in msg.get("content", [])
):
last_user_text_idx = ridx
break

for idx, message in enumerate(messages):
cleaned_content: list[dict[str, Any]] = []
Expand All @@ -412,12 +421,9 @@ def _format_bedrock_messages(self, messages: Messages) -> list[dict[str, Any]]:
if formatted_content is None:
continue

# Wrap text or image content in guardrailContent if this is the last user message
if (
guardrail_latest_message
and idx == len(messages) - 1
and message["role"] == "user"
and ("text" in formatted_content or "image" in formatted_content)
# Wrap text or image content in guardContent if this is the last user text/image message
if idx == last_user_text_idx and (
"text" in formatted_content or "image" in formatted_content
):
if "text" in formatted_content:
formatted_content = {"guardContent": {"text": {"text": formatted_content["text"]}}}
Expand Down
79 changes: 79 additions & 0 deletions tests/strands/models/test_bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -2380,6 +2380,85 @@ async def test_format_request_with_guardrail_latest_message(model):
assert formatted_messages[2]["content"][1]["guardContent"]["image"]["format"] == "png"


@pytest.mark.asyncio
async def test_format_request_with_guardrail_latest_message_after_tool_use(model):
"""Test that guardContent wraps the last user text message even when a toolResult follows it."""
model.update_config(
guardrail_id="test-guardrail",
guardrail_version="DRAFT",
guardrail_latest_message=True,
)

messages = [
{"role": "user", "content": [{"text": "First message"}]},
{"role": "assistant", "content": [{"text": "First response"}]},
{"role": "user", "content": [{"text": "what is the standard deduction?"}]},
{
"role": "assistant",
"content": [
{
"toolUse": {
"toolUseId": "tool-1",
"name": "knowledge_base",
"input": {"query": "standard deduction"},
}
}
],
},
{
"role": "user",
"content": [
{
"toolResult": {
"toolUseId": "tool-1",
"content": [{"text": "The standard deduction for 2024 is $14,600."}],
"status": "success",
}
}
],
},
]

request = model._format_request(messages)
formatted_messages = request["messages"]

assert len(formatted_messages) == 5

# Earlier user message should NOT be wrapped
assert "text" in formatted_messages[0]["content"][0]
assert formatted_messages[0]["content"][0]["text"] == "First message"

# Last user message with text content should be wrapped, even though a toolResult comes after
assert "guardContent" in formatted_messages[2]["content"][0]
assert formatted_messages[2]["content"][0]["guardContent"]["text"]["text"] == "what is the standard deduction?"

# toolResult-only user message should NOT be wrapped
assert "toolResult" in formatted_messages[4]["content"][0]
assert "guardContent" not in formatted_messages[4]["content"][0]


@pytest.mark.asyncio
async def test_format_request_with_guardrail_latest_message_wraps_final_user_text(model):
"""Test that guardContent wraps the last user message when it contains text content."""
model.update_config(
guardrail_id="test-guardrail",
guardrail_version="DRAFT",
guardrail_latest_message=True,
)

messages = [
{"role": "user", "content": [{"text": "First message"}]},
{"role": "assistant", "content": [{"text": "First response"}]},
{"role": "user", "content": [{"text": "Tell me about taxes"}]},
]

request = model._format_request(messages)
formatted_messages = request["messages"]

assert "guardContent" in formatted_messages[2]["content"][0]
assert formatted_messages[2]["content"][0]["guardContent"]["text"]["text"] == "Tell me about taxes"


def test_supports_caching_true_for_claude(bedrock_client):
"""Test that supports_caching returns True for Claude models."""
model = BedrockModel(model_id="us.anthropic.claude-sonnet-4-20250514-v1:0")
Expand Down