Skip to content

Commit a6732f1

Browse files
committed
feat(hooks): add BeforeReduceContextEvent and AfterReduceContextEvent
Fires BeforeReduceContextEvent before reduce_context() and AfterReduceContextEvent after it returns, giving observability plugins a way to detect and measure context compaction without polling or inspecting internals. Closes #2048
1 parent 94fc8dd commit a6732f1

File tree

4 files changed

+81
-1
lines changed

4 files changed

+81
-1
lines changed

src/strands/agent/agent.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@
3535
from ..handlers.callback_handler import PrintingCallbackHandler, null_callback_handler
3636
from ..hooks import (
3737
AfterInvocationEvent,
38+
AfterReduceContextEvent,
3839
AgentInitializedEvent,
3940
BeforeInvocationEvent,
41+
BeforeReduceContextEvent,
4042
HookCallback,
4143
HookProvider,
4244
HookRegistry,
@@ -963,9 +965,13 @@ async def _execute_event_loop_cycle(
963965
yield event
964966

965967
except ContextWindowOverflowException as e:
968+
await self.hooks.invoke_callbacks_async(BeforeReduceContextEvent(agent=self, exception=e))
969+
966970
# Try reducing the context size and retrying
967971
self.conversation_manager.reduce_context(self, e=e)
968972

973+
await self.hooks.invoke_callbacks_async(AfterReduceContextEvent(agent=self))
974+
969975
# Sync agent after reduce_context to keep conversation_manager_state up to date in the session
970976
if self._session_manager:
971977
self._session_manager.sync_agent(self)

src/strands/hooks/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@ def log_end(self, event: AfterInvocationEvent) -> None:
3535
# Multiagent hook events
3636
AfterMultiAgentInvocationEvent,
3737
AfterNodeCallEvent,
38+
AfterReduceContextEvent,
3839
AfterToolCallEvent,
3940
AgentInitializedEvent,
4041
BeforeInvocationEvent,
4142
BeforeModelCallEvent,
4243
BeforeMultiAgentInvocationEvent,
4344
BeforeNodeCallEvent,
45+
BeforeReduceContextEvent,
4446
BeforeToolCallEvent,
4547
MessageAddedEvent,
4648
MultiAgentInitializedEvent,
@@ -55,6 +57,8 @@ def log_end(self, event: AfterInvocationEvent) -> None:
5557
"BeforeModelCallEvent",
5658
"AfterModelCallEvent",
5759
"AfterInvocationEvent",
60+
"BeforeReduceContextEvent",
61+
"AfterReduceContextEvent",
5862
"MessageAddedEvent",
5963
"HookEvent",
6064
"HookProvider",

src/strands/hooks/events.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,39 @@ def should_reverse_callbacks(self) -> bool:
303303
return True
304304

305305

306+
@dataclass
307+
class BeforeReduceContextEvent(HookEvent):
308+
"""Event triggered before the conversation manager reduces context.
309+
310+
This event is fired just before the agent calls reduce_context() in response
311+
to a context window overflow. Hook providers can use this event for logging,
312+
observability, or displaying progress indicators during long-running sessions.
313+
314+
Attributes:
315+
exception: The ContextWindowOverflowException that triggered the context reduction.
316+
"""
317+
318+
exception: Exception
319+
320+
321+
@dataclass
322+
class AfterReduceContextEvent(HookEvent):
323+
"""Event triggered after the conversation manager has reduced context.
324+
325+
This event is fired immediately after reduce_context() returns, before the
326+
agent retries the model call. Hook providers can use this event to log the
327+
outcome of the reduction or update observability dashboards.
328+
329+
Note: This event uses reverse callback ordering, meaning callbacks registered
330+
later will be invoked first during cleanup.
331+
"""
332+
333+
@property
334+
def should_reverse_callbacks(self) -> bool:
335+
"""True to invoke callbacks in reverse order."""
336+
return True
337+
338+
306339
# Multiagent hook events start here
307340
@dataclass
308341
class MultiAgentInitializedEvent(BaseHookEvent):

tests/strands/agent/test_agent.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@
2121
from strands.agent.conversation_manager.sliding_window_conversation_manager import SlidingWindowConversationManager
2222
from strands.agent.state import AgentState
2323
from strands.handlers.callback_handler import PrintingCallbackHandler, null_callback_handler
24-
from strands.hooks import BeforeInvocationEvent, BeforeModelCallEvent, BeforeToolCallEvent
24+
from strands.hooks import (
25+
AfterReduceContextEvent,
26+
BeforeInvocationEvent,
27+
BeforeModelCallEvent,
28+
BeforeReduceContextEvent,
29+
BeforeToolCallEvent,
30+
)
2531
from strands.interrupt import Interrupt
2632
from strands.models.bedrock import DEFAULT_BEDROCK_MODEL_ID, BedrockModel
2733
from strands.session.repository_session_manager import RepositorySessionManager
@@ -2756,3 +2762,34 @@ def test_as_tool_defaults_description_when_agent_has_none():
27562762
tool = agent.as_tool()
27572763

27582764
assert tool.tool_spec["description"] == "Use the researcher agent as a tool by providing a natural language input"
2765+
2766+
2767+
@pytest.mark.asyncio
2768+
async def test_stream_async_fires_before_and_after_reduce_context_hook_events(mock_model, agent, agenerator, alist):
2769+
"""BeforeReduceContextEvent and AfterReduceContextEvent are fired around reduce_context."""
2770+
overflow_exc = ContextWindowOverflowException(RuntimeError("Input is too long for requested model"))
2771+
2772+
mock_model.mock_stream.side_effect = [
2773+
overflow_exc,
2774+
agenerator(
2775+
[
2776+
{"contentBlockStart": {"contentBlockIndex": 0, "start": {}}},
2777+
{"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "OK"}}},
2778+
{"contentBlockStop": {"contentBlockIndex": 0}},
2779+
{"messageStop": {"stopReason": "end_turn"}},
2780+
]
2781+
),
2782+
]
2783+
2784+
before_events = []
2785+
after_events = []
2786+
agent.add_hook(lambda e: before_events.append(e), BeforeReduceContextEvent)
2787+
agent.add_hook(lambda e: after_events.append(e), AfterReduceContextEvent)
2788+
2789+
agent.conversation_manager.reduce_context = unittest.mock.Mock()
2790+
2791+
await alist(agent.stream_async("hello"))
2792+
2793+
assert len(before_events) == 1
2794+
assert before_events[0].exception is overflow_exc
2795+
assert len(after_events) == 1

0 commit comments

Comments
 (0)