Compare commits

...
2 changed files with 47 additions and 1 deletions
@@ -135,6 +135,9 @@ 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()`,
@@ -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:
+34 -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
@@ -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"