mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 17:55:31 -04:00
fix(sdk): patch invalid tool calls (#3386)
Co-authored-by: Aarushi Singh <aarushi07.singh@gmail.com>
This commit is contained in:
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user