Skip to content

Commit f1994a2

Browse files
author
Vincent Bai
committed
feat(hooks): add BeforeReduceContextEvent and AfterReduceContextEvent
Emit hook events around reactive context reduction so observability plugins can subscribe directly instead of polling conversation_manager.removed_message_count for deltas. Events are fired from the single reduce_context() call site in the event loop, so third-party ConversationManager implementations are covered automatically without interface changes. Closes #2048
1 parent 6e208a8 commit f1994a2

5 files changed

Lines changed: 219 additions & 2 deletions

File tree

src/strands/agent/agent.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@
4343
from ..handlers.callback_handler import PrintingCallbackHandler, null_callback_handler
4444
from ..hooks import (
4545
AfterInvocationEvent,
46+
AfterReduceContextEvent,
4647
AgentInitializedEvent,
4748
BeforeInvocationEvent,
49+
BeforeReduceContextEvent,
4850
HookCallback,
4951
HookProvider,
5052
HookRegistry,
@@ -992,8 +994,28 @@ async def _execute_event_loop_cycle(
992994

993995
except ContextWindowOverflowException as e:
994996
# Try reducing the context size and retrying
997+
message_count_before = len(self.messages)
998+
await self.hooks.invoke_callbacks_async(
999+
BeforeReduceContextEvent(
1000+
agent=self,
1001+
exception=e,
1002+
message_count=message_count_before,
1003+
)
1004+
)
1005+
9951006
self.conversation_manager.reduce_context(self, e=e)
9961007

1008+
message_count_after = len(self.messages)
1009+
await self.hooks.invoke_callbacks_async(
1010+
AfterReduceContextEvent(
1011+
agent=self,
1012+
exception=e,
1013+
messages_removed=message_count_before - message_count_after,
1014+
message_count_before=message_count_before,
1015+
message_count_after=message_count_after,
1016+
)
1017+
)
1018+
9971019
# Sync agent after reduce_context to keep conversation_manager_state up to date in the session
9981020
if self._session_manager:
9991021
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,
@@ -54,6 +56,8 @@ def log_end(self, event: AfterInvocationEvent) -> None:
5456
"AfterToolCallEvent",
5557
"BeforeModelCallEvent",
5658
"AfterModelCallEvent",
59+
"BeforeReduceContextEvent",
60+
"AfterReduceContextEvent",
5761
"AfterInvocationEvent",
5862
"MessageAddedEvent",
5963
"HookEvent",

src/strands/hooks/events.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,60 @@ def should_reverse_callbacks(self) -> bool:
308308
return True
309309

310310

311+
@dataclass
312+
class BeforeReduceContextEvent(HookEvent):
313+
"""Event triggered before the agent's conversation context is reduced.
314+
315+
This event is fired when a ``ContextWindowOverflowException`` is raised during
316+
an event loop cycle and the agent is about to invoke
317+
``conversation_manager.reduce_context()`` to make room and retry. Hook providers
318+
can use this event to observe or log the reactive compaction path.
319+
320+
Attributes:
321+
exception: The exception that triggered context reduction. Typically a
322+
``ContextWindowOverflowException``.
323+
message_count: The number of messages in the agent's conversation history
324+
immediately before reduction is applied.
325+
"""
326+
327+
exception: Exception | None = None
328+
message_count: int = 0
329+
330+
331+
@dataclass
332+
class AfterReduceContextEvent(HookEvent):
333+
"""Event triggered after the agent's conversation context has been reduced.
334+
335+
This event is fired after ``conversation_manager.reduce_context()`` completes
336+
successfully in response to a ``ContextWindowOverflowException``. Observability
337+
plugins can subscribe to this event to record compaction activity without
338+
having to poll ``conversation_manager.removed_message_count`` for deltas.
339+
340+
Note: This event uses reverse callback ordering, meaning callbacks registered
341+
later will be invoked first during cleanup.
342+
343+
Attributes:
344+
exception: The exception that triggered context reduction. Typically a
345+
``ContextWindowOverflowException``.
346+
messages_removed: Number of messages removed during this reduction
347+
(``message_count_before - message_count_after``).
348+
message_count_before: Number of messages in the conversation history before
349+
reduction was applied.
350+
message_count_after: Number of messages in the conversation history after
351+
reduction completed.
352+
"""
353+
354+
exception: Exception | None = None
355+
messages_removed: int = 0
356+
message_count_before: int = 0
357+
message_count_after: int = 0
358+
359+
@property
360+
def should_reverse_callbacks(self) -> bool:
361+
"""True to invoke callbacks in reverse order."""
362+
return True
363+
364+
311365
# Multiagent hook events start here
312366
@dataclass
313367
class MultiAgentInitializedEvent(BaseHookEvent):

tests/strands/agent/test_agent.py

Lines changed: 79 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
@@ -529,6 +535,78 @@ def test_agent__call__retry_with_reduced_context(mock_model, agent, tool, agener
529535
assert conversation_manager_spy.apply_management.call_count == 1
530536

531537

538+
def test_agent__call__emits_reduce_context_events(mock_model, agent, agenerator):
539+
"""Verify Before/AfterReduceContextEvent fire with correct metadata when overflow triggers reduction."""
540+
messages: Messages = [
541+
{"role": "user", "content": [{"text": "Hello!"}]},
542+
{"role": "assistant", "content": [{"text": "Hi!"}]},
543+
{"role": "user", "content": [{"text": "Whats your favorite color?"}]},
544+
{"role": "assistant", "content": [{"text": "Blue!"}]},
545+
]
546+
agent.messages = messages
547+
548+
before_events: list[BeforeReduceContextEvent] = []
549+
after_events: list[AfterReduceContextEvent] = []
550+
551+
agent.hooks.add_callback(BeforeReduceContextEvent, before_events.append)
552+
agent.hooks.add_callback(AfterReduceContextEvent, after_events.append)
553+
554+
trigger_exception = ContextWindowOverflowException(RuntimeError("Input is too long for requested model"))
555+
mock_model.mock_stream.side_effect = [
556+
trigger_exception,
557+
agenerator(
558+
[
559+
{"contentBlockStart": {"start": {}}},
560+
{"contentBlockDelta": {"delta": {"text": "Green!"}}},
561+
{"contentBlockStop": {}},
562+
{"messageStop": {"stopReason": "end_turn"}},
563+
]
564+
),
565+
]
566+
567+
agent("And now?")
568+
569+
assert len(before_events) == 1
570+
assert len(after_events) == 1
571+
572+
before_event = before_events[0]
573+
assert before_event.agent is agent
574+
assert before_event.exception is trigger_exception
575+
# Before reduction runs, the prompt "And now?" has already been appended to messages (5 total).
576+
assert before_event.message_count == 5
577+
578+
after_event = after_events[0]
579+
assert after_event.agent is agent
580+
assert after_event.exception is trigger_exception
581+
assert after_event.message_count_before == 5
582+
assert after_event.message_count_after < after_event.message_count_before
583+
assert after_event.messages_removed == after_event.message_count_before - after_event.message_count_after
584+
assert after_event.messages_removed > 0
585+
586+
587+
def test_agent__call__no_reduce_context_events_on_success(mock_model, agent, agenerator):
588+
"""Verify reduce-context events are NOT fired on a normal successful invocation."""
589+
before_events: list[BeforeReduceContextEvent] = []
590+
after_events: list[AfterReduceContextEvent] = []
591+
592+
agent.hooks.add_callback(BeforeReduceContextEvent, before_events.append)
593+
agent.hooks.add_callback(AfterReduceContextEvent, after_events.append)
594+
595+
mock_model.mock_stream.return_value = agenerator(
596+
[
597+
{"contentBlockStart": {"start": {}}},
598+
{"contentBlockDelta": {"delta": {"text": "ok"}}},
599+
{"contentBlockStop": {}},
600+
{"messageStop": {"stopReason": "end_turn"}},
601+
]
602+
)
603+
604+
agent("Hello?")
605+
606+
assert before_events == []
607+
assert after_events == []
608+
609+
532610
def test_agent__call__always_sliding_window_conversation_manager_doesnt_infinite_loop(mock_model, agent, tool):
533611
conversation_manager = SlidingWindowConversationManager(window_size=500, should_truncate_results=False)
534612
conversation_manager_spy = unittest.mock.Mock(wraps=conversation_manager)

tests/strands/hooks/test_events.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Tests for multi-agent execution lifecycle events."""
1+
"""Tests for agent and multi-agent execution lifecycle events."""
22

33
from unittest.mock import Mock
44

@@ -7,9 +7,12 @@
77
from strands.hooks import (
88
AfterMultiAgentInvocationEvent,
99
AfterNodeCallEvent,
10+
AfterReduceContextEvent,
1011
BaseHookEvent,
1112
BeforeMultiAgentInvocationEvent,
1213
BeforeNodeCallEvent,
14+
BeforeReduceContextEvent,
15+
HookEvent,
1316
MultiAgentInitializedEvent,
1417
)
1518

@@ -105,3 +108,59 @@ def test_after_events_should_reverse_callbacks(orchestrator):
105108

106109
assert after_node_event.should_reverse_callbacks is True
107110
assert after_invocation_event.should_reverse_callbacks is True
111+
112+
113+
@pytest.fixture
114+
def agent():
115+
"""Mock agent for testing."""
116+
return Mock()
117+
118+
119+
def test_before_reduce_context_event_defaults(agent):
120+
"""BeforeReduceContextEvent has sensible defaults and inherits from HookEvent."""
121+
event = BeforeReduceContextEvent(agent=agent)
122+
123+
assert event.agent is agent
124+
assert event.exception is None
125+
assert event.message_count == 0
126+
assert event.should_reverse_callbacks is False
127+
assert isinstance(event, HookEvent)
128+
129+
130+
def test_before_reduce_context_event_with_fields(agent):
131+
"""BeforeReduceContextEvent carries the trigger exception and message count."""
132+
exc = RuntimeError("overflow")
133+
event = BeforeReduceContextEvent(agent=agent, exception=exc, message_count=12)
134+
135+
assert event.exception is exc
136+
assert event.message_count == 12
137+
138+
139+
def test_after_reduce_context_event_defaults(agent):
140+
"""AfterReduceContextEvent has sensible defaults and runs callbacks in reverse."""
141+
event = AfterReduceContextEvent(agent=agent)
142+
143+
assert event.agent is agent
144+
assert event.exception is None
145+
assert event.messages_removed == 0
146+
assert event.message_count_before == 0
147+
assert event.message_count_after == 0
148+
assert event.should_reverse_callbacks is True
149+
assert isinstance(event, HookEvent)
150+
151+
152+
def test_after_reduce_context_event_with_fields(agent):
153+
"""AfterReduceContextEvent carries before/after counts and the original exception."""
154+
exc = RuntimeError("overflow")
155+
event = AfterReduceContextEvent(
156+
agent=agent,
157+
exception=exc,
158+
messages_removed=3,
159+
message_count_before=10,
160+
message_count_after=7,
161+
)
162+
163+
assert event.exception is exc
164+
assert event.messages_removed == 3
165+
assert event.message_count_before == 10
166+
assert event.message_count_after == 7

0 commit comments

Comments
 (0)