diff --git a/libs/prebuilt/langgraph/prebuilt/tool_node.py b/libs/prebuilt/langgraph/prebuilt/tool_node.py index 131fa0323..fdd9c1185 100644 --- a/libs/prebuilt/langgraph/prebuilt/tool_node.py +++ b/libs/prebuilt/langgraph/prebuilt/tool_node.py @@ -347,6 +347,23 @@ class ToolCallRequestMismatchError(ValueError): """`tool_call["name"]` and `tool` disagree on a `ToolCallRequest`.""" +def _check_not_redirected_without_tool( + request: ToolCallRequest, original_name: str, original_tool: BaseTool | None +) -> None: + """Raise if an interceptor renamed the call but left `tool` as the resolved one.""" + if ( + original_tool is not None + and request.tool is original_tool + and request.tool_call["name"] != original_name + ): + msg = ( + f"Interceptor set tool_call name to {request.tool_call['name']!r} but left " + f"`tool` as {original_tool.name!r}. Redirecting a call requires setting both; " + f"resolve the replacement from `ToolCallRequest.available_tools`." + ) + raise ToolCallRequestMismatchError(msg) + + class ToolInvocationError(ToolException): """An error occurred while invoking a tool due to invalid arguments. @@ -1062,17 +1079,7 @@ class ToolNode(RunnableCallable): def execute(req: ToolCallRequest) -> ToolMessage | Command: """Execute tool with given request. Can be called multiple times.""" - if ( - original_tool is not None - and req.tool is original_tool - and req.tool_call["name"] != original_name - ): - msg = ( - f"Interceptor set tool_call name to {req.tool_call['name']!r} but left " - f"`tool` as {original_tool.name!r}. Redirecting a call requires setting " - f"both; resolve the replacement from `ToolCallRequest.available_tools`." - ) - raise ToolCallRequestMismatchError(msg) + _check_not_redirected_without_tool(req, original_name, original_tool) return self._execute_tool_sync(req, input_type, config) # Call wrapper with request and execute callable @@ -1225,21 +1232,12 @@ class ToolNode(RunnableCallable): async def execute(req: ToolCallRequest) -> ToolMessage | Command: """Execute tool with given request. Can be called multiple times.""" - if ( - original_tool is not None - and req.tool is original_tool - and req.tool_call["name"] != original_name - ): - msg = ( - f"Interceptor set tool_call name to {req.tool_call['name']!r} but left " - f"`tool` as {original_tool.name!r}. Redirecting a call requires setting " - f"both; resolve the replacement from `ToolCallRequest.available_tools`." - ) - raise ToolCallRequestMismatchError(msg) + _check_not_redirected_without_tool(req, original_name, original_tool) return await self._execute_tool_async(req, input_type, config) def _sync_execute(req: ToolCallRequest) -> ToolMessage | Command: """Sync execute fallback for sync wrapper.""" + _check_not_redirected_without_tool(req, original_name, original_tool) return self._execute_tool_sync(req, input_type, config) # Call wrapper with request and execute callable diff --git a/libs/prebuilt/tests/test_on_tool_call.py b/libs/prebuilt/tests/test_on_tool_call.py index db7c719c5..da7010c27 100644 --- a/libs/prebuilt/tests/test_on_tool_call.py +++ b/libs/prebuilt/tests/test_on_tool_call.py @@ -1499,7 +1499,12 @@ async def test_interceptor_can_redirect_to_another_tool() -> None: AIMessage( "", tool_calls=[ - {"name": "add", "args": {"a": 5, "b": 3}, "id": "1", "type": "tool_call"} + { + "name": "add", + "args": {"a": 5, "b": 3}, + "id": "1", + "type": "tool_call", + } ], ) ], @@ -1517,7 +1522,9 @@ def test_interceptor_tool_call_name_and_tool_must_agree() -> None: request: ToolCallRequest, execute: Callable[[ToolCallRequest], ToolMessage | Command], ) -> ToolMessage | Command: - return execute(request.override(tool_call={**request.tool_call, "name": "other"})) + return execute( + request.override(tool_call={**request.tool_call, "name": "other"}) + ) @tool def other(a: int, b: int) -> int: @@ -1532,7 +1539,49 @@ def test_interceptor_tool_call_name_and_tool_must_agree() -> None: AIMessage( "", tool_calls=[ - {"name": "add", "args": {"a": 1, "b": 2}, "id": "1", "type": "tool_call"} + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + _create_config_with_runtime(), + ) + + +async def test_sync_interceptor_under_ainvoke_also_validates_redirect() -> None: + """The sync-wrapper fallback used by `ainvoke` must validate too, not just `invoke`.""" + + def rename_only( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + return execute( + request.override(tool_call={**request.tool_call, "name": "other"}) + ) + + @tool + def other(a: int, b: int) -> int: + """Another tool.""" + return 0 + + # Only a sync wrapper is configured, so `ainvoke` routes through `_sync_execute`. + node = ToolNode([add, other], wrap_tool_call=rename_only) + with pytest.raises(ToolCallRequestMismatchError, match="other"): + await node.ainvoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "1", + "type": "tool_call", + } ], ) ],