Compare commits

..
Author SHA1 Message Date
Eugene Yurtsev b06dbaae7a x 2025-08-13 11:17:19 -04:00
Eugene Yurtsev d5a835e5fd x 2025-08-13 11:16:28 -04:00
20 changed files with 1169 additions and 2682 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import StructuredTool
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.prebuilt.chat_agent_executor import create_agent
from langgraph.prebuilt.chat_agent_executor import create_react_agent
from langgraph.pregel import Pregel
@@ -60,7 +60,7 @@ def react_agent(n_tools: int, checkpointer: Optional[BaseCheckpointSaver]) -> Pr
]
)
return create_agent(model, [tool], checkpointer=checkpointer)
return create_react_agent(model, [tool], checkpointer=checkpointer)
if __name__ == "__main__":
+3
View File
@@ -30,6 +30,9 @@ test_watch:
make stop-services; \
exit $$EXIT_CODE
snapshot_upate:
LANGGRAPH_TEST_FAST=1 uv run pytest --snapshot-update $(TEST)
######################
# LINTING AND FORMATTING
######################
@@ -1,10 +0,0 @@
from __future__ import annotations
from typing import Awaitable, Callable, TypeVar, Union
from typing_extensions import ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
SyncOrAsync = Callable[P, Union[R, Awaitable[R]]]
File diff suppressed because it is too large Load Diff
@@ -1,397 +0,0 @@
"""Types for setting agent response formats."""
from __future__ import annotations
import sys
import uuid
from dataclasses import dataclass, is_dataclass
from typing import (
Any,
Callable,
Generic,
Iterable,
Literal,
TypeVar,
Union,
get_args,
get_origin,
)
from langchain_core.messages import AIMessage
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, TypeAdapter
from typing_extensions import Self, is_typeddict
# Supported schema types: Pydantic models, dataclasses, TypedDict, JSON schema dicts
SchemaT = TypeVar("SchemaT")
if sys.version_info >= (3, 10):
from types import UnionType
else:
UnionType = Union
SchemaKind = Literal["pydantic", "dataclass", "typeddict", "json_schema"]
class StructuredOutputError(Exception):
"""Base class for structured output errors."""
class MultipleStructuredOutputsError(StructuredOutputError):
"""Raised when model returns multiple structured output tool calls when only one is expected."""
def __init__(self, tool_names: list[str]):
self.tool_names = tool_names
super().__init__(
f"Model incorrectly returned multiple structured responses ({', '.join(tool_names)}) when only one is expected."
)
class StructuredOutputParsingError(StructuredOutputError):
"""Raised when structured output tool call arguments fail to parse according to the schema."""
def __init__(self, tool_name: str, parse_error: Exception):
self.tool_name = tool_name
self.parse_error = parse_error
super().__init__(
f"Failed to parse structured output for tool '{tool_name}': {parse_error}."
)
def _parse_with_schema(
schema: Union[type[SchemaT], dict], schema_kind: SchemaKind, data: dict[str, Any]
) -> Any:
"""Parse data using for any supported schema type.
Args:
schema: The schema type (Pydantic model, dataclass, or TypedDict)
data: The data to parse
Returns:
The parsed instance according to the schema type
Raises:
ValueError: If parsing fails
"""
if schema_kind == "json_schema":
return data
else:
try:
adapter: TypeAdapter[SchemaT] = TypeAdapter(schema)
return adapter.validate_python(data)
except Exception as e:
schema_name = getattr(schema, "__name__", str(schema))
raise ValueError(f"Failed to parse data to {schema_name}: {e}") from e
@dataclass(init=False)
class _SchemaSpec(Generic[SchemaT]):
"""Describes a structured output schema."""
schema: type[SchemaT]
"""The schema for the response, can be a Pydantic model, dataclass, TypedDict, or JSON schema dict."""
name: str
"""Name of the schema, used for tool calling.
If not provided, the name will be the model name or "response_format" if it's a JSON schema.
"""
description: str
"""Custom description of the schema.
If not provided, provided will use the model's docstring.
"""
schema_kind: SchemaKind
"""The kind of schema."""
json_schema: dict[str, Any]
"""JSON schema associated with the schema."""
strict: bool = False
"""Whether to enforce strict validation of the schema."""
def __init__(
self,
schema: type[SchemaT],
*,
name: str | None = None,
description: str | None = None,
strict: bool = False,
) -> None:
"""Initialize SchemaSpec with schema and optional parameters."""
self.schema = schema
if name:
self.name = name
elif isinstance(schema, dict):
self.name = str(
schema.get("title", f"response_format_{str(uuid.uuid4())[:4]}")
)
else:
self.name = str(
getattr(schema, "__name__", f"response_format_{str(uuid.uuid4())[:4]}")
)
self.description = description or (
schema.get("description", "")
if isinstance(schema, dict)
else getattr(schema, "__doc__", None) or ""
)
self.strict = strict
if isinstance(schema, dict):
self.schema_kind = "json_schema"
self.json_schema = schema
elif isinstance(schema, type) and issubclass(schema, BaseModel):
self.schema_kind = "pydantic"
self.json_schema = schema.model_json_schema()
elif is_dataclass(schema):
self.schema_kind = "dataclass"
self.json_schema = TypeAdapter(schema).json_schema()
elif is_typeddict(schema):
self.schema_kind = "typeddict"
self.json_schema = TypeAdapter(schema).json_schema()
else:
raise ValueError(
f"Unsupported schema type: {type(schema)}. "
f"Supported types: Pydantic models, dataclasses, TypedDicts, and JSON schema dicts."
)
@dataclass(init=False)
class ToolOutput(Generic[SchemaT]):
"""Use a tool calling strategy for model responses."""
schema: type[SchemaT]
"""Schema for the tool calls."""
schema_specs: list[_SchemaSpec[SchemaT]]
"""Schema specs for the tool calls."""
tool_message_content: str | None
"""The content of the tool message to be returned when the model calls an artificial structured output tool."""
handle_errors: Union[
bool,
str,
type[Exception],
tuple[type[Exception], ...],
Callable[[Exception], str],
]
"""Error handling strategy for structured output via ToolOutput. Default is True.
- True: Catch all errors with default error template
- str: Catch all errors with this custom message
- type[Exception]: Only catch this exception type with default message
- tuple[type[Exception], ...]: Only catch these exception types with default message
- Callable[[Exception], str]: Custom function that returns error message
- False: No retry, let exceptions propagate
"""
def __init__(
self,
schema: type[SchemaT],
tool_message_content: str | None = None,
handle_errors: Union[
bool,
str,
type[Exception],
tuple[type[Exception], ...],
Callable[[Exception], str],
] = True,
) -> None:
"""Initialize ToolOutput with schemas, tool message content, and error handling strategy."""
self.schema = schema
self.tool_message_content = tool_message_content
self.handle_errors = handle_errors
def _iter_variants(schema: Any) -> Iterable[Any]:
"""Yield leaf variants from Union and JSON Schema oneOf."""
if get_origin(schema) in (UnionType, Union):
for arg in get_args(schema):
yield from _iter_variants(arg)
return
if isinstance(schema, dict) and "oneOf" in schema:
for sub in schema.get("oneOf", []):
yield from _iter_variants(sub)
return
yield schema
self.schema_specs = [_SchemaSpec(s) for s in _iter_variants(schema)]
@dataclass(init=False)
class NativeOutput(Generic[SchemaT]):
"""Use the model provider's native structured output method."""
schema: type[SchemaT]
"""Schema for native mode."""
schema_spec: _SchemaSpec[SchemaT]
"""Schema spec for native mode."""
def __init__(
self,
schema: type[SchemaT],
) -> None:
self.schema = schema
self.schema_spec = _SchemaSpec(schema)
def to_model_kwargs(self) -> dict[str, Any]:
# OpenAI:
# - see https://platform.openai.com/docs/guides/structured-outputs
response_format = {
"type": "json_schema",
"json_schema": {
"name": self.schema_spec.name,
"schema": self.schema_spec.json_schema,
},
}
return {"response_format": response_format}
@dataclass
class OutputToolBinding(Generic[SchemaT]):
"""Information for tracking structured output tool metadata.
This contains all necessary information to handle structured responses
generated via tool calls, including the original schema, its type classification,
and the corresponding tool implementation used by the tools strategy.
"""
schema: type[SchemaT]
"""The original schema provided for structured output (Pydantic model, dataclass, TypedDict, or JSON schema dict)."""
schema_kind: SchemaKind
"""Classification of the schema type for proper response construction."""
tool: BaseTool
"""LangChain tool instance created from the schema for model binding."""
@classmethod
def from_schema_spec(cls, schema_spec: _SchemaSpec[SchemaT]) -> Self:
"""Create an OutputToolBinding instance from a SchemaSpec.
Args:
schema_spec: The SchemaSpec to convert
Returns:
An OutputToolBinding instance with the appropriate tool created
"""
return cls(
schema=schema_spec.schema,
schema_kind=schema_spec.schema_kind,
tool=StructuredTool(
args_schema=schema_spec.json_schema,
name=schema_spec.name,
description=schema_spec.description,
),
)
def parse(self, tool_args: dict[str, Any]) -> SchemaT:
"""Parse tool arguments according to the schema.
Args:
tool_args: The arguments from the tool call
Returns:
The parsed response according to the schema type
Raises:
ValueError: If parsing fails
"""
return _parse_with_schema(self.schema, self.schema_kind, tool_args)
@dataclass
class NativeOutputBinding(Generic[SchemaT]):
"""Information for tracking native structured output metadata.
This contains all necessary information to handle structured responses
generated via native provider output, including the original schema,
its type classification, and parsing logic for provider-enforced JSON.
"""
schema: type[SchemaT]
"""The original schema provided for structured output (Pydantic model, dataclass, TypedDict, or JSON schema dict)."""
schema_kind: SchemaKind
"""Classification of the schema type for proper response construction."""
@classmethod
def from_schema_spec(cls, schema_spec: _SchemaSpec[SchemaT]) -> Self:
"""Create a NativeOutputBinding instance from a SchemaSpec.
Args:
schema_spec: The SchemaSpec to convert
Returns:
A NativeOutputBinding instance for parsing native structured output
"""
return cls(
schema=schema_spec.schema,
schema_kind=schema_spec.schema_kind,
)
def parse(self, response: AIMessage) -> SchemaT:
"""Parse AIMessage content according to the schema.
Args:
response: The AI message containing the structured output
Returns:
The parsed response according to the schema
Raises:
ValueError: If text extraction, JSON parsing or schema validation fails
"""
# Extract text content from AIMessage and parse as JSON
raw_text = self._extract_text_content_from_message(response)
import json
try:
data = json.loads(raw_text)
except Exception as e:
schema_name = getattr(self.schema, "__name__", "response_format")
raise ValueError(
f"Native structured output expected valid JSON for {schema_name}, but parsing failed: {e}."
) from e
# Parse according to schema
return _parse_with_schema(self.schema, self.schema_kind, data)
def _extract_text_content_from_message(self, message: AIMessage) -> str:
"""Extract text content from an AIMessage.
Args:
message: The AI message to extract text from
Returns:
The extracted text content
"""
content = message.content
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for c in content:
if isinstance(c, dict):
if c.get("type") == "text" and "text" in c:
parts.append(str(c["text"]))
elif "content" in c and isinstance(c["content"], str):
parts.append(c["content"])
else:
parts.append(str(c))
return "".join(parts)
return str(content)
ResponseFormat = Union[ToolOutput[SchemaT], NativeOutput[SchemaT]]
+120 -169
View File
@@ -31,8 +31,6 @@ Typical Usage:
```
"""
from __future__ import annotations
import asyncio
import inspect
import json
@@ -70,7 +68,7 @@ from langchain_core.tools.base import (
TOOL_MESSAGE_BLOCK_TYPES,
get_all_basemodel_annotations,
)
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel
from typing_extensions import Annotated, get_args, get_origin
from langgraph._internal._runnable import RunnableCallable
@@ -83,8 +81,6 @@ 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."
def msg_content_output(output: Any) -> Union[str, list[dict]]:
@@ -126,30 +122,6 @@ def msg_content_output(output: Any) -> Union[str, list[dict]]:
return str(output)
class ToolInvocationError(Exception):
"""Exception raised when a tool invocation fails due to invalid arguments."""
def __init__(self, tool_name: str, error: Exception, tool_kwargs: dict[str, Any]):
self.message = TOOL_INVOCATION_ERROR_TEMPLATE.format(
tool_name=tool_name, tool_kwargs=tool_kwargs, error=error
)
self.tool_name = tool_name
self.tool_kwargs = tool_kwargs
self.error = error
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,
*,
@@ -157,7 +129,6 @@ def _handle_tool_error(
bool,
str,
Callable[..., str],
type[Exception],
tuple[type[Exception], ...],
],
) -> str:
@@ -185,14 +156,12 @@ def _handle_tool_error(
The tuple case is handled by the caller through exception type checking,
not by this function directly.
"""
if isinstance(flag, (bool, tuple)) or (
isinstance(flag, type) and issubclass(flag, Exception)
):
if isinstance(flag, (bool, tuple)):
content = TOOL_CALL_ERROR_TEMPLATE.format(error=repr(e))
elif isinstance(flag, str):
content = flag
elif callable(flag):
content = flag(e) # type: ignore [assignment, call-arg]
content = flag(e)
else:
raise ValueError(
f"Got unexpected type of `handle_tool_error`. Expected bool, str "
@@ -268,69 +237,39 @@ def _infer_handled_types(handler: Callable[..., str]) -> tuple[type[Exception],
class ToolNode(RunnableCallable):
"""A node for executing tools in LangGraph workflows.
"""A node that runs the tools called in the last AIMessage.
Handles tool execution patterns including function calls, state injection,
persistent storage, and control flow. Manages parallel execution,
error handling.
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.
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
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
Tool calls can also be passed directly as a list of `ToolCall` dicts.
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
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. 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:
Defaults to True. Supports multiple strategies:
- **True**: Catch all errors and return a ToolMessage with the default
- 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
- 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
- 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
- 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)
- 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.
Defaults to "messages".
Allows custom state schemas with different message field names.
This same key will be used for the output ToolMessages. Defaults to "messages".
Examples:
Basic usage:
Example:
Basic usage with simple tools:
```python
from langgraph.prebuilt import ToolNode
@@ -344,31 +283,38 @@ class ToolNode(RunnableCallable):
tool_node = ToolNode([calculator])
```
State injection:
Custom error handling:
```python
from typing_extensions import Annotated
from langgraph.prebuilt import InjectedState
def handle_math_errors(e: ZeroDivisionError) -> str:
return "Cannot divide by zero!"
@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])
tool_node = ToolNode([calculator], handle_tool_errors=handle_math_errors)
```
Error handling:
Direct tool call execution:
```python
def handle_errors(e: ValueError) -> str:
return "Invalid input provided"
tool_node = ToolNode([my_tool], handle_tool_errors=handle_errors)
tool_calls = [{"name": "calculator", "args": {"a": 5, "b": 3}, "id": "1", "type": "tool_call"}]
result = tool_node.invoke(tool_calls)
```
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 = "tools"
name: str = "ToolNode"
def __init__(
self,
@@ -377,8 +323,8 @@ class ToolNode(RunnableCallable):
name: str = "tools",
tags: Optional[list[str]] = None,
handle_tool_errors: Union[
bool, str, Callable[..., str], type[Exception], tuple[type[Exception], ...]
] = _default_handle_tool_errors,
bool, str, Callable[..., str], tuple[type[Exception], ...]
] = True,
messages_key: str = "messages",
) -> None:
"""Initialize the ToolNode with the provided tools and configuration.
@@ -391,24 +337,25 @@ class ToolNode(RunnableCallable):
messages_key: State key containing messages.
"""
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, Optional[str]]] = {}
self._tool_to_store_arg: dict[str, Optional[str]] = {}
self._handle_tool_errors = handle_tool_errors
self._messages_key = messages_key
for tool in tools:
if not isinstance(tool, BaseTool):
tool_ = create_tool(cast(Type[BaseTool], tool))
self.tools_by_name: dict[str, BaseTool] = {}
self.tool_to_state_args: dict[str, dict[str, Optional[str]]] = {}
self.tool_to_store_arg: dict[str, Optional[str]] = {}
self.structured_output_tools: list[str] = []
self.handle_tool_errors = handle_tool_errors
self.messages_key = messages_key
for tool_ in tools:
if inspect.isclass(tool_) and issubclass(tool_, BaseModel):
# Handle Pydantic model classes as structured output tools
self.tools_by_name[tool_.__name__] = tool_
self.tool_to_state_args[tool_.__name__] = {}
self.tool_to_store_arg[tool_.__name__] = None
self.structured_output_tools.append(tool_.__name__)
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_)
@property
def tools_by_name(self) -> dict[str, BaseTool]:
"""Mapping from tool name to BaseTool instance."""
return self._tools_by_name
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_)
def _func(
self,
@@ -458,7 +405,7 @@ class ToolNode(RunnableCallable):
# 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
@@ -486,7 +433,7 @@ 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:
@@ -503,14 +450,25 @@ class ToolNode(RunnableCallable):
if invalid_tool_message := self._validate_tool_call(call):
return invalid_tool_message
# Handle structured output tools
if call["name"] in self.structured_output_tools:
response_schema = self.tools_by_name[call["name"]]
return Command(
update={
"messages": [
ToolMessage(
content=msg_content_output(call["args"]),
name=call["name"],
tool_call_id=call["id"],
)
],
"structured_response": response_schema(**call["args"]),
}
)
try:
call_args = {**call, **{"type": "tool_call"}}
tool = self.tools_by_name[call["name"]]
try:
response = tool.invoke(call_args, config)
except ValidationError as exc:
raise ToolInvocationError(call["name"], exc, call["args"])
response = self.tools_by_name[call["name"]].invoke(call_args, config)
# GraphInterrupt is a special exception that will always be raised.
# It can be triggered in the following scenarios,
@@ -522,27 +480,20 @@ class ToolNode(RunnableCallable):
except GraphBubbleUp as e:
raise e
except Exception as e:
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)
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):
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)
content = _handle_tool_error(e, flag=self.handle_tool_errors)
return ToolMessage(
content=content,
name=call["name"],
@@ -572,14 +523,25 @@ class ToolNode(RunnableCallable):
if invalid_tool_message := self._validate_tool_call(call):
return invalid_tool_message
# Handle structured output tools
if call["name"] in self.structured_output_tools:
response_schema = self.tools_by_name[call["name"]]
return Command(
update={
"messages": [
ToolMessage(
content=msg_content_output(call["args"]),
name=call["name"],
tool_call_id=call["id"],
)
],
"structured_response": response_schema(**call["args"]),
}
)
try:
call_args = {**call, **{"type": "tool_call"}}
tool = self.tools_by_name[call["name"]]
try:
response = await tool.ainvoke(call_args, config)
except ValidationError as exc:
raise ToolInvocationError(call["name"], exc, call["args"])
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,
@@ -591,27 +553,20 @@ class ToolNode(RunnableCallable):
except GraphBubbleUp as e:
raise e
except Exception as e:
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)
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):
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)
content = _handle_tool_error(e, flag=self.handle_tool_errors)
return ToolMessage(
content=content,
@@ -650,11 +605,9 @@ class ToolNode(RunnableCallable):
else:
input_type = "list"
messages = input
elif isinstance(input, dict) and (
messages := input.get(self._messages_key, [])
):
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:
@@ -674,12 +627,10 @@ class ToolNode(RunnableCallable):
return tool_calls, input_type
def _validate_tool_call(self, call: ToolCall) -> Optional[ToolMessage]:
requested_tool = call["name"]
if requested_tool not in self.tools_by_name:
all_tool_names = list(self.tools_by_name.keys())
if (requested_tool := call["name"]) not in self.tools_by_name:
content = INVALID_TOOL_NAME_ERROR_TEMPLATE.format(
requested_tool=requested_tool,
available_tools=", ".join(all_tool_names),
available_tools=", ".join(self.tools_by_name.keys()),
)
return ToolMessage(
content, name=requested_tool, tool_call_id=call["id"], status="error"
@@ -696,15 +647,15 @@ class ToolNode(RunnableCallable):
BaseModel,
],
) -> ToolCall:
state_args = self._tool_to_state_args[tool_call["name"]]
state_args = self.tool_to_state_args[tool_call["name"]]
if state_args and isinstance(input, list):
required_fields = list(state_args.values())
if (
len(required_fields) == 1
and required_fields[0] == self._messages_key
and required_fields[0] == self.messages_key
or required_fields[0] is None
):
input = {self._messages_key: input}
input = {self.messages_key: input}
else:
err_msg = (
f"Invalid input to ToolNode. Tool {tool_call['name']} requires "
@@ -735,7 +686,7 @@ class ToolNode(RunnableCallable):
def _inject_store(
self, tool_call: ToolCall, store: Optional[BaseStore]
) -> 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
@@ -812,15 +763,15 @@ class ToolNode(RunnableCallable):
# 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, "
f"Tools can provide a dict in Command.update only when using dict with '{self.messages_key}' key as ToolNode input, "
f"got: {command.update} for tool '{call['name']}'"
)
updated_command = deepcopy(command)
state_update = cast(dict[str, Any], updated_command.update) or {}
messages_update = state_update.get(self._messages_key, [])
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, "
@@ -1,5 +1,5 @@
# serializer version: 1
# name: test_react_agent_graph_structure[None-None-tools0]
# name: test_react_agent_graph_structure[None-None-None-tools0]
'''
graph TD;
__start__ --> agent;
@@ -7,7 +7,7 @@
'''
# ---
# name: test_react_agent_graph_structure[None-None-tools1]
# name: test_react_agent_graph_structure[None-None-None-tools1]
'''
graph TD;
__start__ --> agent;
@@ -17,7 +17,7 @@
'''
# ---
# name: test_react_agent_graph_structure[None-pre_model_hook-tools0]
# name: test_react_agent_graph_structure[None-None-pre_model_hook-tools0]
'''
graph TD;
__start__ --> pre_model_hook;
@@ -26,7 +26,7 @@
'''
# ---
# name: test_react_agent_graph_structure[None-pre_model_hook-tools1]
# name: test_react_agent_graph_structure[None-None-pre_model_hook-tools1]
'''
graph TD;
__start__ --> pre_model_hook;
@@ -37,7 +37,7 @@
'''
# ---
# name: test_react_agent_graph_structure[post_model_hook-None-tools0]
# name: test_react_agent_graph_structure[None-post_model_hook-None-tools0]
'''
graph TD;
__start__ --> agent;
@@ -46,7 +46,7 @@
'''
# ---
# name: test_react_agent_graph_structure[post_model_hook-None-tools1]
# name: test_react_agent_graph_structure[None-post_model_hook-None-tools1]
'''
graph TD;
__start__ --> agent;
@@ -58,7 +58,7 @@
'''
# ---
# name: test_react_agent_graph_structure[post_model_hook-pre_model_hook-tools0]
# name: test_react_agent_graph_structure[None-post_model_hook-pre_model_hook-tools0]
'''
graph TD;
__start__ --> pre_model_hook;
@@ -68,7 +68,7 @@
'''
# ---
# name: test_react_agent_graph_structure[post_model_hook-pre_model_hook-tools1]
# name: test_react_agent_graph_structure[None-post_model_hook-pre_model_hook-tools1]
'''
graph TD;
__start__ --> pre_model_hook;
@@ -81,3 +81,281 @@
'''
# ---
# name: test_react_agent_graph_structure[ResponseFormat-None-None-tools0]
'''
graph TD;
__start__ --> agent;
agent --> generate_structured_response;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure[ResponseFormat-None-None-tools1]
'''
graph TD;
__start__ --> agent;
agent -.-> generate_structured_response;
agent -.-> tools;
tools --> agent;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure[ResponseFormat-None-pre_model_hook-tools0]
'''
graph TD;
__start__ --> pre_model_hook;
agent --> generate_structured_response;
pre_model_hook --> agent;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure[ResponseFormat-None-pre_model_hook-tools1]
'''
graph TD;
__start__ --> pre_model_hook;
agent -.-> generate_structured_response;
agent -.-> tools;
pre_model_hook --> agent;
tools --> pre_model_hook;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure[ResponseFormat-post_model_hook-None-tools0]
'''
graph TD;
__start__ --> agent;
agent --> post_model_hook;
post_model_hook --> generate_structured_response;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure[ResponseFormat-post_model_hook-None-tools1]
'''
graph TD;
__start__ --> agent;
agent --> post_model_hook;
post_model_hook -.-> agent;
post_model_hook -.-> generate_structured_response;
post_model_hook -.-> tools;
tools --> agent;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure[ResponseFormat-post_model_hook-pre_model_hook-tools0]
'''
graph TD;
__start__ --> pre_model_hook;
agent --> post_model_hook;
post_model_hook --> generate_structured_response;
pre_model_hook --> agent;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure[ResponseFormat-post_model_hook-pre_model_hook-tools1]
'''
graph TD;
__start__ --> pre_model_hook;
agent --> post_model_hook;
post_model_hook -.-> generate_structured_response;
post_model_hook -.-> pre_model_hook;
post_model_hook -.-> tools;
pre_model_hook --> agent;
tools --> pre_model_hook;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[no_response_format-no_post_hook-no_pre_hook-no_tools]
'''
graph TD;
__start__ --> agent;
agent --> __end__;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[no_response_format-no_post_hook-no_pre_hook-two_tools]
'''
graph TD;
__start__ --> agent;
agent -.-> __end__;
agent -.-> tool;
agent -.-> tool2;
tool --> agent;
tool2 --> agent;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[no_response_format-no_post_hook-with_pre_hook-no_tools]
'''
graph TD;
__start__ --> pre_model_hook;
pre_model_hook --> agent;
agent --> __end__;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[no_response_format-no_post_hook-with_pre_hook-two_tools]
'''
graph TD;
__start__ --> pre_model_hook;
agent -.-> __end__;
agent -.-> tool;
agent -.-> tool2;
pre_model_hook --> agent;
tool --> pre_model_hook;
tool2 --> pre_model_hook;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[no_response_format-with_post_hook-no_pre_hook-no_tools]
'''
graph TD;
__start__ --> agent;
agent --> post_model_hook;
post_model_hook --> __end__;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[no_response_format-with_post_hook-no_pre_hook-two_tools]
'''
graph TD;
__start__ --> agent;
agent --> post_model_hook;
post_model_hook -.-> __end__;
post_model_hook -.-> agent;
post_model_hook -.-> tool;
post_model_hook -.-> tool2;
tool --> agent;
tool2 --> agent;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[no_response_format-with_post_hook-with_pre_hook-no_tools]
'''
graph TD;
__start__ --> pre_model_hook;
agent --> post_model_hook;
pre_model_hook --> agent;
post_model_hook --> __end__;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[no_response_format-with_post_hook-with_pre_hook-two_tools]
'''
graph TD;
__start__ --> pre_model_hook;
agent --> post_model_hook;
post_model_hook -.-> __end__;
post_model_hook -.-> pre_model_hook;
post_model_hook -.-> tool;
post_model_hook -.-> tool2;
pre_model_hook --> agent;
tool --> pre_model_hook;
tool2 --> pre_model_hook;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[with_response_format-no_post_hook-no_pre_hook-no_tools]
'''
graph TD;
__start__ --> agent;
agent --> generate_structured_response;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[with_response_format-no_post_hook-no_pre_hook-two_tools]
'''
graph TD;
__start__ --> agent;
agent -.-> generate_structured_response;
agent -.-> tool;
agent -.-> tool2;
tool --> agent;
tool2 --> agent;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[with_response_format-no_post_hook-with_pre_hook-no_tools]
'''
graph TD;
__start__ --> pre_model_hook;
agent --> generate_structured_response;
pre_model_hook --> agent;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[with_response_format-no_post_hook-with_pre_hook-two_tools]
'''
graph TD;
__start__ --> pre_model_hook;
agent -.-> generate_structured_response;
agent -.-> tool;
agent -.-> tool2;
pre_model_hook --> agent;
tool --> pre_model_hook;
tool2 --> pre_model_hook;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[with_response_format-with_post_hook-no_pre_hook-no_tools]
'''
graph TD;
__start__ --> agent;
agent --> post_model_hook;
post_model_hook --> generate_structured_response;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[with_response_format-with_post_hook-no_pre_hook-two_tools]
'''
graph TD;
__start__ --> agent;
agent --> post_model_hook;
post_model_hook -.-> agent;
post_model_hook -.-> generate_structured_response;
post_model_hook -.-> tool;
post_model_hook -.-> tool2;
tool --> agent;
tool2 --> agent;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[with_response_format-with_post_hook-with_pre_hook-no_tools]
'''
graph TD;
__start__ --> pre_model_hook;
agent --> post_model_hook;
post_model_hook --> generate_structured_response;
pre_model_hook --> agent;
generate_structured_response --> __end__;
'''
# ---
# name: test_react_agent_graph_structure_with_individual_nodes[with_response_format-with_post_hook-with_pre_hook-two_tools]
'''
graph TD;
__start__ --> pre_model_hook;
agent --> post_model_hook;
post_model_hook -.-> generate_structured_response;
post_model_hook -.-> pre_model_hook;
post_model_hook -.-> tool;
post_model_hook -.-> tool2;
pre_model_hook --> agent;
tool --> pre_model_hook;
tool2 --> pre_model_hook;
generate_structured_response --> __end__;
'''
# ---
+22 -38
View File
@@ -1,10 +1,7 @@
import json
from dataclasses import asdict, is_dataclass
from typing import (
Any,
Callable,
Dict,
Generic,
List,
Literal,
Optional,
@@ -21,16 +18,16 @@ from langchain_core.messages import (
ToolCall,
)
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.runnables import Runnable
from langchain_core.runnables import Runnable, RunnableLambda
from langchain_core.tools import BaseTool
from pydantic import BaseModel
from langgraph.prebuilt.chat_agent_executor import StructuredResponseT
from langgraph.prebuilt.chat_agent_executor import StructuredResponse
class FakeToolCallingModel(BaseChatModel, Generic[StructuredResponseT]):
tool_calls: Optional[Union[list[list[ToolCall]], list[list[dict]]]] = None
structured_response: Optional[StructuredResponseT] = None
class FakeToolCallingModel(BaseChatModel):
tool_calls: Optional[list[list[ToolCall]]] = None
structured_response: Optional[StructuredResponse] = None
index: int = 0
tool_style: Literal["openai", "anthropic"] = "openai"
@@ -42,36 +39,15 @@ class FakeToolCallingModel(BaseChatModel, Generic[StructuredResponseT]):
**kwargs: Any,
) -> ChatResult:
"""Top Level call"""
rf = kwargs.get("response_format")
is_native = isinstance(rf, dict) and rf.get("type") == "json_schema"
if self.tool_calls:
if is_native:
tool_calls = (
self.tool_calls[self.index]
if self.index < len(self.tool_calls)
else []
)
else:
tool_calls = self.tool_calls[self.index % len(self.tool_calls)]
else:
tool_calls = []
if is_native and not tool_calls:
if isinstance(self.structured_response, BaseModel):
content_obj = self.structured_response.model_dump()
elif is_dataclass(self.structured_response):
content_obj = asdict(self.structured_response)
elif isinstance(self.structured_response, dict):
content_obj = self.structured_response
message = AIMessage(content=json.dumps(content_obj), id=str(self.index))
else:
messages_string = "-".join([m.content for m in messages])
message = AIMessage(
content=messages_string,
id=str(self.index),
tool_calls=tool_calls.copy(),
)
messages_string = "-".join([m.content for m in messages])
tool_calls = (
self.tool_calls[self.index % len(self.tool_calls)]
if self.tool_calls
else []
)
message = AIMessage(
content=messages_string, id=str(self.index), tool_calls=tool_calls.copy()
)
self.index += 1
return ChatResult(generations=[ChatGeneration(message=message)])
@@ -79,6 +55,14 @@ class FakeToolCallingModel(BaseChatModel, Generic[StructuredResponseT]):
def _llm_type(self) -> str:
return "fake-tool-call-model"
def with_structured_output(
self, schema: Type[BaseModel]
) -> Runnable[LanguageModelInput, StructuredResponse]:
if self.structured_response is None:
raise ValueError("Structured response is not set")
return RunnableLambda(lambda x: self.structured_response)
def bind_tools(
self,
tools: Sequence[Union[Dict[str, Any], Type[BaseModel], Callable, BaseTool]],
@@ -1,87 +0,0 @@
[
{
"name": "updated structured response",
"responseFormat": [
{
"title": "role_schema_structured_output",
"type": "object",
"properties": {
"name": { "type": "string" },
"role": { "type": "string" }
},
"required": ["name", "role"]
},
{
"title": "department_schema_structured_output",
"type": "object",
"properties": {
"name": { "type": "string" },
"department": { "type": "string" }
},
"required": ["name", "department"]
}
],
"assertionsByInvocation": [
{
"prompt": "What is the role of Sabine?",
"toolsWithExpectedCalls": {
"getEmployeeRole": 1,
"getEmployeeDepartment": 0
},
"expectedLastMessage": "Returning structured response: {'name': 'Sabine', 'role': 'Developer'}",
"expectedStructuredResponse": { "name": "Sabine", "role": "Developer" },
"llmRequestCount": 2
},
{
"prompt": "In which department does Henrik work?",
"toolsWithExpectedCalls": {
"getEmployeeRole": 1,
"getEmployeeDepartment": 1
},
"expectedLastMessage": "Returning structured response: {'name': 'Henrik', 'department': 'IT'}",
"expectedStructuredResponse": { "name": "Henrik", "department": "IT" },
"llmRequestCount": 4
}
]
},
{
"name": "asking for information that does not fit into the response format",
"responseFormat": [
{
"schema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"role": { "type": "string" }
},
"required": ["name", "role"]
}
},
{
"schema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"department": { "type": "string" }
},
"required": ["name", "department"]
}
}
],
"assertionsByInvocation": [
{
"prompt": "How much does Saskia earn?",
"toolsWithExpectedCalls": {
"getEmployeeRole": 1,
"getEmployeeDepartment": 0
},
"expectedLastMessage": "Returning structured response: {'name': 'Saskia', 'role': 'Software Engineer'}",
"expectedStructuredResponse": {
"name": "Saskia",
"role": "Software Engineer"
},
"llmRequestCount": 2
}
]
}
]
@@ -1,48 +0,0 @@
[
{
"name": "Scenario: NO return_direct, NO response_format",
"returnDirect": false,
"responseFormat": null,
"expectedToolCalls": 10,
"expectedLastMessage": "Attempts: 10",
"expectedStructuredResponse": null
},
{
"name": "Scenario: NO return_direct, YES response_format",
"returnDirect": false,
"responseFormat": {
"type": "object",
"properties": {
"attempts": { "type": "number" },
"succeeded": { "type": "boolean" }
},
"required": ["attempts", "succeeded"]
},
"expectedToolCalls": 10,
"expectedLastMessage": "Returning structured response: {'attempts': 10, 'succeeded': True}",
"expectedStructuredResponse": { "attempts": 10, "succeeded": true }
},
{
"name": "Scenario: YES return_direct, NO response_format",
"returnDirect": true,
"responseFormat": null,
"expectedToolCalls": 1,
"expectedLastMessage": "{\"status\": \"pending\", \"attempts\": 1}",
"expectedStructuredResponse": null
},
{
"name": "Scenario: YES return_direct, YES response_format",
"returnDirect": true,
"responseFormat": {
"type": "object",
"properties": {
"attempts": { "type": "number" },
"succeeded": { "type": "boolean" }
},
"required": ["attempts", "succeeded"]
},
"expectedToolCalls": 1,
"expectedLastMessage": "{\"status\": \"pending\", \"attempts\": 1}",
"expectedStructuredResponse": null
}
]
+1 -1
View File
@@ -14,7 +14,7 @@ class Config(TypedDict):
@pytest.mark.filterwarnings("ignore:`get_config_jsonschema` is deprecated")
def test_config_schema_deprecation() -> None:
with pytest.warns(
DeprecationWarning,
LangGraphDeprecatedSinceV10,
match="`config_schema` is deprecated and will be removed. Please use `context_schema` instead.",
):
agent = create_react_agent(FakeToolCallingModel(), [], config_schema=Config)
+193 -124
View File
@@ -19,7 +19,7 @@ from langchain_core.messages import (
ToolMessage,
)
from langchain_core.runnables import RunnableConfig, RunnableLambda
from langchain_core.tools import BaseTool, InjectedToolCallId, ToolException
from langchain_core.tools import InjectedToolCallId, ToolException
from langchain_core.tools import tool as dec_tool
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
@@ -34,7 +34,9 @@ from langgraph.prebuilt import (
from langgraph.prebuilt.chat_agent_executor import (
AgentState,
AgentStatePydantic,
StateT,
StateSchemaType,
_get_model,
_should_bind_tools,
_validate_chat_history,
)
from langgraph.prebuilt.tool_node import (
@@ -264,7 +266,7 @@ async def test_prompt_with_store_async():
@pytest.mark.parametrize("tool_style", ["openai", "anthropic"])
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
@pytest.mark.parametrize("include_builtin", [True, False])
def test_model_with_tools(tool_style: str, version: str, include_builtin: bool) -> None:
def test_model_with_tools(tool_style: str, version: str, include_builtin: bool):
model = FakeToolCallingModel(tool_style=tool_style)
@dec_tool
@@ -277,7 +279,7 @@ def test_model_with_tools(tool_style: str, version: str, include_builtin: bool)
"""Tool 2 docstring."""
return f"Tool 2: {some_val}"
tools: list[BaseTool | dict] = [tool1, tool2]
tools = [tool1, tool2]
if include_builtin:
tools.append(
{
@@ -295,12 +297,45 @@ def test_model_with_tools(tool_style: str, version: str, include_builtin: bool)
}
)
# check valid agent constructor
agent = create_react_agent(
model.bind_tools(tools),
tools,
version=version,
)
result = agent.nodes["tools"].invoke(
{
"messages": [
AIMessage(
"hi?",
tool_calls=[
{
"name": "tool1",
"args": {"some_val": 2},
"id": "some 1",
},
{
"name": "tool2",
"args": {"some_val": 2},
"id": "some 2",
},
],
)
]
}
)
tool_messages: ToolMessage = result["messages"][-2:]
for tool_message in tool_messages:
assert tool_message.type == "tool"
assert tool_message.content in {"Tool 1: 2", "Tool 2: 2"}
assert tool_message.tool_call_id in {"some 1", "some 2"}
# test mismatching tool lengths
with pytest.raises(ValueError):
create_react_agent(
model.bind_tools(tools),
tools,
version=version,
)
create_react_agent(model.bind_tools([tool1]), [tool1, tool2])
# test missing bound tools
with pytest.raises(ValueError):
create_react_agent(model.bind_tools([tool1]), [tool2])
def test__validate_messages():
@@ -434,46 +469,27 @@ def test_react_agent_with_structured_response(version: str) -> None:
class WeatherResponse(BaseModel):
temperature: float = Field(description="The temperature in fahrenheit")
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[{"name": "WeatherResponse", "id": "2", "args": {"temperature": 75}}],
]
tool_calls = [[{"args": {}, "id": "1", "name": "get_weather"}], []]
def get_weather():
"""Get the weather"""
return "The weather is sunny and 75°F."
expected_structured_response = WeatherResponse(temperature=75)
model = FakeToolCallingModel[WeatherResponse](
model = FakeToolCallingModel(
tool_calls=tool_calls, structured_response=expected_structured_response
)
agent = create_react_agent(
model,
[get_weather],
response_format=WeatherResponse,
version=version,
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == expected_structured_response
assert len(response["messages"]) == 5
# Check message types in message history
msg_types = [m.type for m in response["messages"]]
assert msg_types == [
"human", # "What's the weather?"
"ai", # "What's the weather?"
"tool", # "The weather is sunny and 75°F."
"ai", # structured response
"tool", # artificial tool message
]
assert [m.content for m in response["messages"]] == [
"What's the weather?",
"What's the weather?",
"The weather is sunny and 75°F.",
"What's the weather?-What's the weather?-The weather is sunny and 75°F.",
"Returning structured response: {'temperature': 75.0}",
]
for response_format in (WeatherResponse, ("Meow", WeatherResponse)):
agent = create_react_agent(
model,
[get_weather],
response_format=response_format,
version=version,
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == expected_structured_response
assert len(response["messages"]) == 4
assert response["messages"][-2].content == "The weather is sunny and 75°F."
class CustomState(AgentState):
@@ -489,7 +505,7 @@ class CustomStatePydantic(AgentStatePydantic):
def test_react_agent_update_state(
sync_checkpointer: BaseCheckpointSaver,
version: Literal["v1", "v2"],
state_schema: StateT,
state_schema: StateSchemaType,
) -> None:
@dec_tool
def get_user_name(tool_call_id: Annotated[str, InjectedToolCallId]):
@@ -637,14 +653,24 @@ class AgentStateExtraKeyPydantic(AgentStatePydantic):
foo: int
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
@pytest.mark.parametrize("version", ["v1", "v2"])
@pytest.mark.parametrize(
"state_schema", [AgentStateExtraKey, AgentStateExtraKeyPydantic]
)
@pytest.mark.parametrize(
"use_individual_tool_nodes",
[False, True],
ids=["single_tool_node", "node_per_tool"],
)
def test_create_react_agent_inject_vars(
version: Literal["v1", "v2"], state_schema: StateT
version: Literal["v1", "v2"],
state_schema: StateSchemaType,
use_individual_tool_nodes: bool,
) -> None:
"""Test that the agent can inject state and store into tool functions."""
if version == "v1" and use_individual_tool_nodes:
pytest.skip("v1 does not support individual tool nodes")
store = InMemoryStore()
namespace = ("test",)
store.put(namespace, "test_key", {"bar": 3})
@@ -683,6 +709,7 @@ def test_create_react_agent_inject_vars(
state_schema=state_schema,
store=store,
version=version,
use_individual_tool_nodes=use_individual_tool_nodes,
)
result = agent.invoke({"messages": [{"role": "user", "content": "hi"}], "foo": 2})
assert result["messages"] == [
@@ -695,7 +722,17 @@ def test_create_react_agent_inject_vars(
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
async def test_return_direct(version: str) -> None:
@pytest.mark.parametrize(
"use_individual_tool_nodes",
[False, True],
ids=["single_tool_node", "node_per_tool"],
)
async def test_return_direct(
version: Literal["v1", "v2"], use_individual_tool_nodes: bool
) -> None:
if version == "v1" and use_individual_tool_nodes:
pytest.skip("v1 does not support individual tool nodes")
@dec_tool(return_direct=True)
def tool_return_direct(input: str) -> str:
"""A tool that returns directly."""
@@ -723,6 +760,7 @@ async def test_return_direct(version: str) -> None:
model,
[tool_return_direct, tool_normal],
version=version,
use_individual_tool_nodes=use_individual_tool_nodes,
)
# Test direct return for tool_return_direct
@@ -816,15 +854,27 @@ def test__get_state_args() -> None:
def test_inspect_react() -> None:
"""Test that we can inspect the agent and its nodes."""
model = FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(model, [])
inspect.getclosurevars(agent.nodes["agent"].bound.func)
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
@pytest.mark.parametrize(
"use_individual_tool_nodes",
[False, True],
ids=["single_tool_node", "node_per_tool"],
)
def test_react_with_subgraph_tools(
sync_checkpointer: BaseCheckpointSaver, version: Literal["v1", "v2"]
sync_checkpointer: BaseCheckpointSaver,
version: Literal["v1", "v2"],
use_individual_tool_nodes: bool,
) -> None:
"""Test React agent with subgraph tools."""
if version == "v1" and use_individual_tool_nodes:
pytest.skip("v1 does not support individual tool nodes")
class State(TypedDict):
a: int
b: int
@@ -880,6 +930,7 @@ def test_react_with_subgraph_tools(
tool_node,
checkpointer=sync_checkpointer,
version=version,
use_individual_tool_nodes=use_individual_tool_nodes,
)
result = agent.invoke(
{"messages": [HumanMessage(content="What's 2 + 3 and 2 * 3?")]},
@@ -911,8 +962,17 @@ def test_react_with_subgraph_tools(
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_react_agent_subgraph_streaming_sync(version: Literal["v1", "v2"]) -> None:
@pytest.mark.parametrize(
"use_individual_tool_nodes",
[False, True],
ids=["single_tool_node", "node_per_tool"],
)
def test_react_agent_subgraph_streaming_sync(
version: Literal["v1", "v2"], use_individual_tool_nodes: bool
) -> None:
"""Test React agent streaming when used as a subgraph node sync version"""
if version == "v1" and use_individual_tool_nodes:
pytest.skip("v1 does not support individual tool nodes")
@dec_tool
def get_weather(city: str) -> str:
@@ -932,6 +992,7 @@ def test_react_agent_subgraph_streaming_sync(version: Literal["v1", "v2"]) -> No
tools=[get_weather],
prompt="You are a helpful travel assistant.",
version=version,
use_individual_tool_nodes=use_individual_tool_nodes,
)
# Create a subgraph that uses the React agent as a node
@@ -1165,6 +1226,69 @@ def test_tool_node_node_interrupt(
)
@pytest.mark.parametrize("tool_style", ["openai", "anthropic"])
def test_should_bind_tools(tool_style: str) -> None:
@dec_tool
def some_tool(some_val: int) -> str:
"""Tool docstring."""
return "meow"
@dec_tool
def some_other_tool(some_val: int) -> str:
"""Tool docstring."""
return "meow"
model = FakeToolCallingModel(tool_style=tool_style)
# should bind when a regular model
assert _should_bind_tools(model, [])
assert _should_bind_tools(model, [some_tool])
# should bind when a seq
seq = model | RunnableLambda(lambda message: message)
assert _should_bind_tools(seq, [])
assert _should_bind_tools(seq, [some_tool])
# should not bind when a model with tools
assert not _should_bind_tools(model.bind_tools([some_tool]), [some_tool])
# should not bind when a seq with tools
seq_with_tools = model.bind_tools([some_tool]) | RunnableLambda(
lambda message: message
)
assert not _should_bind_tools(seq_with_tools, [some_tool])
# should raise on invalid inputs
with pytest.raises(ValueError):
_should_bind_tools(model.bind_tools([some_tool]), [])
with pytest.raises(ValueError):
_should_bind_tools(model.bind_tools([some_tool]), [some_other_tool])
with pytest.raises(ValueError):
_should_bind_tools(model.bind_tools([some_tool]), [some_tool, some_other_tool])
def test_get_model() -> None:
model = FakeToolCallingModel(tool_calls=[])
assert _get_model(model) == model
@dec_tool
def some_tool(some_val: int) -> str:
"""Tool docstring."""
return "meow"
model_with_tools = model.bind_tools([some_tool])
assert _get_model(model_with_tools) == model
seq = model | RunnableLambda(lambda message: message)
assert _get_model(seq) == model
seq_with_tools = model.bind_tools([some_tool]) | RunnableLambda(
lambda message: message
)
assert _get_model(seq_with_tools) == model
with pytest.raises(TypeError):
_get_model(RunnableLambda(lambda message: message))
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_basic(version: str) -> None:
"""Test basic dynamic model functionality."""
@@ -1341,17 +1465,9 @@ def test_dynamic_model_with_structured_response(version: str) -> None:
confidence: float
def dynamic_model(state, runtime: Runtime):
expected_response = TestResponse(message="dynamic response", confidence=0.9)
return FakeToolCallingModel(
tool_calls=[
[
ToolCall(
name="TestResponse",
args={"message": "dynamic response", "confidence": 0.9},
id="1",
type="tool_call",
)
]
],
tool_calls=[], structured_response=expected_response
)
agent = create_react_agent(
@@ -1616,16 +1732,16 @@ def test_post_model_hook_with_structured_output() -> None:
class WeatherResponse(BaseModel):
temperature: float = Field(description="The temperature in fahrenheit")
tool_calls: list[list[ToolCall]] = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[{"args": {"temperature": 75}, "id": "2", "name": "WeatherResponse"}],
]
tool_calls = [[{"args": {}, "id": "1", "name": "get_weather"}]]
def get_weather():
"""Get the weather"""
return "The weather is sunny and 75°F."
expected_structured_response = WeatherResponse(temperature=75)
model = FakeToolCallingModel(
tool_calls=tool_calls, structured_response=expected_structured_response
)
class State(AgentState):
flag: bool
@@ -1634,7 +1750,6 @@ def test_post_model_hook_with_structured_output() -> None:
def post_model_hook(state: State) -> Union[dict[str, bool], Command]:
return {"flag": True}
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model,
[get_weather],
@@ -1644,6 +1759,7 @@ def test_post_model_hook_with_structured_output() -> None:
)
assert "post_model_hook" in agent.nodes
assert "generate_structured_response" in agent.nodes
response = agent.invoke(
{"messages": [HumanMessage("What's the weather?")], "flag": False}
@@ -1651,19 +1767,10 @@ def test_post_model_hook_with_structured_output() -> None:
assert response["flag"] is True
assert response["structured_response"] == expected_structured_response
# Reset the state of the model
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model,
[get_weather],
response_format=WeatherResponse,
post_model_hook=post_model_hook,
state_schema=State,
)
events = list(
agent.stream({"messages": [HumanMessage("What's the weather?")], "flag": False})
)
assert "generate_structured_response" in events[-1]
assert events == [
{
"agent": {
@@ -1672,7 +1779,7 @@ def test_post_model_hook_with_structured_output() -> None:
content="What's the weather?",
additional_kwargs={},
response_metadata={},
id="0",
id="2",
tool_calls=[
{
"name": "get_weather",
@@ -1693,7 +1800,7 @@ def test_post_model_hook_with_structured_output() -> None:
content="The weather is sunny and 75°F.",
name="get_weather",
tool_call_id="1",
)
),
]
}
},
@@ -1704,26 +1811,25 @@ def test_post_model_hook_with_structured_output() -> None:
content="What's the weather?-What's the weather?-The weather is sunny and 75°F.",
additional_kwargs={},
response_metadata={},
id="1",
id="3",
tool_calls=[
{
"name": "WeatherResponse",
"args": {"temperature": 75},
"id": "2",
"name": "get_weather",
"args": {},
"id": "1",
"type": "tool_call",
}
],
),
_AnyIdToolMessage(
content="Returning structured response: {'temperature': 75.0}",
name="WeatherResponse",
tool_call_id="2",
),
],
"structured_response": WeatherResponse(temperature=75.0),
)
]
}
},
{"post_model_hook": {"flag": True}},
{
"generate_structured_response": {
"structured_response": WeatherResponse(temperature=75.0)
}
},
]
@@ -1731,7 +1837,7 @@ def test_post_model_hook_with_structured_output() -> None:
"state_schema", [AgentStateExtraKey, AgentStateExtraKeyPydantic]
)
def test_create_react_agent_inject_vars_with_post_model_hook(
state_schema: StateT,
state_schema: StateSchemaType,
) -> None:
store = InMemoryStore()
namespace = ("test",)
@@ -1786,40 +1892,3 @@ def test_create_react_agent_inject_vars_with_post_model_hook(
AIMessage("hi-hi-6", id="1"),
]
assert result["foo"] == 2
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_response_format_using_tool_choice(version: Literal["v1", "v2"]) -> None:
"""Test response format using tool choice."""
class WeatherResponse(BaseModel):
temperature: float = Field(description="The temperature in fahrenheit")
tool_calls: list[list[ToolCall]] = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[{"args": {"temperature": "75"}, "id": "2", "name": "WeatherResponse"}],
]
def get_weather() -> str:
"""Get the weather"""
return "The weather is sunny and 75°F."
expected_structured_response = WeatherResponse(temperature=75)
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model,
[get_weather],
response_format=WeatherResponse,
version=version,
)
response = agent.invoke(
{
"messages": [
{
"role": "user",
"content": "What's the weather?",
}
]
}
)
assert response.get("structured_response") == expected_structured_response
@@ -15,6 +15,11 @@ def tool() -> None:
...
def tool2() -> None:
"""Another testing tool."""
...
def pre_model_hook() -> None:
"""Pre-model hook."""
...
@@ -34,17 +39,20 @@ class ResponseFormat(BaseModel):
@pytest.mark.parametrize("tools", [[], [tool]])
@pytest.mark.parametrize("pre_model_hook", [None, pre_model_hook])
@pytest.mark.parametrize("post_model_hook", [None, post_model_hook])
@pytest.mark.parametrize("response_format", [None, ResponseFormat])
def test_react_agent_graph_structure(
snapshot: SnapshotAssertion,
tools: list[Callable],
pre_model_hook: Union[Callable, None],
post_model_hook: Union[Callable, None],
response_format: Union[type[BaseModel], None],
) -> None:
agent = create_react_agent(
model,
tools=tools,
pre_model_hook=pre_model_hook,
post_model_hook=post_model_hook,
response_format=response_format,
)
try:
assert agent.get_graph().draw_mermaid(with_styles=False) == snapshot
@@ -55,4 +63,35 @@ def test_react_agent_graph_structure(
f"tools: {tools}, "
f"pre_model_hook: {pre_model_hook}, "
f"post_model_hook: {post_model_hook}, "
f"response_format: {response_format}"
) from e
@pytest.mark.parametrize("tools", [[], [tool, tool2]], ids=["no_tools", "two_tools"])
@pytest.mark.parametrize(
"pre_model_hook", [None, pre_model_hook], ids=["no_pre_hook", "with_pre_hook"]
)
@pytest.mark.parametrize(
"post_model_hook", [None, post_model_hook], ids=["no_post_hook", "with_post_hook"]
)
@pytest.mark.parametrize(
"response_format",
[None, ResponseFormat],
ids=["no_response_format", "with_response_format"],
)
def test_react_agent_graph_structure_with_individual_nodes(
snapshot: SnapshotAssertion,
tools: list[Callable],
pre_model_hook: Union[Callable, None],
post_model_hook: Union[Callable, None],
response_format: Union[type[BaseModel], None],
) -> None:
agent = create_react_agent(
model,
tools=tools,
pre_model_hook=pre_model_hook,
post_model_hook=post_model_hook,
response_format=response_format,
use_individual_tool_nodes=True,
)
assert agent.get_graph().draw_mermaid(with_styles=False) == snapshot
-793
View File
@@ -1,793 +0,0 @@
"""Test suite for create_react_agent with structured output response_format permutations."""
from dataclasses import dataclass
from typing import Union
import pytest
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
from langgraph.prebuilt import create_react_agent
from langgraph.prebuilt.responses import (
MultipleStructuredOutputsError,
NativeOutput,
StructuredOutputParsingError,
ToolOutput,
)
from tests.model import FakeToolCallingModel
try:
from langchain_openai import ChatOpenAI
except ImportError:
skip_openai_integration_tests = True
else:
skip_openai_integration_tests = False
# Test data models
class WeatherBaseModel(BaseModel):
"""Weather response."""
temperature: float = Field(description="The temperature in fahrenheit")
condition: str = Field(description="Weather condition")
@dataclass
class WeatherDataclass:
"""Weather response."""
temperature: float
condition: str
class WeatherTypedDict(TypedDict):
"""Weather response."""
temperature: float
condition: str
weather_json_schema = {
"type": "object",
"properties": {
"temperature": {"type": "number", "description": "Temperature in fahrenheit"},
"condition": {"type": "string", "description": "Weather condition"},
},
"title": "weather_schema",
"required": ["temperature", "condition"],
}
class LocationResponse(BaseModel):
city: str = Field(description="The city name")
country: str = Field(description="The country name")
class LocationTypedDict(TypedDict):
city: str
country: str
location_json_schema = {
"type": "object",
"properties": {
"city": {"type": "string", "description": "The city name"},
"country": {"type": "string", "description": "The country name"},
},
"title": "location_schema",
"required": ["city", "country"],
}
def get_weather() -> str:
"""Get the weather."""
return "The weather is sunny and 75°F."
def get_location() -> str:
"""Get the current location."""
return "You are in New York, USA."
# Standardized test data
WEATHER_DATA = {"temperature": 75.0, "condition": "sunny"}
LOCATION_DATA = {"city": "New York", "country": "USA"}
# Standardized expected responses
EXPECTED_WEATHER_PYDANTIC = WeatherBaseModel(**WEATHER_DATA)
EXPECTED_WEATHER_DATACLASS = WeatherDataclass(**WEATHER_DATA)
EXPECTED_WEATHER_DICT: WeatherTypedDict = {"temperature": 75.0, "condition": "sunny"}
EXPECTED_LOCATION = LocationResponse(**LOCATION_DATA)
EXPECTED_LOCATION_DICT: LocationTypedDict = {"city": "New York", "country": "USA"}
class TestResponseFormatAsModel:
def test_pydantic_model(self) -> None:
"""Test response_format as Pydantic model."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[
{
"name": "WeatherBaseModel",
"id": "2",
"args": WEATHER_DATA,
}
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model, [get_weather], response_format=WeatherBaseModel
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
assert len(response["messages"]) == 5
def test_dataclass(self) -> None:
"""Test response_format as dataclass."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[
{
"name": "WeatherDataclass",
"id": "2",
"args": WEATHER_DATA,
}
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model, [get_weather], response_format=WeatherDataclass
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_DATACLASS
assert len(response["messages"]) == 5
def test_typed_dict(self) -> None:
"""Test response_format as TypedDict."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[
{
"name": "WeatherTypedDict",
"id": "2",
"args": WEATHER_DATA,
}
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model, [get_weather], response_format=WeatherTypedDict
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_DICT
assert len(response["messages"]) == 5
def test_json_schema(self) -> None:
"""Test response_format as JSON schema."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[
{
"name": "weather_schema",
"id": "2",
"args": WEATHER_DATA,
}
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model, [get_weather], response_format=weather_json_schema
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_DICT
assert len(response["messages"]) == 5
class TestResponseFormatAsToolOutput:
def test_pydantic_model(self) -> None:
"""Test response_format as ToolOutput with Pydantic model."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[
{
"name": "WeatherBaseModel",
"id": "2",
"args": WEATHER_DATA,
}
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model, [get_weather], response_format=ToolOutput(WeatherBaseModel)
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
assert len(response["messages"]) == 5
def test_dataclass(self) -> None:
"""Test response_format as ToolOutput with dataclass."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[
{
"name": "WeatherDataclass",
"id": "2",
"args": WEATHER_DATA,
}
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model, [get_weather], response_format=ToolOutput(WeatherDataclass)
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_DATACLASS
assert len(response["messages"]) == 5
def test_typed_dict(self) -> None:
"""Test response_format as ToolOutput with TypedDict."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[
{
"name": "WeatherTypedDict",
"id": "2",
"args": WEATHER_DATA,
}
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model, [get_weather], response_format=ToolOutput(WeatherTypedDict)
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_DICT
assert len(response["messages"]) == 5
def test_json_schema(self) -> None:
"""Test response_format as ToolOutput with JSON schema."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[
{
"name": "weather_schema",
"id": "2",
"args": WEATHER_DATA,
}
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model, [get_weather], response_format=ToolOutput(weather_json_schema)
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_DICT
assert len(response["messages"]) == 5
def test_union_of_json_schemas(self) -> None:
"""Test response_format as ToolOutput with union of JSON schemas."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[
{
"name": "weather_schema",
"id": "2",
"args": WEATHER_DATA,
}
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model,
[get_weather, get_location],
response_format=ToolOutput(
{"oneOf": [weather_json_schema, location_json_schema]}
),
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_DICT
assert len(response["messages"]) == 5
# Test with LocationResponse
tool_calls_location = [
[{"args": {}, "id": "1", "name": "get_location"}],
[
{
"name": "location_schema",
"id": "2",
"args": LOCATION_DATA,
}
],
]
model_location = FakeToolCallingModel(tool_calls=tool_calls_location)
agent_location = create_react_agent(
model_location,
[get_weather, get_location],
response_format=ToolOutput(
{"oneOf": [weather_json_schema, location_json_schema]}
),
)
response_location = agent_location.invoke(
{"messages": [HumanMessage("Where am I?")]}
)
assert response_location["structured_response"] == EXPECTED_LOCATION_DICT
assert len(response_location["messages"]) == 5
def test_union_of_types(self) -> None:
"""Test response_format as ToolOutput with Union of various types."""
# Test with WeatherBaseModel
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[
{
"name": "WeatherBaseModel",
"id": "2",
"args": WEATHER_DATA,
}
],
]
model = FakeToolCallingModel[Union[WeatherBaseModel, LocationResponse]](
tool_calls=tool_calls
)
agent = create_react_agent(
model,
[get_weather, get_location],
response_format=ToolOutput(Union[WeatherBaseModel, LocationResponse]),
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
assert len(response["messages"]) == 5
# Test with LocationResponse
tool_calls_location = [
[{"args": {}, "id": "1", "name": "get_location"}],
[
{
"name": "LocationResponse",
"id": "2",
"args": LOCATION_DATA,
}
],
]
model_location = FakeToolCallingModel(tool_calls=tool_calls_location)
agent_location = create_react_agent(
model_location,
[get_weather, get_location],
response_format=ToolOutput(Union[WeatherBaseModel, LocationResponse]),
)
response_location = agent_location.invoke(
{"messages": [HumanMessage("Where am I?")]}
)
assert response_location["structured_response"] == EXPECTED_LOCATION
assert len(response_location["messages"]) == 5
def test_multiple_structured_outputs_error_without_retry(self) -> None:
"""Test that MultipleStructuredOutputsError is raised when model returns multiple structured tool calls without retry."""
tool_calls = [
[
{
"name": "WeatherBaseModel",
"id": "1",
"args": WEATHER_DATA,
},
{
"name": "LocationResponse",
"id": "2",
"args": LOCATION_DATA,
},
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model,
[],
response_format=ToolOutput(
Union[WeatherBaseModel, LocationResponse],
handle_errors=False,
),
)
with pytest.raises(
MultipleStructuredOutputsError,
match=".*WeatherBaseModel.*LocationResponse.*",
):
agent.invoke({"messages": [HumanMessage("Give me weather and location")]})
def test_multiple_structured_outputs_with_retry(self) -> None:
"""Test that retry handles multiple structured output tool calls."""
tool_calls = [
[
{
"name": "WeatherBaseModel",
"id": "1",
"args": WEATHER_DATA,
},
{
"name": "LocationResponse",
"id": "2",
"args": LOCATION_DATA,
},
],
[
{
"name": "WeatherBaseModel",
"id": "3",
"args": WEATHER_DATA,
},
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model,
[],
response_format=ToolOutput(
Union[WeatherBaseModel, LocationResponse],
handle_errors=True,
),
)
response = agent.invoke({"messages": [HumanMessage("Give me weather")]})
# HumanMessage, AIMessage, ToolMessage, ToolMessage, AI, ToolMessage
assert len(response["messages"]) == 6
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
def test_structured_output_parsing_error_without_retry(self) -> None:
"""Test that StructuredOutputParsingError is raised when tool args fail to parse without retry."""
tool_calls = [
[
{
"name": "WeatherBaseModel",
"id": "1",
"args": {"invalid": "data"},
},
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model,
[],
response_format=ToolOutput(
WeatherBaseModel,
handle_errors=False,
),
)
with pytest.raises(
StructuredOutputParsingError,
match=".*WeatherBaseModel.*",
):
agent.invoke({"messages": [HumanMessage("What's the weather?")]})
def test_structured_output_parsing_error_with_retry(self) -> None:
"""Test that retry handles parsing errors for structured output."""
tool_calls = [
[
{
"name": "WeatherBaseModel",
"id": "1",
"args": {"invalid": "data"},
},
],
[
{
"name": "WeatherBaseModel",
"id": "2",
"args": WEATHER_DATA,
},
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model,
[],
response_format=ToolOutput(
WeatherBaseModel,
handle_errors=(StructuredOutputParsingError,),
),
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
# HumanMessage, AIMessage, ToolMessage, AIMessage, ToolMessage
assert len(response["messages"]) == 5
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
def test_retry_with_custom_function(self) -> None:
"""Test retry with custom message generation."""
tool_calls = [
[
{
"name": "WeatherBaseModel",
"id": "1",
"args": WEATHER_DATA,
},
{
"name": "LocationResponse",
"id": "2",
"args": LOCATION_DATA,
},
],
[
{
"name": "WeatherBaseModel",
"id": "3",
"args": WEATHER_DATA,
},
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
def custom_message(exception: Exception) -> str:
if isinstance(exception, MultipleStructuredOutputsError):
return "Custom error: Multiple outputs not allowed"
return "Custom error"
agent = create_react_agent(
model,
[],
response_format=ToolOutput(
Union[WeatherBaseModel, LocationResponse],
handle_errors=custom_message,
),
)
response = agent.invoke({"messages": [HumanMessage("Give me weather")]})
# HumanMessage, AIMessage, ToolMessage, ToolMessage, AI, ToolMessage
assert len(response["messages"]) == 6
assert (
response["messages"][2].content
== "Custom error: Multiple outputs not allowed"
)
assert (
response["messages"][3].content
== "Custom error: Multiple outputs not allowed"
)
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
def test_retry_with_custom_string_message(self) -> None:
"""Test retry with custom static string message."""
tool_calls = [
[
{
"name": "WeatherBaseModel",
"id": "1",
"args": {"invalid": "data"},
},
],
[
{
"name": "WeatherBaseModel",
"id": "2",
"args": WEATHER_DATA,
},
],
]
model = FakeToolCallingModel(tool_calls=tool_calls)
agent = create_react_agent(
model,
[],
response_format=ToolOutput(
WeatherBaseModel,
handle_errors="Please provide valid weather data with temperature and condition.",
),
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert len(response["messages"]) == 5
assert (
response["messages"][2].content
== "Please provide valid weather data with temperature and condition."
)
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
class TestResponseFormatAsNativeOutput:
def test_pydantic_model(self) -> None:
"""Test response_format as NativeOutput with Pydantic model."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
]
model = FakeToolCallingModel[WeatherBaseModel](
tool_calls=tool_calls, structured_response=EXPECTED_WEATHER_PYDANTIC
)
agent = create_react_agent(
model, [get_weather], response_format=NativeOutput(WeatherBaseModel)
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
assert len(response["messages"]) == 4
def test_dataclass(self) -> None:
"""Test response_format as NativeOutput with dataclass."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
]
model = FakeToolCallingModel[WeatherDataclass](
tool_calls=tool_calls, structured_response=EXPECTED_WEATHER_DATACLASS
)
agent = create_react_agent(
model, [get_weather], response_format=NativeOutput(WeatherDataclass)
)
response = agent.invoke(
{"messages": [HumanMessage("What's the weather?")]},
)
assert response["structured_response"] == EXPECTED_WEATHER_DATACLASS
assert len(response["messages"]) == 4
def test_typed_dict(self) -> None:
"""Test response_format as NativeOutput with TypedDict."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
]
model = FakeToolCallingModel[WeatherTypedDict](
tool_calls=tool_calls, structured_response=EXPECTED_WEATHER_DICT
)
agent = create_react_agent(
model, [get_weather], response_format=NativeOutput(WeatherTypedDict)
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_DICT
assert len(response["messages"]) == 4
def test_json_schema(self) -> None:
"""Test response_format as NativeOutput with JSON schema."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
]
model = FakeToolCallingModel[dict](
tool_calls=tool_calls, structured_response=EXPECTED_WEATHER_DICT
)
agent = create_react_agent(
model, [get_weather], response_format=NativeOutput(weather_json_schema)
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_DICT
assert len(response["messages"]) == 4
def test_union_of_types() -> None:
"""Test response_format as NativeOutput with Union (if supported)."""
tool_calls = [
[{"args": {}, "id": "1", "name": "get_weather"}],
[
{
"name": "WeatherBaseModel",
"id": "2",
"args": WEATHER_DATA,
}
],
]
model = FakeToolCallingModel[Union[WeatherBaseModel, LocationResponse]](
tool_calls=tool_calls, structured_response=EXPECTED_WEATHER_PYDANTIC
)
agent = create_react_agent(
model,
[get_weather, get_location],
response_format=ToolOutput(Union[WeatherBaseModel, LocationResponse]),
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
assert len(response["messages"]) == 5
@pytest.mark.skipif(
skip_openai_integration_tests, reason="OpenAI integration tests are disabled."
)
def test_inference_to_native_output() -> None:
"""Test that native output is inferred when a model supports it."""
model = ChatOpenAI(model="gpt-5")
agent = create_react_agent(
model,
prompt="You are a helpful weather assistant. Please call the get_weather tool, then use the WeatherReport tool to generate the final response.",
tools=[get_weather],
response_format=WeatherBaseModel,
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert isinstance(response["structured_response"], WeatherBaseModel)
assert response["structured_response"].temperature == 75.0
assert response["structured_response"].condition.lower() == "sunny"
assert len(response["messages"]) == 4
assert [m.type for m in response["messages"]] == [
"human", # "What's the weather?"
"ai", # "What's the weather?"
"tool", # "The weather is sunny and 75°F."
"ai", # structured response
]
@pytest.mark.skipif(
skip_openai_integration_tests, reason="OpenAI integration tests are disabled."
)
def test_inference_to_tool_output() -> None:
"""Test that tool output is inferred when a model supports it."""
model = ChatOpenAI(model="gpt-4")
agent = create_react_agent(
model,
prompt="You are a helpful weather assistant. Please call the get_weather tool, then use the WeatherReport tool to generate the final response.",
tools=[get_weather],
response_format=ToolOutput(WeatherBaseModel),
)
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
assert isinstance(response["structured_response"], WeatherBaseModel)
assert response["structured_response"].temperature == 75.0
assert response["structured_response"].condition.lower() == "sunny"
assert len(response["messages"]) == 5
assert [m.type for m in response["messages"]] == [
"human", # "What's the weather?"
"ai", # "What's the weather?"
"tool", # "The weather is sunny and 75°F."
"ai", # structured response
"tool", # artificial tool message
]
-154
View File
@@ -1,154 +0,0 @@
"""Unit tests for langgraph.prebuilt.responses module."""
from typing import Union
import pytest
from pydantic import BaseModel
from langgraph.prebuilt.responses import (
OutputToolBinding,
ToolOutput,
_SchemaSpec,
)
class _TestModel(BaseModel):
"""A test model for structured output."""
name: str
age: int
email: str = "default@example.com"
class CustomModel(BaseModel):
"""Custom model with a custom docstring."""
value: float
description: str
class EmptyDocModel(BaseModel):
# No custom docstring, should have no description in tool
data: str
class TestUsingToolStrategy:
"""Test UsingToolStrategy dataclass."""
def test_basic_creation(self):
"""Test basic UsingToolStrategy creation."""
strategy = ToolOutput(schema=_TestModel)
assert strategy.schema == _TestModel
assert strategy.tool_message_content is None
assert len(strategy.schema_specs) == 1
def test_multiple_schemas(self):
"""Test UsingToolStrategy with multiple schemas."""
strategy = ToolOutput(schema=Union[_TestModel, CustomModel])
assert len(strategy.schema_specs) == 2
assert strategy.schema_specs[0].schema == _TestModel
assert strategy.schema_specs[1].schema == CustomModel
def test_schema_with_tool_message_content(self):
"""Test UsingToolStrategy with tool message content."""
strategy = ToolOutput(schema=_TestModel, tool_message_content="custom message")
assert strategy.schema == _TestModel
assert strategy.tool_message_content == "custom message"
assert len(strategy.schema_specs) == 1
class TestOutputToolBinding:
"""Test OutputToolBinding dataclass and its methods."""
def test_from_schema_spec_basic(self):
"""Test basic OutputToolBinding creation from SchemaSpec."""
schema_spec = _SchemaSpec(schema=_TestModel)
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
assert tool_binding.schema == _TestModel
assert tool_binding.schema_kind == "pydantic"
assert tool_binding.tool is not None
assert tool_binding.tool.name == "_TestModel"
def test_from_schema_spec_with_custom_name(self):
"""Test OutputToolBinding creation with custom name."""
schema_spec = _SchemaSpec(schema=_TestModel, name="custom_tool_name")
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
assert tool_binding.tool.name == "custom_tool_name"
def test_from_schema_spec_with_custom_description(self):
"""Test OutputToolBinding creation with custom description."""
schema_spec = _SchemaSpec(
schema=_TestModel, description="Custom tool description"
)
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
assert tool_binding.tool.description == "Custom tool description"
def test_from_schema_spec_with_model_docstring(self):
"""Test OutputToolBinding creation using model docstring as description."""
schema_spec = _SchemaSpec(schema=CustomModel)
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
assert tool_binding.tool.description == "Custom model with a custom docstring."
@pytest.mark.skip(
reason="Need to fix bug in langchain-core for inheritance of doc-strings."
)
def test_from_schema_spec_empty_docstring(self):
"""Test OutputToolBinding creation with model that has default docstring."""
# Create a model with the same docstring as BaseModel
class DefaultDocModel(BaseModel):
# This should have the same docstring as BaseModel
pass
schema_spec = _SchemaSpec(schema=DefaultDocModel)
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
# Should use empty description when model has default BaseModel docstring
assert tool_binding.tool.description == ""
def test_parse_payload_pydantic_success(self):
"""Test successful parsing for Pydantic model."""
schema_spec = _SchemaSpec(schema=_TestModel)
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
tool_args = {"name": "John", "age": 30}
result = tool_binding.parse(tool_args)
assert isinstance(result, _TestModel)
assert result.name == "John"
assert result.age == 30
assert result.email == "default@example.com" # default value
def test_parse_payload_pydantic_validation_error(self):
"""Test parsing failure for invalid Pydantic data."""
schema_spec = _SchemaSpec(schema=_TestModel)
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
# Missing required field 'name'
tool_args = {"age": 30}
with pytest.raises(ValueError, match="Failed to parse data to _TestModel"):
tool_binding.parse(tool_args)
class TestEdgeCases:
"""Test edge cases and error conditions."""
def test_empty_schemas_list(self) -> None:
"""Test UsingToolStrategy with empty schemas list."""
strategy = ToolOutput(EmptyDocModel)
assert len(strategy.schema_specs) == 1
@pytest.mark.skip(
reason="Need to fix bug in langchain-core for inheritance of doc-strings."
)
def test_base_model_doc_constant(self) -> None:
"""Test that BASE_MODEL_DOC constant is set correctly."""
binding = OutputToolBinding.from_schema_spec(_SchemaSpec(EmptyDocModel))
assert binding.tool.name == "EmptyDocModel"
assert (
binding.tool.description[:5] == ""
) # Should be empty for default docstring
-160
View File
@@ -1,160 +0,0 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Union
from unittest.mock import MagicMock
import httpx
import pytest
from langchain_core.messages import HumanMessage
from langchain_core.tools import tool
from pydantic import BaseModel, create_model
from langgraph.prebuilt import create_react_agent
from langgraph.prebuilt.responses import ToolOutput
from tests.utils import BaseSchema, load_spec
try:
from langchain_openai import ChatOpenAI
except ImportError:
skip_openai_integration_tests = True
else:
skip_openai_integration_tests = False
AGENT_PROMPT = "You are an HR assistant."
class ToolCalls(BaseSchema):
get_employee_role: int
get_employee_department: int
class AssertionByInvocation(BaseSchema):
prompt: str
tools_with_expected_calls: ToolCalls
expected_last_message: str
expected_structured_response: Optional[Dict[str, Any]]
llm_request_count: int
class TestCase(BaseSchema):
name: str
response_format: Union[Dict[str, Any], List[Dict[str, Any]]]
assertions_by_invocation: List[AssertionByInvocation]
class Employee(BaseModel):
name: str
role: str
department: str
EMPLOYEES: list[Employee] = [
Employee(name="Sabine", role="Developer", department="IT"),
Employee(name="Henrik", role="Product Manager", department="IT"),
Employee(name="Jessica", role="HR", department="People"),
]
TEST_CASES = load_spec("responses", as_model=TestCase)
def _make_tool(fn, *, name: str, description: str):
mock = MagicMock(side_effect=lambda *, name: fn(name=name))
InputModel = create_model(f"{name}_input", name=(str, ...))
@tool(name, description=description, args_schema=InputModel)
def _wrapped(name: str):
return mock(name=name)
return {"tool": _wrapped, "mock": mock}
@pytest.mark.skipif(
skip_openai_integration_tests, reason="OpenAI integration tests are disabled."
)
@pytest.mark.parametrize("case", TEST_CASES, ids=[c.name for c in TEST_CASES])
def test_responses_integration_matrix(case: TestCase) -> None:
if case.name == "asking for information that does not fit into the response format":
pytest.xfail(
"currently failing due to undefined behavior when model cannot conform to any of the structured response formats."
)
def get_employee_role(*, name: str) -> Optional[str]:
for e in EMPLOYEES:
if e.name == name:
return e.role
return None
def get_employee_department(*, name: str) -> Optional[str]:
for e in EMPLOYEES:
if e.name == name:
return e.department
return None
role_tool = _make_tool(
get_employee_role,
name="get_employee_role",
description="Get the employee role by name",
)
dept_tool = _make_tool(
get_employee_department,
name="get_employee_department",
description="Get the employee department by name",
)
response_format_spec = case.response_format
if isinstance(response_format_spec, dict):
response_format_spec = [response_format_spec]
# Unwrap nested schema objects
response_format_spec = [item.get("schema", item) for item in response_format_spec]
if len(response_format_spec) == 1:
tool_output = ToolOutput(response_format_spec[0])
else:
tool_output = ToolOutput({"oneOf": response_format_spec})
llm_request_count = 0
for assertion in case.assertions_by_invocation:
def on_request(request: httpx.Request) -> None:
nonlocal llm_request_count
llm_request_count += 1
http_client = httpx.Client(
event_hooks={"request": [on_request]},
)
model = ChatOpenAI(
model="gpt-4o",
temperature=0,
http_client=http_client,
)
agent = create_react_agent(
model,
tools=[role_tool["tool"], dept_tool["tool"]],
prompt=AGENT_PROMPT,
response_format=tool_output,
)
result = agent.invoke({"messages": [HumanMessage(assertion.prompt)]})
# Count tool calls
assert (
role_tool["mock"].call_count
== assertion.tools_with_expected_calls.get_employee_role
)
assert (
dept_tool["mock"].call_count
== assertion.tools_with_expected_calls.get_employee_department
)
# Count LLM calls
assert llm_request_count == assertion.llm_request_count
# Check last message content
last_message = result["messages"][-1]
assert last_message.content == assertion.expected_last_message
# Check structured response
structured_response_json = result["structured_response"]
assert structured_response_json == assertion.expected_structured_response
@@ -1,117 +0,0 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from unittest.mock import MagicMock
import pytest
from langchain_core.messages import HumanMessage
from langchain_core.tools import tool
from langgraph.prebuilt import create_react_agent
from langgraph.prebuilt.responses import ToolOutput
from tests.utils import BaseSchema, load_spec
try:
from langchain_openai import ChatOpenAI
except ImportError:
skip_openai_integration_tests = True
else:
skip_openai_integration_tests = False
AGENT_PROMPT = """
You are a strict polling bot.
- Only use the "poll_job" tool until it returns { status: "succeeded" }.
- If status is "pending", call the tool again. Do not produce a final answer.
- When it is "succeeded", return exactly: "Attempts: <number>" with no extra text.
"""
class TestCase(BaseSchema):
name: str
return_direct: bool
response_format: Optional[Dict[str, Any]]
expected_tool_calls: int
expected_last_message: str
expected_structured_response: Optional[Dict[str, Any]]
TEST_CASES = load_spec("return_direct", as_model=TestCase)
def _make_tool(return_direct: bool):
attempts = 0
def _side_effect():
nonlocal attempts
attempts += 1
return {
"status": "succeeded" if attempts >= 10 else "pending",
"attempts": attempts,
}
mock = MagicMock(side_effect=_side_effect)
@tool(
"pollJob",
description=(
"Check the status of a long-running job. "
"Returns { status: 'pending' | 'succeeded', attempts: number }."
),
return_direct=return_direct,
)
def _wrapped():
return mock()
return {"tool": _wrapped, "mock": mock}
@pytest.mark.skipif(
skip_openai_integration_tests, reason="OpenAI integration tests are disabled."
)
@pytest.mark.parametrize("case", TEST_CASES, ids=[c.name for c in TEST_CASES])
def test_return_direct_integration_matrix(case: TestCase) -> None:
poll_tool = _make_tool(case.return_direct)
model = ChatOpenAI(
model="gpt-4o",
temperature=0,
)
if case.response_format:
agent = create_react_agent(
model,
tools=[poll_tool["tool"]],
prompt=AGENT_PROMPT,
response_format=ToolOutput(case.response_format),
)
else:
agent = create_react_agent(
model,
tools=[poll_tool["tool"]],
prompt=AGENT_PROMPT,
)
result = agent.invoke(
{
"messages": [
HumanMessage(
"Poll the job until it's done and tell me how many attempts it took."
)
]
}
)
# Count tool calls
assert poll_tool["mock"].call_count == case.expected_tool_calls
# Check last message content
last_message = result["messages"][-1]
assert last_message.content == case.expected_last_message
# Check structured response
if case.expected_structured_response is not None:
structured_response_json = result["structured_response"]
assert structured_response_json == case.expected_structured_response
else:
assert "structured_response" not in result
+78 -61
View File
@@ -21,8 +21,9 @@ from langchain_core.messages import (
)
from langchain_core.tools import BaseTool, ToolException
from langchain_core.tools import tool as dec_tool
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
from pydantic.v1 import BaseModel as BaseModelV1
from pydantic.v1 import ValidationError as ValidationErrorV1
from typing_extensions import TypedDict
from langgraph.config import get_stream_writer
@@ -36,7 +37,6 @@ from langgraph.prebuilt.tool_node import (
TOOL_CALL_ERROR_TEMPLATE,
InjectedState,
InjectedStore,
ToolInvocationError,
tools_condition,
)
from langgraph.store.base import BaseStore
@@ -86,8 +86,7 @@ def tool5(some_val: int):
tool5.handle_tool_error = "foo"
async def test_tool_node() -> None:
"""Test tool node."""
async def test_tool_node():
result = ToolNode([tool1]).invoke(
{
"messages": [
@@ -179,7 +178,7 @@ async def test_tool_node() -> None:
assert tool_message.tool_call_id == "some 3"
async def test_tool_node_tool_call_input() -> None:
async def test_tool_node_tool_call_input():
# Single tool call
tool_call_1 = {
"name": "tool1",
@@ -220,67 +219,17 @@ async def test_tool_node_tool_call_input() -> None:
]
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",
},
],
)
]
}
)
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",
},
],
)
]
}
)
async def test_tool_node_error_handling() -> None:
def handle_all(e: Union[ValueError, ToolException, ToolInvocationError]):
async def test_tool_node_error_handling():
def handle_all(e: Union[ValueError, ToolException, ValidationError]):
return TOOL_CALL_ERROR_TEMPLATE.format(error=repr(e))
# test catching all exceptions, via:
# - handle_tool_errors = True
# - passing a single exception
# - passing a tuple of all exceptions
# - passing a callable with all exceptions in the signature
for handle_tool_errors in (
True,
Exception,
(ValueError, ToolException, ToolInvocationError),
(ValueError, ToolException, ValidationError),
handle_all,
):
result_error = await ToolNode(
@@ -332,7 +281,7 @@ async def test_tool_node_error_handling() -> None:
assert result_error["messages"][2].tool_call_id == "another id"
async def test_tool_node_error_handling_callable() -> None:
async def test_tool_node_error_handling_callable():
def handle_value_error(e: ValueError):
return "Value error"
@@ -463,7 +412,7 @@ async def test_tool_node_handle_tool_errors_false():
assert str(exc_info.value) == "Test error"
# test validation errors get raised if handle_tool_errors is False
with pytest.raises((ToolInvocationError)):
with pytest.raises((ValidationError, ValidationErrorV1)):
ToolNode([tool1], handle_tool_errors=False).invoke(
{
"messages": [
@@ -1295,7 +1244,7 @@ def test_tool_node_inject_state(schema_: Type[T]) -> None:
"""Tool 1 docstring."""
return msgs[0].content
node = ToolNode([tool1, tool2, tool3, tool4], handle_tool_errors=True)
node = ToolNode([tool1, tool2, tool3, tool4])
for tool_name in ("tool1", "tool2", "tool3"):
tool_call = {
"name": tool_name,
@@ -1532,3 +1481,71 @@ def test_tool_node_stream_writer() -> None:
},
),
]
async def test_structured_output_tools():
"""Test that ToolNode handles Pydantic model classes as structured output tools."""
class OutputSchema(BaseModel):
name: str
age: int
location: str
tool_node = ToolNode([OutputSchema])
# Test that the structured output tool is registered correctly
assert "OutputSchema" in tool_node.tools_by_name
assert "OutputSchema" in tool_node.structured_output_tools
# Create a tool call that matches the schema
tool_call = {
"name": "OutputSchema",
"args": {"name": "Alice", "age": 30, "location": "NYC"},
"id": "call_123",
"type": "tool_call",
}
# Test sync execution
result = tool_node.invoke(
{"messages": [AIMessage(content="", tool_calls=[tool_call])]}
)
# Should return a Command with structured response
assert isinstance(result, list)
assert len(result) == 1
command = result[0]
assert isinstance(command, Command)
# Check the update structure
assert "messages" in command.update
assert "structured_response" in command.update
# Check the tool message
tool_message = command.update["messages"][0]
assert isinstance(tool_message, ToolMessage)
assert tool_message.name == "OutputSchema"
assert tool_message.tool_call_id == "call_123"
# Check the structured response
structured_response = command.update["structured_response"]
assert isinstance(structured_response, OutputSchema)
assert structured_response.name == "Alice"
assert structured_response.age == 30
assert structured_response.location == "NYC"
# Test async execution
result_async = await tool_node.ainvoke(
{"messages": [AIMessage(content="", tool_calls=[tool_call])]}
)
# Should produce the same result
assert isinstance(result_async, list)
assert len(result_async) == 1
command_async = result_async[0]
assert isinstance(command_async, Command)
assert "structured_response" in command_async.update
structured_response_async = command_async.update["structured_response"]
assert isinstance(structured_response_async, OutputSchema)
assert structured_response_async.name == "Alice"
-22
View File
@@ -1,22 +0,0 @@
import json
from pathlib import Path
from typing import Type
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class BaseSchema(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
from_attributes=True,
)
def load_spec(spec_name: str, as_model: Type[BaseModel]) -> list[BaseModel]:
with (Path(__file__).parent / "specifications" / f"{spec_name}.json").open(
"r", encoding="utf-8"
) as f:
data = json.load(f)
return [as_model(**item) for item in data]