mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-18 07:37:55 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6d8e63e21 | ||
|
|
a4160c8f37 | ||
|
|
dcd325e581 | ||
|
|
ab478cb40f | ||
|
|
218887d7ce | ||
|
|
230927fb3a |
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user