Skip to content
12 changes: 6 additions & 6 deletions src/strands/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ def __init__(
if hooks:
for hook in hooks:
self.hooks.add_hook(hook)
self.hooks.invoke_callbacks(AgentInitializedEvent(agent=self))
self.hooks.invoke_callbacks_sync(AgentInitializedEvent(agent=self))
Comment thread
pgrayy marked this conversation as resolved.
Outdated

@property
def tool(self) -> ToolCaller:
Expand Down Expand Up @@ -531,7 +531,7 @@ async def structured_output_async(self, output_model: Type[T], prompt: AgentInpu
category=DeprecationWarning,
stacklevel=2,
)
self.hooks.invoke_callbacks(BeforeInvocationEvent(agent=self))
await self.hooks.invoke_callbacks(BeforeInvocationEvent(agent=self))
with self.tracer.tracer.start_as_current_span(
"execute_structured_output", kind=trace_api.SpanKind.CLIENT
) as structured_output_span:
Expand Down Expand Up @@ -572,7 +572,7 @@ async def structured_output_async(self, output_model: Type[T], prompt: AgentInpu
return event["output"]

finally:
self.hooks.invoke_callbacks(AfterInvocationEvent(agent=self))
await self.hooks.invoke_callbacks(AfterInvocationEvent(agent=self))

def cleanup(self) -> None:
"""Clean up resources used by the agent.
Expand Down Expand Up @@ -729,7 +729,7 @@ async def _run_loop(
Yields:
Events from the event loop cycle.
"""
self.hooks.invoke_callbacks(BeforeInvocationEvent(agent=self))
await self.hooks.invoke_callbacks(BeforeInvocationEvent(agent=self))

try:
yield InitEventLoopEvent()
Expand Down Expand Up @@ -761,7 +761,7 @@ async def _run_loop(

finally:
self.conversation_manager.apply_management(self)
self.hooks.invoke_callbacks(AfterInvocationEvent(agent=self))
await self.hooks.invoke_callbacks(AfterInvocationEvent(agent=self))

async def _execute_event_loop_cycle(
self, invocation_state: dict[str, Any], structured_output_context: StructuredOutputContext | None = None
Expand Down Expand Up @@ -968,7 +968,7 @@ def _filter_tool_parameters_for_recording(self, tool_name: str, input_params: di
def _append_message(self, message: Message) -> None:
"""Appends a message to the agent's list of messages and invokes the callbacks for the MessageCreatedEvent."""
self.messages.append(message)
self.hooks.invoke_callbacks(MessageAddedEvent(agent=self, message=message))
self.hooks.invoke_callbacks_sync(MessageAddedEvent(agent=self, message=message))
Comment thread
pgrayy marked this conversation as resolved.
Outdated

def _redact_user_content(self, content: list[ContentBlock], redact_message: str) -> list[ContentBlock]:
"""Redact user content preserving toolResult blocks.
Expand Down
10 changes: 5 additions & 5 deletions src/strands/event_loop/event_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ async def _handle_model_execution(
model_id=model_id,
)
with trace_api.use_span(model_invoke_span):
agent.hooks.invoke_callbacks(
await agent.hooks.invoke_callbacks(
BeforeModelCallEvent(
agent=agent,
)
Expand All @@ -342,7 +342,7 @@ async def _handle_model_execution(
stop_reason, message, usage, metrics = event["stop"]
invocation_state.setdefault("request_state", {})

agent.hooks.invoke_callbacks(
await agent.hooks.invoke_callbacks(
AfterModelCallEvent(
agent=agent,
stop_response=AfterModelCallEvent.ModelStopResponse(
Expand All @@ -363,7 +363,7 @@ async def _handle_model_execution(
if model_invoke_span:
tracer.end_span_with_error(model_invoke_span, str(e), e)

agent.hooks.invoke_callbacks(
await agent.hooks.invoke_callbacks(
AfterModelCallEvent(
agent=agent,
exception=e,
Expand Down Expand Up @@ -397,7 +397,7 @@ async def _handle_model_execution(

# Add the response message to the conversation
agent.messages.append(message)
agent.hooks.invoke_callbacks(MessageAddedEvent(agent=agent, message=message))
agent.hooks.invoke_callbacks_sync(MessageAddedEvent(agent=agent, message=message))

# Update metrics
agent.event_loop_metrics.update_usage(usage)
Expand Down Expand Up @@ -502,7 +502,7 @@ async def _handle_tool_execution(
}

agent.messages.append(tool_result_message)
agent.hooks.invoke_callbacks(MessageAddedEvent(agent=agent, message=tool_result_message))
agent.hooks.invoke_callbacks_sync(MessageAddedEvent(agent=agent, message=tool_result_message))

yield ToolResultMessageEvent(message=tool_result_message)

Expand Down
16 changes: 15 additions & 1 deletion src/strands/hooks/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ class AgentInitializedEvent(HookEvent):
event to perform setup tasks that require a fully initialized agent.
"""

pass
@staticmethod
Comment thread
pgrayy marked this conversation as resolved.
Outdated
def is_async() -> bool:
"""False to only support handling under sync callbacks.

AgentInitializedEvent is emitted in Agent.__init__, which runs synchronously.
"""
return False


@dataclass
Expand Down Expand Up @@ -86,6 +92,14 @@ class MessageAddedEvent(HookEvent):

message: Message

@staticmethod
def is_async() -> bool:
"""False to only support handling under sync callbacks.

MessageAddedEvent may be emitted under direct tool calls, which run synchronously.
Comment thread
pgrayy marked this conversation as resolved.
Outdated
"""
return False


@dataclass
class BeforeToolCallEvent(HookEvent, _Interruptible):
Expand Down
52 changes: 48 additions & 4 deletions src/strands/hooks/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
via hook provider objects.
"""

import inspect
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Generator, Generic, Protocol, Type, TypeVar
from typing import TYPE_CHECKING, Any, Awaitable, Generator, Generic, Protocol, Type, TypeVar

from ..interrupt import Interrupt, InterruptException

Expand All @@ -33,6 +34,15 @@ def should_reverse_callbacks(self) -> bool:
"""
return False

@staticmethod
def is_async() -> bool:
"""Determine if callbacks for this event can be invoked asynchronously.

Returns:
True if an event can be handled under an async callback, False otherwise.
"""
return True

def _can_write(self, name: str) -> bool:
"""Check if the given property can be written to.

Expand Down Expand Up @@ -118,14 +128,16 @@ class HookCallback(Protocol, Generic[TEvent]):
argument and perform some action in response. They should not return
values and any exceptions they raise will propagate to the caller.

For events with `is_async()` returning `True`, callbacks may be defined async.

Example:
```python
def my_callback(event: StartRequestEvent) -> None:
print(f"Request started for agent: {event.agent.name}")
```
"""

def __call__(self, event: TEvent) -> None:
def __call__(self, event: TEvent) -> None | Awaitable[None]:
Comment thread
pgrayy marked this conversation as resolved.
"""Handle a hook event.

Args:
Expand Down Expand Up @@ -156,6 +168,10 @@ def add_callback(self, event_type: Type[TEvent], callback: HookCallback[TEvent])
event_type: The class type of events this callback should handle.
callback: The callback function to invoke when events of this type occur.

Raises:
ValueError:
If async callback is added for a sync-only event.

Example:
```python
def my_handler(event: StartRequestEvent):
Expand All @@ -164,6 +180,9 @@ def my_handler(event: StartRequestEvent):
registry.add_callback(StartRequestEvent, my_handler)
```
"""
if not event_type.is_async() and inspect.iscoroutinefunction(callback):
Comment thread
pgrayy marked this conversation as resolved.
Outdated
raise ValueError(f"event_type={event_type} | async callback added for sync-only event")

callbacks = self._registered_callbacks.setdefault(event_type, [])
callbacks.append(callback)

Expand All @@ -189,7 +208,7 @@ def register_hooks(self, registry: HookRegistry):
"""
hook.register_hooks(self)

def invoke_callbacks(self, event: TInvokeEvent) -> tuple[TInvokeEvent, list[Interrupt]]:
async def invoke_callbacks(self, event: TInvokeEvent) -> tuple[TInvokeEvent, list[Interrupt]]:
Comment thread
pgrayy marked this conversation as resolved.
Outdated
"""Invoke all registered callbacks for the given event.

This method finds all callbacks registered for the event's type and
Expand Down Expand Up @@ -218,7 +237,11 @@ def invoke_callbacks(self, event: TInvokeEvent) -> tuple[TInvokeEvent, list[Inte

for callback in self.get_callbacks_for(event):
Comment thread
pgrayy marked this conversation as resolved.
try:
callback(event)
if inspect.iscoroutinefunction(callback):
await callback(event)
else:
callback(event)

except InterruptException as exception:
interrupt = exception.interrupt
if interrupt.name in interrupts:
Expand All @@ -231,6 +254,27 @@ def invoke_callbacks(self, event: TInvokeEvent) -> tuple[TInvokeEvent, list[Inte

return event, list(interrupts.values())

def invoke_callbacks_sync(self, event: TInvokeEvent) -> TInvokeEvent:
Comment thread
pgrayy marked this conversation as resolved.
Outdated
"""Synchronously invoke all registered callbacks for the given event.

Args:
event: The event to dispatch to registered callbacks.

Returns:
The event dispatched to registered callbacks.

Raises:
RuntimeError: If at least one callback is async.
"""
callbacks = self.get_callbacks_for(event)
if any(inspect.iscoroutinefunction(callback) for callback in callbacks):
raise RuntimeError(f"event=<{event}> | cannot invoke async callback on sync-only event")
Comment thread
pgrayy marked this conversation as resolved.
Outdated

for callback in callbacks:
callback(event)

return event

def has_callbacks(self) -> bool:
"""Check if the registry has any registered callbacks.

Expand Down
10 changes: 5 additions & 5 deletions src/strands/tools/executors/_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ async def _stream(
}
)

before_event, interrupts = agent.hooks.invoke_callbacks(
before_event, interrupts = await agent.hooks.invoke_callbacks(
BeforeToolCallEvent(
agent=agent,
selected_tool=tool_func,
Expand All @@ -109,7 +109,7 @@ async def _stream(
"status": "error",
"content": [{"text": cancel_message}],
}
after_event, _ = agent.hooks.invoke_callbacks(
after_event, _ = await agent.hooks.invoke_callbacks(
AfterToolCallEvent(
agent=agent,
tool_use=tool_use,
Expand Down Expand Up @@ -147,7 +147,7 @@ async def _stream(
"status": "error",
"content": [{"text": f"Unknown tool: {tool_name}"}],
}
after_event, _ = agent.hooks.invoke_callbacks(
after_event, _ = await agent.hooks.invoke_callbacks(
AfterToolCallEvent(
agent=agent,
selected_tool=selected_tool,
Expand Down Expand Up @@ -184,7 +184,7 @@ async def _stream(

result = cast(ToolResult, event)

after_event, _ = agent.hooks.invoke_callbacks(
after_event, _ = await agent.hooks.invoke_callbacks(
AfterToolCallEvent(
agent=agent,
selected_tool=selected_tool,
Expand All @@ -204,7 +204,7 @@ async def _stream(
"status": "error",
"content": [{"text": f"Error: {str(e)}"}],
}
after_event, _ = agent.hooks.invoke_callbacks(
after_event, _ = await agent.hooks.invoke_callbacks(
AfterToolCallEvent(
agent=agent,
selected_tool=selected_tool,
Expand Down
1 change: 0 additions & 1 deletion tests/strands/agent/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2162,7 +2162,6 @@ def shell(command: str):
assert agent.messages[-1] == {"content": [{"text": "I invoked a tool!"}], "role": "assistant"}



Comment thread
pgrayy marked this conversation as resolved.
@pytest.mark.parametrize(
"content, expected",
[
Expand Down
Loading