Skip to content

Commit 0b1409c

Browse files
committed
fix: Fix broken converstaion with orphaned toolUse
1 parent db671ba commit 0b1409c

File tree

4 files changed

+420
-1
lines changed

4 files changed

+420
-1
lines changed

src/strands/agent/agent.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ def __init__(
280280
Defaults to None.
281281
session_manager: Manager for handling agent sessions including conversation history and state.
282282
If provided, enables session-based persistence and state management.
283-
tool_executor: Definition of tool execution stragety (e.g., sequential, concurrent, etc.).
283+
tool_executor: Definition of tool execution strategy (e.g., sequential, concurrent, etc.).
284284
285285
Raises:
286286
ValueError: If agent id contains path separators.
@@ -816,6 +816,27 @@ def _convert_prompt_to_messages(self, prompt: AgentInput) -> Messages:
816816

817817
messages: Messages | None = None
818818
if prompt is not None:
819+
# Check if the latest message is toolUse
820+
if len(self.messages) > 0 and any("toolUse" in content for content in self.messages[-1]["content"]):
821+
# Add toolResult message after to have a valid conversation
822+
tool_use_ids = [
823+
content["toolUse"]["toolUseId"] for content in self.messages[-1]["content"] if "toolUse" in content
824+
]
825+
self._append_message(
826+
{
827+
"role": "user",
828+
"content": [
829+
{
830+
"toolResult": {
831+
"toolUseId": tool_use_id,
832+
"status": "error",
833+
"content": [{"text": "Tool was interrupted."}],
834+
}
835+
}
836+
for tool_use_id in tool_use_ids
837+
],
838+
}
839+
)
819840
if isinstance(prompt, str):
820841
# String input - convert to user message
821842
messages = [{"role": "user", "content": [{"text": prompt}]}]

src/strands/session/repository_session_manager.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,54 @@ def initialize(self, agent: "Agent", **kwargs: Any) -> None:
159159
# Restore the agents messages array including the optional prepend messages
160160
agent.messages = prepend_messages + [session_message.to_message() for session_message in session_messages]
161161

162+
# Fix broken session histories: https://github.com/strands-agents/sdk-python/issues/859
163+
agent.messages = self._fix_broken_tool_use(agent.messages)
164+
165+
def _fix_broken_tool_use(self, messages: list[Message]) -> list[Message]:
166+
"""Add tool_result after orphaned tool_use messages.
167+
168+
Before 1.15.0, strands had a bug where they persisted sessions with a potentially broken messages array.
169+
This method retroactively fixes that issue by adding a tool_result outside of session management. After 1.15.0,
170+
this bug is no longer present.
171+
"""
172+
for index, message in enumerate(messages):
173+
# Check all but the latest message in the messages array
174+
if index + 1 < len(messages):
175+
if any("toolUse" in content for content in message["content"]):
176+
tool_use_ids = [
177+
content["toolUse"]["toolUseId"] for content in message["content"] if "toolUse" in content
178+
]
179+
180+
# Check if there are more messages after the current toolUse message
181+
tool_result_ids = [
182+
content["toolResult"]["toolUseId"]
183+
for content in messages[index + 1]["content"]
184+
if "toolResult" in content
185+
]
186+
187+
missing_tool_use_ids = list(set(tool_use_ids) - set(tool_result_ids))
188+
# If there area missing tool use ids, that means the messages history is broken
189+
if missing_tool_use_ids:
190+
# Create the missing toolResult content blocks
191+
missing_content_blocks = [
192+
{
193+
"toolResult": {
194+
"toolUseId": missing_tool_use_id,
195+
"content": [{"text": "Tool execution interrupted."}],
196+
"status": "error",
197+
}
198+
}
199+
for missing_tool_use_id in missing_tool_use_ids
200+
]
201+
202+
if tool_result_ids:
203+
# If there were any toolResult ids, that means only some of the content blocks are missing
204+
messages[index + 1]["content"].extend(missing_content_blocks)
205+
else:
206+
# The message following the toolUse was not a toolResult, so lets insert it
207+
messages.insert(index + 1, {"role": "user", "content": missing_content_blocks})
208+
return messages
209+
162210
def sync_multi_agent(self, source: "MultiAgentBase", **kwargs: Any) -> None:
163211
"""Serialize and update the multi-agent state into the session repository.
164212

tests/strands/agent/test_agent.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2215,3 +2215,131 @@ def test_redact_user_content(content, expected):
22152215
agent = Agent()
22162216
result = agent._redact_user_content(content, "REDACTED")
22172217
assert result == expected
2218+
2219+
2220+
def test_agent_fixes_orphaned_tool_use_on_new_prompt(mock_model, agenerator):
2221+
"""Test that agent adds toolResult for orphaned toolUse when called with new prompt."""
2222+
mock_model.mock_stream.return_value = agenerator([
2223+
{"messageStart": {"role": "assistant"}},
2224+
{"contentBlockStart": {"start": {"text": ""}}},
2225+
{"contentBlockDelta": {"delta": {"text": "Fixed!"}}},
2226+
{"contentBlockStop": {}},
2227+
{"messageStop": {"stopReason": "end_turn"}},
2228+
])
2229+
2230+
# Start with orphaned toolUse message
2231+
messages = [
2232+
{
2233+
"role": "assistant",
2234+
"content": [{
2235+
"toolUse": {
2236+
"toolUseId": "orphaned-123",
2237+
"name": "tool_decorated",
2238+
"input": {"random_string": "test"}
2239+
}
2240+
}]
2241+
}
2242+
]
2243+
2244+
agent = Agent(messages=messages, tools=[tool_decorated])
2245+
2246+
# Call with new prompt should fix orphaned toolUse
2247+
agent("Continue conversation")
2248+
2249+
# Should have added toolResult message
2250+
assert len(agent.messages) >= 3
2251+
assert agent.messages[1]["role"] == "user"
2252+
assert "toolResult" in agent.messages[1]["content"][0]
2253+
assert agent.messages[1]["content"][0]["toolResult"]["toolUseId"] == "orphaned-123"
2254+
assert agent.messages[1]["content"][0]["toolResult"]["status"] == "error"
2255+
assert agent.messages[1]["content"][0]["toolResult"]["content"][0]["text"] == "Tool was interrupted."
2256+
2257+
2258+
def test_agent_fixes_multiple_orphaned_tool_uses(mock_model, agenerator):
2259+
"""Test that agent handles multiple orphaned toolUse messages."""
2260+
mock_model.mock_stream.return_value = agenerator([
2261+
{"messageStart": {"role": "assistant"}},
2262+
{"contentBlockStart": {"start": {"text": ""}}},
2263+
{"contentBlockDelta": {"delta": {"text": "Fixed multiple!"}}},
2264+
{"contentBlockStop": {}},
2265+
{"messageStop": {"stopReason": "end_turn"}},
2266+
])
2267+
2268+
messages = [
2269+
{
2270+
"role": "assistant",
2271+
"content": [
2272+
{
2273+
"toolUse": {
2274+
"toolUseId": "orphaned-123",
2275+
"name": "tool_decorated",
2276+
"input": {"random_string": "test1"}
2277+
}
2278+
},
2279+
{
2280+
"toolUse": {
2281+
"toolUseId": "orphaned-456",
2282+
"name": "tool_decorated",
2283+
"input": {"random_string": "test2"}
2284+
}
2285+
}
2286+
]
2287+
}
2288+
]
2289+
2290+
agent = Agent(messages=messages, tools=[tool_decorated])
2291+
agent("Continue")
2292+
2293+
# Should have toolResult for both toolUse IDs
2294+
tool_results = agent.messages[1]["content"]
2295+
assert len(tool_results) == 2
2296+
tool_use_ids = {tr["toolResult"]["toolUseId"] for tr in tool_results}
2297+
assert tool_use_ids == {"orphaned-123", "orphaned-456"}
2298+
2299+
for tool_result in tool_results:
2300+
assert tool_result["toolResult"]["status"] == "error"
2301+
assert tool_result["toolResult"]["content"][0]["text"] == "Tool was interrupted."
2302+
2303+
2304+
def test_agent_skips_fix_for_valid_conversation(mock_model, agenerator):
2305+
"""Test that agent doesn't modify valid toolUse/toolResult pairs."""
2306+
mock_model.mock_stream.return_value = agenerator([
2307+
{"messageStart": {"role": "assistant"}},
2308+
{"contentBlockStart": {"start": {"text": ""}}},
2309+
{"contentBlockDelta": {"delta": {"text": "No fix needed!"}}},
2310+
{"contentBlockStop": {}},
2311+
{"messageStop": {"stopReason": "end_turn"}},
2312+
])
2313+
2314+
# Valid conversation with toolUse followed by toolResult
2315+
messages = [
2316+
{
2317+
"role": "assistant",
2318+
"content": [{
2319+
"toolUse": {
2320+
"toolUseId": "valid-123",
2321+
"name": "tool_decorated",
2322+
"input": {"random_string": "test"}
2323+
}
2324+
}]
2325+
},
2326+
{
2327+
"role": "user",
2328+
"content": [{
2329+
"toolResult": {
2330+
"toolUseId": "valid-123",
2331+
"status": "success",
2332+
"content": [{"text": "result"}]
2333+
}
2334+
}]
2335+
}
2336+
]
2337+
2338+
agent = Agent(messages=messages, tools=[tool_decorated])
2339+
original_length = len(agent.messages)
2340+
2341+
agent("Continue")
2342+
2343+
# Should not have added any toolResult messages
2344+
# Only the new user message and assistant response should be added
2345+
assert len(agent.messages) == original_length + 2

0 commit comments

Comments
 (0)