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:
Mason Daugherty
2026-06-12 01:33:31 -04:00
committed by GitHub
parent 2bdf6ea329
commit a043c5a671
2 changed files with 137 additions and 4 deletions
@@ -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"