From ceb1e4e6524ed7abcb6228861991f556d754f9ac Mon Sep 17 00:00:00 2001 From: ccurme Date: Tue, 14 Jul 2026 09:02:35 -0400 Subject: [PATCH] feat(langchain): `ToolErrorMiddleware` (#38781) --- .../langchain/agents/middleware/__init__.py | 2 + .../langchain/agents/middleware/tool_error.py | 200 ++++++++++++++++++ .../implementations/test_tool_error.py | 93 ++++++++ 3 files changed, 295 insertions(+) create mode 100644 libs/langchain_v1/langchain/agents/middleware/tool_error.py create mode 100644 libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_error.py diff --git a/libs/langchain_v1/langchain/agents/middleware/__init__.py b/libs/langchain_v1/langchain/agents/middleware/__init__.py index c49f39d85e8..f2d6163fb7b 100644 --- a/libs/langchain_v1/langchain/agents/middleware/__init__.py +++ b/libs/langchain_v1/langchain/agents/middleware/__init__.py @@ -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", diff --git a/libs/langchain_v1/langchain/agents/middleware/tool_error.py b/libs/langchain_v1/langchain/agents/middleware/tool_error.py new file mode 100644 index 00000000000..76888d1eda6 --- /dev/null +++ b/libs/langchain_v1/langchain/agents/middleware/tool_error.py @@ -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", + ) diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_error.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_error.py new file mode 100644 index 00000000000..b8e39378fb9 --- /dev/null +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_error.py @@ -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()