mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(sdk): handle Overwrite-wrapped messages in tool result interception (#3905)
Closes #3899 Fixed a `TypeError: 'Overwrite' object is not iterable` that could occur during large tool-result eviction when using store-backed memory. --- When eviction fires, `FilesystemMiddleware` emits a `Command` whose `messages` update is wrapped in `langgraph.types.Overwrite`, which carries a single `.value` attribute and is not iterable. `_intercept_large_tool_result` (and its async counterpart) assumed `messages` was always a plain list and iterated over it directly, raising `TypeError: 'Overwrite' object is not iterable`. This caused store-backed memory writes to fail silently and the agent to fall back to unrelated paths. The fix detects the `Overwrite` wrapper, unwraps its `.value` before processing the messages, then re-wraps the processed list so the channel-overwrite semantics are preserved. Made by [Open SWE](https://openswe.vercel.app) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -2106,6 +2106,21 @@ class FilesystemMiddleware(AgentMiddleware[FilesystemState, ContextT, ResponseT]
|
||||
|
||||
return self._apply_eviction_and_truncate(messages, write_result, file_path)
|
||||
|
||||
@staticmethod
|
||||
def _unwrap_command_messages(update: Mapping[str, Any]) -> tuple[Any, bool]:
|
||||
"""Return a Command messages update and whether it used Overwrite."""
|
||||
command_messages = update.get("messages", [])
|
||||
if isinstance(command_messages, Overwrite):
|
||||
return command_messages.value, True
|
||||
return command_messages, False
|
||||
|
||||
@staticmethod
|
||||
def _rewrap_command_messages(messages: list[AnyMessage], *, wrapped: bool) -> list[AnyMessage] | Overwrite:
|
||||
"""Restore Overwrite semantics when the original messages update used them."""
|
||||
if wrapped:
|
||||
return Overwrite(messages)
|
||||
return messages
|
||||
|
||||
def _intercept_large_tool_result(self, tool_result: ToolMessage | Command, runtime: ToolRuntime) -> ToolMessage | Command:
|
||||
"""Intercept and process large tool results before they're added to state.
|
||||
|
||||
@@ -2134,7 +2149,7 @@ class FilesystemMiddleware(AgentMiddleware[FilesystemState, ContextT, ResponseT]
|
||||
update = tool_result.update
|
||||
if update is None:
|
||||
return tool_result
|
||||
command_messages = update.get("messages", [])
|
||||
command_messages, wrapped = self._unwrap_command_messages(update)
|
||||
resolved_backend = self._get_backend(runtime)
|
||||
processed_messages = []
|
||||
for message in command_messages:
|
||||
@@ -2147,10 +2162,11 @@ class FilesystemMiddleware(AgentMiddleware[FilesystemState, ContextT, ResponseT]
|
||||
resolved_backend,
|
||||
)
|
||||
processed_messages.append(processed_message)
|
||||
new_messages = self._rewrap_command_messages(processed_messages, wrapped=wrapped)
|
||||
return Command(
|
||||
goto=tool_result.goto,
|
||||
graph=tool_result.graph,
|
||||
update={**update, "messages": processed_messages},
|
||||
update={**update, "messages": new_messages},
|
||||
)
|
||||
msg = f"Unreachable code reached in _intercept_large_tool_result: for tool_result of type {type(tool_result)}"
|
||||
raise AssertionError(msg)
|
||||
@@ -2173,7 +2189,7 @@ class FilesystemMiddleware(AgentMiddleware[FilesystemState, ContextT, ResponseT]
|
||||
update = tool_result.update
|
||||
if update is None:
|
||||
return tool_result
|
||||
command_messages = update.get("messages", [])
|
||||
command_messages, wrapped = self._unwrap_command_messages(update)
|
||||
resolved_backend = self._get_backend(runtime)
|
||||
processed_messages = []
|
||||
for message in command_messages:
|
||||
@@ -2186,10 +2202,11 @@ class FilesystemMiddleware(AgentMiddleware[FilesystemState, ContextT, ResponseT]
|
||||
resolved_backend,
|
||||
)
|
||||
processed_messages.append(processed_message)
|
||||
new_messages = self._rewrap_command_messages(processed_messages, wrapped=wrapped)
|
||||
return Command(
|
||||
goto=tool_result.goto,
|
||||
graph=tool_result.graph,
|
||||
update={**update, "messages": processed_messages},
|
||||
update={**update, "messages": new_messages},
|
||||
)
|
||||
msg = f"Unreachable code reached in _aintercept_large_tool_result: for tool_result of type {type(tool_result)}"
|
||||
raise AssertionError(msg)
|
||||
|
||||
@@ -1136,6 +1136,122 @@ class TestFilesystemMiddleware:
|
||||
assert mem_store.get(("filesystem",), "/large_tool_results/test_123") is not None
|
||||
assert result.update["custom_key"] == "custom_value"
|
||||
|
||||
def test_intercept_command_with_overwrite_messages(self):
|
||||
"""Test that Commands wrapping messages in Overwrite are handled."""
|
||||
backend, mem_store = _make_backend()
|
||||
middleware = FilesystemMiddleware(backend=backend, tool_token_limit_before_evict=1000)
|
||||
runtime = _runtime("test_123")
|
||||
|
||||
large_content = "y" * 5000
|
||||
tool_message = ToolMessage(content=large_content, tool_call_id="test_123")
|
||||
command = Command(update={"messages": Overwrite([tool_message]), "files": {}})
|
||||
result = middleware._intercept_large_tool_result(command, runtime)
|
||||
|
||||
assert isinstance(result, Command)
|
||||
assert isinstance(result.update["messages"], Overwrite)
|
||||
assert mem_store.get(("filesystem",), "/large_tool_results/test_123") is not None
|
||||
assert "Tool result too large" in result.update["messages"].value[0].content
|
||||
|
||||
async def test_aintercept_command_with_overwrite_messages(self):
|
||||
"""Test that the async path handles Overwrite-wrapped messages."""
|
||||
backend, mem_store = _make_backend()
|
||||
middleware = FilesystemMiddleware(backend=backend, tool_token_limit_before_evict=1000)
|
||||
runtime = _runtime("test_123")
|
||||
|
||||
large_content = "y" * 5000
|
||||
tool_message = ToolMessage(content=large_content, tool_call_id="test_123")
|
||||
command = Command(update={"messages": Overwrite([tool_message]), "files": {}})
|
||||
result = await middleware._aintercept_large_tool_result(command, runtime)
|
||||
|
||||
assert isinstance(result, Command)
|
||||
assert isinstance(result.update["messages"], Overwrite)
|
||||
assert mem_store.get(("filesystem",), "/large_tool_results/test_123") is not None
|
||||
assert "Tool result too large" in result.update["messages"].value[0].content
|
||||
|
||||
def test_intercept_command_with_short_overwrite_messages(self):
|
||||
"""A small ToolMessage stays wrapped and unchanged."""
|
||||
backend, mem_store = _make_backend()
|
||||
middleware = FilesystemMiddleware(backend=backend, tool_token_limit_before_evict=1000)
|
||||
runtime = _runtime("test_123")
|
||||
|
||||
small_content = "x" * 1000
|
||||
tool_message = ToolMessage(content=small_content, tool_call_id="test_123")
|
||||
command = Command(
|
||||
update={
|
||||
"messages": Overwrite([tool_message]),
|
||||
"files": {},
|
||||
"custom_key": "custom_value",
|
||||
}
|
||||
)
|
||||
result = middleware._intercept_large_tool_result(command, runtime)
|
||||
|
||||
assert isinstance(result, Command)
|
||||
assert isinstance(result.update["messages"], Overwrite)
|
||||
assert result.update["messages"].value == [tool_message]
|
||||
assert result.update["custom_key"] == "custom_value"
|
||||
assert mem_store.get(("filesystem",), "/large_tool_results/test_123") is None
|
||||
|
||||
def test_intercept_command_with_mixed_overwrite_messages(self):
|
||||
"""Non-tool messages in an Overwrite update survive in order."""
|
||||
backend, mem_store = _make_backend()
|
||||
middleware = FilesystemMiddleware(backend=backend, tool_token_limit_before_evict=1000)
|
||||
runtime = _runtime("test_123")
|
||||
|
||||
ai_message = AIMessage(content="Use a tool")
|
||||
large_content = "y" * 5000
|
||||
tool_message = ToolMessage(content=large_content, tool_call_id="test_123")
|
||||
final_message = AIMessage(content="Done")
|
||||
command = Command(
|
||||
update={
|
||||
"messages": Overwrite([ai_message, tool_message, final_message]),
|
||||
"files": {},
|
||||
"custom_key": "custom_value",
|
||||
}
|
||||
)
|
||||
result = middleware._intercept_large_tool_result(command, runtime)
|
||||
|
||||
assert isinstance(result, Command)
|
||||
assert isinstance(result.update["messages"], Overwrite)
|
||||
messages = result.update["messages"].value
|
||||
assert messages[0] == ai_message
|
||||
assert "Tool result too large" in messages[1].content
|
||||
assert messages[2] == final_message
|
||||
assert result.update["custom_key"] == "custom_value"
|
||||
assert mem_store.get(("filesystem",), "/large_tool_results/test_123") is not None
|
||||
|
||||
async def test_aintercept_command_with_mixed_overwrite_messages(self):
|
||||
"""The async path preserves non-tool messages in Overwrite updates."""
|
||||
backend, mem_store = _make_backend()
|
||||
middleware = FilesystemMiddleware(backend=backend, tool_token_limit_before_evict=1000)
|
||||
runtime = _runtime("test_123")
|
||||
|
||||
ai_message = AIMessage(content="Use a tool")
|
||||
large_content = "y" * 5000
|
||||
tool_message = ToolMessage(content=large_content, tool_call_id="test_123")
|
||||
command = Command(update={"messages": Overwrite([ai_message, tool_message])})
|
||||
result = await middleware._aintercept_large_tool_result(command, runtime)
|
||||
|
||||
assert isinstance(result, Command)
|
||||
assert isinstance(result.update["messages"], Overwrite)
|
||||
messages = result.update["messages"].value
|
||||
assert messages[0] == ai_message
|
||||
assert "Tool result too large" in messages[1].content
|
||||
assert mem_store.get(("filesystem",), "/large_tool_results/test_123") is not None
|
||||
|
||||
def test_intercept_command_with_empty_overwrite_messages(self):
|
||||
"""An empty Overwrite remains an empty Overwrite."""
|
||||
backend, _ = _make_backend()
|
||||
middleware = FilesystemMiddleware(backend=backend, tool_token_limit_before_evict=1000)
|
||||
runtime = _runtime("test_123")
|
||||
|
||||
command = Command(update={"messages": Overwrite([]), "custom_key": "custom_value"})
|
||||
result = middleware._intercept_large_tool_result(command, runtime)
|
||||
|
||||
assert isinstance(result, Command)
|
||||
assert isinstance(result.update["messages"], Overwrite)
|
||||
assert result.update["messages"].value == []
|
||||
assert result.update["custom_key"] == "custom_value"
|
||||
|
||||
def test_sanitize_tool_call_id(self):
|
||||
"""Test that tool_call_id is sanitized to prevent path traversal."""
|
||||
assert sanitize_tool_call_id("call_123") == "call_123"
|
||||
|
||||
Reference in New Issue
Block a user