Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions src/strands/session/repository_session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import TYPE_CHECKING, Any, Optional

from ..agent.state import AgentState
from ..tools._tool_helpers import generate_missing_tool_result_content
from ..tools._tool_helpers import generate_missing_tool_result_content, generate_missing_tool_use_content
from ..types.content import Message
from ..types.exceptions import SessionException
from ..types.session import (
Expand Down Expand Up @@ -164,12 +164,43 @@ def initialize(self, agent: "Agent", **kwargs: Any) -> None:
agent.messages = self._fix_broken_tool_use(agent.messages)

def _fix_broken_tool_use(self, messages: list[Message]) -> list[Message]:
"""Add tool_result after orphaned tool_use messages.
"""Fix broken tool use/result pairs in message history.

Before 1.15.0, strands had a bug where they persisted sessions with a potentially broken messages array.
This method retroactively fixes that issue by adding a tool_result outside of session management. After 1.15.0,
this bug is no longer present.
This method fixes two issues:
1. Orphaned toolUse messages without corresponding toolResult.
Before 1.15.0, strands had a bug where they persisted sessions with a potentially broken messages array.
This method retroactively fixes that issue by adding a tool_result outside of session management.
After 1.15.0, this bug is no longer present.
2. Orphaned toolResult messages without corresponding toolUse (e.g., when pagination truncates messages)

Args:
messages: The list of messages to fix
agent_id: The agent ID for fetching previous messages
removed_message_count: Number of messages removed by the conversation manager

Returns:
Fixed list of messages with proper tool use/result pairs
"""
# First, check if the first message has orphaned toolResult (no preceding toolUse)
if messages:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the bug report - Isn't the problem the opposite?

It's not that the latest message is broken; it's that the oldest message is broken - that's because we have N messages:

  • 55
  • 54
  • 53
  • 52
  • 51
  • 50
  • 49
  • 48

And 48 is a toolResult with 47 being toolUse, but it's not included

In this case messages[0] is 55, while messages[-1] == 48

And I think the fix in this case is to just not include 48

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So here's a full example:

from strands import Agent, tool


@tool
def get_user_location() -> str:
    """Get the user's location."""
    return "Seattle, USA"

MEM_ID='ComprehensiveAgentMemory_sep16-YDe9F76vbr'
ACTOR_ID='actor_id_test_20251201135435'
SESSION_ID='testing_session_id_20251201135435'


config = AgentCoreMemoryConfig(
    memory_id=MEM_ID,
    session_id=SESSION_ID,
    actor_id=ACTOR_ID,
)
session_manager = AgentCoreMemorySessionManager(config, region_name='us-east-1')

agent_1 = Agent(session_manager=session_manager, tools=[get_user_location])

Let's view the agent_1 messages:

agent_1.messages
[{'role': 'assistant',
  'content': [{'toolUse': {'toolUseId': 'tooluse_f09Y0LwyT2yteCYshTzb_Q',
     'name': 'unknown_tool',
     'input': {'error': 'Tool use was truncated due to message limit.'}}}]},
 {'role': 'user',
  'content': [{'toolResult': {'toolUseId': 'tooluse_f09Y0LwyT2yteCYshTzb_Q',
     'status': 'success',
     'content': [{'text': 'Seattle, USA'}]}}]},
 {'role': 'assistant', 'content': [{'text': 'You live in Seattle, USA.'}]},
 {'role': 'user', 'content': [{'text': 'I like pizza'}]},
 {'role': 'user', 'content': [{'text': 'where do I live?'}]},
 {'role': 'user', 'content': [{'text': 'where do I live?'}]},
 {'role': 'assistant',
  'content': [{'text': '\n\nI can see you mentioned that you like pizza again! As I mentioned before, you live in **Seattle, USA**. \n\nSeattle has a fantastic pizza scene! Are you looking for pizza recommendations in your area, or did you have a specific question about pizza places in Seattle?'}]},
 {'role': 'user', 'content': [{'text': 'I like pizza'}]}]

The session manager is replaying the conversation so the -1 message is the newest one (I like pizza) whereas the closer to 0 the further back we go.

Copy link
Member

@zastrowm zastrowm Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right; I was thinking of it backwards.

Still, can we remove the first message instead creating a fake one? IMHO it's better to trim than to synthesize

(and if you do ^, can we update the comment to indicate "check if the oldest message")

first_message = messages[0]
if first_message["role"] == "user" and any("toolResult" in content for content in first_message["content"]):
orphaned_tool_result_ids = [
content["toolResult"]["toolUseId"]
for content in first_message["content"]
if "toolResult" in content
]

if orphaned_tool_result_ids:
logger.warning(
"Session message history starts with orphaned toolResult(s) with no preceding toolUse. "
"This typically happens when messages are truncated due to pagination limits. "
"Adding dummy toolUse message to create valid conversation."
)
missing_tool_use_blocks = generate_missing_tool_use_content(orphaned_tool_result_ids)
messages.insert(0, {"role": "assistant", "content": missing_tool_use_blocks})

# Then check for orphaned toolUse messages
for index, message in enumerate(messages):
# Check all but the latest message in the messages array
# The latest message being orphaned is handled in the agent class
Expand All @@ -187,7 +218,7 @@ def _fix_broken_tool_use(self, messages: list[Message]) -> list[Message]:
]

missing_tool_use_ids = list(set(tool_use_ids) - set(tool_result_ids))
# If there area missing tool use ids, that means the messages history is broken
# If there are missing tool use ids, that means the messages history is broken
if missing_tool_use_ids:
logger.warning(
"Session message history has an orphaned toolUse with no toolResult. "
Expand Down
21 changes: 21 additions & 0 deletions src/strands/tools/_tool_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,24 @@ def generate_missing_tool_result_content(tool_use_ids: list[str]) -> list[Conten
}
for tool_use_id in tool_use_ids
]


def generate_missing_tool_use_content(tool_result_ids: list[str]) -> list[ContentBlock]:
"""Generate ToolUse content blocks for orphaned ToolResult message.

Args:
tool_result_ids: List of toolUseIds from orphaned toolResult blocks

Returns:
List of ContentBlock dictionaries containing dummy toolUse blocks
"""
return [
{
"toolUse": {
"toolUseId": tool_use_id,
"name": "unknown_tool",
"input": {"error": "toolUse is missing. Ignore."},
}
}
for tool_use_id in tool_result_ids
]
77 changes: 77 additions & 0 deletions tests/strands/tools/test_tool_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Tests for tool helper functions."""

from strands.tools._tool_helpers import (
generate_missing_tool_result_content,
generate_missing_tool_use_content,
)


class TestGenerateMissingToolResultContent:
"""Tests for generate_missing_tool_result_content function."""

def test_single_tool_use_id(self):
"""Test generating content for a single tool use ID."""
tool_use_ids = ["tool_123"]
result = generate_missing_tool_result_content(tool_use_ids)

assert len(result) == 1
assert "toolResult" in result[0]
assert result[0]["toolResult"]["toolUseId"] == "tool_123"
assert result[0]["toolResult"]["status"] == "error"
assert result[0]["toolResult"]["content"] == [{"text": "Tool was interrupted."}]

def test_multiple_tool_use_ids(self):
"""Test generating content for multiple tool use IDs."""
tool_use_ids = ["tool_123", "tool_456", "tool_789"]
result = generate_missing_tool_result_content(tool_use_ids)

assert len(result) == 3
for i, tool_id in enumerate(tool_use_ids):
assert "toolResult" in result[i]
assert result[i]["toolResult"]["toolUseId"] == tool_id
assert result[i]["toolResult"]["status"] == "error"

def test_empty_list(self):
"""Test generating content for empty list."""
result = generate_missing_tool_result_content([])
assert result == []


class TestGenerateMissingToolUseContent:
"""Tests for generate_missing_tool_use_content function."""

def test_single_tool_result_id(self):
"""Test generating content for a single tool result ID."""
tool_result_ids = ["tooluse_abc123"]
result = generate_missing_tool_use_content(tool_result_ids)

assert len(result) == 1
assert "toolUse" in result[0]
assert result[0]["toolUse"]["toolUseId"] == "tooluse_abc123"
assert result[0]["toolUse"]["name"] == "unknown_tool"
assert result[0]["toolUse"]["input"] == {"error": "toolUse is missing. Ignore."}

def test_multiple_tool_result_ids(self):
"""Test generating content for multiple tool result IDs."""
tool_result_ids = ["tooluse_abc123", "tooluse_def456", "tooluse_ghi789"]
result = generate_missing_tool_use_content(tool_result_ids)

assert len(result) == 3
for i, tool_id in enumerate(tool_result_ids):
assert "toolUse" in result[i]
assert result[i]["toolUse"]["toolUseId"] == tool_id
assert result[i]["toolUse"]["name"] == "unknown_tool"
assert result[i]["toolUse"]["input"] == {"error": "toolUse is missing. Ignore."}

def test_empty_list(self):
"""Test generating content for empty list."""
result = generate_missing_tool_use_content([])
assert result == []

def test_realistic_tool_use_id_format(self):
"""Test with realistic tool use ID format (like those from Bedrock)."""
tool_result_ids = ["tooluse_f09Y0LwyT2yteCYshTzb_Q"]
result = generate_missing_tool_use_content(tool_result_ids)

assert len(result) == 1
assert result[0]["toolUse"]["toolUseId"] == "tooluse_f09Y0LwyT2yteCYshTzb_Q"
Loading