Compare commits

..
Author SHA1 Message Date
Chester Curme f6d8e63e21 cr 2026-09-16 11:16:26 -04:00
Chester Curme a4160c8f37 fix 2026-09-16 11:06:22 -04:00
Chester Curme dcd325e581 add available_tools to ToolCallRequest 2026-09-16 10:43:28 -04:00
Chester Curme ab478cb40f nit 2026-09-15 12:04:23 -04:00
Chester Curme 218887d7ce prefer ToolCallRequest.tool_call['name'] for deciding what tool is executed in ToolNode 2026-09-15 11:33:32 -04:00
2 changed files with 158 additions and 1 deletions
@@ -135,18 +135,25 @@ class ToolCallRequest:
Attributes:
tool_call: Tool call dict with name, args, and id from model output.
If an interceptor edits `tool_call["name"]` so it differs from `tool`,
`tool_call["name"]` is authoritative 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()`,
validation will occur and raise an error for unregistered tools.
state: Agent state (`dict`, `list`, or `BaseModel`).
runtime: LangGraph runtime context (optional, `None` if outside graph).
available_tools: Client-side tools registered with the `ToolNode`. Provider
and built-in tools are not included. Use this to resolve a replacement
tool when redirecting a call, and set `tool` to the resolved instance.
"""
tool_call: ToolCall
tool: BaseTool | None
state: Any
runtime: ToolRuntime
available_tools: list[BaseTool] = field(default_factory=list)
def __setattr__(self, name: str, value: Any) -> None:
"""Raise deprecation warning when setting attributes directly.
@@ -336,6 +343,27 @@ def msg_content_output(output: Any) -> str | list[dict]:
return str(output)
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.
@@ -1037,6 +1065,7 @@ class ToolNode(RunnableCallable):
tool=tool,
state=tool_runtime.state,
runtime=tool_runtime,
available_tools=list(self.tools_by_name.values()),
)
config = tool_runtime.config
@@ -1046,13 +1075,18 @@ class ToolNode(RunnableCallable):
return self._execute_tool_sync(tool_request, input_type, config)
# Define execute callable that can be called multiple times
original_name, original_tool = call["name"], tool
def execute(req: ToolCallRequest) -> ToolMessage | Command:
"""Execute tool with given request. Can be called multiple times."""
_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
try:
return self._wrap_tool_call(tool_request, execute)
except ToolCallRequestMismatchError:
raise
except Exception as e:
# Wrapper threw an exception
if not self._handle_tool_errors:
@@ -1184,6 +1218,7 @@ class ToolNode(RunnableCallable):
tool=tool,
state=tool_runtime.state,
runtime=tool_runtime,
available_tools=list(self.tools_by_name.values()),
)
config = tool_runtime.config
@@ -1193,12 +1228,16 @@ 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, original_tool = call["name"], tool
async def execute(req: ToolCallRequest) -> ToolMessage | Command:
"""Execute tool with given request. Can be called multiple times."""
_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
@@ -1208,6 +1247,8 @@ class ToolNode(RunnableCallable):
# None check was performed above already
self._wrap_tool_call = cast("ToolCallWrapper", self._wrap_tool_call)
return self._wrap_tool_call(tool_request, _sync_execute)
except ToolCallRequestMismatchError:
raise
except Exception as e:
# Wrapper threw an exception
if not self._handle_tool_errors:
+117 -1
View File
@@ -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
@@ -13,6 +13,7 @@ from langgraph.types import Command
from langgraph.prebuilt.tool_node import (
ToolCallRequest,
ToolCallRequestMismatchError,
ToolNode,
)
@@ -1471,3 +1472,118 @@ 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:
"""Redirecting requires setting both `tool_call` and `tool`; routing follows them."""
@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:
target = next(t for t in request.available_tools if t.name == "subtract")
return await execute(
request.override(
tool_call={**request.tool_call, "name": "subtract"}, tool=target
)
)
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"
def test_interceptor_tool_call_name_and_tool_must_agree() -> None:
"""Renaming `tool_call` without `tool` raises rather than running the wrong tool."""
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
# handle_tool_errors is on by default; the mismatch must not become a ToolMessage
node = ToolNode([add, other], wrap_tool_call=rename_only)
with pytest.raises(ToolCallRequestMismatchError, match="other"):
node.invoke(
[
AIMessage(
"",
tool_calls=[
{
"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",
}
],
)
],
_create_config_with_runtime(),
)