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
6 changes: 6 additions & 0 deletions src/strands/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@
from ..handlers.callback_handler import PrintingCallbackHandler, null_callback_handler
from ..hooks import (
AfterInvocationEvent,
AfterReduceContextEvent,
AgentInitializedEvent,
BeforeInvocationEvent,
BeforeReduceContextEvent,
HookCallback,
HookProvider,
HookRegistry,
Expand Down Expand Up @@ -963,9 +965,13 @@ async def _execute_event_loop_cycle(
yield event

except ContextWindowOverflowException as e:
await self.hooks.invoke_callbacks_async(BeforeReduceContextEvent(agent=self, exception=e))

# Try reducing the context size and retrying
self.conversation_manager.reduce_context(self, e=e)

await self.hooks.invoke_callbacks_async(AfterReduceContextEvent(agent=self))

# Sync agent after reduce_context to keep conversation_manager_state up to date in the session
if self._session_manager:
self._session_manager.sync_agent(self)
Expand Down
4 changes: 4 additions & 0 deletions src/strands/hooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ def log_end(self, event: AfterInvocationEvent) -> None:
# Multiagent hook events
AfterMultiAgentInvocationEvent,
AfterNodeCallEvent,
AfterReduceContextEvent,
AfterToolCallEvent,
AgentInitializedEvent,
BeforeInvocationEvent,
BeforeModelCallEvent,
BeforeMultiAgentInvocationEvent,
BeforeNodeCallEvent,
BeforeReduceContextEvent,
BeforeToolCallEvent,
MessageAddedEvent,
MultiAgentInitializedEvent,
Expand All @@ -55,6 +57,8 @@ def log_end(self, event: AfterInvocationEvent) -> None:
"BeforeModelCallEvent",
"AfterModelCallEvent",
"AfterInvocationEvent",
"BeforeReduceContextEvent",
"AfterReduceContextEvent",
"MessageAddedEvent",
"HookEvent",
"HookProvider",
Expand Down
33 changes: 33 additions & 0 deletions src/strands/hooks/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,39 @@ def should_reverse_callbacks(self) -> bool:
return True


@dataclass
class BeforeReduceContextEvent(HookEvent):
"""Event triggered before the conversation manager reduces context.

This event is fired just before the agent calls reduce_context() in response
to a context window overflow. Hook providers can use this event for logging,
observability, or displaying progress indicators during long-running sessions.

Attributes:
exception: The ContextWindowOverflowException that triggered the context reduction.
"""

exception: Exception


@dataclass
class AfterReduceContextEvent(HookEvent):
"""Event triggered after the conversation manager has reduced context.

This event is fired immediately after reduce_context() returns, before the
agent retries the model call. Hook providers can use this event to log the
outcome of the reduction or update observability dashboards.

Note: This event uses reverse callback ordering, meaning callbacks registered
later will be invoked first during cleanup.
"""

@property
def should_reverse_callbacks(self) -> bool:
"""True to invoke callbacks in reverse order."""
return True


# Multiagent hook events start here
@dataclass
class MultiAgentInitializedEvent(BaseHookEvent):
Expand Down
39 changes: 38 additions & 1 deletion tests/strands/agent/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@
from strands.agent.conversation_manager.sliding_window_conversation_manager import SlidingWindowConversationManager
from strands.agent.state import AgentState
from strands.handlers.callback_handler import PrintingCallbackHandler, null_callback_handler
from strands.hooks import BeforeInvocationEvent, BeforeModelCallEvent, BeforeToolCallEvent
from strands.hooks import (
AfterReduceContextEvent,
BeforeInvocationEvent,
BeforeModelCallEvent,
BeforeReduceContextEvent,
BeforeToolCallEvent,
)
from strands.interrupt import Interrupt
from strands.models.bedrock import DEFAULT_BEDROCK_MODEL_ID, BedrockModel
from strands.session.repository_session_manager import RepositorySessionManager
Expand Down Expand Up @@ -2756,3 +2762,34 @@ def test_as_tool_defaults_description_when_agent_has_none():
tool = agent.as_tool()

assert tool.tool_spec["description"] == "Use the researcher agent as a tool by providing a natural language input"


@pytest.mark.asyncio
async def test_stream_async_fires_before_and_after_reduce_context_hook_events(mock_model, agent, agenerator, alist):
"""BeforeReduceContextEvent and AfterReduceContextEvent are fired around reduce_context."""
overflow_exc = ContextWindowOverflowException(RuntimeError("Input is too long for requested model"))

mock_model.mock_stream.side_effect = [
overflow_exc,
agenerator(
[
{"contentBlockStart": {"contentBlockIndex": 0, "start": {}}},
{"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "OK"}}},
{"contentBlockStop": {"contentBlockIndex": 0}},
{"messageStop": {"stopReason": "end_turn"}},
]
),
]

before_events = []
after_events = []
agent.add_hook(lambda e: before_events.append(e), BeforeReduceContextEvent)
agent.add_hook(lambda e: after_events.append(e), AfterReduceContextEvent)

agent.conversation_manager.reduce_context = unittest.mock.Mock()

await alist(agent.stream_async("hello"))

assert len(before_events) == 1
assert before_events[0].exception is overflow_exc
assert len(after_events) == 1