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
18 changes: 13 additions & 5 deletions src/strands/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,12 +846,20 @@ async def _run_loop(
and event.chunk.get("redactContent")
and event.chunk["redactContent"].get("redactUserContentMessage")
):
self.messages[-1]["content"] = self._redact_user_content(
self.messages[-1]["content"],
str(event.chunk["redactContent"]["redactUserContentMessage"]),
# Find the last user message — not necessarily messages[-1],
# because session managers (e.g. AgentCoreMemorySessionManager)
# may append non-user messages (LTM context) after the user turn.
last_user_msg = next(
(m for m in reversed(self.messages) if m["role"] == "user"),
None,
)
if self._session_manager:
self._session_manager.redact_latest_message(self.messages[-1], self)
if last_user_msg is not None:
last_user_msg["content"] = self._redact_user_content(
last_user_msg["content"],
str(event.chunk["redactContent"]["redactUserContentMessage"]),
)
if self._session_manager:
self._session_manager.redact_latest_message(last_user_msg, self)
yield event

# Capture the result from the final event if available
Expand Down
44 changes: 43 additions & 1 deletion tests/strands/agent/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1580,7 +1580,49 @@ def test_agent_restored_from_session_management_with_redacted_input():
assert agent.messages[0] == agent_2.messages[0]


def test_agent_restored_from_session_management_with_correct_index():
def test_agent_redacts_user_message_not_ltm_context():
"""Test that guardrail redacts the last *user* message, not a trailing LTM assistant message.

Reproduces: https://github.com/strands-agents/sdk-python/issues/1639
When long-term memory (LTM) session managers append an assistant message with
user context after the user turn, the redact logic must still target the user
message rather than the trailing assistant LTM message.
"""
mocked_model = MockedModelProvider(
[{"redactedUserContent": "BLOCKED!", "redactedAssistantContent": "INPUT BLOCKED!"}]
)

agent = Agent(
model=mocked_model,
system_prompt="You are a helpful assistant.",
callback_handler=None,
)

# Simulate LTM session manager appending context after user message:
# messages[0] = user input, messages[1] = assistant LTM context
agent.messages.append({"role": "user", "content": [{"text": "Tell me something bad"}]})
agent.messages.append(
{"role": "assistant", "content": [{"text": "<user_context>Preference: likes cats</user_context>"}]}
)

# Run the agent — guardrail should redact the user message (index 0), not the LTM message
response = agent("ignored") # noqa: F841 -- triggers model which triggers redact

# Find the user messages — the first user message (the actual input) should be redacted
user_messages = [m for m in agent.messages if m["role"] == "user"]
assert len(user_messages) >= 1
# The last user message before the LTM context should have been redacted
# Check that at least one user message was redacted
redacted_user = [m for m in user_messages if m["content"] == [{"text": "BLOCKED!"}]]
assert len(redacted_user) >= 1, f"Expected at least one redacted user message, got: {user_messages}"

# The assistant LTM message should NOT have been redacted
assistant_messages = [m for m in agent.messages if m["role"] == "assistant"]
ltm_messages = [m for m in assistant_messages if any("<user_context>" in str(c) for c in m.get("content", []))]
for ltm in ltm_messages:
assert ltm["content"] != [{"text": "BLOCKED!"}], "LTM context message should not be redacted"


mock_model_provider = MockedModelProvider(
[{"role": "assistant", "content": [{"text": "hello!"}]}, {"role": "assistant", "content": [{"text": "world!"}]}]
)
Expand Down