mirror of
https://github.com/hwchase17/langchain.git
synced 2026-07-16 17:26:50 +00:00
feat(langchain): ToolErrorMiddleware (#38781)
This commit is contained in:
@@ -24,6 +24,7 @@ from langchain.agents.middleware.summarization import SummarizationMiddleware, T
|
||||
from langchain.agents.middleware.todo import TodoListMiddleware
|
||||
from langchain.agents.middleware.tool_call_limit import ToolCallLimitMiddleware
|
||||
from langchain.agents.middleware.tool_emulator import LLMToolEmulator
|
||||
from langchain.agents.middleware.tool_error import ToolErrorMiddleware
|
||||
from langchain.agents.middleware.tool_retry import ToolRetryMiddleware
|
||||
from langchain.agents.middleware.tool_selection import LLMToolSelectorMiddleware
|
||||
from langchain.agents.middleware.types import (
|
||||
@@ -78,6 +79,7 @@ __all__ = [
|
||||
"TodoListMiddleware",
|
||||
"ToolCallLimitMiddleware",
|
||||
"ToolCallRequest",
|
||||
"ToolErrorMiddleware",
|
||||
"ToolRetryMiddleware",
|
||||
"TriggerClause",
|
||||
"after_agent",
|
||||
|
||||
200
libs/langchain_v1/langchain/agents/middleware/tool_error.py
Normal file
200
libs/langchain_v1/langchain/agents/middleware/tool_error.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""Tool error middleware for agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
|
||||
from langchain.agents.middleware.types import AgentMiddleware, AgentState, ContextT, ResponseT
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from langchain_core.messages import ContentBlock
|
||||
from langgraph.types import Command
|
||||
|
||||
from langchain.agents.middleware.types import ToolCallRequest
|
||||
from langchain.tools import BaseTool
|
||||
|
||||
OnError = Callable[[Exception, ToolCallRequest], str | list[ContentBlock] | None]
|
||||
"""Sync handler: return content to surface the error as a `ToolMessage`; return
|
||||
`None` (or nothing) to let the exception propagate."""
|
||||
|
||||
AOnError = Callable[[Exception, ToolCallRequest], Awaitable[str | list[ContentBlock] | None]]
|
||||
"""Async handler: return content to surface the error as a `ToolMessage`; return
|
||||
`None` (or nothing) to let the exception propagate."""
|
||||
|
||||
|
||||
class ToolErrorMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, ResponseT]):
|
||||
"""Return selected tool-execution exceptions to the model as error `ToolMessage`s.
|
||||
|
||||
`on_error` is called for each exception raised by tool execution. Return content
|
||||
(a `str` or a list of content blocks) to convert the exception into a
|
||||
`ToolMessage(status="error")`; return `None` — or simply don't return — to let the
|
||||
exception propagate (halting the run). Handling is therefore opt-in — exceptions you
|
||||
do not return content for propagate unchanged, so arbitrary internal exceptions are
|
||||
never serialized to the model or end user unless you choose to surface them.
|
||||
|
||||
Langgraph control-flow signals (interrupts, parent commands) always propagate and
|
||||
never reach `on_error`.
|
||||
|
||||
Prefer returning content that names the exception type over the raw exception message,
|
||||
which may carry sensitive or internal detail.
|
||||
|
||||
Provide at least one of `on_error` or `aon_error`. `aon_error` handles errors on the
|
||||
async execution path (falling back to `on_error` when omitted); the sync path only
|
||||
ever calls `on_error`. For async-only usage, pass `aon_error` alone — running such a
|
||||
middleware on the sync path raises, since the async handler cannot be awaited there.
|
||||
|
||||
This middleware does not retry. For retries, compose with `ToolRetryMiddleware`
|
||||
placed *inner* and configured with `on_failure="error"` so exceptions reach this
|
||||
middleware.
|
||||
|
||||
This middleware only sees exceptions raised by tool *execution*. Argument-binding
|
||||
and validation errors are handled upstream by `ToolNode` (converted to an error
|
||||
`ToolMessage` before the tool runs), so they do not reach `on_error`.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import ToolErrorMiddleware
|
||||
|
||||
|
||||
def on_error(exc: Exception, request: ToolCallRequest) -> str | None:
|
||||
if isinstance(exc, ValueError):
|
||||
return f"`{request.tool_call['name']}` failed; fix the input and retry."
|
||||
return None # propagate everything else
|
||||
|
||||
|
||||
agent = create_agent(model, tools=[...], middleware=[ToolErrorMiddleware(on_error)])
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_error: OnError | None = None,
|
||||
*,
|
||||
aon_error: AOnError | None = None,
|
||||
tools: list[BaseTool | str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize `ToolErrorMiddleware`.
|
||||
|
||||
Args:
|
||||
on_error: Handler called for each exception raised by tool execution. Return
|
||||
content (`str` or list of content blocks) to convert the exception into an
|
||||
error `ToolMessage`. Return `None` — or simply don't return — to let the
|
||||
exception propagate. Falling through without a return therefore re-raises,
|
||||
so handle only the exceptions you mean to. Receives the exception and the
|
||||
tool call request (tool name, args, call id). Used on the sync path and,
|
||||
unless `aon_error` is given, on the async path.
|
||||
aon_error: Optional async handler, used on the async execution path. Falls back
|
||||
to `on_error` when not provided.
|
||||
tools: Optional list of tools or tool names to apply handling to. If `None`,
|
||||
applies to all tools.
|
||||
|
||||
Raises:
|
||||
ValueError: If neither `on_error` nor `aon_error` is provided.
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
if on_error is None and aon_error is None:
|
||||
msg = "ToolErrorMiddleware requires `on_error` and/or `aon_error`."
|
||||
raise ValueError(msg)
|
||||
|
||||
self.on_error = on_error
|
||||
self.aon_error = aon_error
|
||||
|
||||
# Extract tool names from BaseTool instances or strings
|
||||
self._tool_filter: list[str] | None
|
||||
if tools is not None:
|
||||
self._tool_filter = [tool.name if not isinstance(tool, str) else tool for tool in tools]
|
||||
else:
|
||||
self._tool_filter = None
|
||||
|
||||
self.tools = [] # No additional tools registered by this middleware
|
||||
|
||||
def _should_handle_tool(self, tool_name: str) -> bool:
|
||||
"""Check if error handling should apply to this tool."""
|
||||
if self._tool_filter is None:
|
||||
return True
|
||||
return tool_name in self._tool_filter
|
||||
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]],
|
||||
) -> ToolMessage | Command[Any]:
|
||||
"""Intercept tool execution and convert handled exceptions to error messages.
|
||||
|
||||
Args:
|
||||
request: Tool call request with call dict, `BaseTool`, state, and runtime.
|
||||
handler: Callable to execute the tool.
|
||||
|
||||
Returns:
|
||||
`ToolMessage` or `Command` (the final result).
|
||||
"""
|
||||
tool_name = request.tool.name if request.tool else request.tool_call["name"]
|
||||
|
||||
if not self._should_handle_tool(tool_name):
|
||||
return handler(request)
|
||||
|
||||
try:
|
||||
return handler(request)
|
||||
except GraphBubbleUp:
|
||||
# Control-flow signals (interrupts, parent commands) must propagate.
|
||||
raise
|
||||
except Exception as exc:
|
||||
if self.on_error is None:
|
||||
# Async-only config (aon_error) cannot be awaited on the sync path.
|
||||
msg = (
|
||||
"ToolErrorMiddleware has no sync `on_error`; run async "
|
||||
"(ainvoke/astream) or provide `on_error`."
|
||||
)
|
||||
raise RuntimeError(msg) from exc
|
||||
content = self.on_error(exc, request)
|
||||
if content is None:
|
||||
raise
|
||||
return ToolMessage(
|
||||
content=cast("str | list[str | dict[Any, Any]]", content),
|
||||
tool_call_id=request.tool_call["id"],
|
||||
name=tool_name,
|
||||
status="error",
|
||||
)
|
||||
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
|
||||
) -> ToolMessage | Command[Any]:
|
||||
"""Async version of `wrap_tool_call`.
|
||||
|
||||
Uses `aon_error` if provided, otherwise the sync `on_error`. The sync path never
|
||||
awaits.
|
||||
"""
|
||||
tool_name = request.tool.name if request.tool else request.tool_call["name"]
|
||||
|
||||
if not self._should_handle_tool(tool_name):
|
||||
return await handler(request)
|
||||
|
||||
try:
|
||||
return await handler(request)
|
||||
except GraphBubbleUp:
|
||||
# Control-flow signals (interrupts, parent commands) must propagate.
|
||||
raise
|
||||
except Exception as exc:
|
||||
if self.aon_error is not None:
|
||||
content = await self.aon_error(exc, request)
|
||||
elif self.on_error is not None:
|
||||
content = self.on_error(exc, request)
|
||||
else: # pragma: no cover - __init__ guarantees at least one handler
|
||||
raise
|
||||
if content is None:
|
||||
raise
|
||||
return ToolMessage(
|
||||
content=cast("str | list[str | dict[Any, Any]]", content),
|
||||
tool_call_id=request.tool_call["id"],
|
||||
name=tool_name,
|
||||
status="error",
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for ToolErrorMiddleware functionality."""
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import HumanMessage, ToolCall, ToolMessage
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
|
||||
from langchain.agents.factory import create_agent
|
||||
from langchain.agents.middleware import ToolErrorMiddleware
|
||||
from tests.unit_tests.agents.model import FakeToolCallingModel
|
||||
|
||||
|
||||
@tool
|
||||
def failing_tool(value: str) -> str:
|
||||
"""Tool that always fails."""
|
||||
msg = f"secret detail: {value}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _model() -> FakeToolCallingModel:
|
||||
return FakeToolCallingModel(
|
||||
tool_calls=[
|
||||
[ToolCall(name="failing_tool", args={"value": "x"}, id="1")],
|
||||
[],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_tool_error_handled_returns_tool_message() -> None:
|
||||
"""`on_error` content is returned as an error ToolMessage and controls disclosure."""
|
||||
|
||||
def on_error(exc: Exception, request: ToolCallRequest) -> str | None:
|
||||
return f"`{request.tool_call['name']}` failed with {type(exc).__name__}."
|
||||
|
||||
agent = create_agent(
|
||||
model=_model(),
|
||||
tools=[failing_tool],
|
||||
middleware=[ToolErrorMiddleware(on_error)],
|
||||
)
|
||||
|
||||
result = agent.invoke({"messages": [HumanMessage("go")]})
|
||||
|
||||
tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
|
||||
assert len(tool_messages) == 1
|
||||
assert tool_messages[0].status == "error"
|
||||
assert tool_messages[0].name == "failing_tool"
|
||||
assert tool_messages[0].content == "`failing_tool` failed with ValueError."
|
||||
# on_error controls disclosure — the raw exception message is not leaked.
|
||||
assert "secret detail" not in tool_messages[0].content
|
||||
|
||||
|
||||
def test_tool_error_none_propagates() -> None:
|
||||
"""Returning `None` from `on_error` lets the exception propagate."""
|
||||
|
||||
def on_error(exc: Exception, _request: ToolCallRequest) -> str | None:
|
||||
if isinstance(exc, KeyError):
|
||||
return "handled"
|
||||
return None # ValueError is not handled -> propagates
|
||||
|
||||
agent = create_agent(
|
||||
model=_model(),
|
||||
tools=[failing_tool],
|
||||
middleware=[ToolErrorMiddleware(on_error)],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="secret detail"):
|
||||
agent.invoke({"messages": [HumanMessage("go")]})
|
||||
|
||||
|
||||
async def test_tool_error_async_only() -> None:
|
||||
"""`aon_error` alone (no `on_error`) handles errors on the async path."""
|
||||
|
||||
async def aon_error(exc: Exception, request: ToolCallRequest) -> str | None:
|
||||
return f"async handled `{request.tool_call['name']}`: {type(exc).__name__}"
|
||||
|
||||
agent = create_agent(
|
||||
model=_model(),
|
||||
tools=[failing_tool],
|
||||
middleware=[ToolErrorMiddleware(aon_error=aon_error)],
|
||||
)
|
||||
|
||||
result = await agent.ainvoke({"messages": [HumanMessage("go")]})
|
||||
|
||||
tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
|
||||
assert len(tool_messages) == 1
|
||||
assert tool_messages[0].status == "error"
|
||||
assert tool_messages[0].content == "async handled `failing_tool`: ValueError"
|
||||
|
||||
|
||||
def test_tool_error_requires_a_handler() -> None:
|
||||
"""At least one of `on_error`/`aon_error` must be provided."""
|
||||
with pytest.raises(ValueError, match="on_error"):
|
||||
ToolErrorMiddleware()
|
||||
Reference in New Issue
Block a user