77 from ...agent .agent import Agent
88
99from ...hooks import BeforeModelCallEvent , HookRegistry
10- from ...types .content import Messages
10+ from ...types .content import ContentBlock , Messages
1111from ...types .exceptions import ContextWindowOverflowException
12+ from ...types .tools import ToolResultContent
1213from .conversation_manager import ConversationManager
1314
1415logger = logging .getLogger (__name__ )
1516
17+ _PRESERVE_CHARS = 200
18+
1619
1720class SlidingWindowConversationManager (ConversationManager ):
1821 """Implements a sliding window strategy for managing conversation history.
1922
2023 This class handles the logic of maintaining a conversation window that preserves tool usage pairs and avoids
2124 invalid window states.
2225
26+ When truncation is enabled (the default), large tool results are partially truncated, preserving the first
27+ and last 200 characters, and image blocks inside tool results are replaced with descriptive text placeholders.
28+ Truncation targets the oldest tool results first so the most relevant recent context is preserved as long
29+ as possible.
30+
2331 Supports proactive management during agent loop execution via the per_turn parameter.
2432 """
2533
26- def __init__ (self , window_size : int = 40 , should_truncate_results : bool = True , * , per_turn : bool | int = False ):
34+ def __init__ (
35+ self ,
36+ window_size : int = 40 ,
37+ should_truncate_results : bool = True ,
38+ * ,
39+ per_turn : bool | int = False ,
40+ ):
2741 """Initialize the sliding window conversation manager.
2842
2943 Args:
@@ -44,6 +58,9 @@ def __init__(self, window_size: int = 40, should_truncate_results: bool = True,
4458 Raises:
4559 ValueError: If per_turn is 0 or a negative integer.
4660 """
61+ if isinstance (per_turn , int ) and not isinstance (per_turn , bool ) and per_turn <= 0 :
62+ raise ValueError (f"per_turn must be a positive integer, True, or False, got { per_turn } " )
63+
4764 super ().__init__ ()
4865
4966 self .window_size = window_size
@@ -157,14 +174,14 @@ def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: A
157174 messages = agent .messages
158175
159176 # Try to truncate the tool result first
160- last_message_idx_with_tool_results = self ._find_last_message_with_tool_results (messages )
161- if last_message_idx_with_tool_results is not None and self .should_truncate_results :
177+ oldest_message_idx_with_tool_results = self ._find_oldest_message_with_tool_results (messages )
178+ if oldest_message_idx_with_tool_results is not None and self .should_truncate_results :
162179 logger .debug (
163- "message_index=<%s> | found message with tool results at index" , last_message_idx_with_tool_results
180+ "message_index=<%s> | found message with tool results at index" , oldest_message_idx_with_tool_results
164181 )
165- results_truncated = self ._truncate_tool_results (messages , last_message_idx_with_tool_results )
182+ results_truncated = self ._truncate_tool_results (messages , oldest_message_idx_with_tool_results )
166183 if results_truncated :
167- logger .debug ("message_index=<%s> | tool results truncated" , last_message_idx_with_tool_results )
184+ logger .debug ("message_index=<%s> | tool results truncated" , oldest_message_idx_with_tool_results )
168185 return
169186
170187 # Try to trim index id when tool result cannot be truncated anymore
@@ -197,10 +214,14 @@ def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: A
197214 messages [:] = messages [trim_index :]
198215
199216 def _truncate_tool_results (self , messages : Messages , msg_idx : int ) -> bool :
200- """Truncate tool results in a message to reduce context size.
217+ """Truncate tool results and replace image blocks in a message to reduce context size.
218+
219+ For text blocks within tool results, all blocks are partially truncated unless they
220+ have already been truncated. The first and last _PRESERVE_CHARS characters are kept,
221+ and the removed middle is replaced with a notice indicating how many characters were
222+ removed. The tool result status is not changed.
201223
202- When a message contains tool results that are too large for the model's context window, this function
203- replaces the content of those tool results with a simple error message.
224+ Image blocks nested inside tool result content are replaced with a short descriptive placeholder.
204225
205226 Args:
206227 messages: The conversation message history.
@@ -212,52 +233,82 @@ def _truncate_tool_results(self, messages: Messages, msg_idx: int) -> bool:
212233 if msg_idx >= len (messages ) or msg_idx < 0 :
213234 return False
214235
236+ def _image_placeholder (image_block : Any ) -> str :
237+ source : Any = image_block .get ("source" , {})
238+ media_type = image_block .get ("format" , "unknown" )
239+ data = source .get ("bytes" , b"" )
240+ return f"[image: { media_type } , { len (data ) if data else 0 } bytes]"
241+
215242 message = messages [msg_idx ]
216243 changes_made = False
217- tool_result_too_large_message = "The tool result was too large!"
218- for i , content in enumerate (message .get ("content" , [])):
219- if isinstance (content , dict ) and "toolResult" in content :
220- tool_result_content_text = next (
221- (item ["text" ] for item in content ["toolResult" ]["content" ] if "text" in item ),
222- "" ,
223- )
224- # make the overwriting logic togglable
225- if (
226- message ["content" ][i ]["toolResult" ]["status" ] == "error"
227- and tool_result_content_text == tool_result_too_large_message
228- ):
229- logger .info ("ToolResult has already been updated, skipping overwrite" )
230- return False
231- # Update status to error with informative message
232- message ["content" ][i ]["toolResult" ]["status" ] = "error"
233- message ["content" ][i ]["toolResult" ]["content" ] = [{"text" : tool_result_too_large_message }]
234- changes_made = True
244+ new_content : list [ContentBlock ] = []
245+
246+ for content in message .get ("content" , []):
247+ if "toolResult" in content :
248+ tool_result : Any = content ["toolResult" ]
249+ tool_result_items = tool_result .get ("content" , [])
250+ new_items : list [ToolResultContent ] = []
251+ item_changed = False
252+
253+ for item in tool_result_items :
254+ # Replace image items nested inside toolResult content
255+ if "image" in item :
256+ new_items .append ({"text" : _image_placeholder (item ["image" ])})
257+ item_changed = True
258+ continue
259+
260+ # Partially truncate text items that have not already been truncated
261+ if "text" in item :
262+ text = item ["text" ]
263+ truncation_marker = "... [truncated:"
264+ if truncation_marker not in text and len (text ) > 2 * _PRESERVE_CHARS :
265+ prefix = text [:_PRESERVE_CHARS ]
266+ suffix = text [- _PRESERVE_CHARS :]
267+ removed = len (text ) - 2 * _PRESERVE_CHARS
268+ truncated_text = (
269+ f"{ prefix } ...\n \n ... [truncated: { removed } chars removed] ...\n \n ...{ suffix } "
270+ )
271+ new_items .append ({"text" : truncated_text })
272+ item_changed = True
273+ continue
274+
275+ new_items .append (item )
276+
277+ if item_changed :
278+ updated_tool_result : Any = {
279+ ** {k : v for k , v in tool_result .items () if k != "content" },
280+ "content" : new_items ,
281+ }
282+ new_content .append ({"toolResult" : updated_tool_result })
283+ changes_made = True
284+ else :
285+ new_content .append (content )
286+ continue
287+
288+ new_content .append (content )
289+
290+ if changes_made :
291+ message ["content" ] = new_content
235292
236293 return changes_made
237294
238- def _find_last_message_with_tool_results (self , messages : Messages ) -> int | None :
239- """Find the index of the last message containing tool results.
295+ def _find_oldest_message_with_tool_results (self , messages : Messages ) -> int | None :
296+ """Find the index of the oldest message containing tool results.
240297
241- This is useful for identifying messages that might need to be truncated to reduce context size.
298+ Iterates from oldest to newest so that truncation targets the least-recent
299+ (and therefore least relevant) tool results first.
242300
243301 Args:
244302 messages: The conversation message history.
245303
246304 Returns:
247- Index of the last message with tool results, or None if no such message exists.
305+ Index of the oldest message with tool results, or None if no such message exists.
248306 """
249- # Iterate backwards through all messages (from newest to oldest)
250- for idx in range (len (messages ) - 1 , - 1 , - 1 ):
251- # Check if this message has any content with toolResult
307+ # Iterate from oldest to newest
308+ for idx in range (len (messages )):
252309 current_message = messages [idx ]
253- has_tool_result = False
254-
255310 for content in current_message .get ("content" , []):
256311 if isinstance (content , dict ) and "toolResult" in content :
257- has_tool_result = True
258- break
259-
260- if has_tool_result :
261- return idx
312+ return idx
262313
263314 return None
0 commit comments