fix(sdk): patch invalid tool calls (#3386)

Co-authored-by: Aarushi Singh <aarushi07.singh@gmail.com>
This commit is contained in:
ccurme
2026-05-13 11:19:00 -04:00
committed by GitHub
parent 7cdc61cde4
commit c916d1b2e3
2 changed files with 64 additions and 17 deletions
@@ -3,7 +3,7 @@
from typing import Any
from langchain.agents.middleware import AgentMiddleware, AgentState
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.messages import AIMessage, AnyMessage, ToolMessage
from langgraph.runtime import Runtime
from langgraph.types import Overwrite
@@ -20,25 +20,27 @@ class PatchToolCallsMiddleware(AgentMiddleware):
answered_ids = {msg.tool_call_id for msg in messages if msg.type == "tool"} # ty: ignore[unresolved-attribute]
if not any(
tool_call["id"] not in answered_ids for msg in messages if isinstance(msg, AIMessage) and msg.tool_calls for tool_call in msg.tool_calls
tool_call["id"] is not None and tool_call["id"] not in answered_ids
for msg in messages
if isinstance(msg, AIMessage)
for tool_call in (*msg.tool_calls, *msg.invalid_tool_calls)
):
return None
patched_messages = []
patched_messages: list[AnyMessage] = []
for msg in messages:
patched_messages.append(msg)
if isinstance(msg, AIMessage) and msg.tool_calls:
patched_messages.extend(
ToolMessage(
content=(
f"Tool call {tool_call['name']} with id {tool_call['id']} was "
"cancelled - another message came in before it could be completed."
),
name=tool_call["name"],
tool_call_id=tool_call["id"],
)
for tool_call in msg.tool_calls
if tool_call["id"] not in answered_ids
)
if not isinstance(msg, AIMessage):
continue
for tool_call in (*msg.tool_calls, *msg.invalid_tool_calls):
tool_call_id = tool_call["id"]
if tool_call_id is None or tool_call_id in answered_ids:
continue
name = tool_call["name"] or "unknown"
if tool_call.get("type") == "invalid_tool_call":
content = f"Tool call {name} with id {tool_call_id} could not be executed - arguments were malformed or truncated."
else:
content = f"Tool call {name} with id {tool_call_id} was cancelled - another message came in before it could be completed."
patched_messages.append(ToolMessage(content=content, name=name, tool_call_id=tool_call_id))
return {"messages": Overwrite(patched_messages)}
@@ -14,7 +14,7 @@ from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain.tools import ToolRuntime
from langchain_core.language_models import LanguageModelInput
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_core.runnables import Runnable
from langchain_core.tools import BaseTool, tool
from langgraph.channels.delta import DeltaChannel
@@ -3578,3 +3578,48 @@ class TestDeltaChannels:
state = agent.get_state(config)
files = state.values.get("files", {})
assert any("hello.txt" in k for k in files)
def test_invalid_tool_call_patched_on_next_turn() -> None:
# Turn 1: model truncates and emits an invalid tool call (no matching ToolMessage
# will be produced because agents only route on ``tool_calls``).
# Turn 2: the middleware must patch the dangling call before the model is re-invoked.
fake_model = FakeChatModelWithHistory(
messages=iter(
[
AIMessage(
content="",
invalid_tool_calls=[
{
"id": "call_truncated",
"name": "search",
"args": '{"query": "weath',
"error": "Unterminated string at line 1 column 17",
"type": "invalid_tool_call",
}
],
),
AIMessage(content="Recovered."),
]
)
)
checkpointer = InMemorySaver()
agent = create_deep_agent(model=fake_model, checkpointer=checkpointer)
config: dict = {"configurable": {"thread_id": "patch-invalid-tool-calls"}}
agent.invoke({"messages": [HumanMessage(content="Run a tool")]}, config)
result = agent.invoke({"messages": [HumanMessage(content="Try again")]}, config)
# The second model call must see the dangling invalid_tool_call paired with a ToolMessage.
second_call_inputs = fake_model.call_history[1]["messages"]
synthetic = next(
(m for m in second_call_inputs if isinstance(m, ToolMessage) and m.tool_call_id == "call_truncated"),
None,
)
assert synthetic is not None, "PatchToolCallsMiddleware did not inject a ToolMessage for invalid_tool_calls"
assert "could not be executed" in synthetic.content
assert "malformed or truncated" in synthetic.content
assert synthetic.name == "search"
# Final state must also expose the patched ToolMessage.
assert any(isinstance(m, ToolMessage) and m.tool_call_id == "call_truncated" for m in result["messages"])