Skip to content

Commit 247a316

Browse files
committed
fix: properly redact toolResult blocks
1 parent 8a89d91 commit 247a316

File tree

3 files changed

+159
-5
lines changed

3 files changed

+159
-5
lines changed

src/strands/agent/agent.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -687,9 +687,9 @@ async def _run_loop(self, messages: Messages, invocation_state: dict[str, Any])
687687
and event.chunk.get("redactContent")
688688
and event.chunk["redactContent"].get("redactUserContentMessage")
689689
):
690-
self.messages[-1]["content"] = [
691-
{"text": str(event.chunk["redactContent"]["redactUserContentMessage"])}
692-
]
690+
self.messages[-1]["content"] = self._redact_user_content(
691+
self.messages[-1]["content"], str(event.chunk["redactContent"]["redactUserContentMessage"])
692+
)
693693
if self._session_manager:
694694
self._session_manager.redact_latest_message(self.messages[-1], self)
695695
yield event
@@ -891,3 +891,29 @@ def _append_message(self, message: Message) -> None:
891891
"""Appends a message to the agent's list of messages and invokes the callbacks for the MessageCreatedEvent."""
892892
self.messages.append(message)
893893
self.hooks.invoke_callbacks(MessageAddedEvent(agent=self, message=message))
894+
895+
def _redact_user_content(self, content: list[ContentBlock], redact_message: str) -> list[ContentBlock]:
896+
"""Redact user content preserving toolResult blocks.
897+
898+
Args:
899+
content: content blocks to be redacted
900+
redact_message: redact message to be replaced
901+
902+
Returns:
903+
Redacted content, as follows:
904+
- if the message contains at least a toolResult block,
905+
all toolResult blocks(s) are kept, redacting only the result content;
906+
- otherwise, the entire content of the message is replaced
907+
with a single text block with the redact message.
908+
"""
909+
redacted_content = []
910+
for block in content:
911+
if "toolResult" in block:
912+
block["toolResult"]["content"] = [{"text": redact_message}]
913+
redacted_content.append(block)
914+
915+
if not redacted_content:
916+
# Text content is added only if no toolResult blocks were found
917+
redacted_content = [{"text": redact_message}]
918+
919+
return redacted_content

tests/strands/agent/test_agent.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2061,3 +2061,58 @@ def test_agent_tool_caller_interrupt(user):
20612061
exp_message = r"cannot directly call tool during interrupt"
20622062
with pytest.raises(RuntimeError, match=exp_message):
20632063
agent.tool.test_tool()
2064+
2065+
2066+
@pytest.mark.parametrize(
2067+
"content, expected",
2068+
[
2069+
# Single toolResult block - preserves structure, redacts content
2070+
(
2071+
[{"toolResult": {"toolUseId": "123", "content": [{"text": "original result"}], "status": "success"}}],
2072+
[{"toolResult": {"toolUseId": "123", "content": [{"text": "REDACTED"}], "status": "success"}}],
2073+
),
2074+
# Multiple toolResult blocks - preserves all, redacts each content
2075+
(
2076+
[
2077+
{"toolResult": {"toolUseId": "123", "content": [{"text": "result1"}], "status": "success"}},
2078+
{"toolResult": {"toolUseId": "456", "content": [{"text": "result2"}], "status": "error"}},
2079+
],
2080+
[
2081+
{"toolResult": {"toolUseId": "123", "content": [{"text": "REDACTED"}], "status": "success"}},
2082+
{"toolResult": {"toolUseId": "456", "content": [{"text": "REDACTED"}], "status": "error"}},
2083+
],
2084+
),
2085+
# Text only content - replaces with single text block
2086+
(
2087+
[{"text": "sensitive data"}],
2088+
[{"text": "REDACTED"}],
2089+
),
2090+
# Mixed content with toolResult - keeps only toolResult blocks
2091+
# (This should not actually happen, toolResult is never mixed with other content)
2092+
(
2093+
[
2094+
{"text": "some text"},
2095+
{"toolResult": {"toolUseId": "789", "content": [{"text": "tool output"}], "status": "success"}},
2096+
{"image": {"format": "png", "source": {"bytes": b"fake_data"}}},
2097+
],
2098+
[{"toolResult": {"toolUseId": "789", "content": [{"text": "REDACTED"}], "status": "success"}}],
2099+
),
2100+
# Empty content - returns single text block
2101+
(
2102+
[],
2103+
[{"text": "REDACTED"}],
2104+
),
2105+
],
2106+
ids=[
2107+
"single_tool_result",
2108+
"multiple_tool_results",
2109+
"text_only",
2110+
"mixed_content_with_tool_result",
2111+
"empty_content",
2112+
],
2113+
)
2114+
def test_redact_user_content(content, expected):
2115+
"""Test _redact_user_content function with various content types."""
2116+
agent = Agent()
2117+
result = agent._redact_user_content(content, "REDACTED")
2118+
assert result == expected

tests_integ/test_bedrock_guardrails.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import boto3
66
import pytest
77

8-
from strands import Agent
8+
from strands import Agent, tool
99
from strands.models.bedrock import BedrockModel
1010
from strands.session.file_session_manager import FileSessionManager
1111

@@ -187,7 +187,7 @@ def test_guardrail_output_intervention_redact_output(bedrock_guardrail, processi
187187
In async streaming: The buffering is non-blocking.
188188
Tokens are streamed while Guardrails processes the buffered content in the background.
189189
This means the response may be returned before Guardrails has finished processing.
190-
As a result, we cannot guarantee that the REDACT_MESSAGE is in the response
190+
As a result, we cannot guarantee that the REDACT_MESSAGE is in the response.
191191
"""
192192
if processing_mode == "sync":
193193
assert REDACT_MESSAGE in str(response1)
@@ -203,6 +203,79 @@ def test_guardrail_output_intervention_redact_output(bedrock_guardrail, processi
203203
)
204204

205205

206+
@pytest.mark.parametrize("processing_mode", ["sync", "async"])
207+
def test_guardrail_intervention_properly_redacts_tool_result(bedrock_guardrail, processing_mode):
208+
INPUT_REDACT_MESSAGE = "Input redacted."
209+
OUTPUT_REDACT_MESSAGE = "Output redacted."
210+
bedrock_model = BedrockModel(
211+
guardrail_id=bedrock_guardrail,
212+
guardrail_version="DRAFT",
213+
guardrail_stream_processing_mode=processing_mode,
214+
guardrail_redact_output=True,
215+
guardrail_redact_input_message=INPUT_REDACT_MESSAGE,
216+
guardrail_redact_output_message=OUTPUT_REDACT_MESSAGE,
217+
region_name="us-east-1",
218+
)
219+
220+
@tool
221+
def list_users() -> str:
222+
"List my users"
223+
return """[{"name": "Jerry Merry"}, {"name": "Mr. CACTUS"}]"""
224+
225+
agent = Agent(
226+
model=bedrock_model,
227+
system_prompt="You are a helpful assistant.",
228+
callback_handler=None,
229+
load_tools_from_directory=False,
230+
tools=[list_users],
231+
)
232+
233+
response1 = agent("List my users.")
234+
response2 = agent("Thank you!")
235+
236+
""" Message sequence:
237+
0 (user): request1
238+
1 (assistant): reasoning + tool call
239+
2 (user): tool result
240+
3 (assistant): response1 -> output guardrail intervenes
241+
4 (user): request2
242+
5 (assistant): response2
243+
244+
Guardrail intervened on output in message 3 will cause
245+
the redaction of the preceding input (message 2) and message 3.
246+
"""
247+
248+
assert response1.stop_reason == "guardrail_intervened"
249+
250+
if processing_mode == "sync":
251+
""" In sync mode the guardrail processing is blocking.
252+
The response is already blocked and redacted. """
253+
254+
assert OUTPUT_REDACT_MESSAGE in str(response1)
255+
assert OUTPUT_REDACT_MESSAGE not in str(response2)
256+
257+
"""
258+
In async streaming, the buffering is non-blocking,
259+
so the response may be returned before Guardrails has finished processing.
260+
261+
However, in both sync and async, with guardrail_redact_output=True:
262+
263+
1. the content should be properly redacted in memory, so that
264+
response2 is not blocked by guardrails;
265+
"""
266+
assert response2.stop_reason != "guardrail_intervened"
267+
268+
"""
269+
2. the tool result block should be redacted properly, so that the
270+
conversation is not corrupted.
271+
"""
272+
273+
tool_call = [b for b in agent.messages[1]["content"] if "toolUse" in b][0]["toolUse"]
274+
tool_result = [b for b in agent.messages[2]["content"] if "toolResult" in b][0]["toolResult"]
275+
assert tool_result["toolUseId"] == tool_call["toolUseId"]
276+
assert tool_result["content"][0]["text"] == INPUT_REDACT_MESSAGE
277+
278+
206279
def test_guardrail_input_intervention_properly_redacts_in_session(boto_session, bedrock_guardrail, temp_dir):
207280
bedrock_model = BedrockModel(
208281
guardrail_id=bedrock_guardrail,

0 commit comments

Comments
 (0)