Skip to content

Commit abcf2ed

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

File tree

5 files changed

+356
-2
lines changed

5 files changed

+356
-2
lines changed

src/strands/agent/agent.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from .. import _identifier
3434
from .._async import run_async
3535
from ..event_loop.event_loop import event_loop_cycle
36+
from ..tools._tool_helpers import generate_interrupted_tool_result_content
3637

3738
if TYPE_CHECKING:
3839
from ..experimental.tools import ToolProvider
@@ -280,7 +281,7 @@ def __init__(
280281
Defaults to None.
281282
session_manager: Manager for handling agent sessions including conversation history and state.
282283
If provided, enables session-based persistence and state management.
283-
tool_executor: Definition of tool execution stragety (e.g., sequential, concurrent, etc.).
284+
tool_executor: Definition of tool execution strategy (e.g., sequential, concurrent, etc.).
284285
285286
Raises:
286287
ValueError: If agent id contains path separators.
@@ -816,6 +817,21 @@ def _convert_prompt_to_messages(self, prompt: AgentInput) -> Messages:
816817

817818
messages: Messages | None = None
818819
if prompt is not None:
820+
# Check if the latest message is toolUse
821+
if len(self.messages) > 0 and any("toolUse" in content for content in self.messages[-1]["content"]):
822+
# Add toolResult message after to have a valid conversation
823+
logger.info(
824+
"Agents latest message is toolUse, appending a toolResult message to have valid conversation."
825+
)
826+
tool_use_ids = [
827+
content["toolUse"]["toolUseId"] for content in self.messages[-1]["content"] if "toolUse" in content
828+
]
829+
self._append_message(
830+
{
831+
"role": "user",
832+
"content": generate_interrupted_tool_result_content(tool_use_ids),
833+
}
834+
)
819835
if isinstance(prompt, str):
820836
# String input - convert to user message
821837
messages = [{"role": "user", "content": [{"text": prompt}]}]

src/strands/session/repository_session_manager.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import TYPE_CHECKING, Any, Optional
55

66
from ..agent.state import AgentState
7+
from ..tools._tool_helpers import generate_interrupted_tool_result_content
78
from ..types.content import Message
89
from ..types.exceptions import SessionException
910
from ..types.session import (
@@ -159,6 +160,50 @@ def initialize(self, agent: "Agent", **kwargs: Any) -> None:
159160
# Restore the agents messages array including the optional prepend messages
160161
agent.messages = prepend_messages + [session_message.to_message() for session_message in session_messages]
161162

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

src/strands/tools/_tool_helpers.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Helpers for tools."""
22

3-
from strands.tools.decorator import tool
3+
from ..tools.decorator import tool
4+
from ..types.content import ContentBlock
45

56

67
# https://github.com/strands-agents/sdk-python/issues/998
@@ -13,3 +14,17 @@ def noop_tool() -> None:
1314
summarization will fail. As a workaround, we register the no-op tool.
1415
"""
1516
pass
17+
18+
19+
def generate_interrupted_tool_result_content(tool_use_ids: list[str]) -> list[ContentBlock]:
20+
"""Generate ToolResult content blocks for orphaned ToolUse message."""
21+
return [
22+
{
23+
"toolResult": {
24+
"toolUseId": tool_use_id,
25+
"status": "error",
26+
"content": [{"text": "Tool was interrupted."}],
27+
}
28+
}
29+
for tool_use_id in tool_use_ids
30+
]

tests/strands/agent/test_agent.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2215,3 +2215,125 @@ 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+
[
2224+
{"messageStart": {"role": "assistant"}},
2225+
{"contentBlockStart": {"start": {"text": ""}}},
2226+
{"contentBlockDelta": {"delta": {"text": "Fixed!"}}},
2227+
{"contentBlockStop": {}},
2228+
{"messageStop": {"stopReason": "end_turn"}},
2229+
]
2230+
)
2231+
2232+
# Start with orphaned toolUse message
2233+
messages = [
2234+
{
2235+
"role": "assistant",
2236+
"content": [
2237+
{"toolUse": {"toolUseId": "orphaned-123", "name": "tool_decorated", "input": {"random_string": "test"}}}
2238+
],
2239+
}
2240+
]
2241+
2242+
agent = Agent(model=mock_model, messages=messages)
2243+
2244+
# Call with new prompt should fix orphaned toolUse
2245+
agent("Continue conversation")
2246+
2247+
# Should have added toolResult message
2248+
assert len(agent.messages) >= 3
2249+
assert agent.messages[1]["role"] == "user"
2250+
assert "toolResult" in agent.messages[1]["content"][0]
2251+
assert agent.messages[1]["content"][0]["toolResult"]["toolUseId"] == "orphaned-123"
2252+
assert agent.messages[1]["content"][0]["toolResult"]["status"] == "error"
2253+
assert agent.messages[1]["content"][0]["toolResult"]["content"][0]["text"] == "Tool was interrupted."
2254+
2255+
2256+
def test_agent_fixes_multiple_orphaned_tool_uses(mock_model, agenerator):
2257+
"""Test that agent handles multiple orphaned toolUse messages."""
2258+
mock_model.mock_stream.return_value = agenerator(
2259+
[
2260+
{"messageStart": {"role": "assistant"}},
2261+
{"contentBlockStart": {"start": {"text": ""}}},
2262+
{"contentBlockDelta": {"delta": {"text": "Fixed multiple!"}}},
2263+
{"contentBlockStop": {}},
2264+
{"messageStop": {"stopReason": "end_turn"}},
2265+
]
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(model=mock_model, messages=messages)
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+
[
2308+
{"messageStart": {"role": "assistant"}},
2309+
{"contentBlockStart": {"start": {"text": ""}}},
2310+
{"contentBlockDelta": {"delta": {"text": "No fix needed!"}}},
2311+
{"contentBlockStop": {}},
2312+
{"messageStop": {"stopReason": "end_turn"}},
2313+
]
2314+
)
2315+
2316+
# Valid conversation with toolUse followed by toolResult
2317+
messages = [
2318+
{
2319+
"role": "assistant",
2320+
"content": [
2321+
{"toolUse": {"toolUseId": "valid-123", "name": "tool_decorated", "input": {"random_string": "test"}}}
2322+
],
2323+
},
2324+
{
2325+
"role": "user",
2326+
"content": [
2327+
{"toolResult": {"toolUseId": "valid-123", "status": "success", "content": [{"text": "result"}]}}
2328+
],
2329+
},
2330+
]
2331+
2332+
agent = Agent(model=mock_model, messages=messages)
2333+
original_length = len(agent.messages)
2334+
2335+
agent("Continue")
2336+
2337+
# Should not have added any toolResult messages
2338+
# Only the new user message and assistant response should be added
2339+
assert len(agent.messages) == original_length + 2

0 commit comments

Comments
 (0)