From 707c6e03e55ca563725ec5e68cd34655e1e2b0bc Mon Sep 17 00:00:00 2001 From: Strands Agent <217235299+strands-agent@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:57:30 +0000 Subject: [PATCH 1/5] feat(hooks): expose input messages to BeforeInvocationEvent Add messages attribute to BeforeInvocationEvent to enable input-side guardrails for PII detection, content moderation, and prompt attack prevention. Hooks can now inspect and modify messages before they are added to the agent's conversation history. - Add writable messages attribute to BeforeInvocationEvent (None default) - Pass messages parameter from _run_loop() to BeforeInvocationEvent - Add unit tests for new messages attribute and writability - Add integration tests for message modification use case - Update docs/HOOKS.md with input guardrails documentation Resolves #8 --- docs/HOOKS.md | 46 ++++++++++++ src/strands/agent/agent.py | 2 +- src/strands/hooks/events.py | 18 ++++- tests/strands/agent/hooks/test_events.py | 45 +++++++++++- tests/strands/agent/test_agent_hooks.py | 90 +++++++++++++++++++++++- 5 files changed, 194 insertions(+), 7 deletions(-) diff --git a/docs/HOOKS.md b/docs/HOOKS.md index b447c6400..2e143b4ad 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -22,3 +22,49 @@ The hooks system enables extensible agent functionality through strongly-typed e ## Writable Properties Some events have writable properties that modify agent behavior. Values are re-read after callbacks complete. For example, `BeforeToolCallEvent.selected_tool` is writable - after invoking the callback, the modified `selected_tool` takes effect for the tool call. + +## Input Guardrails with BeforeInvocationEvent + +The `BeforeInvocationEvent` provides access to input messages through its `messages` attribute, enabling hooks to implement input-side guardrails that run before messages are added to the agent's conversation history. + +### Use Cases + +- **PII Detection/Redaction**: Scan and redact sensitive information before processing +- **Content Moderation**: Filter toxic or inappropriate content +- **Prompt Attack Prevention**: Detect and block malicious prompt injection attempts + +### Example: Input Redaction Hook + +```python +from strands import Agent +from strands.hooks import BeforeInvocationEvent, HookProvider, HookRegistry + +class InputGuardrailHook(HookProvider): + def register_hooks(self, registry: HookRegistry) -> None: + registry.add_callback(BeforeInvocationEvent, self.check_input) + + async def check_input(self, event: BeforeInvocationEvent) -> None: + if event.messages is None: + return + + for message in event.messages: + if message.get("role") == "user": + content = message.get("content", []) + for block in content: + if "text" in block: + # Option 1: Redact in-place + block["text"] = redact_pii(block["text"]) + + # Option 2: Abort invocation by raising an exception + # if contains_malicious_content(block["text"]): + # raise ValueError("Malicious content detected") + +agent = Agent(hooks=[InputGuardrailHook()]) +agent("Process this message") # Guardrail runs before message is added to memory +``` + +### Key Behaviors + +- `messages` defaults to `None` for backward compatibility (e.g., when invoked from deprecated methods) +- `messages` is writable, allowing hooks to modify content in-place +- The `AfterInvocationEvent` is always triggered even if a hook raises an exception, maintaining the paired event guarantee diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 7126644e6..617c06f50 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -644,7 +644,7 @@ async def _run_loop( Yields: Events from the event loop cycle. """ - await self.hooks.invoke_callbacks_async(BeforeInvocationEvent(agent=self)) + await self.hooks.invoke_callbacks_async(BeforeInvocationEvent(agent=self, messages=messages)) agent_result: AgentResult | None = None try: diff --git a/src/strands/hooks/events.py b/src/strands/hooks/events.py index 5e11524d1..181be971d 100644 --- a/src/strands/hooks/events.py +++ b/src/strands/hooks/events.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ..agent.agent_result import AgentResult -from ..types.content import Message +from ..types.content import Message, Messages from ..types.interrupt import _Interruptible from ..types.streaming import StopReason from ..types.tools import AgentTool, ToolResult, ToolUse @@ -39,13 +39,27 @@ class BeforeInvocationEvent(HookEvent): before any model inference or tool execution occurs. Hook providers can use this event to perform request-level setup, logging, or validation. + The messages attribute provides access to the input messages for this invocation, + allowing hooks to inspect or modify message content before processing. This is + particularly useful for implementing input guardrails (e.g., PII detection, + content moderation, prompt attack prevention) that need to run before messages + are added to the agent's conversation history. + This event is triggered at the beginning of the following api calls: - Agent.__call__ - Agent.stream_async - Agent.structured_output + + Attributes: + messages: The input messages for this invocation. Can be modified by hooks + to redact or transform content before processing. May be None for + backward compatibility or when invoked from deprecated methods. """ - pass + messages: Messages | None = None + + def _can_write(self, name: str) -> bool: + return name == "messages" @dataclass diff --git a/tests/strands/agent/hooks/test_events.py b/tests/strands/agent/hooks/test_events.py index 9203478b2..dd8b990e6 100644 --- a/tests/strands/agent/hooks/test_events.py +++ b/tests/strands/agent/hooks/test_events.py @@ -11,7 +11,7 @@ BeforeToolCallEvent, MessageAddedEvent, ) -from strands.types.content import Message +from strands.types.content import Message, Messages from strands.types.tools import ToolResult, ToolUse @@ -20,6 +20,11 @@ def agent(): return Mock() +@pytest.fixture +def sample_messages() -> Messages: + return [{"role": "user", "content": [{"text": "Hello, agent!"}]}] + + @pytest.fixture def tool(): tool = Mock() @@ -52,6 +57,11 @@ def start_request_event(agent): return BeforeInvocationEvent(agent=agent) +@pytest.fixture +def start_request_event_with_messages(agent, sample_messages): + return BeforeInvocationEvent(agent=agent, messages=sample_messages) + + @pytest.fixture def messaged_added_event(agent): return MessageAddedEvent(agent=agent, message=Mock()) @@ -159,3 +169,36 @@ def test_after_invocation_event_properties_not_writable(agent): with pytest.raises(AttributeError, match="Property agent is not writable"): event.agent = Mock() + + +def test_before_invocation_event_messages_default_none(agent): + """Test that BeforeInvocationEvent.messages defaults to None for backward compatibility.""" + event = BeforeInvocationEvent(agent=agent) + assert event.messages is None + + +def test_before_invocation_event_messages_set_on_init(agent, sample_messages): + """Test that BeforeInvocationEvent.messages can be set on initialization.""" + event = BeforeInvocationEvent(agent=agent, messages=sample_messages) + assert event.messages is sample_messages + assert event.messages == [{"role": "user", "content": [{"text": "Hello, agent!"}]}] + + +def test_before_invocation_event_messages_writable(agent, sample_messages): + """Test that BeforeInvocationEvent.messages can be modified in-place for guardrail redaction.""" + event = BeforeInvocationEvent(agent=agent, messages=sample_messages) + + # Should be able to modify the messages list in-place + event.messages[0]["content"] = [{"text": "[REDACTED]"}] + assert event.messages[0]["content"] == [{"text": "[REDACTED]"}] + + # Should be able to reassign messages entirely + new_messages: Messages = [{"role": "user", "content": [{"text": "Different message"}]}] + event.messages = new_messages + assert event.messages == new_messages + + +def test_before_invocation_event_agent_not_writable(start_request_event_with_messages): + """Test that BeforeInvocationEvent.agent is not writable.""" + with pytest.raises(AttributeError, match="Property agent is not writable"): + start_request_event_with_messages.agent = Mock() diff --git a/tests/strands/agent/test_agent_hooks.py b/tests/strands/agent/test_agent_hooks.py index 00b9d368a..6a1372845 100644 --- a/tests/strands/agent/test_agent_hooks.py +++ b/tests/strands/agent/test_agent_hooks.py @@ -160,7 +160,14 @@ def test_agent__call__hooks(agent, hook_provider, agent_tool, mock_model, tool_u assert length == 12 - assert next(events) == BeforeInvocationEvent(agent=agent) + # Verify BeforeInvocationEvent includes messages + before_event = next(events) + assert isinstance(before_event, BeforeInvocationEvent) + assert before_event.agent == agent + assert before_event.messages is not None + assert len(before_event.messages) == 1 + assert before_event.messages[0]["role"] == "user" + assert next(events) == MessageAddedEvent( agent=agent, message=agent.messages[0], @@ -214,7 +221,15 @@ async def test_agent_stream_async_hooks(agent, hook_provider, agent_tool, mock_m """Verify that the correct hook events are emitted as part of stream_async.""" iterator = agent.stream_async("test message") await anext(iterator) - assert hook_provider.events_received == [BeforeInvocationEvent(agent=agent)] + + # Verify first event is BeforeInvocationEvent with messages + assert len(hook_provider.events_received) == 1 + before_event = hook_provider.events_received[0] + assert isinstance(before_event, BeforeInvocationEvent) + assert before_event.agent == agent + assert before_event.messages is not None + assert len(before_event.messages) == 1 + assert before_event.messages[0]["role"] == "user" # iterate the rest result = None @@ -226,7 +241,13 @@ async def test_agent_stream_async_hooks(agent, hook_provider, agent_tool, mock_m assert length == 12 - assert next(events) == BeforeInvocationEvent(agent=agent) + # Verify BeforeInvocationEvent includes messages + before_event_2 = next(events) + assert isinstance(before_event_2, BeforeInvocationEvent) + assert before_event_2.agent == agent + assert before_event_2.messages is not None + assert len(before_event_2.messages) == 1 + assert next(events) == MessageAddedEvent( agent=agent, message=agent.messages[0], @@ -596,3 +617,66 @@ async def handle_after_model_call(event: AfterModelCallEvent): # Should succeed after: custom retry + 2 throttle retries assert result.stop_reason == "end_turn" assert result.message["content"][0]["text"] == "Success after mixed retries" + + +def test_before_invocation_event_message_modification(): + """Test that hooks can modify messages in BeforeInvocationEvent for input guardrails.""" + mock_provider = MockedModelProvider( + [ + { + "role": "assistant", + "content": [{"text": "I received your redacted message"}], + }, + ] + ) + + modified_content = None + + async def input_guardrail_hook(event: BeforeInvocationEvent): + """Simulates a guardrail that redacts sensitive content.""" + nonlocal modified_content + if event.messages is not None: + for message in event.messages: + if message.get("role") == "user": + content = message.get("content", []) + for block in content: + if "text" in block and "SECRET" in block["text"]: + # Redact sensitive content in-place + block["text"] = block["text"].replace("SECRET", "[REDACTED]") + modified_content = event.messages[0]["content"][0]["text"] + + agent = Agent(model=mock_provider) + agent.hooks.add_callback(BeforeInvocationEvent, input_guardrail_hook) + + agent("My password is SECRET123") + + # Verify the message was modified before being processed + assert modified_content == "My password is [REDACTED]123" + # Verify the modified message was added to agent's conversation history + assert agent.messages[0]["content"][0]["text"] == "My password is [REDACTED]123" + + +@pytest.mark.asyncio +async def test_before_invocation_event_messages_none_in_structured_output(agenerator): + """Test that BeforeInvocationEvent.messages is None when called from deprecated structured_output.""" + + class Person(BaseModel): + name: str + age: int + + mock_provider = MockedModelProvider([]) + mock_provider.structured_output = Mock(return_value=agenerator([{"output": Person(name="Test", age=30)}])) + + received_messages = "not_set" + + async def capture_messages_hook(event: BeforeInvocationEvent): + nonlocal received_messages + received_messages = event.messages + + agent = Agent(model=mock_provider) + agent.hooks.add_callback(BeforeInvocationEvent, capture_messages_hook) + + await agent.structured_output_async(Person, "Test prompt") + + # structured_output_async uses deprecated path that doesn't pass messages + assert received_messages is None From 128730be0870a0a9775a500dead899cc4c869b4f Mon Sep 17 00:00:00 2001 From: Strands Agent <217235299+strands-agent@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:37:22 +0000 Subject: [PATCH 2/5] refactor: address review feedback - Remove detailed Input Guardrails section from docs/HOOKS.md - Simplify BeforeInvocationEvent docstring per review - Remove backward compatibility note from messages attribute - Remove no-op test for messages initialization --- docs/HOOKS.md | 46 ------------------------ src/strands/hooks/events.py | 9 +---- tests/strands/agent/hooks/test_events.py | 7 ---- 3 files changed, 1 insertion(+), 61 deletions(-) diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 2e143b4ad..b447c6400 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -22,49 +22,3 @@ The hooks system enables extensible agent functionality through strongly-typed e ## Writable Properties Some events have writable properties that modify agent behavior. Values are re-read after callbacks complete. For example, `BeforeToolCallEvent.selected_tool` is writable - after invoking the callback, the modified `selected_tool` takes effect for the tool call. - -## Input Guardrails with BeforeInvocationEvent - -The `BeforeInvocationEvent` provides access to input messages through its `messages` attribute, enabling hooks to implement input-side guardrails that run before messages are added to the agent's conversation history. - -### Use Cases - -- **PII Detection/Redaction**: Scan and redact sensitive information before processing -- **Content Moderation**: Filter toxic or inappropriate content -- **Prompt Attack Prevention**: Detect and block malicious prompt injection attempts - -### Example: Input Redaction Hook - -```python -from strands import Agent -from strands.hooks import BeforeInvocationEvent, HookProvider, HookRegistry - -class InputGuardrailHook(HookProvider): - def register_hooks(self, registry: HookRegistry) -> None: - registry.add_callback(BeforeInvocationEvent, self.check_input) - - async def check_input(self, event: BeforeInvocationEvent) -> None: - if event.messages is None: - return - - for message in event.messages: - if message.get("role") == "user": - content = message.get("content", []) - for block in content: - if "text" in block: - # Option 1: Redact in-place - block["text"] = redact_pii(block["text"]) - - # Option 2: Abort invocation by raising an exception - # if contains_malicious_content(block["text"]): - # raise ValueError("Malicious content detected") - -agent = Agent(hooks=[InputGuardrailHook()]) -agent("Process this message") # Guardrail runs before message is added to memory -``` - -### Key Behaviors - -- `messages` defaults to `None` for backward compatibility (e.g., when invoked from deprecated methods) -- `messages` is writable, allowing hooks to modify content in-place -- The `AfterInvocationEvent` is always triggered even if a hook raises an exception, maintaining the paired event guarantee diff --git a/src/strands/hooks/events.py b/src/strands/hooks/events.py index 181be971d..70764e342 100644 --- a/src/strands/hooks/events.py +++ b/src/strands/hooks/events.py @@ -39,12 +39,6 @@ class BeforeInvocationEvent(HookEvent): before any model inference or tool execution occurs. Hook providers can use this event to perform request-level setup, logging, or validation. - The messages attribute provides access to the input messages for this invocation, - allowing hooks to inspect or modify message content before processing. This is - particularly useful for implementing input guardrails (e.g., PII detection, - content moderation, prompt attack prevention) that need to run before messages - are added to the agent's conversation history. - This event is triggered at the beginning of the following api calls: - Agent.__call__ - Agent.stream_async @@ -52,8 +46,7 @@ class BeforeInvocationEvent(HookEvent): Attributes: messages: The input messages for this invocation. Can be modified by hooks - to redact or transform content before processing. May be None for - backward compatibility or when invoked from deprecated methods. + to redact or transform content before processing. """ messages: Messages | None = None diff --git a/tests/strands/agent/hooks/test_events.py b/tests/strands/agent/hooks/test_events.py index dd8b990e6..83cb1af24 100644 --- a/tests/strands/agent/hooks/test_events.py +++ b/tests/strands/agent/hooks/test_events.py @@ -177,13 +177,6 @@ def test_before_invocation_event_messages_default_none(agent): assert event.messages is None -def test_before_invocation_event_messages_set_on_init(agent, sample_messages): - """Test that BeforeInvocationEvent.messages can be set on initialization.""" - event = BeforeInvocationEvent(agent=agent, messages=sample_messages) - assert event.messages is sample_messages - assert event.messages == [{"role": "user", "content": [{"text": "Hello, agent!"}]}] - - def test_before_invocation_event_messages_writable(agent, sample_messages): """Test that BeforeInvocationEvent.messages can be modified in-place for guardrail redaction.""" event = BeforeInvocationEvent(agent=agent, messages=sample_messages) From a0ad6ec193acc86f2d01fa26a8e7dde764e4f04e Mon Sep 17 00:00:00 2001 From: Strands Agent <217235299+strands-agent@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:24:30 +0000 Subject: [PATCH 3/5] refactor: simplify test assertions per review Use concise equality comparison for BeforeInvocationEvent assertions instead of verbose instance checks and property assertions. --- tests/strands/agent/test_agent_hooks.py | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/tests/strands/agent/test_agent_hooks.py b/tests/strands/agent/test_agent_hooks.py index 6a1372845..15abe3ad8 100644 --- a/tests/strands/agent/test_agent_hooks.py +++ b/tests/strands/agent/test_agent_hooks.py @@ -160,14 +160,7 @@ def test_agent__call__hooks(agent, hook_provider, agent_tool, mock_model, tool_u assert length == 12 - # Verify BeforeInvocationEvent includes messages - before_event = next(events) - assert isinstance(before_event, BeforeInvocationEvent) - assert before_event.agent == agent - assert before_event.messages is not None - assert len(before_event.messages) == 1 - assert before_event.messages[0]["role"] == "user" - + assert next(events) == BeforeInvocationEvent(agent=agent, messages=agent.messages[0:1]) assert next(events) == MessageAddedEvent( agent=agent, message=agent.messages[0], @@ -224,12 +217,8 @@ async def test_agent_stream_async_hooks(agent, hook_provider, agent_tool, mock_m # Verify first event is BeforeInvocationEvent with messages assert len(hook_provider.events_received) == 1 - before_event = hook_provider.events_received[0] - assert isinstance(before_event, BeforeInvocationEvent) - assert before_event.agent == agent - assert before_event.messages is not None - assert len(before_event.messages) == 1 - assert before_event.messages[0]["role"] == "user" + assert hook_provider.events_received[0].messages is not None + assert hook_provider.events_received[0].messages[0]["role"] == "user" # iterate the rest result = None @@ -241,13 +230,7 @@ async def test_agent_stream_async_hooks(agent, hook_provider, agent_tool, mock_m assert length == 12 - # Verify BeforeInvocationEvent includes messages - before_event_2 = next(events) - assert isinstance(before_event_2, BeforeInvocationEvent) - assert before_event_2.agent == agent - assert before_event_2.messages is not None - assert len(before_event_2.messages) == 1 - + assert next(events) == BeforeInvocationEvent(agent=agent, messages=agent.messages[0:1]) assert next(events) == MessageAddedEvent( agent=agent, message=agent.messages[0], From b204bbefd433f9ff39c2be900daa325a5228e4ee Mon Sep 17 00:00:00 2001 From: Nicholas Clegg Date: Thu, 15 Jan 2026 13:46:32 -0500 Subject: [PATCH 4/5] Use overwritten messages array for the agent --- src/strands/agent/agent.py | 5 ++++- tests/strands/agent/test_agent_hooks.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 617c06f50..7f0836c08 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -644,7 +644,10 @@ async def _run_loop( Yields: Events from the event loop cycle. """ - await self.hooks.invoke_callbacks_async(BeforeInvocationEvent(agent=self, messages=messages)) + before_invocation_event, _interrupts = await self.hooks.invoke_callbacks_async( + BeforeInvocationEvent(agent=self, messages=messages) + ) + messages = before_invocation_event.messages agent_result: AgentResult | None = None try: diff --git a/tests/strands/agent/test_agent_hooks.py b/tests/strands/agent/test_agent_hooks.py index 15abe3ad8..be71b5fcf 100644 --- a/tests/strands/agent/test_agent_hooks.py +++ b/tests/strands/agent/test_agent_hooks.py @@ -639,6 +639,29 @@ async def input_guardrail_hook(event: BeforeInvocationEvent): assert agent.messages[0]["content"][0]["text"] == "My password is [REDACTED]123" +def test_before_invocation_event_message_overwrite(): + """Test that hooks can overwrite messages in BeforeInvocationEvent.""" + mock_provider = MockedModelProvider( + [ + { + "role": "assistant", + "content": [{"text": "I received your message message"}], + }, + ] + ) + + async def overwrite_input_hook(event: BeforeInvocationEvent): + event.messages = [{"role": "user", "content": [{"text": "GOODBYE"}]}] + + agent = Agent(model=mock_provider) + agent.hooks.add_callback(BeforeInvocationEvent, overwrite_input_hook) + + agent("HELLO") + + # Verify the message was overwritten to agent's conversation history + assert agent.messages[0]["content"][0]["text"] == "GOODBYE" + + @pytest.mark.asyncio async def test_before_invocation_event_messages_none_in_structured_output(agenerator): """Test that BeforeInvocationEvent.messages is None when called from deprecated structured_output.""" From e803dbb4183b1987025d544cb13dbf0d1b3097c3 Mon Sep 17 00:00:00 2001 From: Nicholas Clegg Date: Thu, 15 Jan 2026 14:20:07 -0500 Subject: [PATCH 5/5] Fix mypy issue --- src/strands/agent/agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 7f0836c08..6df775d20 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -647,7 +647,7 @@ async def _run_loop( before_invocation_event, _interrupts = await self.hooks.invoke_callbacks_async( BeforeInvocationEvent(agent=self, messages=messages) ) - messages = before_invocation_event.messages + messages = before_invocation_event.messages if before_invocation_event.messages is not None else messages agent_result: AgentResult | None = None try: