11"""Abstract interface for conversation history management."""
22
3+ import logging
34from abc import ABC , abstractmethod
45from typing import TYPE_CHECKING , Any
56
7+ from ...hooks .events import BeforeModelCallEvent
68from ...hooks .registry import HookProvider , HookRegistry
79from ...types .content import Message
810
911if TYPE_CHECKING :
1012 from ...agent .agent import Agent
1113
14+ logger = logging .getLogger (__name__ )
15+
16+ DEFAULT_CONTEXT_WINDOW_LIMIT = 200_000
17+
1218
1319class ConversationManager (ABC , HookProvider ):
1420 """Abstract base class for managing conversation history.
@@ -24,6 +30,11 @@ class ConversationManager(ABC, HookProvider):
2430 lifecycle events. Derived classes that override register_hooks must call the base implementation to ensure proper
2531 hook registration.
2632
33+ Optionally, a manager can enable proactive compression by setting ``compression_threshold``
34+ in the constructor. When set, the base class registers a ``BeforeModelCallEvent`` hook that
35+ checks projected input tokens against the model's context window limit and calls
36+ :meth:`reduce_on_threshold` when the threshold is exceeded.
37+
2738 Example:
2839 ```python
2940 class MyConversationManager(ConversationManager):
@@ -33,34 +44,124 @@ def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
3344 ```
3445 """
3546
36- def __init__ (self ) -> None :
47+ def __init__ (self , * , compression_threshold : float | None = None ) -> None :
3748 """Initialize the ConversationManager.
3849
50+ Args:
51+ compression_threshold: Ratio of context window usage that triggers proactive compression.
52+ Value between 0 (exclusive) and 1 (inclusive). For example, 0.7 means compress when 70%
53+ of the context window is used. When not set, proactive compression is disabled and only
54+ reactive overflow recovery is used.
55+
56+ Raises:
57+ ValueError: If compression_threshold is not in the valid range (0, 1].
58+
3959 Attributes:
4060 removed_message_count: The messages that have been removed from the agents messages array.
4161 These represent messages provided by the user or LLM that have been removed, not messages
4262 included by the conversation manager through something like summarization.
4363 """
64+ if compression_threshold is not None and (compression_threshold <= 0 or compression_threshold > 1 ):
65+ raise ValueError (
66+ f"compression_threshold must be between 0 (exclusive) and 1 (inclusive), got { compression_threshold } "
67+ )
68+
4469 self .removed_message_count = 0
70+ self ._compression_threshold = compression_threshold
71+ self ._context_window_limit_warned = False
72+
73+ def reduce_on_threshold (self , agent : "Agent" , ** kwargs : Any ) -> bool :
74+ """Proactively reduce the conversation history before a model call.
75+
76+ Called when projected input tokens exceed the configured compression_threshold
77+ of the model's context window limit. Subclasses implement this to reduce
78+ context before the model call, avoiding overflow errors.
79+
80+ The base class catches any exceptions raised by this method and logs them
81+ at debug level, so subclass implementations do not need to defensively
82+ swallow errors — they can let them propagate. When an exception occurs,
83+ the return value is never observed by the caller.
84+
85+ The default implementation returns False. Subclasses that support proactive
86+ compression should override this method.
87+
88+ Args:
89+ agent: The agent whose conversation history will be reduced.
90+ The agent's messages list should be modified in-place.
91+ **kwargs: Additional keyword arguments for future extensibility.
92+
93+ Returns:
94+ True if the history was reduced, False otherwise. Only observed on success;
95+ if the method raises, the base class catches the exception and the return
96+ value is ignored.
97+ """
98+ return False
4599
46100 def register_hooks (self , registry : HookRegistry , ** kwargs : Any ) -> None :
47101 """Register hooks for agent lifecycle events.
48102
103+ When ``compression_threshold`` is configured and the subclass overrides
104+ ``reduce_on_threshold``, registers a ``BeforeModelCallEvent`` hook for
105+ proactive compression.
106+
49107 Derived classes that override this method must call the base implementation to ensure proper hook
50108 registration chain.
51109
52110 Args:
53111 registry: The hook registry to register callbacks with.
54112 **kwargs: Additional keyword arguments for future extensibility.
113+ """
114+ if self ._compression_threshold is None :
115+ return
55116
56- Example:
57- ```python
58- def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
59- super().register_hooks(registry, **kwargs)
60- registry.add_callback(SomeEvent, self.on_some_event)
61- ```
117+ # Check if the subclass actually overrides reduce_on_threshold
118+ has_override = type (self ).reduce_on_threshold is not ConversationManager .reduce_on_threshold
119+ if not has_override :
120+ logger .warning (
121+ "conversation_manager=<%s> | compression_threshold is configured but reduce_on_threshold is not"
122+ " implemented, proactive compression is disabled" ,
123+ type (self ).__name__ ,
124+ )
125+ return
126+
127+ registry .add_callback (BeforeModelCallEvent , self ._on_before_model_call_threshold )
128+
129+ def _on_before_model_call_threshold (self , event : BeforeModelCallEvent ) -> None :
130+ """Handle BeforeModelCallEvent for proactive compression.
131+
132+ Args:
133+ event: The before model call event.
62134 """
63- pass
135+ context_window_limit = event .agent .model .context_window_limit
136+ if context_window_limit is None :
137+ context_window_limit = DEFAULT_CONTEXT_WINDOW_LIMIT
138+ if not self ._context_window_limit_warned :
139+ self ._context_window_limit_warned = True
140+ logger .warning (
141+ "context_window_limit=<None>, default=<%s>"
142+ " | context_window_limit is not set on the model, using default"
143+ " | set context_window_limit in your model config for accurate threshold checks" ,
144+ DEFAULT_CONTEXT_WINDOW_LIMIT ,
145+ )
146+
147+ if event .projected_input_tokens is None :
148+ logger .debug ("projected_input_tokens=<None> | skipping proactive compression" )
149+ return
150+
151+ ratio = event .projected_input_tokens / context_window_limit
152+ if ratio >= self ._compression_threshold : # type: ignore[operator]
153+ logger .debug (
154+ "projected_tokens=<%s>, limit=<%s>, ratio=<%.2f>, compression_threshold=<%s>"
155+ " | compression threshold exceeded, reducing context" ,
156+ event .projected_input_tokens ,
157+ context_window_limit ,
158+ ratio ,
159+ self ._compression_threshold ,
160+ )
161+ try :
162+ self .reduce_on_threshold (agent = event .agent )
163+ except Exception :
164+ logger .debug ("proactive compression failed, will proceed with model call" , exc_info = True )
64165
65166 def restore_from_session (self , state : dict [str , Any ]) -> list [Message ] | None :
66167 """Restore the Conversation Manager's state from a session.
0 commit comments