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
Lauren Hirata SinghandGitHub 230927fb3a docs: publish the generated llms.txt instead of a hand-written copy (#8922)
## Why

`docs/llms.txt` is written by hand and was last touched in February, so
the index served at <https://langchain-ai.github.io/langgraph/llms.txt>
had drifted badly:

- 12 pages listed, against the 43 the docs site publishes.
- One entry, `/oss/python/langgraph/why-langgraph`, now 404s.

The deploy workflow only fires on `docs/**` pushes, so nothing brought
the file back in line as the docs site changed. It is served verbatim:
`generate_redirects.py` copies it into `docs/_site` via a `static_files`
list, and `deploy-redirects.yml` uploads that directory to Pages.

The docs site already generates exactly this index, and regenerates it
on every deploy:

```
https://docs.langchain.com/oss/python/langgraph/llms.txt
```

## What changed

`generate_redirects.py` now fetches that index at build time and writes
it into `_site`, so the published file cannot drift from the docs site.
The committed `docs/llms.txt` stays as a fallback only, refreshed here
to current content.

`deploy-redirects.yml` gains a weekly `schedule:` trigger, so docs
changes that never touch this repo still reach the deployed file. The
`permissions:` block is unchanged.

## Fetching remote content in CI

The fetched body is published on a public Pages site, so it is validated
before it is written:

- The URL is a hardcoded module constant, never built from input.
- Both that URL and the post-redirect `response.url` are checked against
an HTTPS-plus-single-host allowlist. `urlopen` follows redirects, so
checking only the request URL would not be enough.
- 30 second timeout, response capped at 1MB, decoded as UTF-8.
- The body must open with a markdown heading and contain a
docs.langchain.com link, which rejects an error page or a truncated
response.

Any failure returns `None` and falls back to the committed copy, so a
docs.langchain.com outage degrades to a stale file rather than a broken
deploy or a published error page.

## Verification

Ran `python docs/generate_redirects.py`: 294 redirect files, `llms.txt`
fetched, 43 entries.

Each rejection path was exercised against a stubbed `urlopen` and all
fall back to the committed file:

| Case | Result |
|---|---|
| Network error | falls back |
| Timeout | falls back |
| Redirect to another host | falls back |
| Body over 1MB | falls back |
| Invalid UTF-8 | falls back |
| HTML error page | falls back |
| Empty body | falls back |
| Valid index | published |

Allowlist rejects `http://docs.langchain.com/...`,
`https://evil.com/...`, and `https://docs.langchain.com.evil.com/...`.

## Note on ordering

The fallback committed here is labelled "LangGraph (Python)", which is
what the docs site will serve once langchain-ai/docs#6032 deploys. Until
then the fetched file reads "Open source (Python)". No action needed; it
self-corrects.

---

Written with Claude Code; I reviewed the diff and ran the verification
above.
2026-09-14 15:45:19 -04:00
5 changed files with 291 additions and 40 deletions
+4
View File
@@ -7,6 +7,10 @@ on:
paths: paths:
- 'docs/**' - 'docs/**'
- '.github/workflows/deploy-redirects.yml' - '.github/workflows/deploy-redirects.yml'
# llms.txt is fetched from docs.langchain.com at build time, so redeploy on a
# schedule to pick up docs changes that never touch this repo.
schedule:
- cron: '17 6 * * 1'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
+83 -7
View File
@@ -12,13 +12,26 @@ which is SEO-friendly and treated similarly to 301 redirects by Google.
To add new redirects, simply edit redirects.json and re-run this script. To add new redirects, simply edit redirects.json and re-run this script.
""" """
import http.client
import json import json
import os import os
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path from pathlib import Path
# Default fallback URL for any path not in the redirect map # Default fallback URL for any path not in the redirect map
DEFAULT_REDIRECT = "https://docs.langchain.com/oss/python/langgraph/overview" DEFAULT_REDIRECT = "https://docs.langchain.com/oss/python/langgraph/overview"
# The docs site regenerates this index on every deploy, so fetching it here
# keeps the published llms.txt from drifting. The URL is a hardcoded constant,
# never built from input, and both it and the post-redirect URL are checked
# against ALLOWED_LLMS_HOST before anything is read.
CANONICAL_LLMS_URL = "https://docs.langchain.com/oss/python/langgraph/llms.txt"
ALLOWED_LLMS_HOST = "docs.langchain.com"
LLMS_FETCH_TIMEOUT = 30
LLMS_MAX_BYTES = 1_000_000
HTML_TEMPLATE = """<!doctype html> HTML_TEMPLATE = """<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -75,6 +88,64 @@ CATCHALL_404_TEMPLATE = """<!doctype html>
""" """
def is_allowed_llms_url(url):
"""Return True if url is HTTPS on the one host we accept content from."""
parsed = urllib.parse.urlsplit(url)
return parsed.scheme == "https" and parsed.hostname == ALLOWED_LLMS_HOST
def fetch_canonical_llms_txt():
"""Return the published LangGraph index, or None if it cannot be used.
Returning None leaves the caller on the committed docs/llms.txt, so a
docs.langchain.com outage degrades to a stale file rather than a broken
deploy or a published error page.
"""
if not is_allowed_llms_url(CANONICAL_LLMS_URL):
print(f"Refusing to fetch {CANONICAL_LLMS_URL}: host not allowed")
return None
try:
with urllib.request.urlopen( # noqa: S310 - constant, allowlisted URL
CANONICAL_LLMS_URL, timeout=LLMS_FETCH_TIMEOUT
) as response:
# urlopen follows redirects, so re-check where it actually landed.
if not is_allowed_llms_url(response.url):
print(f"Refusing {CANONICAL_LLMS_URL}: redirected to {response.url}")
return None
body = response.read(LLMS_MAX_BYTES + 1)
# A connection dropped mid-body raises http.client.IncompleteRead, which
# descends from HTTPException rather than OSError, so catching only the
# urllib and OS errors would let it escape and fail the whole deploy.
except (
urllib.error.URLError,
http.client.HTTPException,
TimeoutError,
OSError,
) as exc:
print(f"Could not fetch {CANONICAL_LLMS_URL}: {type(exc).__name__}: {exc}")
return None
if len(body) > LLMS_MAX_BYTES:
print(f"Refusing {CANONICAL_LLMS_URL}: larger than {LLMS_MAX_BYTES} bytes")
return None
try:
text = body.decode("utf-8")
except UnicodeDecodeError as exc:
print(f"Refusing {CANONICAL_LLMS_URL}: not valid UTF-8: {exc}")
return None
# An index opens with a markdown heading and links to the docs site. A
# body that does not is an error page or a truncated response, not content
# worth publishing.
if not text.startswith("# ") or f"https://{ALLOWED_LLMS_HOST}/" not in text:
print(f"Refusing {CANONICAL_LLMS_URL}: does not look like an llms.txt index")
return None
return text
def generate_redirects(): def generate_redirects():
script_dir = Path(__file__).parent script_dir = Path(__file__).parent
output_dir = script_dir / "_site" output_dir = script_dir / "_site"
@@ -126,14 +197,19 @@ def generate_redirects():
catchall_404.write_text(CATCHALL_404_TEMPLATE.format(default_url=DEFAULT_REDIRECT)) catchall_404.write_text(CATCHALL_404_TEMPLATE.format(default_url=DEFAULT_REDIRECT))
print(f"Created: {catchall_404}") print(f"Created: {catchall_404}")
# Copy static files (like llms.txt) that can't be redirected via HTML # llms.txt can't be redirected via HTML, so publish the docs site's own
static_files = ["llms.txt"] # generated index. The committed copy is only a fallback.
for static_file in static_files: llms_txt = fetch_canonical_llms_txt()
src = script_dir / static_file if llms_txt is not None:
(output_dir / "llms.txt").write_text(llms_txt)
print(f"Fetched: {output_dir / 'llms.txt'} (from {CANONICAL_LLMS_URL})")
else:
src = script_dir / "llms.txt"
if src.exists(): if src.exists():
dst = output_dir / static_file (output_dir / "llms.txt").write_text(src.read_text())
dst.write_text(src.read_text()) print(f"Copied: {output_dir / 'llms.txt'} (fallback, may be stale)")
print(f"Copied: {dst}") else:
print("No llms.txt fetched and no committed fallback; skipping")
print(f"\nGenerated {len(redirects)} redirect files in {output_dir}") print(f"\nGenerated {len(redirects)} redirect files in {output_dir}")
+46 -32
View File
@@ -1,35 +1,49 @@
# LangGraph # Docs by LangChain: LangGraph (Python)
LangGraph documentation has moved to docs.langchain.com. > Markdown index of the LangGraph (Python) documentation.
## Overview ## LangGraph (Python)
- [LangGraph Overview](https://docs.langchain.com/oss/python/langgraph/overview): Introduction to LangGraph, a library for building stateful, multi-actor applications with LLMs. - [Memory](https://docs.langchain.com/oss/python/langgraph/add-memory.md)
- [Why LangGraph?](https://docs.langchain.com/oss/python/langgraph/why-langgraph): Motivation for LangGraph and its key features. - [Build a custom RAG agent with LangGraph](https://docs.langchain.com/oss/python/langgraph/agentic-rag.md)
- [Application structure](https://docs.langchain.com/oss/python/langgraph/application-structure.md)
## Core Concepts - [Backward compatibility](https://docs.langchain.com/oss/python/langgraph/backward-compatibility.md)
- [Case studies](https://docs.langchain.com/oss/python/langgraph/case-studies.md)
- [Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api): Learn how to define state, create nodes, and connect them with edges. - [Changelog](https://docs.langchain.com/oss/python/langgraph/changelog-js.md)
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming): Stream outputs from your graph for better UX. - [Changelog](https://docs.langchain.com/oss/python/langgraph/changelog-py.md)
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence): Add memory and checkpointing to your graphs. - [Checkpointers](https://docs.langchain.com/oss/python/langgraph/checkpointers.md)
- [Add Memory](https://docs.langchain.com/oss/python/langgraph/add-memory): Implement short-term and long-term memory. - [Choosing between Graph and Functional APIs](https://docs.langchain.com/oss/python/langgraph/choosing-apis.md)
- [Workflows & Agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents): Build agents and workflows with LangGraph. - [Deployment](https://docs.langchain.com/oss/python/langgraph/deploy.md)
- [GRAPH_RECURSION_LIMIT](https://docs.langchain.com/oss/python/langgraph/errors/GRAPH_RECURSION_LIMIT.md)
## How-To Guides - [INVALID_CHAT_HISTORY](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CHAT_HISTORY.md)
- [INVALID_CONCURRENT_GRAPH_UPDATE](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CONCURRENT_GRAPH_UPDATE.md)
- [Use Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs): Compose graphs using subgraphs. - [INVALID_GRAPH_NODE_RETURN_VALUE](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_GRAPH_NODE_RETURN_VALUE.md)
- [Observability](https://docs.langchain.com/oss/python/langgraph/observability): Add tracing and debugging to your graphs. - [MISSING_CHECKPOINTER](https://docs.langchain.com/oss/python/langgraph/errors/MISSING_CHECKPOINTER.md)
- [Common Errors](https://docs.langchain.com/oss/python/langgraph/common-errors): Troubleshoot common LangGraph errors. - [MULTIPLE_SUBGRAPHS](https://docs.langchain.com/oss/python/langgraph/errors/MULTIPLE_SUBGRAPHS.md)
- [Event streaming](https://docs.langchain.com/oss/python/langgraph/event-streaming.md)
## Tutorials - [Fault tolerance](https://docs.langchain.com/oss/python/langgraph/fault-tolerance.md)
- [Custom stream channels](https://docs.langchain.com/oss/python/langgraph/frontend/custom-stream-channels.md)
- [Agentic RAG](https://docs.langchain.com/oss/python/langgraph/agentic-rag): Build an agentic RAG system with LangGraph. - [Graph execution](https://docs.langchain.com/oss/python/langgraph/frontend/graph-execution.md)
- [SQL Agent](https://docs.langchain.com/oss/python/langgraph/sql-agent): Create a SQL agent with LangGraph. - [Overview](https://docs.langchain.com/oss/python/langgraph/frontend/overview.md)
- [Functional API overview](https://docs.langchain.com/oss/python/langgraph/functional-api.md)
## Reference - [Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api.md)
- [Install LangGraph](https://docs.langchain.com/oss/python/langgraph/install.md)
- [API Reference](https://reference.langchain.com/python/langgraph/): Complete API documentation for LangGraph. - [Interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts.md)
- [Run a local server](https://docs.langchain.com/oss/python/langgraph/local-server.md)
## LangGraph Platform - [LangSmith Observability](https://docs.langchain.com/oss/python/langgraph/observability.md)
- [LangGraph overview](https://docs.langchain.com/oss/python/langgraph/overview.md)
For deploying LangGraph applications in production, see the [LangSmith documentation](https://docs.langchain.com/langsmith/agent-server). - [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence.md)
- [LangGraph runtime](https://docs.langchain.com/oss/python/langgraph/pregel.md)
- [Quickstart](https://docs.langchain.com/oss/python/langgraph/quickstart.md)
- [Build a custom SQL agent](https://docs.langchain.com/oss/python/langgraph/sql-agent.md)
- [Stores](https://docs.langchain.com/oss/python/langgraph/stores.md)
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming.md)
- [LangSmith Studio](https://docs.langchain.com/oss/python/langgraph/studio.md)
- [Test](https://docs.langchain.com/oss/python/langgraph/test.md)
- [Thinking in LangGraph](https://docs.langchain.com/oss/python/langgraph/thinking-in-langgraph.md)
- [Agent Chat UI](https://docs.langchain.com/oss/python/langgraph/ui.md)
- [Use the functional API](https://docs.langchain.com/oss/python/langgraph/use-functional-api.md)
- [Use the graph API](https://docs.langchain.com/oss/python/langgraph/use-graph-api.md)
- [Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs.md)
- [Use time-travel](https://docs.langchain.com/oss/python/langgraph/use-time-travel.md)
- [Workflows and agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents.md)
@@ -135,18 +135,25 @@ class ToolCallRequest:
Attributes: Attributes:
tool_call: Tool call dict with name, args, and id from model output. 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 tool: BaseTool instance to be invoked, or None if tool is not
registered with the `ToolNode`. When tool is `None`, interceptors can registered with the `ToolNode`. When tool is `None`, interceptors can
handle the request without validation. If the interceptor calls `execute()`, handle the request without validation. If the interceptor calls `execute()`,
validation will occur and raise an error for unregistered tools. validation will occur and raise an error for unregistered tools.
state: Agent state (`dict`, `list`, or `BaseModel`). state: Agent state (`dict`, `list`, or `BaseModel`).
runtime: LangGraph runtime context (optional, `None` if outside graph). 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_call: ToolCall
tool: BaseTool | None tool: BaseTool | None
state: Any state: Any
runtime: ToolRuntime runtime: ToolRuntime
available_tools: list[BaseTool] = field(default_factory=list)
def __setattr__(self, name: str, value: Any) -> None: def __setattr__(self, name: str, value: Any) -> None:
"""Raise deprecation warning when setting attributes directly. """Raise deprecation warning when setting attributes directly.
@@ -336,6 +343,27 @@ def msg_content_output(output: Any) -> str | list[dict]:
return str(output) 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): class ToolInvocationError(ToolException):
"""An error occurred while invoking a tool due to invalid arguments. """An error occurred while invoking a tool due to invalid arguments.
@@ -1037,6 +1065,7 @@ class ToolNode(RunnableCallable):
tool=tool, tool=tool,
state=tool_runtime.state, state=tool_runtime.state,
runtime=tool_runtime, runtime=tool_runtime,
available_tools=list(self.tools_by_name.values()),
) )
config = tool_runtime.config config = tool_runtime.config
@@ -1046,13 +1075,18 @@ class ToolNode(RunnableCallable):
return self._execute_tool_sync(tool_request, input_type, config) return self._execute_tool_sync(tool_request, input_type, config)
# Define execute callable that can be called multiple times # Define execute callable that can be called multiple times
original_name, original_tool = call["name"], tool
def execute(req: ToolCallRequest) -> ToolMessage | Command: def execute(req: ToolCallRequest) -> ToolMessage | Command:
"""Execute tool with given request. Can be called multiple times.""" """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) return self._execute_tool_sync(req, input_type, config)
# Call wrapper with request and execute callable # Call wrapper with request and execute callable
try: try:
return self._wrap_tool_call(tool_request, execute) return self._wrap_tool_call(tool_request, execute)
except ToolCallRequestMismatchError:
raise
except Exception as e: except Exception as e:
# Wrapper threw an exception # Wrapper threw an exception
if not self._handle_tool_errors: if not self._handle_tool_errors:
@@ -1184,6 +1218,7 @@ class ToolNode(RunnableCallable):
tool=tool, tool=tool,
state=tool_runtime.state, state=tool_runtime.state,
runtime=tool_runtime, runtime=tool_runtime,
available_tools=list(self.tools_by_name.values()),
) )
config = tool_runtime.config config = tool_runtime.config
@@ -1193,12 +1228,16 @@ class ToolNode(RunnableCallable):
return await self._execute_tool_async(tool_request, input_type, config) return await self._execute_tool_async(tool_request, input_type, config)
# Define async execute callable that can be called multiple times # Define async execute callable that can be called multiple times
original_name, original_tool = call["name"], tool
async def execute(req: ToolCallRequest) -> ToolMessage | Command: async def execute(req: ToolCallRequest) -> ToolMessage | Command:
"""Execute tool with given request. Can be called multiple times.""" """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) return await self._execute_tool_async(req, input_type, config)
def _sync_execute(req: ToolCallRequest) -> ToolMessage | Command: def _sync_execute(req: ToolCallRequest) -> ToolMessage | Command:
"""Sync execute fallback for sync wrapper.""" """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) return self._execute_tool_sync(req, input_type, config)
# Call wrapper with request and execute callable # Call wrapper with request and execute callable
@@ -1208,6 +1247,8 @@ class ToolNode(RunnableCallable):
# None check was performed above already # None check was performed above already
self._wrap_tool_call = cast("ToolCallWrapper", self._wrap_tool_call) self._wrap_tool_call = cast("ToolCallWrapper", self._wrap_tool_call)
return self._wrap_tool_call(tool_request, _sync_execute) return self._wrap_tool_call(tool_request, _sync_execute)
except ToolCallRequestMismatchError:
raise
except Exception as e: except Exception as e:
# Wrapper threw an exception # Wrapper threw an exception
if not self._handle_tool_errors: if not self._handle_tool_errors:
+117 -1
View File
@@ -1,7 +1,7 @@
"""Unit tests for tool call interceptor in ToolNode.""" """Unit tests for tool call interceptor in ToolNode."""
import functools import functools
from collections.abc import Callable from collections.abc import Awaitable, Callable
from unittest.mock import Mock from unittest.mock import Mock
import pytest import pytest
@@ -13,6 +13,7 @@ from langgraph.types import Command
from langgraph.prebuilt.tool_node import ( from langgraph.prebuilt.tool_node import (
ToolCallRequest, ToolCallRequest,
ToolCallRequestMismatchError,
ToolNode, 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.tool == add # Other fields should remain the same
assert fresh_new_request.state == state assert fresh_new_request.state == state
assert fresh_new_request.runtime is None 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(),
)