Skip to content

Commit 003d6cc

Browse files
Unshureawsarron
authored andcommitted
feat: Update SlidingWindowConversationManager (strands-agents#120)
1 parent 1652dda commit 003d6cc

File tree

3 files changed

+39
-89
lines changed

3 files changed

+39
-89
lines changed

src/strands/agent/conversation_manager/sliding_window_conversation_manager.py

Lines changed: 21 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
"""Sliding window conversation history management."""
22

3-
import json
43
import logging
5-
from typing import List, Optional, cast
4+
from typing import Optional
65

7-
from ...types.content import ContentBlock, Message, Messages
6+
from ...types.content import Message, Messages
87
from ...types.exceptions import ContextWindowOverflowException
9-
from ...types.tools import ToolResult
108
from .conversation_manager import ConversationManager
119

1210
logger = logging.getLogger(__name__)
@@ -110,8 +108,9 @@ def _remove_dangling_messages(self, messages: Messages) -> None:
110108
def reduce_context(self, messages: Messages, e: Optional[Exception] = None) -> None:
111109
"""Trim the oldest messages to reduce the conversation context size.
112110
113-
The method handles special cases where tool results need to be converted to regular content blocks to maintain
114-
conversation coherence after trimming.
111+
The method handles special cases where trimming the messages leads to:
112+
- toolResult with no corresponding toolUse
113+
- toolUse with no corresponding toolResult
115114
116115
Args:
117116
messages: The messages to reduce.
@@ -126,52 +125,24 @@ def reduce_context(self, messages: Messages, e: Optional[Exception] = None) -> N
126125
# If the number of messages is less than the window_size, then we default to 2, otherwise, trim to window size
127126
trim_index = 2 if len(messages) <= self.window_size else len(messages) - self.window_size
128127

129-
# Throw if we cannot trim any messages from the conversation
130-
if trim_index >= len(messages):
131-
raise ContextWindowOverflowException("Unable to trim conversation context!") from e
132-
133-
# If the message at the cut index has ToolResultContent, then we map that to ContentBlock. This gets around the
134-
# limitation of needing ToolUse and ToolResults to be paired.
135-
if any("toolResult" in content for content in messages[trim_index]["content"]):
136-
if len(messages[trim_index]["content"]) == 1:
137-
messages[trim_index]["content"] = self._map_tool_result_content(
138-
cast(ToolResult, messages[trim_index]["content"][0]["toolResult"])
128+
# Find the next valid trim_index
129+
while trim_index < len(messages):
130+
if (
131+
# Oldest message cannot be a toolResult because it needs a toolUse preceding it
132+
any("toolResult" in content for content in messages[trim_index]["content"])
133+
or (
134+
# Oldest message can be a toolUse only if a toolResult immediately follows it.
135+
any("toolUse" in content for content in messages[trim_index]["content"])
136+
and trim_index + 1 < len(messages)
137+
and not any("toolResult" in content for content in messages[trim_index + 1]["content"])
139138
)
140-
141-
# If there is more content than just one ToolResultContent, then we cannot cut at this index.
139+
):
140+
trim_index += 1
142141
else:
143-
raise ContextWindowOverflowException("Unable to trim conversation context!") from e
142+
break
143+
else:
144+
# If we didn't find a valid trim_index, then we throw
145+
raise ContextWindowOverflowException("Unable to trim conversation context!") from e
144146

145147
# Overwrite message history
146148
messages[:] = messages[trim_index:]
147-
148-
def _map_tool_result_content(self, tool_result: ToolResult) -> List[ContentBlock]:
149-
"""Convert a ToolResult to a list of standard ContentBlocks.
150-
151-
This method transforms tool result content into standard content blocks that can be preserved when trimming the
152-
conversation history.
153-
154-
Args:
155-
tool_result: The ToolResult to convert.
156-
157-
Returns:
158-
A list of content blocks representing the tool result.
159-
"""
160-
contents = []
161-
text_content = "Tool Result Status: " + tool_result["status"] if tool_result["status"] else ""
162-
163-
for tool_result_content in tool_result["content"]:
164-
if "text" in tool_result_content:
165-
text_content = "\nTool Result Text Content: " + tool_result_content["text"] + f"\n{text_content}"
166-
elif "json" in tool_result_content:
167-
text_content = (
168-
"\nTool Result JSON Content: " + json.dumps(tool_result_content["json"]) + f"\n{text_content}"
169-
)
170-
elif "image" in tool_result_content:
171-
contents.append(ContentBlock(image=tool_result_content["image"]))
172-
elif "document" in tool_result_content:
173-
contents.append(ContentBlock(document=tool_result_content["document"]))
174-
else:
175-
logger.warning("unsupported content type")
176-
contents.append(ContentBlock(text=text_content))
177-
return contents

tests/strands/agent/test_agent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ def test_agent__call__always_sliding_window_conversation_manager_doesnt_infinite
459459
with pytest.raises(ContextWindowOverflowException):
460460
agent("Test!")
461461

462-
assert conversation_manager_spy.reduce_context.call_count == 251
462+
assert conversation_manager_spy.reduce_context.call_count > 0
463463
assert conversation_manager_spy.apply_management.call_count == 1
464464

465465

tests/strands/agent/test_conversation_manager.py

Lines changed: 17 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -111,41 +111,7 @@ def conversation_manager(request):
111111
{"role": "assistant", "content": [{"text": "Second response"}]},
112112
],
113113
),
114-
# 7 - Message count above max window size - Remove dangling tool uses and tool results
115-
(
116-
{"window_size": 1},
117-
[
118-
{"role": "user", "content": [{"text": "First message"}]},
119-
{"role": "assistant", "content": [{"toolUse": {"toolUseId": "321", "name": "tool1", "input": {}}}]},
120-
{
121-
"role": "user",
122-
"content": [
123-
{"toolResult": {"toolUseId": "123", "content": [{"text": "Hello!"}], "status": "success"}}
124-
],
125-
},
126-
],
127-
[
128-
{
129-
"role": "user",
130-
"content": [{"text": "\nTool Result Text Content: Hello!\nTool Result Status: success"}],
131-
},
132-
],
133-
),
134-
# 8 - Message count above max window size - Remove multiple tool use/tool result pairs
135-
(
136-
{"window_size": 1},
137-
[
138-
{"role": "user", "content": [{"toolResult": {"toolUseId": "123", "content": [], "status": "success"}}]},
139-
{"role": "assistant", "content": [{"toolUse": {"toolUseId": "123", "name": "tool1", "input": {}}}]},
140-
{"role": "user", "content": [{"toolResult": {"toolUseId": "456", "content": [], "status": "success"}}]},
141-
{"role": "assistant", "content": [{"toolUse": {"toolUseId": "456", "name": "tool1", "input": {}}}]},
142-
{"role": "user", "content": [{"toolResult": {"toolUseId": "789", "content": [], "status": "success"}}]},
143-
],
144-
[
145-
{"role": "user", "content": [{"text": "Tool Result Status: success"}]},
146-
],
147-
),
148-
# 9 - Message count above max window size - Preserve tool use/tool result pairs
114+
# 7 - Message count above max window size - Preserve tool use/tool result pairs
149115
(
150116
{"window_size": 2},
151117
[
@@ -158,7 +124,7 @@ def conversation_manager(request):
158124
{"role": "user", "content": [{"toolResult": {"toolUseId": "456", "content": [], "status": "success"}}]},
159125
],
160126
),
161-
# 10 - Test sliding window behavior - preserve tool use/result pairs across cut boundary
127+
# 8 - Test sliding window behavior - preserve tool use/result pairs across cut boundary
162128
(
163129
{"window_size": 3},
164130
[
@@ -173,7 +139,7 @@ def conversation_manager(request):
173139
{"role": "assistant", "content": [{"text": "Response after tool use"}]},
174140
],
175141
),
176-
# 11 - Test sliding window with multiple tool pairs that need preservation
142+
# 9 - Test sliding window with multiple tool pairs that need preservation
177143
(
178144
{"window_size": 4},
179145
[
@@ -185,7 +151,6 @@ def conversation_manager(request):
185151
{"role": "assistant", "content": [{"text": "Final response"}]},
186152
],
187153
[
188-
{"role": "user", "content": [{"text": "Tool Result Status: success"}]},
189154
{"role": "assistant", "content": [{"toolUse": {"toolUseId": "456", "name": "tool2", "input": {}}}]},
190155
{"role": "user", "content": [{"toolResult": {"toolUseId": "456", "content": [], "status": "success"}}]},
191156
{"role": "assistant", "content": [{"text": "Final response"}]},
@@ -200,6 +165,20 @@ def test_apply_management(conversation_manager, messages, expected_messages):
200165
assert messages == expected_messages
201166

202167

168+
def test_sliding_window_conversation_manager_with_untrimmable_history_raises_context_window_overflow_exception():
169+
manager = strands.agent.conversation_manager.SlidingWindowConversationManager(1)
170+
messages = [
171+
{"role": "assistant", "content": [{"toolUse": {"toolUseId": "456", "name": "tool1", "input": {}}}]},
172+
{"role": "user", "content": [{"toolResult": {"toolUseId": "789", "content": [], "status": "success"}}]},
173+
]
174+
original_messages = messages.copy()
175+
176+
with pytest.raises(ContextWindowOverflowException):
177+
manager.apply_management(messages)
178+
179+
assert messages == original_messages
180+
181+
203182
def test_null_conversation_manager_reduce_context_raises_context_window_overflow_exception():
204183
"""Test that NullConversationManager doesn't modify messages."""
205184
manager = strands.agent.conversation_manager.NullConversationManager()

0 commit comments

Comments
 (0)