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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from ...agent.agent import Agent

from ...types.exceptions import ContextWindowOverflowException
from ...hooks import BeforeReduceContextEvent
from .conversation_manager import ConversationManager


Expand Down Expand Up @@ -40,6 +41,9 @@ def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: A
e: If provided.
ContextWindowOverflowException: If e is None.
"""
# Fire before event
agent.hooks.invoke_callbacks(BeforeReduceContextEvent(agent=agent, exception=e))

if e:
raise e
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
if TYPE_CHECKING:
from ...agent.agent import Agent

from ...hooks import BeforeModelCallEvent, HookRegistry
from ...hooks import BeforeModelCallEvent, BeforeReduceContextEvent, AfterReduceContextEvent, HookRegistry
from ...types.content import ContentBlock, Messages
from ...types.exceptions import ContextWindowOverflowException
from ...types.tools import ToolResultContent
Expand Down Expand Up @@ -171,6 +171,9 @@ def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: A
Such as when the conversation is already minimal or when tool result messages cannot be properly
converted.
"""
# Fire before event
agent.hooks.invoke_callbacks(BeforeReduceContextEvent(agent=agent, exception=e))

messages = agent.messages

# Try to truncate the tool result first
Expand Down Expand Up @@ -213,6 +216,9 @@ def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: A
# Overwrite message history
messages[:] = messages[trim_index:]

# Fire after event
agent.hooks.invoke_callbacks(AfterReduceContextEvent(agent=agent))

def _truncate_tool_results(self, messages: Messages, msg_idx: int) -> bool:
"""Truncate tool results and replace image blocks in a message to reduce context size.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ...types.exceptions import ContextWindowOverflowException
from ...types.tools import AgentTool
from .conversation_manager import ConversationManager
from ...hooks import BeforeReduceContextEvent, AfterReduceContextEvent

if TYPE_CHECKING:
from ..agent import Agent
Expand Down Expand Up @@ -135,6 +136,9 @@ def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: A
Raises:
ContextWindowOverflowException: If the context cannot be summarized.
"""
# Fire before event
agent.hooks.invoke_callbacks(BeforeReduceContextEvent(agent=agent, exception=e))

try:
# Calculate how many messages to summarize
messages_to_summarize_count = max(1, int(len(agent.messages) * self.summary_ratio))
Expand Down Expand Up @@ -171,6 +175,9 @@ def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: A
# Replace the summarized messages with the summary
agent.messages[:] = [self._summary_message] + remaining_messages

# Fire after event
agent.hooks.invoke_callbacks(AfterReduceContextEvent(agent=agent))

except Exception as summarization_error:
logger.error("Summarization failed: %s", summarization_error)
raise summarization_error from e
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