From 218887d7ce5c979f6de7541cd7211e74d04b5bcb Mon Sep 17 00:00:00 2001 From: Chester Curme Date: Tue, 15 Sep 2026 11:33:32 -0400 Subject: [PATCH] prefer ToolCallRequest.tool_call['name'] for deciding what tool is executed in ToolNode --- libs/prebuilt/langgraph/prebuilt/tool_node.py | 13 +++++++ libs/prebuilt/tests/test_on_tool_call.py | 35 ++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/libs/prebuilt/langgraph/prebuilt/tool_node.py b/libs/prebuilt/langgraph/prebuilt/tool_node.py index 95e161b90..168e2a884 100644 --- a/libs/prebuilt/langgraph/prebuilt/tool_node.py +++ b/libs/prebuilt/langgraph/prebuilt/tool_node.py @@ -135,6 +135,9 @@ class ToolCallRequest: Attributes: tool_call: Tool call dict with name, args, and id from model output. + + If `tool_call["name"]` differs from `tool`, `tool_call["name"]` is + preferred as the authoritative source for what tool is executed. tool: BaseTool instance to be invoked, or None if tool is not registered with the `ToolNode`. When tool is `None`, interceptors can handle the request without validation. If the interceptor calls `execute()`, @@ -1046,8 +1049,13 @@ class ToolNode(RunnableCallable): return self._execute_tool_sync(tool_request, input_type, config) # Define execute callable that can be called multiple times + original_name = call["name"] + def execute(req: ToolCallRequest) -> ToolMessage | Command: """Execute tool with given request. Can be called multiple times.""" + if req.tool_call["name"] != original_name: + # An interceptor (e.g., HITL) redirected the tool call + req = replace(req, tool=self.tools_by_name.get(req.tool_call["name"])) return self._execute_tool_sync(req, input_type, config) # Call wrapper with request and execute callable @@ -1193,8 +1201,13 @@ class ToolNode(RunnableCallable): return await self._execute_tool_async(tool_request, input_type, config) # Define async execute callable that can be called multiple times + original_name = call["name"] + async def execute(req: ToolCallRequest) -> ToolMessage | Command: """Execute tool with given request. Can be called multiple times.""" + if req.tool_call["name"] != original_name: + # An interceptor (e.g., HITL) redirected the tool call + req = replace(req, tool=self.tools_by_name.get(req.tool_call["name"])) return await self._execute_tool_async(req, input_type, config) def _sync_execute(req: ToolCallRequest) -> ToolMessage | Command: diff --git a/libs/prebuilt/tests/test_on_tool_call.py b/libs/prebuilt/tests/test_on_tool_call.py index f2af11b46..5cd482466 100644 --- a/libs/prebuilt/tests/test_on_tool_call.py +++ b/libs/prebuilt/tests/test_on_tool_call.py @@ -1,7 +1,7 @@ """Unit tests for tool call interceptor in ToolNode.""" import functools -from collections.abc import Callable +from collections.abc import Awaitable, Callable from unittest.mock import Mock import pytest @@ -1471,3 +1471,36 @@ def test_tool_call_request_is_frozen() -> None: assert fresh_new_request.tool == add # Other fields should remain the same assert fresh_new_request.state == state assert fresh_new_request.runtime is None + + +async def test_interceptor_can_redirect_to_another_tool() -> None: + """Overriding `tool_call["name"]` routes execution to the newly named tool.""" + + @tool + def subtract(a: int, b: int) -> int: + """Subtract two numbers.""" + return a - b + + async def redirect( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], + ) -> ToolMessage | Command: + return await execute( + request.override(tool_call={**request.tool_call, "name": "subtract"}) + ) + + node = ToolNode([add, subtract], awrap_tool_call=redirect) + result = await node.ainvoke( + [ + AIMessage( + "", + tool_calls=[ + {"name": "add", "args": {"a": 5, "b": 3}, "id": "1", "type": "tool_call"} + ], + ) + ], + _create_config_with_runtime(), + ) + + # `add` would return 8; `subtract` returns 2. + assert result[0].content == "2"