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
@@ -1,3 +1,3 @@
"""Agent Interface.

This module implements the core Agent class that serves as the primary entry point for interacting with foundation
Expand Down Expand Up @@ -226,7 +226,7 @@
else:
self.callback_handler = callback_handler

self.conversation_manager = conversation_manager if conversation_manager else SlidingWindowConversationManager()

Check warning on line 229 in src/strands/agent/agent.py

View workflow job for this annotation

GitHub Actions / check-api

Agent.conversation_manager

Attribute value was changed: `None` -> `conversation_manager if conversation_manager else SlidingWindowConversationManager()`

Check warning on line 229 in src/strands/agent/agent.py

View workflow job for this annotation

GitHub Actions / check-api

Agent.conversation_manager

Attribute value was changed: `None` -> `conversation_manager if conversation_manager else SlidingWindowConversationManager()`

# Process trace attributes to ensure they're of compatible types
self.trace_attributes: dict[str, AttributeValue] = {}
Expand Down Expand Up @@ -846,12 +846,20 @@
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:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The None guard silently drops the redaction when no user message is found. While this shouldn't happen in normal operation, silently ignoring a guardrail redaction could mask issues.

Suggestion: Consider adding a logger.warning in the else branch so unexpected scenarios are surfaced:

if last_user_msg is not None:
    ...
else:
    logger.warning("no user message found to redact | guardrail redaction skipped")

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Minor — response is assigned but unused, requiring a noqa suppression.

Suggestion: Simply call agent("ignored") without assigning to a variable, which eliminates the need for the noqa comment.


# 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}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The assertions here are overly loose for a deterministic scenario. With the test setup (one user message + one assistant LTM message → agent("ignored") adds another user message → guardrail redacts the last user), you know exactly which message should be redacted and the exact final state of agent.messages.

Suggestion: Assert on the exact expected state rather than using >= 1 checks. For example:

# The last user message (the "ignored" input from agent()) should be redacted
last_user_msgs = [m for m in agent.messages if m["role"] == "user"]
assert last_user_msgs[-1]["content"] == [{"text": "BLOCKED!"}]

# The first user message ("Tell me something bad") should NOT be redacted
assert last_user_msgs[0]["content"] == [{"text": "Tell me something bad"}]

This makes the test clearer about what "last user message" means and catches regressions more precisely.


# 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(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The new test test_agent_redacts_user_message_not_ltm_context accidentally absorbed the entire body of the existing test_agent_restored_from_session_management_with_correct_index function. The diff replaced the old def line but the old function's body (lines 1626-1648) now runs as dead code inside the new test.

You can verify this by running the test — it prints hello!world! which comes from the old test's MockedModelProvider and agent("Hello!") calls that are now orphaned code within your new test function.

Suggestion: The new test should end at line 1623 (after the LTM assertions). The old test_agent_restored_from_session_management_with_correct_index function definition needs to be preserved as a separate function. This likely happened because the PR branch was forked from a slightly different base. Please rebase onto main and ensure the new test is inserted before the existing test_agent_restored_from_session_management_with_correct_index, not replacing it.

[{"role": "assistant", "content": [{"text": "hello!"}]}, {"role": "assistant", "content": [{"text": "world!"}]}]
)
Expand Down
Loading