Files
EvoScientist-Multi/tests/test_tool_history_repair_middleware.py
T
Sanjay Santhanam f802a49535 fix: repair interrupted tool call history (#366)
* fix: repair interrupted tool call history

Normalize incomplete tool exchanges before model calls so strict providers do not reject resumed sessions. Preserve completed exchanges and cover sync and async model paths.

* fix: repair malformed tool calls and dedupe repair warnings

Track AIMessage.invalid_tool_calls alongside tool_calls so interrupted
threads with syntactically invalid tool calls get synthesized error
results and are accepted by strict providers.

Preserve the originating tool call's name in the synthesized ToolMessage,
and deduplicate repair warnings per unique tool-call id via a warned set
owned by the middleware instance, since the middleware rewrites the
request but not thread state.

Document the middleware's scope versus deepagents' PatchToolCallsMiddleware
(orphan ToolMessage dropping and mid-run coverage).

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-07-22 15:43:58 +01:00

174 lines
4.7 KiB
Python

from unittest.mock import AsyncMock, MagicMock
from langchain.agents.middleware.types import ModelRequest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from EvoScientist.middleware.tool_history_repair import (
ToolHistoryRepairMiddleware,
repair_tool_history,
)
def _request(messages):
return ModelRequest(
messages=messages,
model=MagicMock(),
state={},
runtime=MagicMock(),
system_message=MagicMock(),
)
def _tool_call(tool_call_id):
return {"id": tool_call_id, "name": "execute", "args": {}}
def _invalid_tool_call(tool_call_id):
return {
"id": tool_call_id,
"name": "execute",
"args": "{not valid json",
"error": "could not parse args",
}
def test_synthesizes_results_for_interrupted_tool_calls():
messages = [
HumanMessage("run tools"),
AIMessage(content="", tool_calls=[_tool_call("one"), _tool_call("two")]),
HumanMessage("continue"),
]
repaired = repair_tool_history(messages)
assert [type(message) for message in repaired] == [
HumanMessage,
AIMessage,
ToolMessage,
ToolMessage,
HumanMessage,
]
assert [message.tool_call_id for message in repaired[2:4]] == ["one", "two"]
assert all(message.status == "error" for message in repaired[2:4])
def test_drops_orphan_tool_results():
messages = [
HumanMessage("old request"),
ToolMessage("late result", tool_call_id="orphan"),
HumanMessage("continue"),
]
assert repair_tool_history(messages) == [messages[0], messages[2]]
def test_preserves_complete_tool_exchanges():
messages = [
HumanMessage("run tool"),
AIMessage(content="", tool_calls=[_tool_call("complete")]),
ToolMessage("done", tool_call_id="complete"),
HumanMessage("continue"),
]
assert repair_tool_history(messages) == messages
def test_wrap_model_call_repairs_request():
request = _request(
[
ToolMessage("late result", tool_call_id="orphan"),
HumanMessage("continue"),
]
)
handler = MagicMock(return_value="ok")
assert ToolHistoryRepairMiddleware().wrap_model_call(request, handler) == "ok"
assert handler.call_args.args[0].messages == [request.messages[1]]
async def test_awrap_model_call_repairs_request():
request = _request(
[
AIMessage(content="", tool_calls=[_tool_call("interrupted")]),
HumanMessage("continue"),
]
)
handler = AsyncMock(return_value="ok")
assert (
await ToolHistoryRepairMiddleware().awrap_model_call(request, handler) == "ok"
)
repaired = handler.call_args.args[0].messages
assert isinstance(repaired[1], ToolMessage)
assert repaired[1].tool_call_id == "interrupted"
def test_synthesizes_results_for_invalid_tool_calls():
messages = [
HumanMessage("run tools"),
AIMessage(
content="",
tool_calls=[_tool_call("good")],
invalid_tool_calls=[_invalid_tool_call("bad")],
),
HumanMessage("continue"),
]
repaired = repair_tool_history(messages)
assert [type(message) for message in repaired] == [
HumanMessage,
AIMessage,
ToolMessage,
ToolMessage,
HumanMessage,
]
assert [message.tool_call_id for message in repaired[2:4]] == ["good", "bad"]
assert all(message.status == "error" for message in repaired[2:4])
def test_preserves_tool_call_name_in_synthesized_result():
messages = [
HumanMessage("run tool"),
AIMessage(content="", tool_calls=[_tool_call("one")]),
]
repaired = repair_tool_history(messages)
assert repaired[-1].name == "execute"
def test_warning_deduplicates_across_calls(caplog):
messages = [
HumanMessage("run tools"),
AIMessage(content="", tool_calls=[_tool_call("one")]),
]
warned: set[str] = set()
with caplog.at_level("WARNING"):
repair_tool_history(messages, warned=warned)
first_warnings = len(caplog.records)
repair_tool_history(messages, warned=warned)
second_warnings = len(caplog.records)
assert first_warnings == 1
assert second_warnings == 1
assert warned == {"one"}
def test_middleware_warns_once_per_thread(caplog):
middleware = ToolHistoryRepairMiddleware()
request = _request(
[
AIMessage(content="", tool_calls=[_tool_call("interrupted")]),
HumanMessage("continue"),
]
)
handler = MagicMock(return_value="ok")
with caplog.at_level("WARNING"):
middleware.wrap_model_call(request, handler)
middleware.wrap_model_call(request, handler)
assert len(caplog.records) == 1