Skip to content

Commit 417ebea

Browse files
authored
fix: Fix broken converstaion with orphaned toolUse (#1123)
* fix: Fix broken converstaion with orphaned toolUse * fix: Address pr cmments
1 parent bed1b68 commit 417ebea

File tree

5 files changed

+398
-2
lines changed

5 files changed

+398
-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_missing_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_missing_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_missing_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_missing_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_missing_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: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2215,3 +2215,143 @@ 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] == {
2250+
"role": "user",
2251+
"content": [
2252+
{
2253+
"toolResult": {
2254+
"toolUseId": "orphaned-123",
2255+
"status": "error",
2256+
"content": [{"text": "Tool was interrupted."}],
2257+
}
2258+
}
2259+
],
2260+
}
2261+
2262+
2263+
def test_agent_fixes_multiple_orphaned_tool_uses(mock_model, agenerator):
2264+
"""Test that agent handles multiple orphaned toolUse messages."""
2265+
mock_model.mock_stream.return_value = agenerator(
2266+
[
2267+
{"messageStart": {"role": "assistant"}},
2268+
{"contentBlockStart": {"start": {"text": ""}}},
2269+
{"contentBlockDelta": {"delta": {"text": "Fixed multiple!"}}},
2270+
{"contentBlockStop": {}},
2271+
{"messageStop": {"stopReason": "end_turn"}},
2272+
]
2273+
)
2274+
2275+
messages = [
2276+
{
2277+
"role": "assistant",
2278+
"content": [
2279+
{
2280+
"toolUse": {
2281+
"toolUseId": "orphaned-123",
2282+
"name": "tool_decorated",
2283+
"input": {"random_string": "test1"},
2284+
}
2285+
},
2286+
{
2287+
"toolUse": {
2288+
"toolUseId": "orphaned-456",
2289+
"name": "tool_decorated",
2290+
"input": {"random_string": "test2"},
2291+
}
2292+
},
2293+
],
2294+
}
2295+
]
2296+
2297+
agent = Agent(model=mock_model, messages=messages)
2298+
agent("Continue")
2299+
2300+
# Should have toolResult for both toolUse IDs
2301+
assert agent.messages[1] == {
2302+
"role": "user",
2303+
"content": [
2304+
{
2305+
"toolResult": {
2306+
"toolUseId": "orphaned-123",
2307+
"status": "error",
2308+
"content": [{"text": "Tool was interrupted."}],
2309+
}
2310+
},
2311+
{
2312+
"toolResult": {
2313+
"toolUseId": "orphaned-456",
2314+
"status": "error",
2315+
"content": [{"text": "Tool was interrupted."}],
2316+
}
2317+
},
2318+
],
2319+
}
2320+
2321+
2322+
def test_agent_skips_fix_for_valid_conversation(mock_model, agenerator):
2323+
"""Test that agent doesn't modify valid toolUse/toolResult pairs."""
2324+
mock_model.mock_stream.return_value = agenerator(
2325+
[
2326+
{"messageStart": {"role": "assistant"}},
2327+
{"contentBlockStart": {"start": {"text": ""}}},
2328+
{"contentBlockDelta": {"delta": {"text": "No fix needed!"}}},
2329+
{"contentBlockStop": {}},
2330+
{"messageStop": {"stopReason": "end_turn"}},
2331+
]
2332+
)
2333+
2334+
# Valid conversation with toolUse followed by toolResult
2335+
messages = [
2336+
{
2337+
"role": "assistant",
2338+
"content": [
2339+
{"toolUse": {"toolUseId": "valid-123", "name": "tool_decorated", "input": {"random_string": "test"}}}
2340+
],
2341+
},
2342+
{
2343+
"role": "user",
2344+
"content": [
2345+
{"toolResult": {"toolUseId": "valid-123", "status": "success", "content": [{"text": "result"}]}}
2346+
],
2347+
},
2348+
]
2349+
2350+
agent = Agent(model=mock_model, messages=messages)
2351+
original_length = len(agent.messages)
2352+
2353+
agent("Continue")
2354+
2355+
# Should not have added any toolResult messages
2356+
# Only the new user message and assistant response should be added
2357+
assert len(agent.messages) == original_length + 2

0 commit comments

Comments
 (0)