Skip to content

Commit bfe9d02

Browse files
authored
feat(hooks): add resume flag to AfterInvocationEvent (#1767)
1 parent 021344b commit bfe9d02

File tree

4 files changed

+426
-37
lines changed

4 files changed

+426
-37
lines changed

src/strands/agent/agent.py

Lines changed: 54 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -814,49 +814,66 @@ async def _run_loop(
814814
Yields:
815815
Events from the event loop cycle.
816816
"""
817-
before_invocation_event, _interrupts = await self.hooks.invoke_callbacks_async(
818-
BeforeInvocationEvent(agent=self, invocation_state=invocation_state, messages=messages)
819-
)
820-
messages = before_invocation_event.messages if before_invocation_event.messages is not None else messages
817+
current_messages: Messages | None = messages
821818

822-
agent_result: AgentResult | None = None
823-
try:
824-
yield InitEventLoopEvent()
819+
while current_messages is not None:
820+
before_invocation_event, _interrupts = await self.hooks.invoke_callbacks_async(
821+
BeforeInvocationEvent(agent=self, invocation_state=invocation_state, messages=current_messages)
822+
)
823+
current_messages = (
824+
before_invocation_event.messages if before_invocation_event.messages is not None else current_messages
825+
)
825826

826-
await self._append_messages(*messages)
827+
agent_result: AgentResult | None = None
828+
try:
829+
yield InitEventLoopEvent()
827830

828-
structured_output_context = StructuredOutputContext(
829-
structured_output_model or self._default_structured_output_model,
830-
structured_output_prompt=structured_output_prompt or self._structured_output_prompt,
831-
)
831+
await self._append_messages(*current_messages)
832832

833-
# Execute the event loop cycle with retry logic for context limits
834-
events = self._execute_event_loop_cycle(invocation_state, structured_output_context)
835-
async for event in events:
836-
# Signal from the model provider that the message sent by the user should be redacted,
837-
# likely due to a guardrail.
838-
if (
839-
isinstance(event, ModelStreamChunkEvent)
840-
and event.chunk
841-
and event.chunk.get("redactContent")
842-
and event.chunk["redactContent"].get("redactUserContentMessage")
843-
):
844-
self.messages[-1]["content"] = self._redact_user_content(
845-
self.messages[-1]["content"], str(event.chunk["redactContent"]["redactUserContentMessage"])
846-
)
847-
if self._session_manager:
848-
self._session_manager.redact_latest_message(self.messages[-1], self)
849-
yield event
833+
structured_output_context = StructuredOutputContext(
834+
structured_output_model or self._default_structured_output_model,
835+
structured_output_prompt=structured_output_prompt or self._structured_output_prompt,
836+
)
850837

851-
# Capture the result from the final event if available
852-
if isinstance(event, EventLoopStopEvent):
853-
agent_result = AgentResult(*event["stop"])
838+
# Execute the event loop cycle with retry logic for context limits
839+
events = self._execute_event_loop_cycle(invocation_state, structured_output_context)
840+
async for event in events:
841+
# Signal from the model provider that the message sent by the user should be redacted,
842+
# likely due to a guardrail.
843+
if (
844+
isinstance(event, ModelStreamChunkEvent)
845+
and event.chunk
846+
and event.chunk.get("redactContent")
847+
and event.chunk["redactContent"].get("redactUserContentMessage")
848+
):
849+
self.messages[-1]["content"] = self._redact_user_content(
850+
self.messages[-1]["content"],
851+
str(event.chunk["redactContent"]["redactUserContentMessage"]),
852+
)
853+
if self._session_manager:
854+
self._session_manager.redact_latest_message(self.messages[-1], self)
855+
yield event
856+
857+
# Capture the result from the final event if available
858+
if isinstance(event, EventLoopStopEvent):
859+
agent_result = AgentResult(*event["stop"])
854860

855-
finally:
856-
self.conversation_manager.apply_management(self)
857-
await self.hooks.invoke_callbacks_async(
858-
AfterInvocationEvent(agent=self, invocation_state=invocation_state, result=agent_result)
859-
)
861+
finally:
862+
self.conversation_manager.apply_management(self)
863+
after_invocation_event, _interrupts = await self.hooks.invoke_callbacks_async(
864+
AfterInvocationEvent(agent=self, invocation_state=invocation_state, result=agent_result)
865+
)
866+
867+
# Convert resume input to messages for next iteration, or None to stop
868+
if after_invocation_event.resume is not None:
869+
logger.debug("resume=<True> | hook requested agent resume with new input")
870+
# If in interrupt state, process interrupt responses before continuing.
871+
# This mirrors the _interrupt_state.resume() call in stream_async and will
872+
# raise TypeError if the resume input is not valid interrupt responses.
873+
self._interrupt_state.resume(after_invocation_event.resume)
874+
current_messages = await self._convert_prompt_to_messages(after_invocation_event.resume)
875+
else:
876+
current_messages = None
860877

861878
async def _execute_event_loop_cycle(
862879
self, invocation_state: dict[str, Any], structured_output_context: StructuredOutputContext | None = None

src/strands/hooks/events.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
if TYPE_CHECKING:
1313
from ..agent.agent_result import AgentResult
1414

15+
from ..types.agent import AgentInput
1516
from ..types.content import Message, Messages
1617
from ..types.interrupt import _Interruptible
1718
from ..types.streaming import StopReason
@@ -78,17 +79,31 @@ class AfterInvocationEvent(HookEvent):
7879
- Agent.stream_async
7980
- Agent.structured_output
8081
82+
Resume:
83+
When ``resume`` is set to a non-None value by a hook callback, the agent will
84+
automatically re-invoke itself with the provided input. This enables hooks to
85+
implement autonomous looping patterns where the agent continues processing
86+
based on its previous result. The resume triggers a full new invocation cycle
87+
including ``BeforeInvocationEvent``.
88+
8189
Attributes:
8290
invocation_state: State and configuration passed through the agent invocation.
8391
This can include shared context for multi-agent coordination, request tracking,
8492
and dynamic configuration.
8593
result: The result of the agent invocation, if available.
8694
This will be None when invoked from structured_output methods, as those return typed output directly rather
8795
than AgentResult.
96+
resume: When set to a non-None agent input by a hook callback, the agent will
97+
re-invoke itself with this input. The value can be any valid AgentInput
98+
(str, content blocks, messages, etc.). Defaults to None (no resume).
8899
"""
89100

90101
invocation_state: dict[str, Any] = field(default_factory=dict)
91102
result: "AgentResult | None" = None
103+
resume: AgentInput = None
104+
105+
def _can_write(self, name: str) -> bool:
106+
return name == "resume"
92107

93108
@property
94109
def should_reverse_callbacks(self) -> bool:

tests/strands/agent/hooks/test_events.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,33 @@ def test_before_invocation_event_agent_not_writable(start_request_event_with_mes
230230
"""Test that BeforeInvocationEvent.agent is not writable."""
231231
with pytest.raises(AttributeError, match="Property agent is not writable"):
232232
start_request_event_with_messages.agent = Mock()
233+
234+
235+
def test_after_invocation_event_resume_defaults_to_none(agent):
236+
"""Test that AfterInvocationEvent.resume defaults to None."""
237+
event = AfterInvocationEvent(agent=agent, result=None)
238+
assert event.resume is None
239+
240+
241+
def test_after_invocation_event_resume_is_writable(agent):
242+
"""Test that AfterInvocationEvent.resume can be set by hooks."""
243+
event = AfterInvocationEvent(agent=agent, result=None)
244+
event.resume = "continue with this input"
245+
assert event.resume == "continue with this input"
246+
247+
248+
def test_after_invocation_event_resume_accepts_various_input_types(agent):
249+
"""Test that resume accepts all AgentInput types."""
250+
event = AfterInvocationEvent(agent=agent, result=None)
251+
252+
# String input
253+
event.resume = "hello"
254+
assert event.resume == "hello"
255+
256+
# Content block list
257+
event.resume = [{"text": "hello"}]
258+
assert event.resume == [{"text": "hello"}]
259+
260+
# None to stop
261+
event.resume = None
262+
assert event.resume is None

0 commit comments

Comments
 (0)