diff --git a/libs/langgraph/pyproject.toml b/libs/langgraph/pyproject.toml index 427d343ca..71fd4fc5a 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "langgraph" -version = "1.0.1" +version = "1.0.2" description = "Building stateful, multi-actor applications with LLMs" authors = [] requires-python = ">=3.10" @@ -27,7 +27,7 @@ dependencies = [ "langchain-core>=0.1", "langgraph-checkpoint>=2.1.0,<4.0.0", "langgraph-sdk>=0.2.2,<0.3.0", - "langgraph-prebuilt>=1.0.0,<1.1.0", + "langgraph-prebuilt>=1.0.1,<1.1.0", "xxhash>=3.5.0", "pydantic>=2.7.4", ] diff --git a/libs/langgraph/uv.lock b/libs/langgraph/uv.lock index 3fc3ffca1..e52a8bbd3 100644 --- a/libs/langgraph/uv.lock +++ b/libs/langgraph/uv.lock @@ -1345,7 +1345,7 @@ wheels = [ [[package]] name = "langgraph" -version = "1.0.1" +version = "1.0.2" source = { editable = "." } dependencies = [ { name = "langchain-core" }, @@ -1710,7 +1710,7 @@ test = [ [[package]] name = "langgraph-prebuilt" -version = "1.0.1" +version = "1.0.2" source = { editable = "../prebuilt" } dependencies = [ { name = "langchain-core" }, @@ -1732,6 +1732,7 @@ dev = [ { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, { name = "mypy" }, + { name = "psycopg-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, @@ -1750,6 +1751,7 @@ test = [ { name = "langgraph-checkpoint", editable = "../checkpoint" }, { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "psycopg-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, diff --git a/libs/prebuilt/langgraph/prebuilt/__init__.py b/libs/prebuilt/langgraph/prebuilt/__init__.py index 0b9581053..a93cc0219 100644 --- a/libs/prebuilt/langgraph/prebuilt/__init__.py +++ b/libs/prebuilt/langgraph/prebuilt/__init__.py @@ -5,6 +5,7 @@ from langgraph.prebuilt.tool_node import ( InjectedState, InjectedStore, ToolNode, + ToolRuntime, tools_condition, ) from langgraph.prebuilt.tool_validator import ValidationNode @@ -16,4 +17,5 @@ __all__ = [ "ValidationNode", "InjectedState", "InjectedStore", + "ToolRuntime", ] diff --git a/libs/prebuilt/langgraph/prebuilt/chat_agent_executor.py b/libs/prebuilt/langgraph/prebuilt/chat_agent_executor.py index 668a8bbf3..c30c64676 100644 --- a/libs/prebuilt/langgraph/prebuilt/chat_agent_executor.py +++ b/libs/prebuilt/langgraph/prebuilt/chat_agent_executor.py @@ -44,7 +44,7 @@ from langgraph.warnings import LangGraphDeprecatedSinceV10 from pydantic import BaseModel from typing_extensions import NotRequired, TypedDict, deprecated -from langgraph.prebuilt.tool_node import ToolNode +from langgraph.prebuilt.tool_node import ToolCallWithContext, ToolNode StructuredResponse = dict | BaseModel StructuredResponseSchema = dict | type[BaseModel] @@ -826,11 +826,17 @@ def create_react_agent( elif version == "v2": if post_model_hook is not None: return "post_model_hook" - tool_calls = [ - tool_node.inject_tool_args(call, state, store) # type: ignore[arg-type] + return [ + Send( + "tools", + ToolCallWithContext( + __type="tool_call_with_context", + tool_call=call, + state=state, + ), + ) for call in last_message.tool_calls ] - return [Send("tools", [tool_call]) for tool_call in tool_calls] # Define a new graph workflow = StateGraph( @@ -911,11 +917,17 @@ def create_react_agent( ] if pending_tool_calls: - pending_tool_calls = [ - tool_node.inject_tool_args(call, state, store) # type: ignore[arg-type] + return [ + Send( + "tools", + ToolCallWithContext( + __type="tool_call_with_context", + tool_call=call, + state=state, + ), + ) for call in pending_tool_calls ] - return [Send("tools", [tool_call]) for tool_call in pending_tool_calls] elif isinstance(messages[-1], ToolMessage): return entrypoint elif response_format is not None: diff --git a/libs/prebuilt/langgraph/prebuilt/tool_node.py b/libs/prebuilt/langgraph/prebuilt/tool_node.py index 8bbc81c29..78c6222f1 100644 --- a/libs/prebuilt/langgraph/prebuilt/tool_node.py +++ b/libs/prebuilt/langgraph/prebuilt/tool_node.py @@ -5,7 +5,7 @@ This module provides prebuilt functionality for executing tools in LangGraph. Tools are functions that models can call to interact with external systems, APIs, databases, or perform computations. -The module implements several key design patterns: +The module implements design patterns for: - Parallel execution of multiple tool calls for efficiency - Robust error handling with customizable error messages - State injection for tools that need access to graph state @@ -13,35 +13,43 @@ The module implements several key design patterns: - Command-based state updates for advanced control flow Key Components: - ToolNode: Main class for executing tools in LangGraph workflows - InjectedState: Annotation for injecting graph state into tools - InjectedStore: Annotation for injecting persistent store into tools - tools_condition: Utility function for conditional routing based on tool calls + `ToolNode`: Main class for executing tools in LangGraph workflows + `InjectedState`: Annotation for injecting graph state into tools + `InjectedStore`: Annotation for injecting persistent store into tools + `ToolRuntime`: Runtime information for tools, bundling together state, context, config, stream_writer, tool_call_id, and store + `tools_condition`: Utility function for conditional routing based on tool calls Typical Usage: ```python from langchain_core.tools import tool - from langgraph.prebuilt import ToolNode + from langchain.tools import ToolNode + @tool def my_tool(x: int) -> str: return f"Result: {x}" + tool_node = ToolNode([my_tool]) ``` """ +from __future__ import annotations + import asyncio import inspect import json -import types -from collections.abc import Callable, Sequence +from collections.abc import Awaitable, Callable from copy import copy, deepcopy -from dataclasses import replace +from dataclasses import dataclass, replace +from types import UnionType from typing import ( + TYPE_CHECKING, Annotated, Any, + Generic, Literal, + TypedDict, Union, cast, get_args, @@ -57,8 +65,8 @@ from langchain_core.messages import ( ToolMessage, convert_to_messages, ) -from langchain_core.runnables import RunnableConfig from langchain_core.runnables.config import ( + RunnableConfig, get_config_list, get_executor_for_config, ) @@ -66,69 +74,303 @@ from langchain_core.tools import BaseTool, InjectedToolArg from langchain_core.tools import tool as create_tool from langchain_core.tools.base import ( TOOL_MESSAGE_BLOCK_TYPES, + ToolException, + _DirectlyInjectedToolArg, get_all_basemodel_annotations, ) from langgraph._internal._runnable import RunnableCallable from langgraph.errors import GraphBubbleUp from langgraph.graph.message import REMOVE_ALL_MESSAGES -from langgraph.store.base import BaseStore -from langgraph.types import Command, Send -from pydantic import BaseModel +from langgraph.store.base import BaseStore # noqa: TC002 +from langgraph.types import Command, Send, StreamWriter +from pydantic import BaseModel, ValidationError +from typing_extensions import TypeVar, Unpack + +if TYPE_CHECKING: + from collections.abc import Sequence + + from langgraph.runtime import Runtime + from pydantic_core import ErrorDetails + +# right now we use a dict as the default, can change this to AgentState, but depends +# on if this lives in LangChain or LangGraph... ideally would have some typed +# messages key +StateT = TypeVar("StateT", default=dict) +ContextT = TypeVar("ContextT", default=None) INVALID_TOOL_NAME_ERROR_TEMPLATE = ( "Error: {requested_tool} is not a valid tool, try one of [{available_tools}]." ) TOOL_CALL_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes." +TOOL_EXECUTION_ERROR_TEMPLATE = ( + "Error executing tool '{tool_name}' with kwargs {tool_kwargs} with error:\n" + " {error}\n" + " Please fix the error and try again." +) +TOOL_INVOCATION_ERROR_TEMPLATE = ( + "Error invoking tool '{tool_name}' with kwargs {tool_kwargs} with error:\n" + " {error}\n" + " Please fix the error and try again." +) + + +class _ToolCallRequestOverrides(TypedDict, total=False): + """Possible overrides for ToolCallRequest.override() method.""" + + tool_call: ToolCall + + +@dataclass +class ToolCallRequest: + """Tool execution request passed to tool call interceptors. + + Attributes: + tool_call: Tool call dict with name, args, and id from model output. + 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). + """ + + tool_call: ToolCall + tool: BaseTool | None + state: Any + runtime: ToolRuntime + + def override( + self, **overrides: Unpack[_ToolCallRequestOverrides] + ) -> ToolCallRequest: + """Replace the request with a new request with the given overrides. + + Returns a new `ToolCallRequest` instance with the specified attributes replaced. + This follows an immutable pattern, leaving the original request unchanged. + + Args: + **overrides: Keyword arguments for attributes to override. Supported keys: + - tool_call: Tool call dict with name, args, and id + + Returns: + New ToolCallRequest instance with specified overrides applied. + + Examples: + ```python + # Modify tool call arguments without mutating original + modified_call = {**request.tool_call, "args": {"value": 10}} + new_request = request.override(tool_call=modified_call) + + # Override multiple attributes + new_request = request.override(tool_call=modified_call, state=new_state) + ``` + """ + return replace(self, **overrides) + + +ToolCallWrapper = Callable[ + [ToolCallRequest, Callable[[ToolCallRequest], ToolMessage | Command]], + ToolMessage | Command, +] +"""Wrapper for tool call execution with multi-call support. + +Wrapper receives: + request: ToolCallRequest with tool_call, tool, state, and runtime. + execute: Callable to execute the tool (CAN BE CALLED MULTIPLE TIMES). + +Returns: + ToolMessage or Command (the final result). + +The execute callable can be invoked multiple times for retry logic, +with potentially modified requests each time. Each call to execute +is independent and stateless. + +!!! note + When implementing middleware for `create_agent`, use + `AgentMiddleware.wrap_tool_call` which provides properly typed + state parameter for better type safety. + +Examples: + Passthrough (execute once): + + def handler(request, execute): + return execute(request) + + Modify request before execution: + + ```python + def handler(request, execute): + request.tool_call["args"]["value"] *= 2 + return execute(request) + ``` + + Retry on error (execute multiple times): + + ```python + def handler(request, execute): + for attempt in range(3): + try: + result = execute(request) + if is_valid(result): + return result + except Exception: + if attempt == 2: + raise + return result + ``` + + Conditional retry based on response: + + ```python + def handler(request, execute): + for attempt in range(3): + result = execute(request) + if isinstance(result, ToolMessage) and result.status != "error": + return result + if attempt < 2: + continue + return result + ``` + + Cache/short-circuit without calling execute: + + ```python + def handler(request, execute): + if cached := get_cache(request): + return ToolMessage(content=cached, tool_call_id=request.tool_call["id"]) + result = execute(request) + save_cache(request, result) + return result + ``` +""" + +AsyncToolCallWrapper = Callable[ + [ToolCallRequest, Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]]], + Awaitable[ToolMessage | Command], +] +"""Async wrapper for tool call execution with multi-call support.""" + + +class ToolCallWithContext(TypedDict): + """ToolCall with additional context for graph state. + + This is an internal data structure meant to help the `ToolNode` accept + tool calls with additional context (e.g. state) when dispatched using the + Send API. + + The Send API is used in create_agent to distribute tool calls in parallel + and support human-in-the-loop workflows where graph execution may be paused + for an indefinite time. + """ + + tool_call: ToolCall + __type: Literal["tool_call_with_context"] + """Type to parameterize the payload. + + Using "__" as a prefix to be defensive against potential name collisions with + regular user state. + """ + state: Any + """The state is provided as additional context.""" def msg_content_output(output: Any) -> str | list[dict]: - """Convert tool output to valid message content format. + """Convert tool output to `ToolMessage` content format. - LangChain ToolMessages accept either string content or a list of content blocks. - This function ensures tool outputs are properly formatted for message consumption - by attempting to preserve structured data when possible, falling back to JSON - serialization or string conversion. + Handles `str`, `list[dict]` (content blocks), and arbitrary objects by attempting + JSON serialization with fallback to str(). Args: - output: The raw output from a tool execution. Can be any type. + output: Tool execution output of any type. Returns: - Either a string representation of the output or a list of content blocks - if the output is already in the correct format for structured content. - - Note: - This function prioritizes backward compatibility by defaulting to JSON - serialization rather than supporting all possible message content formats. + String or list of content blocks suitable for `ToolMessage.content`. """ - if isinstance(output, str): - return output - elif isinstance(output, list) and all( - [ + if isinstance(output, str) or ( + isinstance(output, list) + and all( isinstance(x, dict) and x.get("type") in TOOL_MESSAGE_BLOCK_TYPES for x in output - ] + ) ): return output # Technically a list of strings is also valid message content, but it's # not currently well tested that all chat models support this. # And for backwards compatibility we want to make sure we don't break # any existing ToolNode usage. - else: - try: - return json.dumps(output, ensure_ascii=False) - except Exception: - return str(output) + try: + return json.dumps(output, ensure_ascii=False) + except Exception: # noqa: BLE001 + return str(output) + + +class ToolInvocationError(ToolException): + """An error occurred while invoking a tool due to invalid arguments. + + This exception is only raised when invoking a tool using the `ToolNode`! + """ + + def __init__( + self, + tool_name: str, + source: ValidationError, + tool_kwargs: dict[str, Any], + filtered_errors: list[ErrorDetails] | None = None, + ) -> None: + """Initialize the ToolInvocationError. + + Args: + tool_name: The name of the tool that failed. + source: The exception that occurred. + tool_kwargs: The keyword arguments that were passed to the tool. + filtered_errors: Optional list of filtered validation errors excluding + injected arguments. + """ + # Format error display based on filtered errors if provided + if filtered_errors is not None: + # Manually format the filtered errors without URLs or fancy formatting + error_str_parts = [] + for error in filtered_errors: + loc_str = ".".join(str(loc) for loc in error.get("loc", ())) + msg = error.get("msg", "Unknown error") + error_str_parts.append(f"{loc_str}: {msg}") + error_display_str = "\n".join(error_str_parts) + else: + error_display_str = str(source) + + self.message = TOOL_INVOCATION_ERROR_TEMPLATE.format( + tool_name=tool_name, tool_kwargs=tool_kwargs, error=error_display_str + ) + self.tool_name = tool_name + self.tool_kwargs = tool_kwargs + self.source = source + self.filtered_errors = filtered_errors + super().__init__(self.message) + + +def _default_handle_tool_errors(e: Exception) -> str: + """Default error handler for tool errors. + + If the tool is a tool invocation error, return its message. + Otherwise, raise the error. + """ + if isinstance(e, ToolInvocationError): + return e.message + raise e def _handle_tool_error( e: Exception, *, - flag: bool | str | Callable[..., str] | tuple[type[Exception], ...], + flag: bool + | str + | Callable[..., str] + | type[Exception] + | tuple[type[Exception], ...], ) -> str: """Generate error message content based on exception handling configuration. This function centralizes error message generation logic, supporting different - error handling strategies configured via the ToolNode's handle_tool_errors + error handling strategies configured via the `ToolNode`'s `handle_tool_errors` parameter. Args: @@ -140,26 +382,29 @@ def _handle_tool_error( - tuple: Not used in this context (handled by caller) Returns: - A string containing the error message to include in the ToolMessage. + A string containing the error message to include in the `ToolMessage`. Raises: ValueError: If flag is not one of the supported types. - Note: + !!! note The tuple case is handled by the caller through exception type checking, not by this function directly. """ - if isinstance(flag, (bool, tuple)): + if isinstance(flag, (bool, tuple)) or ( + isinstance(flag, type) and issubclass(flag, Exception) + ): content = TOOL_CALL_ERROR_TEMPLATE.format(error=repr(e)) elif isinstance(flag, str): content = flag elif callable(flag): - content = flag(e) + content = flag(e) # type: ignore [assignment, call-arg] else: - raise ValueError( + msg = ( f"Got unexpected type of `handle_tool_error`. Expected bool, str " f"or callable. Received: {flag}" ) + raise ValueError(msg) return content @@ -181,9 +426,9 @@ def _infer_handled_types(handler: Callable[..., str]) -> tuple[type[Exception], Raises: ValueError: If the handler's annotation contains non-Exception types or - if Union types contain non-Exception types. + if Union types contain non-Exception types. - Note: + !!! note This function supports both single exception types and Union types for handlers that need to handle multiple exception types differently. """ @@ -199,51 +444,159 @@ def _infer_handled_types(handler: Callable[..., str]) -> tuple[type[Exception], type_hints = get_type_hints(handler) if first_param.name in type_hints: origin = get_origin(first_param.annotation) - # Handle both typing.Union and types.UnionType (Python 3.10+ X | Y syntax) - if origin is Union or origin is types.UnionType: + if origin in [Union, UnionType]: args = get_args(first_param.annotation) if all(issubclass(arg, Exception) for arg in args): return tuple(args) - else: - raise ValueError( - "All types in the error handler error annotation must be " - "Exception types. For example, " - "`def custom_handler(e: Union[ValueError, TypeError])`. " - f"Got '{first_param.annotation}' instead." - ) + msg = ( + "All types in the error handler error annotation must be " + "Exception types. For example, " + "`def custom_handler(e: Union[ValueError, TypeError])`. " + f"Got '{first_param.annotation}' instead." + ) + raise ValueError(msg) exception_type = type_hints[first_param.name] if Exception in exception_type.__mro__: return (exception_type,) - else: - raise ValueError( - f"Arbitrary types are not supported in the error handler " - f"signature. Please annotate the error with either a " - f"specific Exception type or a union of Exception types. " - "For example, `def custom_handler(e: ValueError)` or " - "`def custom_handler(e: Union[ValueError, TypeError])`. " - f"Got '{exception_type}' instead." - ) + msg = ( + f"Arbitrary types are not supported in the error handler " + f"signature. Please annotate the error with either a " + f"specific Exception type or a union of Exception types. " + "For example, `def custom_handler(e: ValueError)` or " + "`def custom_handler(e: Union[ValueError, TypeError])`. " + f"Got '{exception_type}' instead." + ) + raise ValueError(msg) # If no type information is available, return (Exception,) # for backwards compatibility. return (Exception,) +def _filter_validation_errors( + validation_error: ValidationError, + tool_to_state_args: dict[str, str | None], + tool_to_store_arg: str | None, + tool_to_runtime_arg: str | None, +) -> list[ErrorDetails]: + """Filter validation errors to only include LLM-controlled arguments. + + When a tool invocation fails validation, only errors for arguments that the LLM + controls should be included in error messages. This ensures the LLM receives + focused, actionable feedback about parameters it can actually fix. System-injected + arguments (state, store, runtime) are filtered out since the LLM has no control + over them. + + This function also removes injected argument values from the `input` field in error + details, ensuring that only LLM-provided arguments appear in error messages. + + Args: + validation_error: The Pydantic ValidationError raised during tool invocation. + tool_to_state_args: Mapping of state argument names to state field names. + tool_to_store_arg: Name of the store argument, if any. + tool_to_runtime_arg: Name of the runtime argument, if any. + + Returns: + List of ErrorDetails containing only errors for LLM-controlled arguments, + with system-injected argument values removed from the input field. + """ + injected_args = set(tool_to_state_args.keys()) + if tool_to_store_arg: + injected_args.add(tool_to_store_arg) + if tool_to_runtime_arg: + injected_args.add(tool_to_runtime_arg) + + filtered_errors: list[ErrorDetails] = [] + for error in validation_error.errors(): + # Check if error location contains any injected argument + # error['loc'] is a tuple like ('field_name',) or ('field_name', 'nested_field') + if error["loc"] and error["loc"][0] not in injected_args: + # Create a copy of the error dict to avoid mutating the original + error_copy: dict[str, Any] = {**error} + + # Remove injected arguments from input_value if it's a dict + if isinstance(error_copy.get("input"), dict): + input_dict = error_copy["input"] + input_copy = { + k: v for k, v in input_dict.items() if k not in injected_args + } + error_copy["input"] = input_copy + + # Cast is safe because ErrorDetails is a TypedDict compatible with this structure + filtered_errors.append(error_copy) # type: ignore[arg-type] + + return filtered_errors + + class ToolNode(RunnableCallable): - """A node that runs the tools called in the last AIMessage. + """A node for executing tools in LangGraph workflows. - It can be used either in StateGraph with a "messages" state key (or a custom key passed via ToolNode's 'messages_key'). - If multiple tool calls are requested, they will be run in parallel. The output will be - a list of ToolMessages, one for each tool call. + Handles tool execution patterns including function calls, state injection, + persistent storage, and control flow. Manages parallel execution, + error handling. - Tool calls can also be passed directly as a list of `ToolCall` dicts. + Input Formats: + 1. Graph state with `messages` key that has a list of messages: + - Common representation for agentic workflows + - Supports custom messages key via `messages_key` parameter - Example: - Basic usage with simple tools: + 2. **Message List**: `[AIMessage(..., tool_calls=[...])]` + - List of messages with tool calls in the last AIMessage + + 3. **Direct Tool Calls**: `[{"name": "tool", "args": {...}, "id": "1", "type": "tool_call"}]` + - Bypasses message parsing for direct tool execution + - For programmatic tool invocation and testing + + Output Formats: + Output format depends on input type and tool behavior: + + **For Regular tools**: + - Dict input → `{"messages": [ToolMessage(...)]}` + - List input → `[ToolMessage(...)]` + + **For Command tools**: + - Returns `[Command(...)]` or mixed list with regular tool outputs + - Commands can update state, trigger navigation, or send messages + + Args: + tools: A sequence of tools that can be invoked by this node. Supports: + - **BaseTool instances**: Tools with schemas and metadata + - **Plain functions**: Automatically converted to tools with inferred schemas + name: The name identifier for this node in the graph. Used for debugging + and visualization. Defaults to "tools". + tags: Optional metadata tags to associate with the node for filtering + and organization. Defaults to `None`. + handle_tool_errors: Configuration for error handling during tool execution. + Supports multiple strategies: + + - **True**: Catch all errors and return a ToolMessage with the default + error template containing the exception details. + - **str**: Catch all errors and return a ToolMessage with this custom + error message string. + - **type[Exception]**: Only catch exceptions with the specified type and + return the default error message for it. + - **tuple[type[Exception], ...]**: Only catch exceptions with the specified + types and return default error messages for them. + - **Callable[..., str]**: Catch exceptions matching the callable's signature + and return the string result of calling it with the exception. + - **False**: Disable error handling entirely, allowing exceptions to + propagate. + + Defaults to a callable that: + - catches tool invocation errors (due to invalid arguments provided by the model) and returns a descriptive error message + - ignores tool execution errors (they will be re-raised) + + messages_key: The key in the state dictionary that contains the message list. + This same key will be used for the output `ToolMessage` objects. + Defaults to "messages". + Allows custom state schemas with different message field names. + + Examples: + Basic usage: ```python - from langgraph.prebuilt import ToolNode + from langchain.tools import ToolNode from langchain_core.tools import tool @tool @@ -254,38 +607,32 @@ class ToolNode(RunnableCallable): tool_node = ToolNode([calculator]) ``` - Custom error handling: + State injection: ```python - def handle_math_errors(e: ZeroDivisionError) -> str: - return "Cannot divide by zero!" + from typing_extensions import Annotated + from langchain.tools import InjectedState - tool_node = ToolNode([calculator], handle_tool_errors=handle_math_errors) + @tool + def context_tool(query: str, state: Annotated[dict, InjectedState]) -> str: + \"\"\"Some tool that uses state.\"\"\" + return f"Query: {query}, Messages: {len(state['messages'])}" + + tool_node = ToolNode([context_tool]) ``` - Direct tool call execution: + Error handling: ```python - tool_calls = [{"name": "calculator", "args": {"a": 5, "b": 3}, "id": "1", "type": "tool_call"}] - result = tool_node.invoke(tool_calls) + def handle_errors(e: ValueError) -> str: + return "Invalid input provided" + + + tool_node = ToolNode([my_tool], handle_tool_errors=handle_errors) ``` + """ # noqa: E501 - Note: - The ToolNode expects input in one of three formats: - 1. A dictionary with a messages key containing a list of messages - 2. A list of messages directly - 3. A list of tool call dictionaries - - When using message formats, the last message must be an AIMessage with - tool_calls populated. The node automatically extracts and processes these - tool calls concurrently. - - For advanced use cases involving state injection or store access, tools - can be annotated with InjectedState or InjectedStore to receive graph - context automatically. - """ - - name: str = "ToolNode" + name: str = "tools" def __init__( self, @@ -296,60 +643,79 @@ class ToolNode(RunnableCallable): handle_tool_errors: bool | str | Callable[..., str] - | tuple[type[Exception], ...] = True, + | type[Exception] + | tuple[type[Exception], ...] = _default_handle_tool_errors, messages_key: str = "messages", + wrap_tool_call: ToolCallWrapper | None = None, + awrap_tool_call: AsyncToolCallWrapper | None = None, ) -> None: - """Initialize the ToolNode with the provided tools and configuration. + """Initialize `ToolNode` with tools and configuration. Args: - tools: A sequence of tools that can be invoked by this node. Tools can be - BaseTool instances or plain functions that will be converted to tools. - name: The name identifier for this node in the graph. Used for debugging - and visualization. - tags: Optional metadata tags to associate with the node for filtering - and organization. - handle_tool_errors: Configuration for error handling during tool execution. - Defaults to True. Supports multiple strategies: - - - True: Catch all errors and return a ToolMessage with the default - error template containing the exception details. - - str: Catch all errors and return a ToolMessage with this custom - error message string. - - tuple[type[Exception], ...]: Only catch exceptions of the specified - types and return default error messages for them. - - Callable[..., str]: Catch exceptions matching the callable's signature - and return the string result of calling it with the exception. - - False: Disable error handling entirely, allowing exceptions to propagate. - messages_key: The key in the state dictionary that contains the message list. - This same key will be used for the output ToolMessages. + tools: Sequence of tools to make available for execution. + name: Node name for graph identification. + tags: Optional metadata tags. + handle_tool_errors: Error handling configuration. + messages_key: State key containing messages. + wrap_tool_call: Sync wrapper function to intercept tool execution. Receives + ToolCallRequest and execute callable, returns ToolMessage or Command. + Enables retries, caching, request modification, and control flow. + awrap_tool_call: Async wrapper function to intercept tool execution. + If not provided, falls back to wrap_tool_call for async execution. """ super().__init__(self._func, self._afunc, name=name, tags=tags, trace=False) - self.tools_by_name: dict[str, BaseTool] = {} - self.tool_to_state_args: dict[str, dict[str, str | None]] = {} - self.tool_to_store_arg: dict[str, str | None] = {} - self.handle_tool_errors = handle_tool_errors - self.messages_key = messages_key - for tool_ in tools: - if not isinstance(tool_, BaseTool): - tool_ = create_tool(tool_) - self.tools_by_name[tool_.name] = tool_ - self.tool_to_state_args[tool_.name] = _get_state_args(tool_) - self.tool_to_store_arg[tool_.name] = _get_store_arg(tool_) + self._tools_by_name: dict[str, BaseTool] = {} + self._tool_to_state_args: dict[str, dict[str, str | None]] = {} + self._tool_to_store_arg: dict[str, str | None] = {} + self._tool_to_runtime_arg: dict[str, str | None] = {} + self._handle_tool_errors = handle_tool_errors + self._messages_key = messages_key + self._wrap_tool_call = wrap_tool_call + self._awrap_tool_call = awrap_tool_call + for tool in tools: + if not isinstance(tool, BaseTool): + tool_ = create_tool(cast("type[BaseTool]", tool)) + else: + tool_ = tool + self._tools_by_name[tool_.name] = tool_ + self._tool_to_state_args[tool_.name] = _get_state_args(tool_) + self._tool_to_store_arg[tool_.name] = _get_store_arg(tool_) + self._tool_to_runtime_arg[tool_.name] = _get_runtime_arg(tool_) + + @property + def tools_by_name(self) -> dict[str, BaseTool]: + """Mapping from tool name to BaseTool instance.""" + return self._tools_by_name def _func( self, input: list[AnyMessage] | dict[str, Any] | BaseModel, config: RunnableConfig, - *, - store: BaseStore | None, + runtime: Runtime, ) -> Any: - tool_calls, input_type = self._parse_input(input, store) + tool_calls, input_type = self._parse_input(input) config_list = get_config_list(config, len(tool_calls)) + + # Construct ToolRuntime instances at the top level for each tool call + tool_runtimes = [] + for call, cfg in zip(tool_calls, config_list, strict=False): + state = self._extract_state(input) + tool_runtime = ToolRuntime( + state=state, + tool_call_id=call["id"], + config=cfg, + context=runtime.context, + store=runtime.store, + stream_writer=runtime.stream_writer, + ) + tool_runtimes.append(tool_runtime) + + # Pass original tool calls without injection input_types = [input_type] * len(tool_calls) with get_executor_for_config(config) as executor: - outputs = [ - *executor.map(self._run_one, tool_calls, input_types, config_list) - ] + outputs = list( + executor.map(self._run_one, tool_calls, input_types, tool_runtimes) + ) return self._combine_tool_outputs(outputs, input_type) @@ -357,26 +723,43 @@ class ToolNode(RunnableCallable): self, input: list[AnyMessage] | dict[str, Any] | BaseModel, config: RunnableConfig, - *, - store: BaseStore | None, + runtime: Runtime, ) -> Any: - tool_calls, input_type = self._parse_input(input, store) - outputs = await asyncio.gather( - *(self._arun_one(call, input_type, config) for call in tool_calls) - ) + tool_calls, input_type = self._parse_input(input) + config_list = get_config_list(config, len(tool_calls)) + + # Construct ToolRuntime instances at the top level for each tool call + tool_runtimes = [] + for call, cfg in zip(tool_calls, config_list, strict=False): + state = self._extract_state(input) + tool_runtime = ToolRuntime( + state=state, + tool_call_id=call["id"], + config=cfg, + context=runtime.context, + store=runtime.store, + stream_writer=runtime.stream_writer, + ) + tool_runtimes.append(tool_runtime) + + # Pass original tool calls without injection + coros = [] + for call, tool_runtime in zip(tool_calls, tool_runtimes, strict=False): + coros.append(self._arun_one(call, input_type, tool_runtime)) # type: ignore[arg-type] + outputs = await asyncio.gather(*coros) return self._combine_tool_outputs(outputs, input_type) def _combine_tool_outputs( self, - outputs: list[ToolMessage], + outputs: list[ToolMessage | Command], input_type: Literal["list", "dict", "tool_calls"], ) -> list[Command | list[ToolMessage] | dict[str, list[ToolMessage]]]: # preserve existing behavior for non-command tool outputs for backwards # compatibility if not any(isinstance(output, Command) for output in outputs): # TypedDict, pydantic, dataclass, etc. should all be able to load from dict - return outputs if input_type == "list" else {self.messages_key: outputs} + return outputs if input_type == "list" else {self._messages_key: outputs} # LangGraph will automatically handle list of Command and non-command node # updates @@ -396,7 +779,7 @@ class ToolNode(RunnableCallable): if parent_command: parent_command = replace( parent_command, - goto=cast(list[Send], parent_command.goto) + output.goto, + goto=cast("list[Send]", parent_command.goto) + output.goto, ) else: parent_command = Command(graph=Command.PARENT, goto=output.goto) @@ -404,50 +787,97 @@ class ToolNode(RunnableCallable): combined_outputs.append(output) else: combined_outputs.append( - [output] if input_type == "list" else {self.messages_key: [output]} + [output] if input_type == "list" else {self._messages_key: [output]} ) if parent_command: combined_outputs.append(parent_command) return combined_outputs - def _run_one( + def _execute_tool_sync( self, - call: ToolCall, + request: ToolCallRequest, input_type: Literal["list", "dict", "tool_calls"], config: RunnableConfig, - ) -> ToolMessage: - """Run a single tool call synchronously.""" - if invalid_tool_message := self._validate_tool_call(call): - return invalid_tool_message + ) -> ToolMessage | Command: + """Execute tool call with configured error handling. + + Args: + request: Tool execution request. + input_type: Input format. + config: Runnable configuration. + + Returns: + ToolMessage or Command. + + Raises: + Exception: If tool fails and handle_tool_errors is False. + """ + call = request.tool_call + tool = request.tool + + # Validate tool exists when we actually need to execute it + if tool is None: + if invalid_tool_message := self._validate_tool_call(call): + return invalid_tool_message + # This should never happen if validation works correctly + msg = f"Tool {call['name']} is not registered with ToolNode" + raise TypeError(msg) + + # Inject state, store, and runtime right before invocation + injected_call = self._inject_tool_args(call, request.runtime) + call_args = {**injected_call, "type": "tool_call"} + try: - call_args = {**call, **{"type": "tool_call"}} - response = self.tools_by_name[call["name"]].invoke(call_args, config) + try: + response = tool.invoke(call_args, config) + except ValidationError as exc: + # Filter out errors for injected arguments + filtered_errors = _filter_validation_errors( + exc, + self._tool_to_state_args.get(call["name"], {}), + self._tool_to_store_arg.get(call["name"]), + self._tool_to_runtime_arg.get(call["name"]), + ) + # Use original call["args"] without injected values for error reporting + raise ToolInvocationError( + call["name"], exc, call["args"], filtered_errors + ) from exc # GraphInterrupt is a special exception that will always be raised. # It can be triggered in the following scenarios, - # Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation most commonly: + # Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation + # most commonly: # (1) a GraphInterrupt is raised inside a tool # (2) a GraphInterrupt is raised inside a graph node for a graph called as a tool - # (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph called as a tool + # (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph + # called as a tool # (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture) - except GraphBubbleUp as e: - raise e + except GraphBubbleUp: + raise except Exception as e: - if isinstance(self.handle_tool_errors, tuple): - handled_types: tuple = self.handle_tool_errors - elif callable(self.handle_tool_errors): - handled_types = _infer_handled_types(self.handle_tool_errors) + # Determine which exception types are handled + handled_types: tuple[type[Exception], ...] + if isinstance(self._handle_tool_errors, type) and issubclass( + self._handle_tool_errors, Exception + ): + handled_types = (self._handle_tool_errors,) + elif isinstance(self._handle_tool_errors, tuple): + handled_types = self._handle_tool_errors + elif callable(self._handle_tool_errors) and not isinstance( + self._handle_tool_errors, type + ): + handled_types = _infer_handled_types(self._handle_tool_errors) else: # default behavior is catching all exceptions handled_types = (Exception,) - # Unhandled - if not self.handle_tool_errors or not isinstance(e, handled_types): - raise e - # Handled - else: - content = _handle_tool_error(e, flag=self.handle_tool_errors) + # Check if this error should be handled + if not self._handle_tool_errors or not isinstance(e, handled_types): + raise + + # Error is handled - create error ToolMessage + content = _handle_tool_error(e, flag=self._handle_tool_errors) return ToolMessage( content=content, name=call["name"], @@ -455,133 +885,322 @@ class ToolNode(RunnableCallable): status="error", ) + # Process successful response if isinstance(response, Command): - return self._validate_tool_command(response, call, input_type) - elif isinstance(response, ToolMessage): - response.content = cast(str | list, msg_content_output(response.content)) + # Validate Command before returning to handler + return self._validate_tool_command(response, request.tool_call, input_type) + if isinstance(response, ToolMessage): + response.content = cast("str | list", msg_content_output(response.content)) return response - else: - raise TypeError( - f"Tool {call['name']} returned unexpected type: {type(response)}" + + msg = f"Tool {call['name']} returned unexpected type: {type(response)}" + raise TypeError(msg) + + def _run_one( + self, + call: ToolCall, + input_type: Literal["list", "dict", "tool_calls"], + tool_runtime: ToolRuntime, + ) -> ToolMessage | Command: + """Execute single tool call with wrap_tool_call wrapper if configured. + + Args: + call: Tool call dict. + input_type: Input format. + tool_runtime: Tool runtime. + + Returns: + ToolMessage or Command. + """ + # Validation is deferred to _execute_tool_sync to allow interceptors + # to short-circuit requests for unregistered tools + tool = self.tools_by_name.get(call["name"]) + + # Create the tool request with state and runtime + tool_request = ToolCallRequest( + tool_call=call, + tool=tool, + state=tool_runtime.state, + runtime=tool_runtime, + ) + + config = tool_runtime.config + + if self._wrap_tool_call is None: + # No wrapper - execute directly + return self._execute_tool_sync(tool_request, input_type, config) + + # Define execute callable that can be called multiple times + def execute(req: ToolCallRequest) -> ToolMessage | Command: + """Execute tool with given request. Can be called multiple times.""" + 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 Exception as e: + # Wrapper threw an exception + if not self._handle_tool_errors: + raise + # Convert to error message + content = _handle_tool_error(e, flag=self._handle_tool_errors) + return ToolMessage( + content=content, + name=tool_request.tool_call["name"], + tool_call_id=tool_request.tool_call["id"], + status="error", ) + async def _execute_tool_async( + self, + request: ToolCallRequest, + input_type: Literal["list", "dict", "tool_calls"], + config: RunnableConfig, + ) -> ToolMessage | Command: + """Execute tool call asynchronously with configured error handling. + + Args: + request: Tool execution request. + input_type: Input format. + config: Runnable configuration. + + Returns: + ToolMessage or Command. + + Raises: + Exception: If tool fails and handle_tool_errors is False. + """ + call = request.tool_call + tool = request.tool + + # Validate tool exists when we actually need to execute it + if tool is None: + if invalid_tool_message := self._validate_tool_call(call): + return invalid_tool_message + # This should never happen if validation works correctly + msg = f"Tool {call['name']} is not registered with ToolNode" + raise TypeError(msg) + + # Inject state, store, and runtime right before invocation + injected_call = self._inject_tool_args(call, request.runtime) + call_args = {**injected_call, "type": "tool_call"} + + try: + try: + response = await tool.ainvoke(call_args, config) + except ValidationError as exc: + # Filter out errors for injected arguments + filtered_errors = _filter_validation_errors( + exc, + self._tool_to_state_args.get(call["name"], {}), + self._tool_to_store_arg.get(call["name"]), + self._tool_to_runtime_arg.get(call["name"]), + ) + # Use original call["args"] without injected values for error reporting + raise ToolInvocationError( + call["name"], exc, call["args"], filtered_errors + ) from exc + + # GraphInterrupt is a special exception that will always be raised. + # It can be triggered in the following scenarios, + # Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation + # most commonly: + # (1) a GraphInterrupt is raised inside a tool + # (2) a GraphInterrupt is raised inside a graph node for a graph called as a tool + # (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph + # called as a tool + # (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture) + except GraphBubbleUp: + raise + except Exception as e: + # Determine which exception types are handled + handled_types: tuple[type[Exception], ...] + if isinstance(self._handle_tool_errors, type) and issubclass( + self._handle_tool_errors, Exception + ): + handled_types = (self._handle_tool_errors,) + elif isinstance(self._handle_tool_errors, tuple): + handled_types = self._handle_tool_errors + elif callable(self._handle_tool_errors) and not isinstance( + self._handle_tool_errors, type + ): + handled_types = _infer_handled_types(self._handle_tool_errors) + else: + # default behavior is catching all exceptions + handled_types = (Exception,) + + # Check if this error should be handled + if not self._handle_tool_errors or not isinstance(e, handled_types): + raise + + # Error is handled - create error ToolMessage + content = _handle_tool_error(e, flag=self._handle_tool_errors) + return ToolMessage( + content=content, + name=call["name"], + tool_call_id=call["id"], + status="error", + ) + + # Process successful response + if isinstance(response, Command): + # Validate Command before returning to handler + return self._validate_tool_command(response, request.tool_call, input_type) + if isinstance(response, ToolMessage): + response.content = cast("str | list", msg_content_output(response.content)) + return response + + msg = f"Tool {call['name']} returned unexpected type: {type(response)}" + raise TypeError(msg) + async def _arun_one( self, call: ToolCall, input_type: Literal["list", "dict", "tool_calls"], - config: RunnableConfig, - ) -> ToolMessage: - """Run a single tool call asynchronously.""" - if invalid_tool_message := self._validate_tool_call(call): - return invalid_tool_message + tool_runtime: ToolRuntime, + ) -> ToolMessage | Command: + """Execute single tool call asynchronously with awrap_tool_call wrapper if configured. + Args: + call: Tool call dict. + input_type: Input format. + tool_runtime: Tool runtime. + + Returns: + ToolMessage or Command. + """ + # Validation is deferred to _execute_tool_async to allow interceptors + # to short-circuit requests for unregistered tools + tool = self.tools_by_name.get(call["name"]) + + # Create the tool request with state and runtime + tool_request = ToolCallRequest( + tool_call=call, + tool=tool, + state=tool_runtime.state, + runtime=tool_runtime, + ) + + config = tool_runtime.config + + if self._awrap_tool_call is None and self._wrap_tool_call is None: + # No wrapper - execute directly + return await self._execute_tool_async(tool_request, input_type, config) + + # Define async execute callable that can be called multiple times + async def execute(req: ToolCallRequest) -> ToolMessage | Command: + """Execute tool with given request. Can be called multiple times.""" + return await self._execute_tool_async(req, input_type, config) + + def _sync_execute(req: ToolCallRequest) -> ToolMessage | Command: + """Sync execute fallback for sync wrapper.""" + return self._execute_tool_sync(req, input_type, config) + + # Call wrapper with request and execute callable try: - call_args = {**call, **{"type": "tool_call"}} - response = await self.tools_by_name[call["name"]].ainvoke(call_args, config) - - # GraphInterrupt is a special exception that will always be raised. - # It can be triggered in the following scenarios, - # Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation most commonly: - # (1) a GraphInterrupt is raised inside a tool - # (2) a GraphInterrupt is raised inside a graph node for a graph called as a tool - # (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph called as a tool - # (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture) - except GraphBubbleUp as e: - raise e + if self._awrap_tool_call is not None: + return await self._awrap_tool_call(tool_request, execute) + # 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 Exception as e: - if isinstance(self.handle_tool_errors, tuple): - handled_types: tuple = self.handle_tool_errors - elif callable(self.handle_tool_errors): - handled_types = _infer_handled_types(self.handle_tool_errors) - else: - # default behavior is catching all exceptions - handled_types = (Exception,) - - # Unhandled - if not self.handle_tool_errors or not isinstance(e, handled_types): - raise e - # Handled - else: - content = _handle_tool_error(e, flag=self.handle_tool_errors) - + # Wrapper threw an exception + if not self._handle_tool_errors: + raise + # Convert to error message + content = _handle_tool_error(e, flag=self._handle_tool_errors) return ToolMessage( content=content, - name=call["name"], - tool_call_id=call["id"], + name=tool_request.tool_call["name"], + tool_call_id=tool_request.tool_call["id"], status="error", ) - if isinstance(response, Command): - return self._validate_tool_command(response, call, input_type) - elif isinstance(response, ToolMessage): - response.content = cast(str | list, msg_content_output(response.content)) - return response - else: - raise TypeError( - f"Tool {call['name']} returned unexpected type: {type(response)}" - ) - def _parse_input( self, input: list[AnyMessage] | dict[str, Any] | BaseModel, - store: BaseStore | None, ) -> tuple[list[ToolCall], Literal["list", "dict", "tool_calls"]]: input_type: Literal["list", "dict", "tool_calls"] if isinstance(input, list): if isinstance(input[-1], dict) and input[-1].get("type") == "tool_call": input_type = "tool_calls" - tool_calls = cast(list[ToolCall], input) + tool_calls = cast("list[ToolCall]", input) return tool_calls, input_type - else: - input_type = "list" - messages = input - elif isinstance(input, dict) and (messages := input.get(self.messages_key, [])): + input_type = "list" + messages = input + elif ( + isinstance(input, dict) and input.get("__type") == "tool_call_with_context" + ): + # Handle ToolCallWithContext from Send API + # mypy will not be able to type narrow correctly since the signature + # for input contains dict[str, Any]. We'd need to narrow dict[str, Any] + # before we can apply correct typing. + input_with_ctx = cast("ToolCallWithContext", input) + input_type = "tool_calls" + return [input_with_ctx["tool_call"]], input_type + elif isinstance(input, dict) and ( + messages := input.get(self._messages_key, []) + ): input_type = "dict" - elif messages := getattr(input, self.messages_key, []): + elif messages := getattr(input, self._messages_key, []): # Assume dataclass-like state that can coerce from dict input_type = "dict" else: - raise ValueError("No message found in input") + msg = "No message found in input" + raise ValueError(msg) try: latest_ai_message = next( m for m in reversed(messages) if isinstance(m, AIMessage) ) except StopIteration: - raise ValueError("No AIMessage found in input") + msg = "No AIMessage found in input" + raise ValueError(msg) - tool_calls = [ - self.inject_tool_args(call, input, store) - for call in latest_ai_message.tool_calls - ] + tool_calls = list(latest_ai_message.tool_calls) return tool_calls, input_type def _validate_tool_call(self, call: ToolCall) -> ToolMessage | None: - if (requested_tool := call["name"]) not in self.tools_by_name: + requested_tool = call["name"] + if requested_tool not in self.tools_by_name: + all_tool_names = list(self.tools_by_name.keys()) content = INVALID_TOOL_NAME_ERROR_TEMPLATE.format( requested_tool=requested_tool, - available_tools=", ".join(self.tools_by_name.keys()), + available_tools=", ".join(all_tool_names), ) return ToolMessage( content, name=requested_tool, tool_call_id=call["id"], status="error" ) - else: - return None + return None + + def _extract_state( + self, input: list[AnyMessage] | dict[str, Any] | BaseModel + ) -> list[AnyMessage] | dict[str, Any] | BaseModel: + """Extract state from input, handling ToolCallWithContext if present. + + Args: + input: The input which may be raw state or ToolCallWithContext. + + Returns: + The actual state to pass to wrap_tool_call wrappers. + """ + if isinstance(input, dict) and input.get("__type") == "tool_call_with_context": + return input["state"] + return input def _inject_state( self, tool_call: ToolCall, - input: list[AnyMessage] | dict[str, Any] | BaseModel, + state: list[AnyMessage] | dict[str, Any] | BaseModel, ) -> ToolCall: - state_args = self.tool_to_state_args[tool_call["name"]] - if state_args and isinstance(input, list): + state_args = self._tool_to_state_args[tool_call["name"]] + + if state_args and isinstance(state, list): required_fields = list(state_args.values()) if ( - len(required_fields) == 1 - and required_fields[0] == self.messages_key - or required_fields[0] is None - ): - input = {self.messages_key: input} + len(required_fields) == 1 and required_fields[0] == self._messages_key + ) or required_fields[0] is None: + state = {self._messages_key: state} else: err_msg = ( f"Invalid input to ToolNode. Tool {tool_call['name']} requires " @@ -592,14 +1211,14 @@ class ToolNode(RunnableCallable): err_msg += f" State should contain fields {required_fields_str}." raise ValueError(err_msg) - if isinstance(input, dict): + if isinstance(state, dict): tool_state_args = { - tool_arg: input[state_field] if state_field else input + tool_arg: state[state_field] if state_field else state for tool_arg, state_field in state_args.items() } else: tool_state_args = { - tool_arg: getattr(input, state_field) if state_field else input + tool_arg: getattr(state, state_field) if state_field else state for tool_arg, state_field in state_args.items() } @@ -610,15 +1229,16 @@ class ToolNode(RunnableCallable): return tool_call def _inject_store(self, tool_call: ToolCall, store: BaseStore | None) -> ToolCall: - store_arg = self.tool_to_store_arg[tool_call["name"]] + store_arg = self._tool_to_store_arg[tool_call["name"]] if not store_arg: return tool_call if store is None: - raise ValueError( + msg = ( "Cannot inject store into tools with InjectedStore annotations - " "please compile your graph with a store." ) + raise ValueError(msg) tool_call["args"] = { **tool_call["args"], @@ -626,18 +1246,40 @@ class ToolNode(RunnableCallable): } return tool_call - def inject_tool_args( + def _inject_runtime( + self, tool_call: ToolCall, tool_runtime: ToolRuntime + ) -> ToolCall: + """Inject ToolRuntime into tool call arguments. + + Args: + tool_call: The tool call to inject runtime into. + tool_runtime: The ToolRuntime instance to inject. + + Returns: + The tool call with runtime injected if needed. + """ + runtime_arg = self._tool_to_runtime_arg.get(tool_call["name"]) + if not runtime_arg: + return tool_call + + tool_call["args"] = { + **tool_call["args"], + runtime_arg: tool_runtime, + } + return tool_call + + def _inject_tool_args( self, tool_call: ToolCall, - input: list[AnyMessage] | dict[str, Any] | BaseModel, - store: BaseStore | None, + tool_runtime: ToolRuntime, ) -> ToolCall: - """Inject graph state and store into tool call arguments. + """Inject graph state, store, and runtime into tool call arguments. - This method enables tools to access graph context that should not be controlled - by the model. Tools can declare dependencies on graph state or persistent storage - using InjectedState and InjectedStore annotations. This method automatically - identifies these dependencies and injects the appropriate values. + This is an internal method that enables tools to access graph context that + should not be controlled by the model. Tools can declare dependencies on graph + state, persistent storage, or runtime context using InjectedState, InjectedStore, + and ToolRuntime annotations. This method automatically identifies these + dependencies and injects the appropriate values. The injection process preserves the original tool call structure while adding the necessary context arguments. This allows tools to be both model-callable @@ -646,10 +1288,8 @@ class ToolNode(RunnableCallable): Args: tool_call: The tool call dictionary to augment with injected arguments. Must contain 'name', 'args', 'id', and 'type' fields. - input: The current graph state to inject into tools requiring state access. - Can be a message list, state dictionary, or BaseModel instance. - store: The persistent store instance to inject into tools requiring storage. - Will be None if no store is configured for the graph. + tool_runtime: The ToolRuntime instance containing all runtime context + (state, config, store, context, stream_writer) to inject into tools. Returns: A new ToolCall dictionary with the same structure as the input but with @@ -657,21 +1297,21 @@ class ToolNode(RunnableCallable): Raises: ValueError: If a tool requires store injection but no store is provided, - or if state injection requirements cannot be satisfied. + or if state injection requirements cannot be satisfied. - Note: - This method is automatically called during tool execution but can also - be used manually when working with the Send API or custom routing logic. - The injection is performed on a copy of the tool call to avoid mutating - the original. + !!! note + This method is called automatically during tool execution. It should not + be called from outside the `ToolNode`. """ if tool_call["name"] not in self.tools_by_name: return tool_call tool_call_copy: ToolCall = copy(tool_call) - tool_call_with_state = self._inject_state(tool_call_copy, input) - tool_call_with_store = self._inject_store(tool_call_with_state, store) - return tool_call_with_store + tool_call_with_state = self._inject_state(tool_call_copy, tool_runtime.state) + tool_call_with_store = self._inject_store( + tool_call_with_state, tool_runtime.store + ) + return self._inject_runtime(tool_call_with_store, tool_runtime) def _validate_tool_command( self, @@ -680,23 +1320,29 @@ class ToolNode(RunnableCallable): input_type: Literal["list", "dict", "tool_calls"], ) -> Command: if isinstance(command.update, dict): - # input type is dict when ToolNode is invoked with a dict input (e.g. {"messages": [AIMessage(..., tool_calls=[...])]}) + # input type is dict when ToolNode is invoked with a dict input + # (e.g. {"messages": [AIMessage(..., tool_calls=[...])]}) if input_type not in ("dict", "tool_calls"): - raise ValueError( - f"Tools can provide a dict in Command.update only when using dict with '{self.messages_key}' key as ToolNode input, " + msg = ( + "Tools can provide a dict in Command.update only when using dict " + f"with '{self._messages_key}' key as ToolNode input, " f"got: {command.update} for tool '{call['name']}'" ) + raise ValueError(msg) updated_command = deepcopy(command) - state_update = cast(dict[str, Any], updated_command.update) or {} - messages_update = state_update.get(self.messages_key, []) + state_update = cast("dict[str, Any]", updated_command.update) or {} + messages_update = state_update.get(self._messages_key, []) elif isinstance(command.update, list): - # input type is list when ToolNode is invoked with a list input (e.g. [AIMessage(..., tool_calls=[...])]) + # Input type is list when ToolNode is invoked with a list input + # (e.g. [AIMessage(..., tool_calls=[...])]) if input_type != "list": - raise ValueError( - f"Tools can provide a list of messages in Command.update only when using list of messages as ToolNode input, " + msg = ( + "Tools can provide a list of messages in Command.update " + "only when using list of messages as ToolNode input, " f"got: {command.update} for tool '{call['name']}'" ) + raise ValueError(msg) updated_command = deepcopy(command) messages_update = updated_command.update @@ -723,15 +1369,20 @@ class ToolNode(RunnableCallable): # Command.update if command is sent to the CURRENT graph if updated_command.graph is None and not has_matching_tool_message: example_update = ( - '`Command(update={"messages": [ToolMessage("Success", tool_call_id=tool_call_id), ...]}, ...)`' + '`Command(update={"messages": ' + '[ToolMessage("Success", tool_call_id=tool_call_id), ...]}, ...)`' if input_type == "dict" - else '`Command(update=[ToolMessage("Success", tool_call_id=tool_call_id), ...], ...)`' + else "`Command(update=" + '[ToolMessage("Success", tool_call_id=tool_call_id), ...], ...)`' ) - raise ValueError( - f"Expected to have a matching ToolMessage in Command.update for tool '{call['name']}', got: {messages_update}. " - "Every tool call (LLM requesting to call a tool) in the message history MUST have a corresponding ToolMessage. " + msg = ( + "Expected to have a matching ToolMessage in Command.update " + f"for tool '{call['name']}', got: {messages_update}. " + "Every tool call (LLM requesting to call a tool) " + "in the message history MUST have a corresponding ToolMessage. " f"You can fix it by modifying the tool to return {example_update}." ) + raise ValueError(msg) return updated_command @@ -770,19 +1421,22 @@ def tools_condition( ```python from langgraph.graph import StateGraph - from langgraph.prebuilt import ToolNode, tools_condition + from langchain.tools import ToolNode + from langchain.tools.tool_node import tools_condition from typing_extensions import TypedDict + class State(TypedDict): messages: list + graph = StateGraph(State) graph.add_node("llm", call_model) graph.add_node("tools", ToolNode([my_tool])) graph.add_conditional_edges( "llm", tools_condition, # Routes to "tools" or "__end__" - {"tools": "tools", "__end__": "__end__"} + {"tools": "tools", "__end__": "__end__"}, ) ``` @@ -793,48 +1447,114 @@ def tools_condition( return tools_condition(state, messages_key="chat_history") ``` - Note: - This function is designed to work seamlessly with ToolNode and standard - LangGraph patterns. It expects the last message to be an AIMessage when + !!! note + This function is designed to work seamlessly with `ToolNode` and standard + LangGraph patterns. It expects the last message to be an `AIMessage` when tool calls are present, which is the standard output format for tool-calling language models. """ if isinstance(state, list): ai_message = state[-1] - elif isinstance(state, dict) and (messages := state.get(messages_key, [])): - ai_message = messages[-1] - elif messages := getattr(state, messages_key, []): + elif (isinstance(state, dict) and (messages := state.get(messages_key, []))) or ( + messages := getattr(state, messages_key, []) + ): ai_message = messages[-1] else: - raise ValueError(f"No messages found in input state to tool_edge: {state}") + msg = f"No messages found in input state to tool_edge: {state}" + raise ValueError(msg) if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0: return "tools" return "__end__" +@dataclass +class ToolRuntime(_DirectlyInjectedToolArg, Generic[ContextT, StateT]): + """Runtime context automatically injected into tools. + + When a tool function has a parameter named `tool_runtime` with type hint + `ToolRuntime`, the tool execution system will automatically inject an instance + containing: + + - `state`: The current graph state + - `tool_call_id`: The ID of the current tool call + - `config`: `RunnableConfig` for the current execution + - `context`: Runtime context (from langgraph `Runtime`) + - `store`: `BaseStore` instance for persistent storage (from langgraph `Runtime`) + - `stream_writer`: `StreamWriter` for streaming output (from langgraph `Runtime`) + + No `Annotated` wrapper is needed - just use `runtime: ToolRuntime` + as a parameter. + + Example: + ```python + from langchain_core.tools import tool + from langchain.tools import ToolRuntime + + @tool + def my_tool(x: int, runtime: ToolRuntime) -> str: + \"\"\"Tool that accesses runtime context.\"\"\" + # Access state + messages = tool_runtime.state["messages"] + + # Access tool_call_id + print(f"Tool call ID: {tool_runtime.tool_call_id}") + + # Access config + print(f"Run ID: {tool_runtime.config.get('run_id')}") + + # Access runtime context + user_id = tool_runtime.context.get("user_id") + + # Access store + tool_runtime.store.put(("metrics",), "count", 1) + + # Stream output + tool_runtime.stream_writer.write("Processing...") + + return f"Processed {x}" + ``` + + !!! note + This is a marker class used for type checking and detection. + The actual runtime object will be constructed during tool execution. + """ + + state: StateT + context: ContextT + config: RunnableConfig + stream_writer: StreamWriter + tool_call_id: str | None + store: BaseStore | None + + class InjectedState(InjectedToolArg): """Annotation for injecting graph state into tool arguments. This annotation enables tools to access graph state without exposing state - management details to the language model. Tools annotated with InjectedState + management details to the language model. Tools annotated with `InjectedState` receive state data automatically during execution while remaining invisible to the model's tool-calling interface. + Args: + field: Optional key to extract from the state dictionary. If `None`, the entire + state is injected. If specified, only that field's value is injected. + This allows tools to request specific state components rather than + processing the full state structure. + Example: ```python from typing import List from typing_extensions import Annotated, TypedDict from langchain_core.messages import BaseMessage, AIMessage - from langchain_core.tools import tool - - from langgraph.prebuilt import InjectedState, ToolNode + from langchain.tools import InjectedState, ToolNode, tool class AgentState(TypedDict): messages: List[BaseMessage] foo: str + @tool def state_tool(x: int, state: Annotated[dict, InjectedState]) -> str: '''Do something with state.''' @@ -843,11 +1563,13 @@ class InjectedState(InjectedToolArg): else: return "not enough messages" + @tool def foo_tool(x: int, foo: Annotated[str, InjectedState("foo")]) -> str: '''Do something else with state.''' return foo + str(x + 1) + node = ToolNode([state_tool, foo_tool]) tool_call1 = {"name": "state_tool", "args": {"x": 1}, "id": "1", "type": "tool_call"} @@ -859,32 +1581,25 @@ class InjectedState(InjectedToolArg): node.invoke(state) ``` - ```pycon + ```python [ - ToolMessage(content='not enough messages', name='state_tool', tool_call_id='1'), - ToolMessage(content='bar2', name='foo_tool', tool_call_id='2') + ToolMessage(content="not enough messages", name="state_tool", tool_call_id="1"), + ToolMessage(content="bar2", name="foo_tool", tool_call_id="2"), ] ``` - Note: - - InjectedState arguments are automatically excluded from tool schemas - presented to language models - - ToolNode handles the injection process during execution + !!! note + - `InjectedState` arguments are automatically excluded from tool schemas + presented to language models + - `ToolNode` handles the injection process during execution - Tools can mix regular arguments (controlled by the model) with injected - arguments (controlled by the system) + arguments (controlled by the system) - State injection occurs after the model generates tool calls but before - tool execution - """ # noqa: E501 + tool execution + """ def __init__(self, field: str | None = None) -> None: - """Initialize InjectedState annotation. - - Args: - field: Optional key to extract from the state dictionary. If `None`, the entire - state is injected. If specified, only that field's value is injected. - This allows tools to request specific state components rather than - processing the full state structure. - """ + """Initialize the `InjectedState` annotation.""" self.field = field @@ -900,15 +1615,14 @@ class InjectedStore(InjectedToolArg): for maintaining context, user preferences, or any other data that needs to persist beyond individual workflow executions. - !!! Warning + !!! warning `InjectedStore` annotation requires `langchain-core >= 0.3.8` Example: ```python from typing_extensions import Annotated - from langchain_core.tools import tool from langgraph.store.memory import InMemoryStore - from langgraph.prebuilt import InjectedStore, ToolNode + from langchain.tools import InjectedStore, ToolNode, tool @tool def save_preference( @@ -930,7 +1644,7 @@ class InjectedStore(InjectedToolArg): return result.value if result else "Not found" ``` - Usage with ToolNode and graph compilation: + Usage with `ToolNode` and graph compilation: ```python from langgraph.graph import StateGraph @@ -954,18 +1668,19 @@ class InjectedStore(InjectedToolArg): result2 = graph.invoke({"messages": [HumanMessage("What's my favorite color?")]}) ``` - Note: - - InjectedStore arguments are automatically excluded from tool schemas - presented to language models - - The store instance is automatically injected by ToolNode during execution + !!! note + - `InjectedStore` arguments are automatically excluded from tool schemas + presented to language models + - The store instance is automatically injected by `ToolNode` during execution - Tools can access namespaced storage using the store's get/put methods - Store injection requires the graph to be compiled with a store instance - Multiple tools can share the same store instance for data consistency - """ # noqa: E501 + """ def _is_injection( - type_arg: Any, injection_type: type[InjectedState] | type[InjectedStore] + type_arg: Any, + injection_type: type[InjectedState | InjectedStore | ToolRuntime], ) -> bool: """Check if a type argument represents an injection annotation. @@ -1014,11 +1729,12 @@ def _get_state_args(tool: BaseTool) -> dict[str, str | None]: if _is_injection(type_arg, InjectedState) ] if len(injections) > 1: - raise ValueError( + msg = ( "A tool argument should not be annotated with InjectedState more than " f"once. Received arg {name} with annotations {injections}." ) - elif len(injections) == 1: + raise ValueError(msg) + if len(injections) == 1: injection = injections[0] if isinstance(injection, InjectedState) and injection.field: tool_args_to_state_fields[name] = injection.field @@ -1054,13 +1770,55 @@ def _get_store_arg(tool: BaseTool) -> str | None: if _is_injection(type_arg, InjectedStore) ] if len(injections) > 1: - raise ValueError( + msg = ( "A tool argument should not be annotated with InjectedStore more than " f"once. Received arg {name} with annotations {injections}." ) - elif len(injections) == 1: + raise ValueError(msg) + if len(injections) == 1: + return name + + return None + + +def _get_runtime_arg(tool: BaseTool) -> str | None: + """Extract runtime injection argument from tool annotations. + + This function analyzes a tool's input schema to identify the argument that + should be injected with the ToolRuntime instance. Only one runtime argument + is supported per tool. + + Args: + tool: The tool to analyze for runtime injection requirements. + + Returns: + The name of the argument that should receive the runtime injection, or None + if no runtime injection is required. + + Raises: + ValueError: If a tool argument has multiple ToolRuntime annotations. + """ + full_schema = tool.get_input_schema() + for name, type_ in get_all_basemodel_annotations(full_schema).items(): + # Check if the parameter name is "runtime" (regardless of type) + if name == "runtime": + return name + # Check if the type itself is ToolRuntime (direct usage) + if _is_injection(type_, ToolRuntime): + return name + # Check if ToolRuntime is in Annotated args + injections = [ + type_arg + for type_arg in get_args(type_) + if _is_injection(type_arg, ToolRuntime) + ] + if len(injections) > 1: + msg = ( + "A tool argument should not be annotated with ToolRuntime more than " + f"once. Received arg {name} with annotations {injections}." + ) + raise ValueError(msg) + if len(injections) == 1: return name - else: - pass return None diff --git a/libs/prebuilt/pyproject.toml b/libs/prebuilt/pyproject.toml index 50ad837fe..fcd6a0e25 100644 --- a/libs/prebuilt/pyproject.toml +++ b/libs/prebuilt/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "langgraph-prebuilt" -version = "1.0.1" +version = "1.0.2" description = "Library with high-level APIs for creating and executing LangGraph agents and tools." authors = [] requires-python = ">=3.10" @@ -43,6 +43,7 @@ test = [ "langgraph-checkpoint-sqlite", "langgraph-checkpoint-postgres", "syrupy", + "psycopg-binary", ] lint = [ "ruff", diff --git a/libs/prebuilt/tests/test_on_tool_call.py b/libs/prebuilt/tests/test_on_tool_call.py new file mode 100644 index 000000000..6f6158c35 --- /dev/null +++ b/libs/prebuilt/tests/test_on_tool_call.py @@ -0,0 +1,1307 @@ +"""Unit tests for tool call interceptor in ToolNode.""" + +from collections.abc import Callable +from unittest.mock import Mock + +import pytest +from langchain_core.messages import AIMessage, ToolCall, ToolMessage +from langchain_core.runnables import RunnableConfig +from langchain_core.tools import tool +from langgraph.store.base import BaseStore +from langgraph.types import Command + +from langgraph.prebuilt.tool_node import ( + ToolCallRequest, + ToolNode, +) + +pytestmark = pytest.mark.anyio + + +def _create_mock_runtime(store: BaseStore | None = None) -> Mock: + mock_runtime = Mock() + mock_runtime.store = store + mock_runtime.context = None + mock_runtime.stream_writer = lambda _: None + return mock_runtime + + +def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig: + return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}} + + +@tool +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +@tool +def failing_tool(a: int) -> int: + """A tool that always fails.""" + msg = f"This tool always fails (input: {a})" + raise ValueError(msg) + + +@tool +def command_tool(goto: str) -> Command: + """A tool that returns a Command.""" + return Command(goto=goto) + + +def test_passthrough_handler() -> None: + """Test a simple passthrough handler that doesn't modify anything.""" + + def passthrough_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Simple passthrough handler.""" + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=passthrough_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.content == "3" + assert tool_message.tool_call_id == "call_1" + assert tool_message.status != "error" + + +async def test_passthrough_handler_async() -> None: + """Test passthrough handler with async tool.""" + + def passthrough_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Simple passthrough handler.""" + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=passthrough_handler) + + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 2, "b": 3}, + "id": "call_2", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.content == "5" + assert tool_message.tool_call_id == "call_2" + + +def test_modify_arguments() -> None: + """Test handler that modifies tool arguments before execution.""" + + def modify_args_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that doubles the input arguments.""" + # Modify the arguments + request.tool_call["args"]["a"] *= 2 + request.tool_call["args"]["b"] *= 2 + + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=modify_args_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_3", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + # Original args were (1, 2), doubled to (2, 4), so result is 6 + assert tool_message.content == "6" + + +def test_handler_validation_no_return() -> None: + """Test that handler must return a result.""" + + def handler_with_explicit_none( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that executes and returns result.""" + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=handler_with_explicit_none) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_6", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + assert isinstance(result, dict) + messages = result["messages"] + assert len(messages) == 1 + assert isinstance(messages[0], ToolMessage) + assert messages[0].content == "3" + + +def test_handler_validation_no_yield() -> None: + """Test that handler that doesn't call execute returns None (bad behavior).""" + + def bad_handler( + _request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that doesn't call execute - will cause type error.""" + # Don't call execute, just return None (invalid) + return None # type: ignore[return-value] + + tool_node = ToolNode([add], wrap_tool_call=bad_handler) + + # This will return None wrapped in messages + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_7", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Result contains None in messages (bad handler behavior) + assert isinstance(result, dict) + assert result["messages"][0] is None + + +def test_handler_with_handle_tool_errors_true() -> None: + """Test that handle_tool_errors=True works with on_tool_call handler.""" + + def passthrough_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Simple passthrough handler.""" + message = execute(request) + # When handle_tool_errors=True, errors should be converted to error messages + assert isinstance(message, ToolMessage) + assert message.status == "error" + return message + + tool_node = ToolNode( + [failing_tool], wrap_tool_call=passthrough_handler, handle_tool_errors=True + ) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "failing", + tool_calls=[ + { + "name": "failing_tool", + "args": {"a": 1}, + "id": "call_9", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.status == "error" + + +def test_multiple_tool_calls_with_handler() -> None: + """Test handler with multiple tool calls in one message.""" + call_count = 0 + + def counting_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that counts calls.""" + nonlocal call_count + call_count += 1 + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=counting_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding multiple", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_10", + }, + { + "name": "add", + "args": {"a": 3, "b": 4}, + "id": "call_11", + }, + { + "name": "add", + "args": {"a": 5, "b": 6}, + "id": "call_12", + }, + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Handler should be called once for each tool call + assert call_count == 3 + + # Verify all results + messages = result["messages"] + assert len(messages) == 3 + assert all(isinstance(m, ToolMessage) for m in messages) + assert messages[0].content == "3" + assert messages[1].content == "7" + assert messages[2].content == "11" + + +def test_tool_call_request_dataclass() -> None: + """Test ToolCallRequest dataclass.""" + tool_call: ToolCall = {"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"} + state: dict = {"messages": []} + runtime = None + + request = ToolCallRequest( + tool_call=tool_call, tool=add, state=state, runtime=runtime + ) # type: ignore[arg-type] + + assert request.tool_call == tool_call + assert request.tool == add + assert request.state == state + assert request.runtime is None + assert request.tool_call["name"] == "add" + + +async def test_handler_with_async_execution() -> None: + """Test handler works correctly with async tool execution.""" + + @tool + def async_add(a: int, b: int) -> int: + """Async add two numbers.""" + return a + b + + def modifying_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that modifies arguments.""" + # Add 10 to both arguments + request.tool_call["args"]["a"] += 10 + request.tool_call["args"]["b"] += 10 + return execute(request) + + tool_node = ToolNode([async_add], wrap_tool_call=modifying_handler) + + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "async_add", + "args": {"a": 1, "b": 2}, + "id": "call_13", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + # Original: 1 + 2 = 3, with modifications: 11 + 12 = 23 + assert tool_message.content == "23" + + +def test_short_circuit_with_tool_message() -> None: + """Test handler that returns ToolMessage to short-circuit tool execution.""" + + def short_circuit_handler( + request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns cached result without executing tool.""" + # Return a ToolMessage directly instead of calling execute + return ToolMessage( + content="cached_result", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + + tool_node = ToolNode([add], wrap_tool_call=short_circuit_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_16", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.content == "cached_result" + assert tool_message.tool_call_id == "call_16" + assert tool_message.name == "add" + + +async def test_short_circuit_with_tool_message_async() -> None: + """Test async handler that returns ToolMessage to short-circuit tool execution.""" + + def short_circuit_handler( + request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns cached result without executing tool.""" + return ToolMessage( + content="async_cached_result", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + + tool_node = ToolNode([add], wrap_tool_call=short_circuit_handler) + + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 2, "b": 3}, + "id": "call_17", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.content == "async_cached_result" + assert tool_message.tool_call_id == "call_17" + + +def test_conditional_short_circuit() -> None: + """Test handler that conditionally short-circuits based on request.""" + call_count = {"count": 0} + + def conditional_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that caches even numbers, executes odd.""" + call_count["count"] += 1 + a = request.tool_call["args"]["a"] + + if a % 2 == 0: + # Even: use cached result + return ToolMessage( + content=f"cached_{a}", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + # Odd: execute normally + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=conditional_handler) + + # Test with even number (should be cached) + result1 = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 2, "b": 3}, + "id": "call_18", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message1 = result1["messages"][-1] + assert tool_message1.content == "cached_2" + + # Test with odd number (should execute) + result2 = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 3, "b": 4}, + "id": "call_19", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message2 = result2["messages"][-1] + assert tool_message2.content == "7" # Actual execution: 3 + 4 + + +def test_direct_return_tool_message() -> None: + """Test handler that returns ToolMessage directly without calling execute.""" + + def direct_return_handler( + request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns ToolMessage directly.""" + # Return ToolMessage directly instead of calling execute + return ToolMessage( + content="direct_return", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + + tool_node = ToolNode([add], wrap_tool_call=direct_return_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_21", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.content == "direct_return" + assert tool_message.tool_call_id == "call_21" + assert tool_message.name == "add" + + +async def test_direct_return_tool_message_async() -> None: + """Test async handler that returns ToolMessage directly without calling execute.""" + + def direct_return_handler( + request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns ToolMessage directly.""" + return ToolMessage( + content="async_direct_return", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + + tool_node = ToolNode([add], wrap_tool_call=direct_return_handler) + + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 2, "b": 3}, + "id": "call_22", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.content == "async_direct_return" + assert tool_message.tool_call_id == "call_22" + + +def test_conditional_direct_return() -> None: + """Test handler that conditionally returns ToolMessage directly or executes tool.""" + + def conditional_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns cached or executes based on condition.""" + a = request.tool_call["args"]["a"] + + if a == 0: + # Return ToolMessage directly for zero + return ToolMessage( + content="zero_cached", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + # Execute tool normally + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=conditional_handler) + + # Test with zero (should return directly) + result1 = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 0, "b": 5}, + "id": "call_23", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message1 = result1["messages"][-1] + assert tool_message1.content == "zero_cached" + + # Test with non-zero (should execute) + result2 = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 3, "b": 4}, + "id": "call_24", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message2 = result2["messages"][-1] + assert tool_message2.content == "7" # Actual execution: 3 + 4 + + +def test_handler_can_throw_exception() -> None: + """Test that a handler can throw an exception to signal error.""" + + def throwing_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that throws an exception after receiving response.""" + response = execute(request) + # Check response and throw if invalid + if isinstance(response, ToolMessage): + msg = "Handler rejected the response" + raise TypeError(msg) + return response + + tool_node = ToolNode( + [add], wrap_tool_call=throwing_handler, handle_tool_errors=True + ) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_exc_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should get error message due to handle_tool_errors=True + messages = result["messages"] + assert len(messages) == 1 + assert isinstance(messages[0], ToolMessage) + assert messages[0].status == "error" + assert "Handler rejected the response" in messages[0].content + + +def test_handler_throw_without_handle_errors() -> None: + """Test that exception propagates when handle_tool_errors=False.""" + + def throwing_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that throws an exception.""" + execute(request) + msg = "Handler error" + raise ValueError(msg) + + tool_node = ToolNode( + [add], wrap_tool_call=throwing_handler, handle_tool_errors=False + ) + + with pytest.raises(ValueError, match="Handler error"): + tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_exc_2", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + +def test_retry_middleware_with_exception() -> None: + """Test retry middleware pattern that can call execute multiple times.""" + attempt_count = {"count": 0} + + def retry_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that can retry by calling execute multiple times.""" + max_retries = 3 + + for _attempt in range(max_retries): + attempt_count["count"] += 1 + response = execute(request) + + # Simulate checking for retriable errors + # In real use case, would check response.status or content + if isinstance(response, ToolMessage): + # For this test, just succeed immediately + return response + + # If we exhausted retries, return last response + return response + + tool_node = ToolNode([add], wrap_tool_call=retry_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_exc_3", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should succeed after 1 attempt + assert attempt_count["count"] == 1 + messages = result["messages"] + assert len(messages) == 1 + assert isinstance(messages[0], ToolMessage) + assert messages[0].content == "3" + + +async def test_async_handler_can_throw_exception() -> None: + """Test that async execution also supports exception throwing.""" + + def throwing_handler( + _request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that throws an exception before calling execute.""" + # Throw exception before executing (to avoid async/await complications) + msg = "Async handler rejected the request" + raise ValueError(msg) + + tool_node = ToolNode( + [add], wrap_tool_call=throwing_handler, handle_tool_errors=True + ) + + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_exc_4", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should get error message due to handle_tool_errors=True + messages = result["messages"] + assert len(messages) == 1 + assert isinstance(messages[0], ToolMessage) + assert messages[0].status == "error" + assert "Async handler rejected the request" in messages[0].content + + +def test_handler_cannot_yield_multiple_tool_messages() -> None: + """Test that handler can only return once (not applicable to handler pattern).""" + # With handler pattern, you can only return once by definition + # This test is no longer relevant - handlers naturally return once + # Keep test for compatibility but with simple passthrough + + def single_return_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns once (as all handlers do).""" + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=single_return_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_multi_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should succeed - handlers can only return once + assert isinstance(result, dict) + assert len(result["messages"]) == 1 + + +def test_handler_cannot_yield_request_after_tool_message() -> None: + """Test that handler pattern doesn't allow multiple returns (not applicable).""" + # With handler pattern, you can only return once + # This test is no longer relevant + + def single_return_handler( + request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns cached result.""" + # Return cached result (short-circuit) + return ToolMessage("cached", tool_call_id=request.tool_call["id"], name="add") + + tool_node = ToolNode([add], wrap_tool_call=single_return_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_confused_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should succeed with cached result + assert isinstance(result, dict) + assert result["messages"][0].content == "cached" + + +def test_handler_can_short_circuit_with_command() -> None: + """Test that handler can short-circuit by returning Command.""" + + def command_handler( + _request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that short-circuits with Command.""" + # Short-circuit with Command instead of executing tool + return Command(goto="end") + + tool_node = ToolNode([add], wrap_tool_call=command_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_cmd_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should get Command in result list + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], Command) + assert result[0].goto == "end" + + +def test_handler_cannot_yield_multiple_commands() -> None: + """Test that handler can only return once (not applicable to handler pattern).""" + # With handler pattern, you can only return once + # This test is no longer relevant + + def single_command_handler( + _request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns Command once.""" + return Command(goto="step1") + + tool_node = ToolNode([add], wrap_tool_call=single_command_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_multicmd_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should succeed - handlers naturally return once + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], Command) + assert result[0].goto == "step1" + + +def test_handler_cannot_yield_request_after_command() -> None: + """Test that handler can only return once (not applicable to handler pattern).""" + # With handler pattern, you can only return once + # This test is no longer relevant + + def command_handler( + _request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns Command.""" + return Command(goto="somewhere") + + tool_node = ToolNode([add], wrap_tool_call=command_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_cmdreq_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should succeed with Command + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], Command) + assert result[0].goto == "somewhere" + + +def test_tool_returning_command_sent_to_handler() -> None: + """Test that when tool returns Command, it's sent to handler.""" + received_commands = [] + + def command_inspector_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that inspects Command returned by tool.""" + result = execute(request) + # Should receive Command from tool + if isinstance(result, Command): + received_commands.append(result) + return result + + tool_node = ToolNode([command_tool], wrap_tool_call=command_inspector_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "navigating", + tool_calls=[ + { + "name": "command_tool", + "args": {"goto": "next_step"}, + "id": "call_cmdtool_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Handler should have received the Command + assert len(received_commands) == 1 + assert received_commands[0].goto == "next_step" + + # Final result should be the Command in result list + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], Command) + assert result[0].goto == "next_step" + + +def test_handler_can_modify_command_from_tool() -> None: + """Test that handler can inspect and modify Command from tool.""" + + def command_modifier_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that modifies Command returned by tool.""" + result = execute(request) + # Modify the Command + if isinstance(result, Command): + return Command(goto=f"modified_{result.goto}") + return result + + tool_node = ToolNode([command_tool], wrap_tool_call=command_modifier_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "navigating", + tool_calls=[ + { + "name": "command_tool", + "args": {"goto": "original"}, + "id": "call_cmdmod_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Final result should be the modified Command in result list + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], Command) + assert result[0].goto == "modified_original" + + +def test_state_extraction_with_dict_input() -> None: + """Test that state is correctly passed when input is a dict.""" + state_seen = [] + + def state_inspector_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that records the state it receives.""" + state_seen.append(request.state) + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler) + + input_state = { + "messages": [ + AIMessage( + "test", + tool_calls=[{"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"}], + ) + ], + "other_field": "value", + } + + tool_node.invoke(input_state, config=_create_config_with_runtime()) + + # State should be the dict we passed in + assert len(state_seen) == 1 + assert state_seen[0] == input_state + assert isinstance(state_seen[0], dict) + assert "messages" in state_seen[0] + assert "other_field" in state_seen[0] + assert "__type" not in state_seen[0] + + +def test_state_extraction_with_list_input() -> None: + """Test that state is correctly passed when input is a list.""" + state_seen = [] + + def state_inspector_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that records the state it receives.""" + state_seen.append(request.state) + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler) + + input_state = [ + AIMessage( + "test", + tool_calls=[{"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"}], + ) + ] + + tool_node.invoke(input_state, config=_create_config_with_runtime()) + + # State should be the list we passed in + assert len(state_seen) == 1 + assert state_seen[0] == input_state + assert isinstance(state_seen[0], list) + + +def test_state_extraction_with_tool_call_with_context() -> None: + """Test that state is correctly extracted from ToolCallWithContext. + + This tests the scenario where ToolNode is invoked via the Send API in + create_agent, which wraps the tool call with additional context including + the graph state. + """ + state_seen = [] + + def state_inspector_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that records the state it receives.""" + state_seen.append(request.state) + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler) + + # Simulate ToolCallWithContext as used by create_agent with Send API + actual_state = { + "messages": [AIMessage("test")], + "thread_model_call_count": 1, + "run_model_call_count": 1, + "custom_field": "custom_value", + } + + tool_call_with_context = { + "__type": "tool_call_with_context", + "tool_call": { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_1", + "type": "tool_call", + }, + "state": actual_state, + } + + tool_node.invoke(tool_call_with_context, config=_create_config_with_runtime()) + + # State should be the extracted state from ToolCallWithContext, not the wrapper + assert len(state_seen) == 1 + assert state_seen[0] == actual_state + assert isinstance(state_seen[0], dict) + assert "messages" in state_seen[0] + assert "thread_model_call_count" in state_seen[0] + assert "custom_field" in state_seen[0] + # Most importantly, __type should NOT be in the extracted state + assert "__type" not in state_seen[0] + # And tool_call should not be in the state + assert "tool_call" not in state_seen[0] + + +async def test_state_extraction_with_tool_call_with_context_async() -> None: + """Test that state is correctly extracted from ToolCallWithContext in async mode.""" + state_seen = [] + + def state_inspector_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that records the state it receives.""" + state_seen.append(request.state) + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler) + + # Simulate ToolCallWithContext as used by create_agent with Send API + actual_state = { + "messages": [AIMessage("test")], + "thread_model_call_count": 1, + "run_model_call_count": 1, + } + + tool_call_with_context = { + "__type": "tool_call_with_context", + "tool_call": { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_1", + "type": "tool_call", + }, + "state": actual_state, + } + + await tool_node.ainvoke( + tool_call_with_context, config=_create_config_with_runtime() + ) + + # State should be the extracted state from ToolCallWithContext + assert len(state_seen) == 1 + assert state_seen[0] == actual_state + assert "__type" not in state_seen[0] + assert "tool_call" not in state_seen[0] diff --git a/libs/prebuilt/tests/test_react_agent.py b/libs/prebuilt/tests/test_react_agent.py index ada9499da..e4b05e5ad 100644 --- a/libs/prebuilt/tests/test_react_agent.py +++ b/libs/prebuilt/tests/test_react_agent.py @@ -8,6 +8,7 @@ from typing import ( Literal, TypeVar, ) +from unittest.mock import Mock import pytest from langchain_core.language_models import BaseChatModel @@ -64,6 +65,29 @@ pytestmark = pytest.mark.anyio REACT_TOOL_CALL_VERSIONS = ["v1", "v2"] +def _create_mock_runtime(store: BaseStore | None = None) -> Mock: + """Create a mock Runtime object for testing ToolNode outside of graph context. + + This helper is needed because ToolNode._func expects a Runtime parameter + which is injected by RunnableCallable from config["configurable"]["__pregel_runtime"]. + When testing ToolNode directly (outside a graph), we need to provide this manually. + """ + mock_runtime = Mock() + mock_runtime.store = store + mock_runtime.context = None + mock_runtime.stream_writer = lambda *args, **kwargs: None + return mock_runtime + + +def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig: + """Create a RunnableConfig with mock Runtime for testing ToolNode. + + Returns: + RunnableConfig with __pregel_runtime in configurable dict. + """ + return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}} + + @pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) def test_no_prompt(sync_checkpointer: BaseCheckpointSaver, version: str) -> None: model = FakeToolCallingModel() @@ -327,7 +351,8 @@ def test_model_with_tools(tool_style: str, version: str, include_builtin: bool): ], ) ] - } + }, + config=_create_config_with_runtime(), ) tool_messages: ToolMessage = result["messages"][-2:] for tool_message in tool_messages: @@ -728,37 +753,13 @@ def test_tool_node_inject_state(schema_: type[T]) -> None: "type": "tool_call", } msg = AIMessage("hi?", tool_calls=[tool_call]) - result = node.invoke(schema_(**{"messages": [msg], "foo": "bar"})) + result = node.invoke( + schema_(**{"messages": [msg], "foo": "bar"}), + config=_create_config_with_runtime(), + ) tool_message = result["messages"][-1] assert tool_message.content == "bar", f"Failed for tool={tool_name}" - if tool_name == "tool3": - failure_input = None - try: - failure_input = schema_(**{"messages": [msg], "notfoo": "bar"}) - except Exception: - pass - if failure_input is not None: - with pytest.raises(KeyError): - node.invoke(failure_input) - - with pytest.raises(ValueError): - node.invoke([msg]) - else: - failure_input = None - try: - failure_input = schema_(**{"messages": [msg], "notfoo": "bar"}) - except Exception: - # We'd get a validation error from pydantic state and wouldn't make it to the node - # anyway - pass - if failure_input is not None: - messages_ = node.invoke(failure_input) - tool_message = messages_["messages"][-1] - assert "KeyError" in tool_message.content - tool_message = node.invoke([msg])[-1] - assert "KeyError" in tool_message.content - tool_call = { "name": "tool4", "args": {"some_val": 1}, @@ -766,11 +767,13 @@ def test_tool_node_inject_state(schema_: type[T]) -> None: "type": "tool_call", } msg = AIMessage("hi?", tool_calls=[tool_call]) - result = node.invoke(schema_(**{"messages": [msg], "foo": ""})) + result = node.invoke( + schema_(**{"messages": [msg], "foo": ""}), config=_create_config_with_runtime() + ) tool_message = result["messages"][-1] assert tool_message.content == "hi?" - result = node.invoke([msg]) + result = node.invoke([msg], config=_create_config_with_runtime()) tool_message = result[-1] assert tool_message.content == "hi?" @@ -882,7 +885,9 @@ def test_tool_node_inject_store() -> None: "type": "tool_call", } msg = AIMessage("hi?", tool_calls=[tool_call]) - node_result = node.invoke({"messages": [msg]}, store=store) + node_result = node.invoke( + {"messages": [msg]}, config=_create_config_with_runtime(store=store) + ) graph_result = graph.invoke({"messages": [msg]}) for result in (node_result, graph_result): result["messages"][-1] @@ -898,7 +903,10 @@ def test_tool_node_inject_store() -> None: "type": "tool_call", } msg = AIMessage("hi?", tool_calls=[tool_call]) - node_result = node.invoke({"messages": [msg], "bar": "baz"}, store=store) + node_result = node.invoke( + {"messages": [msg], "bar": "baz"}, + config=_create_config_with_runtime(store=store), + ) graph_result = graph.invoke({"messages": [msg], "bar": "baz"}) for result in (node_result, graph_result): result["messages"][-1] @@ -923,7 +931,8 @@ def test_tool_node_ensure_utf8() -> None: tools = [get_day_list] tool_calls = [ToolCall(name=get_day_list.name, args={"days": data}, id="test_id")] outputs: list[ToolMessage] = ToolNode(tools).invoke( - [AIMessage(content="", tool_calls=tool_calls)] + [AIMessage(content="", tool_calls=tool_calls)], + config=_create_config_with_runtime(), ) assert outputs[0].content == json.dumps(data, ensure_ascii=False) diff --git a/libs/prebuilt/tests/test_tool_node.py b/libs/prebuilt/tests/test_tool_node.py index ff7fccfac..a97bd1565 100644 --- a/libs/prebuilt/tests/test_tool_node.py +++ b/libs/prebuilt/tests/test_tool_node.py @@ -1,39 +1,92 @@ +import contextlib +import dataclasses +import json +import sys +from functools import partial from typing import ( Annotated, Any, + NoReturn, + TypeVar, ) +from unittest.mock import Mock import pytest from langchain_core.messages import ( AIMessage, + AnyMessage, + HumanMessage, RemoveMessage, + ToolCall, ToolMessage, ) +from langchain_core.runnables.config import RunnableConfig from langchain_core.tools import BaseTool, ToolException from langchain_core.tools import tool as dec_tool +from langgraph.config import get_stream_writer from langgraph.errors import GraphBubbleUp, GraphInterrupt -from langgraph.graph.message import REMOVE_ALL_MESSAGES +from langgraph.graph import START, MessagesState, StateGraph +from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages +from langgraph.store.base import BaseStore +from langgraph.store.memory import InMemoryStore from langgraph.types import Command, Send -from pydantic import BaseModel, ValidationError -from pydantic.v1 import ValidationError as ValidationErrorV1 +from pydantic import BaseModel +from pydantic.v1 import BaseModel as BaseModelV1 +from typing_extensions import TypedDict -from langgraph.prebuilt import ToolNode -from langgraph.prebuilt.tool_node import TOOL_CALL_ERROR_TEMPLATE +from langgraph.prebuilt import ( + InjectedState, + InjectedStore, + ToolNode, +) +from langgraph.prebuilt.tool_node import ( + TOOL_CALL_ERROR_TEMPLATE, + ToolInvocationError, + tools_condition, +) + +from .messages import _AnyIdHumanMessage, _AnyIdToolMessage +from .model import FakeToolCallingModel pytestmark = pytest.mark.anyio +def _create_mock_runtime(store: BaseStore | None = None) -> Mock: + """Create a mock Runtime object for testing ToolNode outside of graph context. + + This helper is needed because ToolNode._func expects a Runtime parameter + which is injected by RunnableCallable from config["configurable"]["__pregel_runtime"]. + When testing ToolNode directly (outside a graph), we need to provide this manually. + """ + mock_runtime = Mock() + mock_runtime.store = store + mock_runtime.context = None + mock_runtime.stream_writer = lambda *args, **kwargs: None + return mock_runtime + + +def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig: + """Create a RunnableConfig with mock Runtime for testing ToolNode. + + Returns: + RunnableConfig with __pregel_runtime in configurable dict. + """ + return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}} + + def tool1(some_val: int, some_other_val: str) -> str: """Tool 1 docstring.""" if some_val == 0: - raise ValueError("Test error") + msg = "Test error" + raise ValueError(msg) return f"{some_val} - {some_other_val}" async def tool2(some_val: int, some_other_val: str) -> str: """Tool 2 docstring.""" if some_val == 0: - raise ToolException("Test error") + msg = "Test error" + raise ToolException(msg) return f"tool2: {some_val} - {some_other_val}" @@ -53,15 +106,17 @@ async def tool4(some_val: int, some_other_val: str) -> str: @dec_tool -def tool5(some_val: int): +def tool5(some_val: int) -> NoReturn: """Tool 5 docstring.""" - raise ToolException("Test error") + msg = "Test error" + raise ToolException(msg) tool5.handle_tool_error = "foo" -async def test_tool_node(): +async def test_tool_node() -> None: + """Test tool node.""" result = ToolNode([tool1]).invoke( { "messages": [ @@ -76,7 +131,8 @@ async def test_tool_node(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) tool_message: ToolMessage = result["messages"][-1] @@ -98,7 +154,8 @@ async def test_tool_node(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) tool_message: ToolMessage = result2["messages"][-1] @@ -120,7 +177,8 @@ async def test_tool_node(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) tool_message: ToolMessage = result3["messages"][-1] assert tool_message.type == "tool" @@ -145,7 +203,8 @@ async def test_tool_node(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) tool_message: ToolMessage = result4["messages"][-1] assert tool_message.type == "tool" @@ -153,7 +212,7 @@ async def test_tool_node(): assert tool_message.tool_call_id == "some 3" -async def test_tool_node_tool_call_input(): +async def test_tool_node_tool_call_input() -> None: # Single tool call tool_call_1 = { "name": "tool1", @@ -161,7 +220,9 @@ async def test_tool_node_tool_call_input(): "id": "some 0", "type": "tool_call", } - result = ToolNode([tool1]).invoke([tool_call_1]) + result = ToolNode([tool1]).invoke( + [tool_call_1], config=_create_config_with_runtime() + ) assert result["messages"] == [ ToolMessage(content="1 - foo", tool_call_id="some 0", name="tool1"), ] @@ -173,7 +234,9 @@ async def test_tool_node_tool_call_input(): "id": "some 1", "type": "tool_call", } - result = ToolNode([tool1]).invoke([tool_call_1, tool_call_2]) + result = ToolNode([tool1]).invoke( + [tool_call_1, tool_call_2], config=_create_config_with_runtime() + ) assert result["messages"] == [ ToolMessage(content="1 - foo", tool_call_id="some 0", name="tool1"), ToolMessage(content="2 - bar", tool_call_id="some 1", name="tool1"), @@ -182,7 +245,9 @@ async def test_tool_node_tool_call_input(): # Test with unknown tool tool_call_3 = tool_call_1.copy() tool_call_3["name"] = "tool2" - result = ToolNode([tool1]).invoke([tool_call_1, tool_call_3]) + result = ToolNode([tool1]).invoke( + [tool_call_1, tool_call_3], config=_create_config_with_runtime() + ) assert result["messages"] == [ ToolMessage(content="1 - foo", tool_call_id="some 0", name="tool1"), ToolMessage( @@ -194,8 +259,58 @@ async def test_tool_node_tool_call_input(): ] -async def test_tool_node_error_handling(): - def handle_all(e: ValueError | ToolException | ValidationError): +def test_tool_node_error_handling_default_invocation() -> None: + tn = ToolNode([tool1]) + result = tn.invoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool1", + "args": {"invalid": 0, "args": "foo"}, + "id": "some id", + }, + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + assert all(m.type == "tool" for m in result["messages"]) + assert all(m.status == "error" for m in result["messages"]) + assert ( + "Error invoking tool 'tool1' with kwargs {'invalid': 0, 'args': 'foo'} with error:\n" + in result["messages"][0].content + ) + + +def test_tool_node_error_handling_default_exception() -> None: + tn = ToolNode([tool1]) + with pytest.raises(ValueError): + tn.invoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool1", + "args": {"some_val": 0, "some_other_val": "foo"}, + "id": "some id", + }, + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + +async def test_tool_node_error_handling() -> None: + def handle_all(e: ValueError | ToolException | ToolInvocationError): return TOOL_CALL_ERROR_TEMPLATE.format(error=repr(e)) # test catching all exceptions, via: @@ -204,7 +319,7 @@ async def test_tool_node_error_handling(): # - passing a callable with all exceptions in the signature for handle_tool_errors in ( True, - (ValueError, ToolException, ValidationError), + (ValueError, ToolException, ToolInvocationError), handle_all, ): result_error = await ToolNode( @@ -233,34 +348,33 @@ async def test_tool_node_error_handling(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) assert all(m.type == "tool" for m in result_error["messages"]) assert all(m.status == "error" for m in result_error["messages"]) assert ( result_error["messages"][0].content - == f"Error: {repr(ValueError('Test error'))}\n Please fix your mistakes." + == f"Error: {ValueError('Test error')!r}\n Please fix your mistakes." ) assert ( result_error["messages"][1].content - == f"Error: {repr(ToolException('Test error'))}\n Please fix your mistakes." - ) - assert ( - "ValidationError" in result_error["messages"][2].content - or "validation error" in result_error["messages"][2].content + == f"Error: {ToolException('Test error')!r}\n Please fix your mistakes." ) + # Check that the validation error contains the field name + assert "some_other_val" in result_error["messages"][2].content assert result_error["messages"][0].tool_call_id == "some id" assert result_error["messages"][1].tool_call_id == "some other id" assert result_error["messages"][2].tool_call_id == "another id" -async def test_tool_node_error_handling_callable(): - def handle_value_error(e: ValueError): +async def test_tool_node_error_handling_callable() -> None: + def handle_value_error(e: ValueError) -> str: return "Value error" - def handle_tool_exception(e: ToolException): + def handle_tool_exception(e: ToolException) -> str: return "Tool exception" for handle_tool_errors in ("Value error", handle_value_error): @@ -280,7 +394,8 @@ async def test_tool_node_error_handling_callable(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) tool_message: ToolMessage = result_error["messages"][-1] assert tool_message.type == "tool" @@ -313,7 +428,8 @@ async def test_tool_node_error_handling_callable(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) assert str(exc_info.value) == "Test error" @@ -340,12 +456,13 @@ async def test_tool_node_error_handling_callable(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) assert str(exc_info.value) == "Test error" -async def test_tool_node_handle_tool_errors_false(): +async def test_tool_node_handle_tool_errors_false() -> None: with pytest.raises(ValueError) as exc_info: ToolNode([tool1], handle_tool_errors=False).invoke( { @@ -361,7 +478,8 @@ async def test_tool_node_handle_tool_errors_false(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) assert str(exc_info.value) == "Test error" @@ -381,13 +499,14 @@ async def test_tool_node_handle_tool_errors_false(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) assert str(exc_info.value) == "Test error" # test validation errors get raised if handle_tool_errors is False - with pytest.raises((ValidationError, ValidationErrorV1)): + with pytest.raises(ToolInvocationError): ToolNode([tool1], handle_tool_errors=False).invoke( { "messages": [ @@ -402,11 +521,12 @@ async def test_tool_node_handle_tool_errors_false(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) -def test_tool_node_individual_tool_error_handling(): +def test_tool_node_individual_tool_error_handling() -> None: # test error handling on individual tools (and that it overrides overall error handling!) result_individual_tool_error_handler = ToolNode( [tool5], handle_tool_errors="bar" @@ -424,7 +544,8 @@ def test_tool_node_individual_tool_error_handling(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) tool_message: ToolMessage = result_individual_tool_error_handler["messages"][-1] @@ -434,7 +555,7 @@ def test_tool_node_individual_tool_error_handling(): assert tool_message.tool_call_id == "some 0" -def test_tool_node_incorrect_tool_name(): +def test_tool_node_incorrect_tool_name() -> None: result_incorrect_name = ToolNode([tool1, tool2]).invoke( { "messages": [ @@ -449,7 +570,8 @@ def test_tool_node_incorrect_tool_name(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) tool_message: ToolMessage = result_incorrect_name["messages"][-1] @@ -462,12 +584,13 @@ def test_tool_node_incorrect_tool_name(): assert tool_message.tool_call_id == "some 0" -def test_tool_node_node_interrupt(): +def test_tool_node_node_interrupt() -> None: def tool_interrupt(some_val: int) -> None: """Tool docstring.""" - raise GraphBubbleUp("foo") + msg = "foo" + raise GraphBubbleUp(msg) - def handle(e: GraphInterrupt): + def handle(e: GraphInterrupt) -> str: return "handled" for handle_tool_errors in (True, (GraphBubbleUp,), "handled", handle, False): @@ -487,13 +610,14 @@ def test_tool_node_node_interrupt(): ], ) ] - } + }, + config=_create_config_with_runtime(), ) assert exc_info.value == "foo" @pytest.mark.parametrize("input_type", ["dict", "tool_calls"]) -async def test_tool_node_command(input_type: str): +async def test_tool_node_command(input_type: str) -> None: from langchain_core.tools.base import InjectedToolCallId @dec_tool @@ -578,7 +702,9 @@ async def test_tool_node_command(input_type: str): input_ = {"messages": [AIMessage("", tool_calls=tool_calls)]} elif input_type == "tool_calls": input_ = tool_calls - result = ToolNode([add, transfer_to_bob]).invoke(input_) + result = ToolNode([add, transfer_to_bob]).invoke( + input_, config=_create_config_with_runtime() + ) assert result == [ { @@ -616,7 +742,8 @@ async def test_tool_node_command(input_type: str): "", tool_calls=[{"args": {}, "id": "1", "name": tool.name}] ) ] - } + }, + config=_create_config_with_runtime(), ) assert result == [ Command( @@ -643,7 +770,8 @@ async def test_tool_node_command(input_type: str): "", tool_calls=[{"args": {}, "id": "1", "name": tool.name}] ) ] - } + }, + config=_create_config_with_runtime(), ) assert result == [ Command( @@ -673,7 +801,8 @@ async def test_tool_node_command(input_type: str): ], ) ] - } + }, + config=_create_config_with_runtime(), ) assert result == [ Command( @@ -724,7 +853,8 @@ async def test_tool_node_command(input_type: str): ], ) ] - } + }, + config=_create_config_with_runtime(), ) # test validation (missing tool message in the update for current graph) @@ -743,7 +873,8 @@ async def test_tool_node_command(input_type: str): tool_calls=[{"args": {}, "id": "1", "name": "no_update_tool"}], ) ] - } + }, + config=_create_config_with_runtime(), ) # test validation (tool message with a wrong tool call ID) @@ -770,7 +901,8 @@ async def test_tool_node_command(input_type: str): ], ) ] - } + }, + config=_create_config_with_runtime(), ) # test validation (missing tool message in the update for parent graph is OK) @@ -789,11 +921,12 @@ async def test_tool_node_command(input_type: str): ], ) ] - } + }, + config=_create_config_with_runtime(), ) == [Command(update={"messages": []}, graph=Command.PARENT)] -async def test_tool_node_command_list_input(): +async def test_tool_node_command_list_input() -> None: from langchain_core.tools.base import InjectedToolCallId @dec_tool @@ -871,7 +1004,8 @@ async def test_tool_node_command_list_input(): {"args": {}, "id": "2", "name": "transfer_to_bob"}, ], ) - ] + ], + config=_create_config_with_runtime(), ) assert result == [ @@ -900,7 +1034,8 @@ async def test_tool_node_command_list_input(): # test sync tools for tool in [transfer_to_bob, custom_tool]: result = ToolNode([tool]).invoke( - [AIMessage("", tool_calls=[{"args": {}, "id": "1", "name": tool.name}])] + [AIMessage("", tool_calls=[{"args": {}, "id": "1", "name": tool.name}])], + config=_create_config_with_runtime(), ) assert result == [ Command( @@ -919,7 +1054,8 @@ async def test_tool_node_command_list_input(): # test async tools for tool in [async_transfer_to_bob, async_custom_tool]: result = await ToolNode([tool]).ainvoke( - [AIMessage("", tool_calls=[{"args": {}, "id": "1", "name": tool.name}])] + [AIMessage("", tool_calls=[{"args": {}, "id": "1", "name": tool.name}])], + config=_create_config_with_runtime(), ) assert result == [ Command( @@ -945,7 +1081,8 @@ async def test_tool_node_command_list_input(): {"args": {}, "id": "2", "name": "custom_transfer_to_bob"}, ], ) - ] + ], + config=_create_config_with_runtime(), ) assert result == [ Command( @@ -990,7 +1127,8 @@ async def test_tool_node_command_list_input(): "", tool_calls=[{"args": {}, "id": "1", "name": "list_update_tool"}], ) - ] + ], + config=_create_config_with_runtime(), ) # test validation (missing tool message in the update for current graph) @@ -1007,7 +1145,8 @@ async def test_tool_node_command_list_input(): "", tool_calls=[{"args": {}, "id": "1", "name": "no_update_tool"}], ) - ] + ], + config=_create_config_with_runtime(), ) # test validation (tool message with a wrong tool call ID) @@ -1026,7 +1165,8 @@ async def test_tool_node_command_list_input(): {"args": {}, "id": "1", "name": "mismatching_tool_call_id_tool"} ], ) - ] + ], + config=_create_config_with_runtime(), ) # test validation (missing tool message in the update for parent graph is OK) @@ -1041,11 +1181,12 @@ async def test_tool_node_command_list_input(): "", tool_calls=[{"args": {}, "id": "1", "name": "node_update_parent_tool"}], ) - ] + ], + config=_create_config_with_runtime(), ) == [Command(update=[], graph=Command.PARENT)] -def test_tool_node_parent_command_with_send(): +def test_tool_node_parent_command_with_send() -> None: from langchain_core.tools.base import InjectedToolCallId @dec_tool @@ -1096,7 +1237,8 @@ def test_tool_node_parent_command_with_send(): ] result = ToolNode([transfer_to_alice, transfer_to_bob]).invoke( - [AIMessage("", tool_calls=tool_calls)] + [AIMessage("", tool_calls=tool_calls)], + config=_create_config_with_runtime(), ) assert result == [ @@ -1132,7 +1274,7 @@ def test_tool_node_parent_command_with_send(): ] -async def test_tool_node_command_remove_all_messages(): +async def test_tool_node_command_remove_all_messages() -> None: from langchain_core.tools.base import InjectedToolCallId @dec_tool @@ -1147,7 +1289,8 @@ async def test_tool_node_command_remove_all_messages(): "id": "tool_call_123", } result = await tool_node.ainvoke( - {"messages": [AIMessage(content="", tool_calls=[tool_call])]} + {"messages": [AIMessage(content="", tool_calls=[tool_call])]}, + config=_create_config_with_runtime(), ) assert isinstance(result, list) @@ -1155,3 +1298,315 @@ async def test_tool_node_command_remove_all_messages(): command = result[0] assert isinstance(command, Command) assert command.update == {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]} + + +class _InjectStateSchema(TypedDict): + messages: list + foo: str + + +class _InjectedStatePydanticV2Schema(BaseModel): + messages: list + foo: str + + +@dataclasses.dataclass +class _InjectedStateDataclassSchema: + messages: list + foo: str + + +_INJECTED_STATE_SCHEMAS = [ + _InjectStateSchema, + _InjectedStatePydanticV2Schema, + _InjectedStateDataclassSchema, +] + +if sys.version_info < (3, 14): + + class _InjectedStatePydanticSchema(BaseModelV1): + messages: list + foo: str + + _INJECTED_STATE_SCHEMAS.append(_InjectedStatePydanticSchema) + +T = TypeVar("T") + + +@pytest.mark.parametrize("schema_", _INJECTED_STATE_SCHEMAS) +def test_tool_node_inject_state(schema_: type[T]) -> None: + def tool1(some_val: int, state: Annotated[T, InjectedState]) -> str: + """Tool 1 docstring.""" + if isinstance(state, dict): + return state["foo"] + return state.foo + + def tool2(some_val: int, state: Annotated[T, InjectedState()]) -> str: + """Tool 2 docstring.""" + if isinstance(state, dict): + return state["foo"] + return state.foo + + def tool3( + some_val: int, + foo: Annotated[str, InjectedState("foo")], + msgs: Annotated[list[AnyMessage], InjectedState("messages")], + ) -> str: + """Tool 1 docstring.""" + return foo + + def tool4( + some_val: int, msgs: Annotated[list[AnyMessage], InjectedState("messages")] + ) -> str: + """Tool 1 docstring.""" + return msgs[0].content + + node = ToolNode([tool1, tool2, tool3, tool4], handle_tool_errors=True) + for tool_name in ("tool1", "tool2", "tool3"): + tool_call = { + "name": tool_name, + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + result = node.invoke( + schema_(messages=[msg], foo="bar"), config=_create_config_with_runtime() + ) + tool_message = result["messages"][-1] + assert tool_message.content == "bar", f"Failed for tool={tool_name}" + + if tool_name == "tool3": + failure_input = None + with contextlib.suppress(Exception): + failure_input = schema_(messages=[msg], notfoo="bar") + if failure_input is not None: + with pytest.raises(KeyError): + node.invoke(failure_input, config=_create_config_with_runtime()) + + with pytest.raises(ValueError): + node.invoke([msg], config=_create_config_with_runtime()) + else: + failure_input = None + try: + failure_input = schema_(messages=[msg], notfoo="bar") + except Exception: + # We'd get a validation error from pydantic state and wouldn't make it to the node + # anyway + pass + if failure_input is not None: + messages_ = node.invoke( + failure_input, config=_create_config_with_runtime() + ) + tool_message = messages_["messages"][-1] + assert "KeyError" in tool_message.content + tool_message = node.invoke([msg], config=_create_config_with_runtime())[ + -1 + ] + assert "KeyError" in tool_message.content + + tool_call = { + "name": "tool4", + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + result = node.invoke( + schema_(messages=[msg], foo=""), config=_create_config_with_runtime() + ) + tool_message = result["messages"][-1] + assert tool_message.content == "hi?" + + result = node.invoke([msg], config=_create_config_with_runtime()) + tool_message = result[-1] + assert tool_message.content == "hi?" + + +def test_tool_node_inject_store() -> None: + store = InMemoryStore() + namespace = ("test",) + + def tool1(some_val: int, store: Annotated[BaseStore, InjectedStore()]) -> str: + """Tool 1 docstring.""" + store_val = store.get(namespace, "test_key").value["foo"] + return f"Some val: {some_val}, store val: {store_val}" + + def tool2(some_val: int, store: Annotated[BaseStore, InjectedStore()]) -> str: + """Tool 2 docstring.""" + store_val = store.get(namespace, "test_key").value["foo"] + return f"Some val: {some_val}, store val: {store_val}" + + def tool3( + some_val: int, + bar: Annotated[str, InjectedState("bar")], + store: Annotated[BaseStore, InjectedStore()], + ) -> str: + """Tool 3 docstring.""" + store_val = store.get(namespace, "test_key").value["foo"] + return f"Some val: {some_val}, store val: {store_val}, state val: {bar}" + + node = ToolNode([tool1, tool2, tool3], handle_tool_errors=True) + store.put(namespace, "test_key", {"foo": "bar"}) + + class State(MessagesState): + bar: str + + builder = StateGraph(State) + builder.add_node("tools", node) + builder.add_edge(START, "tools") + graph = builder.compile(store=store) + + for tool_name in ("tool1", "tool2"): + tool_call = { + "name": tool_name, + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + node_result = node.invoke( + {"messages": [msg]}, config=_create_config_with_runtime(store=store) + ) + graph_result = graph.invoke({"messages": [msg]}) + for result in (node_result, graph_result): + result["messages"][-1] + tool_message = result["messages"][-1] + assert tool_message.content == "Some val: 1, store val: bar", ( + f"Failed for tool={tool_name}" + ) + + tool_call = { + "name": "tool3", + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + node_result = node.invoke( + {"messages": [msg], "bar": "baz"}, + config=_create_config_with_runtime(store=store), + ) + graph_result = graph.invoke({"messages": [msg], "bar": "baz"}) + for result in (node_result, graph_result): + result["messages"][-1] + tool_message = result["messages"][-1] + assert tool_message.content == "Some val: 1, store val: bar, state val: baz", ( + f"Failed for tool={tool_name}" + ) + + # test injected store without passing store to compiled graph + failing_graph = builder.compile() + with pytest.raises(ValueError): + failing_graph.invoke({"messages": [msg], "bar": "baz"}) + + +def test_tool_node_ensure_utf8() -> None: + @dec_tool + def get_day_list(days: list[str]) -> list[str]: + """choose days""" + return days + + data = ["星期一", "水曜日", "목요일", "Friday"] + tools = [get_day_list] + tool_calls = [ToolCall(name=get_day_list.name, args={"days": data}, id="test_id")] + outputs: list[ToolMessage] = ToolNode(tools).invoke( + [AIMessage(content="", tool_calls=tool_calls)], + config=_create_config_with_runtime(), + ) + assert outputs[0].content == json.dumps(data, ensure_ascii=False) + + +def test_tool_node_messages_key() -> None: + @dec_tool + def add(a: int, b: int) -> int: + """Adds a and b.""" + return a + b + + model = FakeToolCallingModel( + tool_calls=[[ToolCall(name=add.name, args={"a": 1, "b": 2}, id="test_id")]] + ) + + class State(TypedDict): + subgraph_messages: Annotated[list[AnyMessage], add_messages] + + def call_model(state: State) -> dict[str, Any]: + response = model.invoke(state["subgraph_messages"]) + model.tool_calls = [] + return {"subgraph_messages": response} + + builder = StateGraph(State) + builder.add_node("agent", call_model) + builder.add_node("tools", ToolNode([add], messages_key="subgraph_messages")) + builder.add_conditional_edges( + "agent", partial(tools_condition, messages_key="subgraph_messages") + ) + builder.add_edge(START, "agent") + builder.add_edge("tools", "agent") + + graph = builder.compile() + result = graph.invoke({"subgraph_messages": [HumanMessage(content="hi")]}) + assert result["subgraph_messages"] == [ + _AnyIdHumanMessage(content="hi"), + AIMessage( + content="hi", + id="0", + tool_calls=[ToolCall(name=add.name, args={"a": 1, "b": 2}, id="test_id")], + ), + _AnyIdToolMessage(content="3", name=add.name, tool_call_id="test_id"), + AIMessage(content="hi-hi-3", id="1"), + ] + + +def test_tool_node_stream_writer() -> None: + @dec_tool + def streaming_tool(x: int) -> str: + """Do something with writer.""" + my_writer = get_stream_writer() + for value in ["foo", "bar", "baz"]: + my_writer({"custom_tool_value": value}) + + return x + + tool_node = ToolNode([streaming_tool]) + graph = ( + StateGraph(MessagesState) + .add_node("tools", tool_node) + .add_edge(START, "tools") + .compile() + ) + + tool_call = { + "name": "streaming_tool", + "args": {"x": 1}, + "id": "1", + "type": "tool_call", + } + inputs = { + "messages": [AIMessage("", tool_calls=[tool_call])], + } + + assert list(graph.stream(inputs, stream_mode="custom")) == [ + {"custom_tool_value": "foo"}, + {"custom_tool_value": "bar"}, + {"custom_tool_value": "baz"}, + ] + assert list(graph.stream(inputs, stream_mode=["custom", "updates"])) == [ + ("custom", {"custom_tool_value": "foo"}), + ("custom", {"custom_tool_value": "bar"}), + ("custom", {"custom_tool_value": "baz"}), + ( + "updates", + { + "tools": { + "messages": [ + _AnyIdToolMessage( + content="1", + name="streaming_tool", + tool_call_id="1", + ), + ], + }, + }, + ), + ] diff --git a/libs/prebuilt/tests/test_tool_node_interceptor_unregistered.py b/libs/prebuilt/tests/test_tool_node_interceptor_unregistered.py new file mode 100644 index 000000000..581b68cb4 --- /dev/null +++ b/libs/prebuilt/tests/test_tool_node_interceptor_unregistered.py @@ -0,0 +1,578 @@ +"""Test tool node interceptor handling of unregistered tools.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import Mock + +import pytest +from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.runnables.config import RunnableConfig +from langchain_core.tools import tool as dec_tool +from langgraph.store.base import BaseStore +from langgraph.types import Command + +from langgraph.prebuilt import ToolNode +from langgraph.prebuilt.tool_node import ToolCallRequest + +pytestmark = pytest.mark.anyio + + +def _create_mock_runtime(store: BaseStore | None = None) -> Mock: + """Create a mock Runtime object for testing ToolNode outside of graph context. + + This helper is needed because ToolNode._func expects a Runtime parameter + which is injected by RunnableCallable from config["configurable"]["__pregel_runtime"]. + When testing ToolNode directly (outside a graph), we need to provide this manually. + """ + mock_runtime = Mock() + mock_runtime.store = store + mock_runtime.context = None + mock_runtime.stream_writer = lambda *args, **kwargs: None + return mock_runtime + + +def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig: + """Create a RunnableConfig with mock Runtime for testing ToolNode. + + Returns: + RunnableConfig with __pregel_runtime in configurable dict. + """ + return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}} + + +@dec_tool +def registered_tool(x: int) -> str: + """A registered tool.""" + return f"Result: {x}" + + +def test_interceptor_can_handle_unregistered_tool_sync() -> None: + """Test that interceptor can handle requests for unregistered tools (sync).""" + + def interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Intercept and handle unregistered tools.""" + if request.tool_call["name"] == "unregistered_tool": + # Short-circuit without calling execute for unregistered tool + return ToolMessage( + content="Handled by interceptor", + tool_call_id=request.tool_call["id"], + name="unregistered_tool", + ) + # Pass through for registered tools + return execute(request) + + node = ToolNode([registered_tool], wrap_tool_call=interceptor) + + # Test registered tool works normally + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "registered_tool", + "args": {"x": 42}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + assert result[0].content == "Result: 42" + assert result[0].tool_call_id == "1" + + # Test unregistered tool is intercepted and handled + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "unregistered_tool", + "args": {"x": 99}, + "id": "2", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + assert result[0].content == "Handled by interceptor" + assert result[0].tool_call_id == "2" + assert result[0].name == "unregistered_tool" + + +async def test_interceptor_can_handle_unregistered_tool_async() -> None: + """Test that interceptor can handle requests for unregistered tools (async).""" + + async def async_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], + ) -> ToolMessage | Command: + """Intercept and handle unregistered tools.""" + if request.tool_call["name"] == "unregistered_tool": + # Short-circuit without calling execute for unregistered tool + return ToolMessage( + content="Handled by async interceptor", + tool_call_id=request.tool_call["id"], + name="unregistered_tool", + ) + # Pass through for registered tools + return await execute(request) + + node = ToolNode([registered_tool], awrap_tool_call=async_interceptor) + + # Test registered tool works normally + result = await node.ainvoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "registered_tool", + "args": {"x": 42}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + assert result[0].content == "Result: 42" + assert result[0].tool_call_id == "1" + + # Test unregistered tool is intercepted and handled + result = await node.ainvoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "unregistered_tool", + "args": {"x": 99}, + "id": "2", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + assert result[0].content == "Handled by async interceptor" + assert result[0].tool_call_id == "2" + assert result[0].name == "unregistered_tool" + + +def test_unregistered_tool_error_when_interceptor_calls_execute() -> None: + """Test that unregistered tools error if interceptor tries to execute them.""" + + def bad_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Interceptor that tries to execute unregistered tool.""" + # This should fail validation when execute is called + return execute(request) + + node = ToolNode([registered_tool], wrap_tool_call=bad_interceptor) + + # Registered tool should still work + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "registered_tool", + "args": {"x": 42}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + assert result[0].content == "Result: 42" + + # Unregistered tool should error when interceptor calls execute + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "unregistered_tool", + "args": {"x": 99}, + "id": "2", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + # Should get validation error message + assert result[0].status == "error" + assert "is not a valid tool" in result[0].content + assert result[0].tool_call_id == "2" + + +def test_interceptor_handles_mix_of_registered_and_unregistered() -> None: + """Test interceptor handling mix of registered and unregistered tools.""" + + def selective_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handle unregistered tools, pass through registered ones.""" + if request.tool_call["name"] == "magic_tool": + return ToolMessage( + content=f"Magic result: {request.tool_call['args'].get('value', 0) * 2}", + tool_call_id=request.tool_call["id"], + name="magic_tool", + ) + return execute(request) + + node = ToolNode([registered_tool], wrap_tool_call=selective_interceptor) + + # Test multiple tool calls - mix of registered and unregistered + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "registered_tool", + "args": {"x": 10}, + "id": "1", + "type": "tool_call", + }, + { + "name": "magic_tool", + "args": {"value": 5}, + "id": "2", + "type": "tool_call", + }, + { + "name": "registered_tool", + "args": {"x": 20}, + "id": "3", + "type": "tool_call", + }, + ], + ) + ], + config=_create_config_with_runtime(), + ) + + # All tools should execute successfully + assert len(result) == 3 + assert result[0].content == "Result: 10" + assert result[0].tool_call_id == "1" + assert result[1].content == "Magic result: 10" + assert result[1].tool_call_id == "2" + assert result[2].content == "Result: 20" + assert result[2].tool_call_id == "3" + + +def test_interceptor_command_for_unregistered_tool() -> None: + """Test interceptor returning Command for unregistered tool.""" + + def command_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Return Command for unregistered tools.""" + if request.tool_call["name"] == "routing_tool": + return Command( + update=[ + ToolMessage( + content="Routing to special handler", + tool_call_id=request.tool_call["id"], + name="routing_tool", + ) + ], + goto="special_node", + ) + return execute(request) + + node = ToolNode([registered_tool], wrap_tool_call=command_interceptor) + + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "routing_tool", + "args": {}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + # Should get Command back + assert len(result) == 1 + assert isinstance(result[0], Command) + assert result[0].goto == "special_node" + assert result[0].update is not None + assert len(result[0].update) == 1 + assert result[0].update[0].content == "Routing to special handler" + + +def test_interceptor_exception_with_unregistered_tool() -> None: + """Test that interceptor exceptions are caught by error handling.""" + + def failing_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Interceptor that throws exception for unregistered tools.""" + if request.tool_call["name"] == "bad_tool": + msg = "Interceptor failed" + raise ValueError(msg) + return execute(request) + + node = ToolNode( + [registered_tool], wrap_tool_call=failing_interceptor, handle_tool_errors=True + ) + + # Interceptor exception should be caught and converted to error message + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "bad_tool", + "args": {}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert len(result) == 1 + assert result[0].status == "error" + assert "Interceptor failed" in result[0].content + assert result[0].tool_call_id == "1" + + # Test that exception is raised when handle_tool_errors is False + node_no_handling = ToolNode( + [registered_tool], wrap_tool_call=failing_interceptor, handle_tool_errors=False + ) + + with pytest.raises(ValueError, match="Interceptor failed"): + node_no_handling.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "bad_tool", + "args": {}, + "id": "2", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + +async def test_async_interceptor_exception_with_unregistered_tool() -> None: + """Test that async interceptor exceptions are caught by error handling.""" + + async def failing_async_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], + ) -> ToolMessage | Command: + """Async interceptor that throws exception for unregistered tools.""" + if request.tool_call["name"] == "bad_async_tool": + msg = "Async interceptor failed" + raise RuntimeError(msg) + return await execute(request) + + node = ToolNode( + [registered_tool], + awrap_tool_call=failing_async_interceptor, + handle_tool_errors=True, + ) + + # Interceptor exception should be caught and converted to error message + result = await node.ainvoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "bad_async_tool", + "args": {}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert len(result) == 1 + assert result[0].status == "error" + assert "Async interceptor failed" in result[0].content + assert result[0].tool_call_id == "1" + + # Test that exception is raised when handle_tool_errors is False + node_no_handling = ToolNode( + [registered_tool], + awrap_tool_call=failing_async_interceptor, + handle_tool_errors=False, + ) + + with pytest.raises(RuntimeError, match="Async interceptor failed"): + await node_no_handling.ainvoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "bad_async_tool", + "args": {}, + "id": "2", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + +def test_interceptor_with_dict_input_format() -> None: + """Test that interceptor works with dict input format.""" + + def interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Intercept unregistered tools with dict input.""" + if request.tool_call["name"] == "dict_tool": + return ToolMessage( + content="Handled dict input", + tool_call_id=request.tool_call["id"], + name="dict_tool", + ) + return execute(request) + + node = ToolNode([registered_tool], wrap_tool_call=interceptor) + + # Test with dict input format + result = node.invoke( + { + "messages": [ + AIMessage( + "", + tool_calls=[ + { + "name": "dict_tool", + "args": {"value": 5}, + "id": "1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should return dict format output + assert isinstance(result, dict) + assert "messages" in result + assert len(result["messages"]) == 1 + assert result["messages"][0].content == "Handled dict input" + assert result["messages"][0].tool_call_id == "1" + + +def test_interceptor_verifies_tool_is_none_for_unregistered() -> None: + """Test that request.tool is None for unregistered tools.""" + + captured_requests: list[ToolCallRequest] = [] + + def capturing_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Capture request to verify tool field.""" + captured_requests.append(request) + if request.tool is None: + # Tool is unregistered + return ToolMessage( + content=f"Unregistered: {request.tool_call['name']}", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + # Tool is registered + return execute(request) + + node = ToolNode([registered_tool], wrap_tool_call=capturing_interceptor) + + # Test unregistered tool + node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "unknown_tool", + "args": {}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert len(captured_requests) == 1 + assert captured_requests[0].tool is None + assert captured_requests[0].tool_call["name"] == "unknown_tool" + + # Clear and test registered tool + captured_requests.clear() + node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "registered_tool", + "args": {"x": 10}, + "id": "2", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert len(captured_requests) == 1 + assert captured_requests[0].tool is not None + assert captured_requests[0].tool.name == "registered_tool" diff --git a/libs/prebuilt/tests/test_tool_node_validation_error_filtering.py b/libs/prebuilt/tests/test_tool_node_validation_error_filtering.py new file mode 100644 index 000000000..af2fd00e3 --- /dev/null +++ b/libs/prebuilt/tests/test_tool_node_validation_error_filtering.py @@ -0,0 +1,470 @@ +"""Unit tests for ValidationError filtering in ToolNode. + +This module tests that validation errors are filtered to only include arguments +that the LLM controls. Injected arguments (InjectedState, InjectedStore, +ToolRuntime) are automatically provided by the system and should not appear in +validation error messages. This ensures the LLM receives focused, actionable +feedback about the parameters it can actually control, improving error correction +and reducing confusion from irrelevant system implementation details. +""" + +from typing import Annotated +from unittest.mock import Mock + +import pytest +from langchain_core.messages import AIMessage +from langchain_core.runnables.config import RunnableConfig +from langchain_core.tools import tool as dec_tool +from langgraph.store.base import BaseStore +from langgraph.store.memory import InMemoryStore + +from langgraph.prebuilt import InjectedState, InjectedStore, ToolNode, ToolRuntime +from langgraph.prebuilt.tool_node import ToolInvocationError + +pytestmark = pytest.mark.anyio + + +def _create_mock_runtime(store: BaseStore | None = None) -> Mock: + """Create a mock Runtime object for testing ToolNode outside of graph context.""" + mock_runtime = Mock() + mock_runtime.store = store + mock_runtime.context = None + mock_runtime.stream_writer = lambda *args, **kwargs: None + return mock_runtime + + +def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig: + """Create a RunnableConfig with mock Runtime for testing ToolNode.""" + return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}} + + +async def test_filter_injected_state_validation_errors() -> None: + """Test that validation errors for InjectedState arguments are filtered out. + + InjectedState parameters are not controlled by the LLM, so any validation + errors related to them should not appear in error messages. This ensures + the LLM receives only actionable feedback about its own tool call arguments. + """ + + @dec_tool + def my_tool( + value: int, + state: Annotated[dict, InjectedState], + ) -> str: + """Tool that uses injected state. + + Args: + value: An integer value. + state: The graph state (injected). + """ + return f"value={value}, messages={len(state.get('messages', []))}" + + tool_node = ToolNode([my_tool]) + + # Call with invalid 'value' argument (should be int, not str) + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {"value": "not_an_int"}, # Invalid type + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should get a ToolMessage with error + assert len(result["messages"]) == 1 + tool_message = result["messages"][0] + assert tool_message.status == "error" + assert tool_message.tool_call_id == "call_1" + + # Error should mention 'value' but NOT 'state' (which is injected) + assert "value" in tool_message.content + assert "state" not in tool_message.content.lower() + + +async def test_filter_injected_store_validation_errors() -> None: + """Test that validation errors for InjectedStore arguments are filtered out. + + InjectedStore parameters are not controlled by the LLM, so any validation + errors related to them should not appear in error messages. This keeps + error feedback focused on LLM-controllable parameters. + """ + + @dec_tool + def my_tool( + key: str, + store: Annotated[BaseStore, InjectedStore()], + ) -> str: + """Tool that uses injected store. + + Args: + key: A key to look up. + store: The persistent store (injected). + """ + return f"key={key}" + + tool_node = ToolNode([my_tool]) + + # Call with invalid 'key' argument (missing required argument) + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {}, # Missing 'key' + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(store=InMemoryStore()), + ) + + # Should get a ToolMessage with error + assert len(result["messages"]) == 1 + tool_message = result["messages"][0] + assert tool_message.status == "error" + + # Error should mention 'key' is required + assert "key" in tool_message.content.lower() + # The error should be about 'key' field specifically (not about store field) + # Note: 'store' might appear in input_value representation, but the validation + # error itself should only be for 'key' + assert ( + "field required" in tool_message.content.lower() + or "missing" in tool_message.content.lower() + ) + + +async def test_filter_tool_runtime_validation_errors() -> None: + """Test that validation errors for ToolRuntime arguments are filtered out. + + ToolRuntime parameters are not controlled by the LLM, so any validation + errors related to them should not appear in error messages. This ensures + the LLM only sees errors for parameters it can fix. + """ + + @dec_tool + def my_tool( + query: str, + runtime: ToolRuntime, + ) -> str: + """Tool that uses ToolRuntime. + + Args: + query: A query string. + runtime: The tool runtime context (injected). + """ + return f"query={query}" + + tool_node = ToolNode([my_tool]) + + # Call with invalid 'query' argument (wrong type) + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {"query": 123}, # Should be str, not int + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should get a ToolMessage with error + assert len(result["messages"]) == 1 + tool_message = result["messages"][0] + assert tool_message.status == "error" + + # Error should mention 'query' but NOT 'runtime' (which is injected) + assert "query" in tool_message.content.lower() + assert "runtime" not in tool_message.content.lower() + + +async def test_filter_multiple_injected_args() -> None: + """Test filtering when a tool has multiple injected arguments. + + When a tool uses multiple injected parameters (state, store, runtime), none of + them should appear in validation error messages since they're all system-provided + and not controlled by the LLM. Only LLM-controllable parameter errors should appear. + """ + + @dec_tool + def my_tool( + value: int, + state: Annotated[dict, InjectedState], + store: Annotated[BaseStore, InjectedStore()], + runtime: ToolRuntime, + ) -> str: + """Tool with multiple injected arguments. + + Args: + value: An integer value. + state: The graph state (injected). + store: The persistent store (injected). + runtime: The tool runtime context (injected). + """ + return f"value={value}" + + tool_node = ToolNode([my_tool]) + + # Call with invalid 'value' - injected args should be filtered from error + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {"value": "not_an_int"}, + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(store=InMemoryStore()), + ) + + tool_message = result["messages"][0] + assert tool_message.status == "error" + + # Only 'value' error should be reported + assert "value" in tool_message.content + # None of the injected args should appear in error + assert "state" not in tool_message.content.lower() + assert "store" not in tool_message.content.lower() + assert "runtime" not in tool_message.content.lower() + + +async def test_no_filtering_when_all_errors_are_model_args() -> None: + """Test that validation errors for LLM-controlled arguments are preserved. + + When validation fails for arguments the LLM controls, those errors should + be fully reported to help the LLM correct its tool calls. This ensures + the LLM receives complete feedback about all issues it can fix. + """ + + @dec_tool + def my_tool( + value1: int, + value2: str, + state: Annotated[dict, InjectedState], + ) -> str: + """Tool with both regular and injected arguments. + + Args: + value1: First value. + value2: Second value. + state: The graph state (injected). + """ + return f"value1={value1}, value2={value2}" + + tool_node = ToolNode([my_tool]) + + # Call with invalid arguments for BOTH non-injected parameters + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": { + "value1": "not_an_int", # Invalid + "value2": 456, # Invalid (should be str) + }, + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][0] + assert tool_message.status == "error" + + # Both errors should be present + assert "value1" in tool_message.content + assert "value2" in tool_message.content + # Injected state should not appear + assert "state" not in tool_message.content.lower() + + +async def test_validation_error_with_no_injected_args() -> None: + """Test that tools without injected arguments show all validation errors. + + For tools that only have LLM-controlled parameters, all validation errors + should be reported since everything is under the LLM's control and can be + corrected by the LLM in subsequent tool calls. + """ + + @dec_tool + def my_tool(value1: int, value2: str) -> str: + """Regular tool without injected arguments. + + Args: + value1: First value. + value2: Second value. + """ + return f"{value1} {value2}" + + tool_node = ToolNode([my_tool]) + + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {"value1": "invalid", "value2": 123}, + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][0] + assert tool_message.status == "error" + + # Both errors should be present since there are no injected args to filter + assert "value1" in tool_message.content + assert "value2" in tool_message.content + + +async def test_tool_invocation_error_without_handle_errors() -> None: + """Test that ToolInvocationError contains only LLM-controlled parameter errors. + + When handle_tool_errors is False, the raised ToolInvocationError should still + filter out system-injected arguments from the error details, ensuring that + error messages focus on what the LLM can control. + """ + + @dec_tool + def my_tool( + value: int, + state: Annotated[dict, InjectedState], + ) -> str: + """Tool with injected state. + + Args: + value: An integer value. + state: The graph state (injected). + """ + return f"value={value}" + + tool_node = ToolNode([my_tool], handle_tool_errors=False) + + # Should raise ToolInvocationError with filtered errors + with pytest.raises(ToolInvocationError) as exc_info: + await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {"value": "not_an_int"}, + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + error = exc_info.value + assert error.tool_name == "my_tool" + assert error.filtered_errors is not None + assert len(error.filtered_errors) > 0 + + # Filtered errors should only contain 'value' error, not 'state' + error_locs = [err["loc"] for err in error.filtered_errors] + assert any("value" in str(loc) for loc in error_locs) + assert not any("state" in str(loc) for loc in error_locs) + + +async def test_sync_tool_validation_error_filtering() -> None: + """Test that error filtering works for sync tools. + + Error filtering should work identically for both sync and async tool execution, + excluding injected arguments from validation error messages. + """ + + @dec_tool + def my_tool( + value: int, + state: Annotated[dict, InjectedState], + ) -> str: + """Sync tool with injected state. + + Args: + value: An integer value. + state: The graph state (injected). + """ + return f"value={value}" + + tool_node = ToolNode([my_tool]) + + # Test sync invocation + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {"value": "not_an_int"}, + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][0] + assert tool_message.status == "error" + assert "value" in tool_message.content + assert "state" not in tool_message.content.lower() diff --git a/libs/prebuilt/uv.lock b/libs/prebuilt/uv.lock index b5b028033..048649361 100644 --- a/libs/prebuilt/uv.lock +++ b/libs/prebuilt/uv.lock @@ -246,7 +246,7 @@ wheels = [ [[package]] name = "langgraph" -version = "1.0.1" +version = "1.0.2" source = { editable = "../langgraph" } dependencies = [ { name = "langchain-core" }, @@ -467,7 +467,7 @@ test = [ [[package]] name = "langgraph-prebuilt" -version = "1.0.1" +version = "1.0.2" source = { editable = "." } dependencies = [ { name = "langchain-core" }, @@ -483,6 +483,7 @@ dev = [ { name = "langgraph-checkpoint-postgres" }, { name = "langgraph-checkpoint-sqlite" }, { name = "mypy" }, + { name = "psycopg-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, @@ -501,6 +502,7 @@ test = [ { name = "langgraph-checkpoint" }, { name = "langgraph-checkpoint-postgres" }, { name = "langgraph-checkpoint-sqlite" }, + { name = "psycopg-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, @@ -523,6 +525,7 @@ dev = [ { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, { name = "mypy" }, + { name = "psycopg-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, @@ -541,6 +544,7 @@ test = [ { name = "langgraph-checkpoint", editable = "../checkpoint" }, { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "psycopg-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, @@ -828,6 +832,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/90/422ffbbeeb9418c795dae2a768db860401446af0c6768bc061ce22325f58/psycopg-3.2.10-py3-none-any.whl", hash = "sha256:ab5caf09a9ec42e314a21f5216dbcceac528e0e05142e42eea83a3b28b320ac3", size = 206586, upload-time = "2025-09-08T09:07:50.121Z" }, ] +[[package]] +name = "psycopg-binary" +version = "3.2.11" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/96/9fe31ef61b311c697a98709a31b875d152e4f67924dd2cb94a4de0396d74/psycopg_binary-3.2.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f72146ad5b69ea177c2707578e5a4a9422b79e50d5a80992dabc5619b0929771", size = 4031016, upload-time = "2025-10-18T22:43:35.867Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/3ae6be34bfda1ba6dfd4e3b5c1d68bc51d4593399b5a10faaff68937c9a1/psycopg_binary-3.2.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b051aa1e67f0d03ccdb4503d716f22da56229896526f0aa721e5a199baa9e5d4", size = 4090430, upload-time = "2025-10-18T22:43:41.154Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ea/7aa84f6bb64f94bfbe7d494d384a0d2bc66ba66e8607f5e9b515aa6af627/psycopg_binary-3.2.11-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49d76391b225f72dd63fcab87937ccf307ae0f093b5a382eeacf05f19a57c176", size = 4641307, upload-time = "2025-10-18T22:43:45.959Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/ef12ff8a530230824965668b44ccd58a88dae40511f7bbd125defb7972c4/psycopg_binary-3.2.11-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:58997db1aa48a1119e26c1c2f893d1c92339bd3be5d1f25334f22eaeaeeca90e", size = 4742204, upload-time = "2025-10-18T22:43:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/f7/9c/8f35345fe22a0e5997cbfba0b7e1a58f26b290400cb9a6cb67e72e503331/psycopg_binary-3.2.11-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e3b6328bc2f3ca233f9a5f08d266089b96a534eca9ee4e45cb92d0a8d4629d9c", size = 4425352, upload-time = "2025-10-18T22:43:55.039Z" }, + { url = "https://files.pythonhosted.org/packages/f1/29/f0c585c6b48526f0ecf179e13ea2b6d8fed0dbba8c1a0d61da8ece149b0e/psycopg_binary-3.2.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bc571786a256a2fa2d8f13b5ecf714020b753bc76c2fa6d308e46751946dc31", size = 3885019, upload-time = "2025-10-18T22:43:59.256Z" }, + { url = "https://files.pythonhosted.org/packages/20/6d/a139e1c7e9840491d9ad3c837264a900ac95ba35e5f83fd5715b1ce7a729/psycopg_binary-3.2.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:766089fdaa8af1b5f7e2ec9fd7ad190c865e226b4fb0e7b1bd8dbcd62b5b923e", size = 3568192, upload-time = "2025-10-18T22:44:03.915Z" }, + { url = "https://files.pythonhosted.org/packages/74/ea/43a2b6fcfa816797dc6d2ac67e9cd09b3a7e4da0a29467a8b5940e7a1312/psycopg_binary-3.2.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5fb27dd9c52ae13cb4de90244207155b694f76a75a816115ead2d573f40e1e36", size = 3609300, upload-time = "2025-10-18T22:44:09.168Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/c9d46e626528a44b0feb881064e8018107b603ac683a658be3ee9ca00222/psycopg_binary-3.2.11-cp310-cp310-win_amd64.whl", hash = "sha256:3f32b09fba85d9e239229bdc5b6254420c02054f6954fe7fbd1ecf1ca93009ed", size = 2918105, upload-time = "2025-10-18T22:44:14.203Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c4/350473820759d7e599e68bd79c88d32376353ceb0f764db05de8f13ff421/psycopg_binary-3.2.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6688807ed07436c18e9946d01372bc80b9d20b7732cde27de9313e0860910c84", size = 4037740, upload-time = "2025-10-18T22:44:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/50/e0/00bf3e207676bbe6e9f32c0f924f0e5be1efcd1a9fb2fd84d1c3d9958a96/psycopg_binary-3.2.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:478a68d50f34f6203642d245e2046d266c719ab4e593a1bb94c3be5f82e1aee1", size = 4098558, upload-time = "2025-10-18T22:44:26.948Z" }, + { url = "https://files.pythonhosted.org/packages/1e/db/bc1d22fe57b01fa76b02943e1034cb59070bf906e982ebc507d079998b5b/psycopg_binary-3.2.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e7575ca710277cc3e9257ff803a3e0e3cb7cc1b7851639cb783a7cd55ebfc815", size = 4646689, upload-time = "2025-10-18T22:44:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6e/b1234e784af5c999ca4bd2e3a8673c58e941926dc4a53b9196d00929f7c9/psycopg_binary-3.2.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:110a2036007230416fcc2c17bfe7aaa2c1fa9b6e9d21e2cd551523e3f6489759", size = 4749164, upload-time = "2025-10-18T22:44:39.529Z" }, + { url = "https://files.pythonhosted.org/packages/ee/77/98c2e6c683941e54560ef3449fbc97b7ca31318436576e0c9d92c1dc875d/psycopg_binary-3.2.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31f1d5630afa673c37a6327f8e3efa1f17d4e4e42972643b3478b52275233529", size = 4432473, upload-time = "2025-10-18T22:44:45.323Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/73e72427152c03f61b75c14642a7187b16be0e03480f7329ab5cf618fdac/psycopg_binary-3.2.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9f12a34bddaeffa7840a61163595ec0d70a9db855896865dcfbb731510014484", size = 3890114, upload-time = "2025-10-18T22:44:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ed/08a6b135ece52bb4024e19d03a294f002992d2f0c60fccdc35f245801d9c/psycopg_binary-3.2.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:82fe30afbdd66fbdad583b02baad5c15930a3dc8a3756d2ae15fc874e9be8ec8", size = 3571474, upload-time = "2025-10-18T22:44:52.476Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/b0c857cd0718b1a8af86a24e61deeb9643e9e4731f732a9b7cab280b1323/psycopg_binary-3.2.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:592fb928efe0674a7400af914bcf931eb5267d36237925947aaecf63bd9a91aa", size = 3613401, upload-time = "2025-10-18T22:44:56.473Z" }, + { url = "https://files.pythonhosted.org/packages/ec/29/437255bc149b132c63ab0279f8850648cd3ae524667f475b621a2a3d0d5b/psycopg_binary-3.2.11-cp311-cp311-win_amd64.whl", hash = "sha256:20d41bcd9ac289d44ac1f6151594f7883483b4ad14680a63e04b639dc90c3349", size = 2919850, upload-time = "2025-10-18T22:45:00.108Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9e/58945c828b60820e5c192d04f238f1aa49de0fe5f3b9883e277f33c17c0a/psycopg_binary-3.2.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4cae9bdc482e36e825d5102a9f3010e729f33a4ca83fc8a1f439ba16eb61e1f1", size = 4019920, upload-time = "2025-10-18T22:45:05.023Z" }, + { url = "https://files.pythonhosted.org/packages/73/c4/ac7f600ae5d8fb7a89c2712163b642d88739b3bb4c8d0fb3178c084dc521/psycopg_binary-3.2.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:749d23fbfd642a7abfef5fc0f6ca185fa82a2c0f895e6eab42c3f2a5d88f6011", size = 4092123, upload-time = "2025-10-18T22:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/866c8b2c83490f0d55c4a27d16c0b733744faac442adf181eb59d8d48a3d/psycopg_binary-3.2.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58d8f9f80ae79ba7f2a0509424939236220d7d66a4f8256ae999b882cc58065b", size = 4626894, upload-time = "2025-10-18T22:45:13.367Z" }, + { url = "https://files.pythonhosted.org/packages/17/a8/e7c1eba4ca230d510b76b3f8701321e0c21820953744db67ec7c8fb67537/psycopg_binary-3.2.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eab6959fade522e586b8ec37d3fe337ce10861965edef3292f52e66e36dc375d", size = 4719913, upload-time = "2025-10-18T22:45:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/de0dea38cef6e050ff8e9acd0f7c5d956251fcfece5360973329eb10b84b/psycopg_binary-3.2.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe5e3648e855df4fba1d70c18aef18c9880ea8d123fdfae754c18787c8cb37b3", size = 4411018, upload-time = "2025-10-18T22:45:24.717Z" }, + { url = "https://files.pythonhosted.org/packages/8f/bf/2bbefb24e491f2fa4a7c627d14680429ca33092176eadae88fab4fbce8c6/psycopg_binary-3.2.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:30e2c114d26554ae677088de5d4133cc112344d7a233200fdbf4a2ca5754c7ec", size = 3861940, upload-time = "2025-10-18T22:45:28.624Z" }, + { url = "https://files.pythonhosted.org/packages/67/07/d68f78df7490fcd17eef7f138f96bf3398a961208262498cde7d30266481/psycopg_binary-3.2.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e3f5887019dfb094c60e7026968ca3a964ca16305807ba5e43f9a78483767d5f", size = 3534831, upload-time = "2025-10-18T22:45:32.089Z" }, + { url = "https://files.pythonhosted.org/packages/d0/18/fc5a881ca3d8b40b8e37a396bf14176b8439a7e4b1a29848af325009f955/psycopg_binary-3.2.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9b4b0fc4e774063ae64c92cc57e2b10160150de68c96d71743218159d953869d", size = 3583559, upload-time = "2025-10-18T22:45:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/c4418b609ffea80907861ddb01c043af860b179cb8fb41905ad2f0a4f400/psycopg_binary-3.2.11-cp312-cp312-win_amd64.whl", hash = "sha256:9bdc762600fcc8e4ad3224734a4e70cc226207fd8f2de47c36b115efeed01782", size = 2910294, upload-time = "2025-10-18T22:45:40.135Z" }, + { url = "https://files.pythonhosted.org/packages/f2/93/9cea78ed3b279909f0fd6c2badb24b2361b93c875d6a7c921e26f6254044/psycopg_binary-3.2.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47f6cf8a1d02d25238bdb8741ac641ff0ec22b1c6ff6a2acd057d0da5c712842", size = 4017939, upload-time = "2025-10-18T22:45:45.114Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/fc9925f500b2c140c0bb8c1f8fcd04f8c45c76d4852e87baf4c75182de8c/psycopg_binary-3.2.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91268f04380964a5e767f8102d05f1e23312ddbe848de1a9514b08b3fc57d354", size = 4090150, upload-time = "2025-10-18T22:45:50.214Z" }, + { url = "https://files.pythonhosted.org/packages/4e/10/752b698da1ca9e6c5f15d8798cb637c3615315fd2da17eee4a90cf20ee08/psycopg_binary-3.2.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:199f88a05dd22133eab2deb30348ef7a70c23d706c8e63fdc904234163c63517", size = 4625597, upload-time = "2025-10-18T22:45:54.638Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9f/b578545c3c23484f4e234282d97ab24632a1d3cbfec64209786872e7cc8f/psycopg_binary-3.2.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7b3c5474dbad63bcccb8d14d4d4c7c19f1dc6f8e8c1914cbc771d261cf8eddca", size = 4720326, upload-time = "2025-10-18T22:45:59.266Z" }, + { url = "https://files.pythonhosted.org/packages/43/3b/ba548d3fe65a7d4c96e568c2188e4b665802e3cba41664945ed95d16eae9/psycopg_binary-3.2.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:581358e770a4536e546841b78fd0fe318added4a82443bf22d0bbe3109cf9582", size = 4411647, upload-time = "2025-10-18T22:46:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/26/65/559ab485b198600e7ff70d70786ae5c89d63475ca01d43a7dda0d7c91386/psycopg_binary-3.2.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54a30f00a51b9043048b3e7ee806ffd31fc5fbd02a20f0e69d21306ff33dc473", size = 3863037, upload-time = "2025-10-18T22:46:08.469Z" }, + { url = "https://files.pythonhosted.org/packages/8c/29/05d0b48c8bef147e8216a36a1263a309a6240dcc09a56f5b8174fa6216d2/psycopg_binary-3.2.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2a438fad4cc081b018431fde0e791b6d50201526edf39522a85164f606c39ddb", size = 3536975, upload-time = "2025-10-18T22:46:12.982Z" }, + { url = "https://files.pythonhosted.org/packages/d4/75/304e133d3ab1a49602616192edb81f603ed574f79966449105f2e200999d/psycopg_binary-3.2.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f5e7415b5d0f58edf2708842c66605092df67f3821161d861b09695fc326c4de", size = 3586213, upload-time = "2025-10-18T22:46:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/c47cce42fa3c37d439e1400eaa5eeb2ce53dc3abc84d52c8a8a9e544d945/psycopg_binary-3.2.11-cp313-cp313-win_amd64.whl", hash = "sha256:6b9632c42f76d5349e7dd50025cff02688eb760b258e891ad2c6428e7e4917d5", size = 2912997, upload-time = "2025-10-18T22:46:24.978Z" }, + { url = "https://files.pythonhosted.org/packages/85/13/728b4763ef76a688737acebfcb5ab8696b024adc49a69c86081392b0e5ba/psycopg_binary-3.2.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:260738ae222b41dbefd0d84cb2e150a112f90b41688630f57fdac487ab6d6f38", size = 4016962, upload-time = "2025-10-18T22:46:29.207Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0f/6180149621a907c5b60a2fae87d6ee10cc13e8c9f58d8250c310634ced04/psycopg_binary-3.2.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c594c199869099c59c85b9f4423370b6212491fb929e7fcda0da1768761a2c2c", size = 4090614, upload-time = "2025-10-18T22:46:33.073Z" }, + { url = "https://files.pythonhosted.org/packages/f8/97/cce19bdef510b698c9036d5573b941b539ffcaa7602450da559c8a62e0c3/psycopg_binary-3.2.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5768a9e7d393b2edd3a28de5a6d5850d054a016ed711f7044a9072f19f5e50d5", size = 4629749, upload-time = "2025-10-18T22:46:37.415Z" }, + { url = "https://files.pythonhosted.org/packages/93/9d/9bff18989fb2bf05d18c1431dd8bec4a1d90141beb11fc45d3269947ddf3/psycopg_binary-3.2.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:27eb6367350b75fef882c40cd6f748bfd976db2f8651f7511956f11efc15154f", size = 4724035, upload-time = "2025-10-18T22:46:42.568Z" }, + { url = "https://files.pythonhosted.org/packages/08/e5/39b930323428596990367b7953197730213d3d9d07bcedcad1d026608178/psycopg_binary-3.2.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa2aa5094dc962967ca0978c035b3ef90329b802501ef12a088d3bac6a55598e", size = 4411419, upload-time = "2025-10-18T22:46:47.745Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9c/97c25438d1e51ddc6a7f67990b4c59f94bc515114ada864804ccee27ef1b/psycopg_binary-3.2.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7744b4ed1f3b76fe37de7e9ef98014482fe74b6d3dfe1026cc4cfb4b4404e74f", size = 3867844, upload-time = "2025-10-18T22:46:53.328Z" }, + { url = "https://files.pythonhosted.org/packages/91/51/8c1e291cf4aa9982666f71a886aa782d990aa16853a42de545a0a9a871ef/psycopg_binary-3.2.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5f6f948ff1cd252003ff534d7b50a2b25453b4212b283a7514ff8751bdb68c37", size = 3541539, upload-time = "2025-10-18T22:46:58.993Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/e25edcdfa1111bfc5c95668b7469b5a957b40ce10cc81383688d65564826/psycopg_binary-3.2.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3bd2c8fb1dec6f93383fbaa561591fa3d676e079f9cb9889af17c3020a19715f", size = 3588090, upload-time = "2025-10-18T22:47:04.105Z" }, + { url = "https://files.pythonhosted.org/packages/a3/aa/f8c2f4b4c13d5680a20e5bfcd61f9e154bce26e7a2c70cb0abeade088d61/psycopg_binary-3.2.11-cp314-cp314-win_amd64.whl", hash = "sha256:c45f61202e5691090a697e599997eaffa3ec298209743caa4fd346145acabafe", size = 3006049, upload-time = "2025-10-18T22:47:07.923Z" }, +] + [[package]] name = "psycopg-pool" version = "3.2.6"