diff --git a/libs/prebuilt/langgraph/prebuilt/tool_node.py b/libs/prebuilt/langgraph/prebuilt/tool_node.py index 78c6222f1..076be3234 100644 --- a/libs/prebuilt/langgraph/prebuilt/tool_node.py +++ b/libs/prebuilt/langgraph/prebuilt/tool_node.py @@ -891,6 +891,9 @@ class ToolNode(RunnableCallable): return self._validate_tool_command(response, request.tool_call, input_type) if isinstance(response, ToolMessage): response.content = cast("str | list", msg_content_output(response.content)) + # Enrich ToolMessage with name if not set (e.g., from fallback handlers) + if response.name is None: + response.name = call["name"] return response msg = f"Tool {call['name']} returned unexpected type: {type(response)}" @@ -1048,6 +1051,9 @@ class ToolNode(RunnableCallable): return self._validate_tool_command(response, request.tool_call, input_type) if isinstance(response, ToolMessage): response.content = cast("str | list", msg_content_output(response.content)) + # Enrich ToolMessage with name if not set (e.g., from fallback handlers) + if response.name is None: + response.name = call["name"] return response msg = f"Tool {call['name']} returned unexpected type: {type(response)}" diff --git a/test_tool_error_fallback.py b/test_tool_error_fallback.py new file mode 100644 index 000000000..5d1c0d906 --- /dev/null +++ b/test_tool_error_fallback.py @@ -0,0 +1,106 @@ +"""Test to reproduce the tool error fallback issue. + +When using ToolNode(...).with_fallbacks(...) with an error handler that returns +ToolMessage objects, the messages are losing: +1. The `name` field (becomes `None` instead of the tool name) +2. The `status` field (becomes `success` instead of `error`) +""" + +from unittest.mock import Mock + +from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.runnables import RunnableLambda +from langchain_core.runnables.config import RunnableConfig +from langchain_core.tools import tool + +from langgraph.prebuilt import ToolNode + + +def _create_mock_runtime(): + """Create a mock Runtime object for testing ToolNode outside of graph context.""" + mock_runtime = Mock() + mock_runtime.store = None + mock_runtime.context = None + mock_runtime.stream_writer = lambda *args, **kwargs: None + return mock_runtime + + +def _create_config_with_runtime() -> RunnableConfig: + """Create a RunnableConfig with mock Runtime for testing ToolNode.""" + return {"configurable": {"__pregel_runtime": _create_mock_runtime()}} + + +@tool +def failing_tool(x: int) -> str: + """A tool that always fails.""" + raise RuntimeError("This tool always fails!") + + +def handle_tool_error(state) -> dict: + """Error handler that returns ToolMessages.""" + print(f"handle_tool_error called with state: {state}") + print(f"State type: {type(state)}") + print(f"State keys: {state.keys() if isinstance(state, dict) else 'N/A'}") + error = state.get("error") + print(f"Error: {error}") + tool_calls = state["messages"][-1].tool_calls + print(f"Tool calls: {tool_calls}") + return { + "messages": [ + ToolMessage( + content=f"Error: {repr(error)}\n please fix your mistakes.", + tool_call_id=tc["id"], + ) + for tc in tool_calls + ] + } + + +fallback_runnable = RunnableLambda(handle_tool_error) + + +def test_tool_error_with_fallbacks(): + """Test that ToolMessages from fallback handlers preserve name and status.""" + # Create a ToolNode with a fallback + tool_node = ToolNode([failing_tool], handle_tool_errors=True).with_fallbacks( + [fallback_runnable], + ) + + # Create an AI message with a tool call + messages = [ + AIMessage( + content="", + tool_calls=[ + { + "name": "failing_tool", + "args": {"x": 1}, + "id": "call_123", + "type": "tool_call", + } + ], + ) + ] + + # Invoke the tool node + result = tool_node.invoke({"messages": messages}, config=_create_config_with_runtime()) + + print("Result:", result) + print("Result type:", type(result)) + print("Result keys:", result.keys() if isinstance(result, dict) else "N/A") + print("\nTool messages:") + for msg in result["messages"]: + if isinstance(msg, ToolMessage): + print(f" - content: {msg.content[:50]}...") + print(f" name: {msg.name}") + print(f" tool_call_id: {msg.tool_call_id}") + print(f" status: {msg.status}") + print() + + # Check expectations + assert msg.name == "failing_tool", f"Expected name='failing_tool', got {msg.name}" + assert msg.status == "error", f"Expected status='error', got {msg.status}" + print("✓ Test passed!") + + +if __name__ == "__main__": + test_tool_error_with_fallbacks()