diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index f378a886a..d92d6a2ba 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -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 diff --git a/tests/strands/agent/test_agent.py b/tests/strands/agent/test_agent.py index 967a0dafb..3e8a0fdd4 100644 --- a/tests/strands/agent/test_agent.py +++ b/tests/strands/agent/test_agent.py @@ -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": "Preference: likes cats"}]} + ) + + # 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("" 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!"}]}] )