From 3024c2d1afb27ea4423ef9c671d6eec549478102 Mon Sep 17 00:00:00 2001 From: Austin Welch Date: Tue, 10 Feb 2026 09:47:21 -0500 Subject: [PATCH] fix: preserve guardrail_latest_message wrapping after tool execution When guardrail_latest_message=True, the guardContent wrapping was lost after tool execution because the last message was a toolResult (with no text/image content). Pre-compute the index of the last user message containing text or image content so wrapping is applied correctly even when subsequent toolResult messages follow. Fixes #1651 Co-Authored-By: Claude Opus 4.6 --- src/strands/models/bedrock.py | 20 ++++--- tests/strands/models/test_bedrock.py | 79 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index 596936e6f..0e9e3acc2 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -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]] = [] @@ -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"]}}} diff --git a/tests/strands/models/test_bedrock.py b/tests/strands/models/test_bedrock.py index 1410e129b..01c5b1828 100644 --- a/tests/strands/models/test_bedrock.py +++ b/tests/strands/models/test_bedrock.py @@ -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")