This commit is contained in:
Chester Curme
2026-09-16 11:16:26 -04:00
parent a4160c8f37
commit f6d8e63e21
2 changed files with 72 additions and 25 deletions
+20 -22
View File
@@ -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
+52 -3
View File
@@ -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",
}
],
)
],