Skip to content

Commit 250cd55

Browse files
author
Vincent Bai
committed
feat(hooks): emit Before/AfterReduceContextEvent from ConversationManager
Move event emission from the single overflow call site in agent.py into ConversationManager.reduce_context as a concrete template method. Subclasses implement _reduce_context; the framework wraps it with Before/After event emission so every call path (reactive overflow, proactive apply_management, per-turn, direct tool calls) gets events automatically. An __init_subclass__ shim detects third-party subclasses that override reduce_context directly, transparently re-wires them to _reduce_context, and emits a DeprecationWarning. Closes #2048
1 parent 6e208a8 commit 250cd55

10 files changed

Lines changed: 379 additions & 11 deletions

src/strands/agent/conversation_manager/conversation_manager.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
"""Abstract interface for conversation history management."""
22

3+
import logging
4+
import warnings
35
from abc import ABC, abstractmethod
46
from typing import TYPE_CHECKING, Any
57

8+
from ...hooks.events import AfterReduceContextEvent, BeforeReduceContextEvent
69
from ...hooks.registry import HookProvider, HookRegistry
710
from ...types.content import Message
811

912
if TYPE_CHECKING:
1013
from ...agent.agent import Agent
1114

15+
logger = logging.getLogger(__name__)
16+
1217

1318
class ConversationManager(ABC, HookProvider):
1419
"""Abstract base class for managing conversation history.
@@ -24,6 +29,10 @@ class ConversationManager(ABC, HookProvider):
2429
lifecycle events. Derived classes that override register_hooks must call the base implementation to ensure proper
2530
hook registration.
2631
32+
Subclasses should override ``_reduce_context`` (not ``reduce_context``) to implement their reduction strategy.
33+
The framework wraps ``_reduce_context`` with ``BeforeReduceContextEvent`` / ``AfterReduceContextEvent`` emission
34+
automatically.
35+
2736
Example:
2837
```python
2938
class MyConversationManager(ConversationManager):
@@ -33,6 +42,21 @@ def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
3342
```
3443
"""
3544

45+
def __init_subclass__(cls, **kwargs: Any) -> None:
46+
"""Detect legacy subclasses that override reduce_context directly and re-wire them."""
47+
super().__init_subclass__(**kwargs)
48+
if "reduce_context" in cls.__dict__ and "_reduce_context" not in cls.__dict__:
49+
warnings.warn(
50+
f"{cls.__name__} overrides reduce_context() directly. "
51+
f"This still works but the recommended pattern is to override _reduce_context(). "
52+
f"Before/AfterReduceContextEvent will continue to fire because the framework "
53+
f"wraps the override transparently.",
54+
DeprecationWarning,
55+
stacklevel=2,
56+
)
57+
cls._reduce_context = cls.__dict__["reduce_context"] # type: ignore[attr-defined]
58+
del cls.reduce_context
59+
3660
def __init__(self) -> None:
3761
"""Initialize the ConversationManager.
3862
@@ -97,12 +121,43 @@ def apply_management(self, agent: "Agent", **kwargs: Any) -> None:
97121
"""
98122
pass
99123

100-
@abstractmethod
101124
def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: Any) -> None:
102-
"""Called when the model's context window is exceeded.
125+
"""Reduce the conversation context, emitting Before/After hook events automatically.
126+
127+
This is a concrete template method. Subclasses should override ``_reduce_context`` instead.
128+
129+
Args:
130+
agent: The agent whose conversation history will be reduced.
131+
e: The exception that triggered the context reduction, if any.
132+
**kwargs: Additional keyword arguments for future extensibility.
133+
"""
134+
message_count_before = len(agent.messages)
135+
agent.hooks.invoke_callbacks(
136+
BeforeReduceContextEvent(
137+
agent=agent,
138+
exception=e,
139+
message_count=message_count_before,
140+
)
141+
)
142+
self._reduce_context(agent, e=e, **kwargs)
143+
message_count_after = len(agent.messages)
144+
agent.hooks.invoke_callbacks(
145+
AfterReduceContextEvent(
146+
agent=agent,
147+
exception=e,
148+
messages_removed=message_count_before - message_count_after,
149+
message_count_before=message_count_before,
150+
message_count_after=message_count_after,
151+
)
152+
)
153+
154+
@abstractmethod
155+
def _reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: Any) -> None:
156+
"""Subclass implementation of context reduction.
103157
104-
This method should implement the specific strategy for reducing the window size when a context overflow occurs.
105-
It is typically called after a ContextWindowOverflowException is caught.
158+
Called by the framework via ``reduce_context()``. Subclasses should not emit
159+
``BeforeReduceContextEvent`` / ``AfterReduceContextEvent`` themselves — the
160+
framework does that automatically.
106161
107162
Implementations might use strategies such as:
108163

src/strands/agent/conversation_manager/null_conversation_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def apply_management(self, agent: "Agent", **kwargs: Any) -> None:
2828
"""
2929
pass
3030

31-
def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: Any) -> None:
31+
def _reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: Any) -> None:
3232
"""Does not reduce context and raises an exception.
3333
3434
Args:

src/strands/agent/conversation_manager/sliding_window_conversation_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def apply_management(self, agent: "Agent", **kwargs: Any) -> None:
155155
return
156156
self.reduce_context(agent)
157157

158-
def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: Any) -> None:
158+
def _reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: Any) -> None:
159159
"""Trim the oldest messages to reduce the conversation context size.
160160
161161
The method handles special cases where trimming the messages leads to:

src/strands/agent/conversation_manager/summarizing_conversation_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def apply_management(self, agent: "Agent", **kwargs: Any) -> None:
123123
# No proactive management - summarization only happens on context overflow
124124
pass
125125

126-
def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: Any) -> None:
126+
def _reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: Any) -> None:
127127
"""Reduce context using summarization.
128128
129129
Args:

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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,58 @@ 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+
Fired whenever ``reduce_context()`` is about to run, regardless of trigger
316+
(reactive overflow exception, proactive sliding-window overflow, or any
317+
third-party manager's own logic).
318+
319+
Attributes:
320+
exception: The exception that triggered context reduction, if any.
321+
``None`` when reduction was triggered proactively (e.g. sliding-window overflow).
322+
message_count: The number of messages in the agent's conversation history
323+
immediately before reduction is applied.
324+
"""
325+
326+
exception: Exception | None = None
327+
message_count: int = 0
328+
329+
330+
@dataclass
331+
class AfterReduceContextEvent(HookEvent):
332+
"""Event triggered after the agent's conversation context has been reduced.
333+
334+
Fired only on successful completion of ``reduce_context()``. If the underlying
335+
reduction raises, no ``AfterReduceContextEvent`` is emitted; the exception
336+
propagates normally.
337+
338+
Note: This event uses reverse callback ordering, meaning callbacks registered
339+
later will be invoked first during cleanup.
340+
341+
Attributes:
342+
exception: The exception that triggered context reduction, if any.
343+
``None`` for proactive reductions.
344+
messages_removed: Number of messages removed during this reduction
345+
(``message_count_before - message_count_after``).
346+
message_count_before: Number of messages in the conversation history before
347+
reduction was applied.
348+
message_count_after: Number of messages in the conversation history after
349+
reduction completed.
350+
"""
351+
352+
exception: Exception | None = None
353+
messages_removed: int = 0
354+
message_count_before: int = 0
355+
message_count_after: int = 0
356+
357+
@property
358+
def should_reverse_callbacks(self) -> bool:
359+
"""True to invoke callbacks in reverse order."""
360+
return True
361+
362+
311363
# Multiagent hook events start here
312364
@dataclass
313365
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)

0 commit comments

Comments
 (0)