Skip to content

Commit 1a3b429

Browse files
authored
feat(conversation-manager): improve tool result truncation strategy (#1756)
1 parent c50457d commit 1a3b429

3 files changed

Lines changed: 290 additions & 66 deletions

File tree

src/strands/agent/conversation_manager/sliding_window_conversation_manager.py

Lines changed: 93 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,37 @@
77
from ...agent.agent import Agent
88

99
from ...hooks import BeforeModelCallEvent, HookRegistry
10-
from ...types.content import Messages
10+
from ...types.content import ContentBlock, Messages
1111
from ...types.exceptions import ContextWindowOverflowException
12+
from ...types.tools import ToolResultContent
1213
from .conversation_manager import ConversationManager
1314

1415
logger = logging.getLogger(__name__)
1516

17+
_PRESERVE_CHARS = 200
18+
1619

1720
class 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

tests/strands/agent/test_agent.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ def test_agent__call__retry_with_overwritten_tool(mock_model, agent, tool, agene
621621
},
622622
},
623623
},
624-
{"contentBlockDelta": {"delta": {"toolUse": {"input": '{"random_string": "abcdEfghI123"}'}}}},
624+
{"contentBlockDelta": {"delta": {"toolUse": {"input": '{"random_string": "' + "X" * 500 + '"}'}}}},
625625
{"contentBlockStop": {}},
626626
{"messageStop": {"stopReason": "tool_use"}},
627627
]
@@ -635,12 +635,14 @@ def test_agent__call__retry_with_overwritten_tool(mock_model, agent, tool, agene
635635

636636
agent("test message")
637637

638+
large_input = "X" * 500
639+
truncated_text = large_input[:200] + "...\n\n... [truncated: 100 chars removed] ...\n\n..." + large_input[-200:]
638640
expected_messages = [
639641
{"role": "user", "content": [{"text": "test message"}]},
640642
{
641643
"role": "assistant",
642644
"content": [
643-
{"toolUse": {"toolUseId": "t1", "name": "tool_decorated", "input": {"random_string": "abcdEfghI123"}}}
645+
{"toolUse": {"toolUseId": "t1", "name": "tool_decorated", "input": {"random_string": large_input}}}
644646
],
645647
},
646648
{
@@ -649,8 +651,8 @@ def test_agent__call__retry_with_overwritten_tool(mock_model, agent, tool, agene
649651
{
650652
"toolResult": {
651653
"toolUseId": "t1",
652-
"status": "error",
653-
"content": [{"text": "The tool result was too large!"}],
654+
"status": "success",
655+
"content": [{"text": truncated_text}],
654656
}
655657
}
656658
],

0 commit comments

Comments
 (0)