mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-11 20:27:54 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d79f94742 | ||
|
|
d64447c4c2 | ||
|
|
0d2db35d93 | ||
|
|
b290e1ffdc | ||
|
|
a5eb6a75bf | ||
|
|
7a136aaff6 | ||
|
|
e973e936c3 | ||
|
|
066f3b21f8 |
@@ -58,7 +58,7 @@ The `langchain-mcp-adapters` package enables agents to use tools defined across
|
||||
```python title="Workflow using MCP tools with ToolNode"
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
from langchain.chat_models import init_chat_model
|
||||
from langgraph.graph import StateGraph, MessagesState, START
|
||||
from langgraph.graph import StateGraph, MessagesState, START, END
|
||||
from langgraph.prebuilt import ToolNode
|
||||
|
||||
# Initialize the model
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.2.91 (2025-07-16)
|
||||
- Reduced writes to the `checkpoint_blobs` table by inlining small values (null, numeric, str, etc.). This means we don't need to store extra values for channels that haven't been updated.
|
||||
|
||||
## v0.2.90 (2025-07-16)
|
||||
- Improve checkpoint writes via node-local background queueing.
|
||||
|
||||
|
||||
## v0.2.89 (2025-07-15)
|
||||
- Decoupled checkpoint writing from thread/run state by removing foreign keys and updated logger to prevent timeout-related failures.
|
||||
|
||||
|
||||
@@ -289,6 +289,7 @@ class PostgresSaver(BasePostgresSaver):
|
||||
)
|
||||
|
||||
copy = checkpoint.copy()
|
||||
copy["channel_values"] = copy["channel_values"].copy()
|
||||
next_config = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
@@ -297,16 +298,28 @@ class PostgresSaver(BasePostgresSaver):
|
||||
}
|
||||
}
|
||||
|
||||
# inline primitive values in checkpoint table
|
||||
# others are stored in blobs table
|
||||
blob_values = {}
|
||||
for k, v in checkpoint["channel_values"].items():
|
||||
if v is None or isinstance(v, (str, int, float, bool)):
|
||||
pass
|
||||
else:
|
||||
blob_values[k] = copy["channel_values"].pop(k)
|
||||
|
||||
with self._cursor(pipeline=True) as cur:
|
||||
cur.executemany(
|
||||
self.UPSERT_CHECKPOINT_BLOBS_SQL,
|
||||
self._dump_blobs(
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
copy.pop("channel_values"), # type: ignore[misc]
|
||||
new_versions,
|
||||
),
|
||||
)
|
||||
if blob_versions := {
|
||||
k: v for k, v in new_versions.items() if k in blob_values
|
||||
}:
|
||||
cur.executemany(
|
||||
self.UPSERT_CHECKPOINT_BLOBS_SQL,
|
||||
self._dump_blobs(
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
blob_values,
|
||||
blob_versions,
|
||||
),
|
||||
)
|
||||
cur.execute(
|
||||
self.UPSERT_CHECKPOINTS_SQL,
|
||||
(
|
||||
@@ -439,7 +452,10 @@ class PostgresSaver(BasePostgresSaver):
|
||||
},
|
||||
{
|
||||
**value["checkpoint"],
|
||||
"channel_values": self._load_blobs(value["channel_values"]),
|
||||
"channel_values": {
|
||||
**value["checkpoint"].get("channel_values"),
|
||||
**self._load_blobs(value["channel_values"]),
|
||||
},
|
||||
},
|
||||
value["metadata"],
|
||||
(
|
||||
|
||||
@@ -245,6 +245,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
)
|
||||
|
||||
copy = checkpoint.copy()
|
||||
copy["channel_values"] = copy["channel_values"].copy()
|
||||
next_config = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
@@ -253,17 +254,29 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
}
|
||||
}
|
||||
|
||||
# inline primitive values in checkpoint table
|
||||
# others are stored in blobs table
|
||||
blob_values = {}
|
||||
for k, v in checkpoint["channel_values"].items():
|
||||
if v is None or isinstance(v, (str, int, float, bool)):
|
||||
pass
|
||||
else:
|
||||
blob_values[k] = copy["channel_values"].pop(k)
|
||||
|
||||
async with self._cursor(pipeline=True) as cur:
|
||||
await cur.executemany(
|
||||
self.UPSERT_CHECKPOINT_BLOBS_SQL,
|
||||
await asyncio.to_thread(
|
||||
self._dump_blobs,
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
copy.pop("channel_values"), # type: ignore[misc]
|
||||
new_versions,
|
||||
),
|
||||
)
|
||||
if blob_versions := {
|
||||
k: v for k, v in new_versions.items() if k in blob_values
|
||||
}:
|
||||
await cur.executemany(
|
||||
self.UPSERT_CHECKPOINT_BLOBS_SQL,
|
||||
await asyncio.to_thread(
|
||||
self._dump_blobs,
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
blob_values,
|
||||
blob_versions,
|
||||
),
|
||||
)
|
||||
await cur.execute(
|
||||
self.UPSERT_CHECKPOINTS_SQL,
|
||||
(
|
||||
@@ -397,7 +410,10 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
},
|
||||
{
|
||||
**value["checkpoint"],
|
||||
"channel_values": self._load_blobs(value["channel_values"]),
|
||||
"channel_values": {
|
||||
**value["checkpoint"].get("channel_values"),
|
||||
**self._load_blobs(value["channel_values"]),
|
||||
},
|
||||
},
|
||||
value["metadata"],
|
||||
(
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "2.0.22"
|
||||
version = "2.0.23"
|
||||
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
||||
authors = []
|
||||
requires-python = ">=3.9"
|
||||
|
||||
Generated
+1
-1
@@ -334,7 +334,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "2.0.22"
|
||||
version = "2.0.23"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
|
||||
Generated
+1
-1
@@ -1331,7 +1331,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "2.0.22"
|
||||
version = "2.0.23"
|
||||
source = { editable = "../checkpoint-postgres" }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all format lint test test_watch integration_tests spell_check spell_fix benchmark profile
|
||||
.PHONY: all format lint test test-fast test_watch integration_tests spell_check spell_fix benchmark profile
|
||||
|
||||
# Default target executed when no arguments are given to make.
|
||||
all: help
|
||||
@@ -15,14 +15,17 @@ stop-postgres:
|
||||
|
||||
TEST ?= .
|
||||
|
||||
test-fast:
|
||||
LANGGRAPH_TEST_FAST=1 uv run pytest $(TEST)
|
||||
|
||||
test:
|
||||
make start-postgres && uv run pytest $(TEST); \
|
||||
make start-postgres && LANGGRAPH_TEST_FAST=0 uv run pytest $(TEST); \
|
||||
EXIT_CODE=$$?; \
|
||||
make stop-postgres; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
test_watch:
|
||||
make start-postgres && uv run ptw $(TEST); \
|
||||
make start-postgres && LANGGRAPH_TEST_FAST=0 uv run ptw $(TEST); \
|
||||
EXIT_CODE=$$?; \
|
||||
make stop-postgres; \
|
||||
exit $$EXIT_CODE
|
||||
@@ -74,5 +77,6 @@ help:
|
||||
@echo '-- TESTS --'
|
||||
@echo 'coverage - run unit tests and generate coverage report'
|
||||
@echo 'test - run unit tests'
|
||||
@echo 'test-fast - run unit tests with in-memory checkpointer only'
|
||||
@echo 'test TEST_FILE=<test_file> - run all tests in file'
|
||||
@echo 'test_watch - run unit tests in watch mode'
|
||||
|
||||
@@ -187,7 +187,7 @@ def _should_bind_tools(
|
||||
return False
|
||||
|
||||
|
||||
def _get_model(model: LanguageModelLike) -> BaseChatModel:
|
||||
def _get_underlying_model(model: LanguageModelLike) -> BaseChatModel:
|
||||
"""Get the underlying model from a RunnableBinding or return the model itself."""
|
||||
if isinstance(model, RunnableSequence):
|
||||
model = next(
|
||||
@@ -241,8 +241,11 @@ def _validate_chat_history(
|
||||
raise ValueError(error_message)
|
||||
|
||||
|
||||
DynamicModel = Callable[[StateSchemaType, RunnableConfig], BaseChatModel]
|
||||
|
||||
|
||||
def create_react_agent(
|
||||
model: Union[str, LanguageModelLike],
|
||||
model: Union[str, LanguageModelLike, DynamicModel],
|
||||
tools: Union[Sequence[Union[BaseTool, Callable, dict[str, Any]]], ToolNode],
|
||||
*,
|
||||
prompt: Optional[Prompt] = None,
|
||||
@@ -432,6 +435,8 @@ def create_react_agent(
|
||||
tool_node = ToolNode([t for t in tools if not isinstance(t, dict)])
|
||||
tool_classes = list(tool_node.tools_by_name.values())
|
||||
|
||||
tool_calling_enabled = len(tool_classes) > 0
|
||||
|
||||
if isinstance(model, str):
|
||||
try:
|
||||
from langchain.chat_models import ( # type: ignore[import-not-found]
|
||||
@@ -441,18 +446,52 @@ def create_react_agent(
|
||||
raise ImportError(
|
||||
"Please install langchain (`pip install langchain`) to use '<provider>:<model>' string syntax for `model` parameter."
|
||||
)
|
||||
model_instance = cast(BaseChatModel, init_chat_model(model))
|
||||
elif isinstance(model, Runnable):
|
||||
model_instance = _get_underlying_model(model)
|
||||
elif callable(model):
|
||||
model_instance = None
|
||||
else:
|
||||
raise TypeError(
|
||||
"Expected `model` to be a string, LanguageModelLike, "
|
||||
f"or callable, got {type(model)}"
|
||||
)
|
||||
|
||||
model = cast(BaseChatModel, init_chat_model(model))
|
||||
# If we have a static model, we'll attempt to bind tools to it.
|
||||
if model_instance:
|
||||
# Apply tool binding for static model
|
||||
if (
|
||||
_should_bind_tools(model, tool_classes, num_builtin=len(llm_builtin_tools))
|
||||
and len(tool_classes + llm_builtin_tools) > 0
|
||||
):
|
||||
model_instance = cast(BaseChatModel, model_instance).bind_tools(
|
||||
tool_classes + llm_builtin_tools
|
||||
) # type: ignore[operator]
|
||||
static_model: Optional[BaseChatModel] = (
|
||||
_get_prompt_runnable(prompt) | model_instance
|
||||
)
|
||||
else:
|
||||
static_model = None
|
||||
|
||||
tool_calling_enabled = len(tool_classes) > 0
|
||||
def _resolve_model(state: StateSchema, config: RunnableConfig) -> BaseChatModel:
|
||||
"""Resolve the model, handling both static and dynamic models."""
|
||||
if static_model:
|
||||
return static_model
|
||||
|
||||
if (
|
||||
_should_bind_tools(model, tool_classes, num_builtin=len(llm_builtin_tools))
|
||||
and len(tool_classes + llm_builtin_tools) > 0
|
||||
):
|
||||
model = cast(BaseChatModel, model).bind_tools(tool_classes + llm_builtin_tools) # type: ignore[operator]
|
||||
# If we have a dynamic model, we need to resolve it at runtime
|
||||
resolved_model = model(state, config) # type: ignore[call-arg]
|
||||
# Apply tools binding if needed
|
||||
if (
|
||||
_should_bind_tools(
|
||||
resolved_model, tool_classes, num_builtin=len(llm_builtin_tools)
|
||||
)
|
||||
and len(tool_classes + llm_builtin_tools) > 0
|
||||
):
|
||||
resolved_model = cast(BaseChatModel, resolved_model).bind_tools(
|
||||
tool_classes + llm_builtin_tools
|
||||
)
|
||||
|
||||
model_runnable = _get_prompt_runnable(prompt) | model
|
||||
return cast(BaseChatModel, _get_prompt_runnable(prompt) | resolved_model)
|
||||
|
||||
# If any of the tools are configured to return_directly after running,
|
||||
# our graph needs to check if these were called
|
||||
@@ -504,7 +543,8 @@ def create_react_agent(
|
||||
# Define the function that calls the model
|
||||
def call_model(state: StateSchema, config: RunnableConfig) -> StateSchema:
|
||||
state = _get_model_input_state(state)
|
||||
response = cast(AIMessage, model_runnable.invoke(state, config))
|
||||
runnable = _resolve_model(state, config)
|
||||
response = runnable.invoke(state, config)
|
||||
# add agent name to the AIMessage
|
||||
response.name = name
|
||||
|
||||
@@ -522,7 +562,8 @@ def create_react_agent(
|
||||
|
||||
async def acall_model(state: StateSchema, config: RunnableConfig) -> StateSchema:
|
||||
state = _get_model_input_state(state)
|
||||
response = cast(AIMessage, await model_runnable.ainvoke(state, config))
|
||||
runnable = _resolve_model(state, config)
|
||||
response = await runnable.ainvoke(state, config)
|
||||
# add agent name to the AIMessage
|
||||
response.name = name
|
||||
if _are_more_steps_needed(state, response):
|
||||
@@ -561,13 +602,17 @@ def create_react_agent(
|
||||
def generate_structured_response(
|
||||
state: StateSchema, config: RunnableConfig
|
||||
) -> StateSchema:
|
||||
"""Generate a structured response from the model."""
|
||||
messages = _get_state_value(state, "messages")
|
||||
structured_response_schema = response_format
|
||||
if isinstance(response_format, tuple):
|
||||
system_prompt, structured_response_schema = response_format
|
||||
messages = [SystemMessage(content=system_prompt)] + list(messages)
|
||||
|
||||
model_with_structured_output = _get_model(model).with_structured_output(
|
||||
# We need to re-bind structured outputs to the model
|
||||
model_instance_ = _resolve_model(state, config)
|
||||
underlying_model = _get_underlying_model(model_instance_)
|
||||
model_with_structured_output = underlying_model.with_structured_output(
|
||||
cast(StructuredResponseSchema, structured_response_schema)
|
||||
)
|
||||
response = model_with_structured_output.invoke(messages, config)
|
||||
@@ -576,13 +621,17 @@ def create_react_agent(
|
||||
async def agenerate_structured_response(
|
||||
state: StateSchema, config: RunnableConfig
|
||||
) -> StateSchema:
|
||||
"""Generate a structured response from the model."""
|
||||
messages = _get_state_value(state, "messages")
|
||||
structured_response_schema = response_format
|
||||
if isinstance(response_format, tuple):
|
||||
system_prompt, structured_response_schema = response_format
|
||||
messages = [SystemMessage(content=system_prompt)] + list(messages)
|
||||
|
||||
model_with_structured_output = _get_model(model).with_structured_output(
|
||||
# We need to re-bind structured outputs to the model
|
||||
model_instance_ = _resolve_model(state, config)
|
||||
underlying_model = _get_underlying_model(model_instance_)
|
||||
model_with_structured_output = underlying_model.with_structured_output(
|
||||
cast(StructuredResponseSchema, structured_response_schema)
|
||||
)
|
||||
response = await model_with_structured_output.ainvoke(messages, config)
|
||||
@@ -651,7 +700,7 @@ def create_react_agent(
|
||||
if post_model_hook is not None:
|
||||
return "post_model_hook"
|
||||
tool_calls = [
|
||||
tool_node.inject_tool_args(call, state, store) # type: ignore[arg-type]
|
||||
tool_node.inject_tool_args(call, state, store)
|
||||
for call in last_message.tool_calls
|
||||
]
|
||||
return [Send("tools", [tool_call]) for tool_call in tool_calls]
|
||||
@@ -734,7 +783,7 @@ def create_react_agent(
|
||||
|
||||
if pending_tool_calls:
|
||||
pending_tool_calls = [
|
||||
tool_node.inject_tool_args(call, state, store) # type: ignore[arg-type]
|
||||
tool_node.inject_tool_args(call, state, store)
|
||||
for call in pending_tool_calls
|
||||
]
|
||||
return [Send("tools", [tool_call]) for tool_call in pending_tool_calls]
|
||||
|
||||
@@ -35,7 +35,8 @@ import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from copy import copy, deepcopy
|
||||
from dataclasses import replace
|
||||
from dataclasses import dataclass, replace
|
||||
from itertools import repeat
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
@@ -73,6 +74,7 @@ from typing_extensions import Annotated, get_args, get_origin
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
from langgraph.store.base import BaseStore
|
||||
from langgraph.types import Command, Send
|
||||
from langgraph.typing import StateLike
|
||||
from langgraph.utils.runnable import RunnableCallable
|
||||
|
||||
INVALID_TOOL_NAME_ERROR_TEMPLATE = (
|
||||
@@ -81,6 +83,43 @@ INVALID_TOOL_NAME_ERROR_TEMPLATE = (
|
||||
TOOL_CALL_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes."
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolResolver:
|
||||
"""Encapsulates tool metadata for thread-safe tool execution.
|
||||
|
||||
This class holds all the precomputed metadata needed for tool execution,
|
||||
including tool instances, state injection mappings, and store injection settings.
|
||||
"""
|
||||
|
||||
tools_by_name: dict[str, BaseTool]
|
||||
tool_to_state_args: dict[str, dict[str, Optional[str]]]
|
||||
tool_to_store_arg: dict[str, Optional[str]]
|
||||
|
||||
@staticmethod
|
||||
def from_tools(tools: Sequence[Union[BaseTool, Callable]]) -> "ToolResolver":
|
||||
"""Create a ToolResolver from a sequence of tools.
|
||||
|
||||
Args:
|
||||
tools: Sequence of tools to process. Can be BaseTool instances or
|
||||
callables that will be converted to tools.
|
||||
|
||||
Returns:
|
||||
A ToolResolver containing all the metadata for the provided tools.
|
||||
"""
|
||||
tools_by_name = {}
|
||||
tool_to_state_args = {}
|
||||
tool_to_store_arg = {}
|
||||
|
||||
for tool in tools:
|
||||
if not isinstance(tool, BaseTool):
|
||||
tool = create_tool(tool)
|
||||
tools_by_name[tool.name] = tool
|
||||
tool_to_state_args[tool.name] = _get_state_args(tool)
|
||||
tool_to_store_arg[tool.name] = _get_store_arg(tool)
|
||||
|
||||
return ToolResolver(tools_by_name, tool_to_state_args, tool_to_store_arg)
|
||||
|
||||
|
||||
def msg_content_output(output: Any) -> Union[str, list[dict]]:
|
||||
"""Convert tool output to valid message content format.
|
||||
|
||||
@@ -244,8 +283,11 @@ class ToolNode(RunnableCallable):
|
||||
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. Tools can be
|
||||
BaseTool instances or plain functions that will be converted to tools.
|
||||
tools: Either a sequence of tools that can be invoked by this node, or a
|
||||
callable that returns tools dynamically based on input, config, and store.
|
||||
Static tools can be BaseTool instances or plain functions that will be
|
||||
converted to tools. Dynamic tool providers receive (input, config, store)
|
||||
and should return a sequence of tools for that specific execution.
|
||||
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
|
||||
@@ -316,7 +358,13 @@ class ToolNode(RunnableCallable):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tools: Sequence[Union[BaseTool, Callable]],
|
||||
tools: Union[
|
||||
Sequence[Union[BaseTool, Callable]],
|
||||
Callable[
|
||||
[StateLike, RunnableConfig, Optional[BaseStore]],
|
||||
Sequence[Union[BaseTool, Callable]],
|
||||
],
|
||||
],
|
||||
*,
|
||||
name: str = "tools",
|
||||
tags: Optional[list[str]] = None,
|
||||
@@ -328,24 +376,64 @@ class ToolNode(RunnableCallable):
|
||||
"""Initialize the ToolNode with the provided tools and configuration.
|
||||
|
||||
Args:
|
||||
tools: Sequence of tools to make available for execution.
|
||||
tools: Either a sequence of tools to make available for execution,
|
||||
or a callable that returns tools dynamically based on input,
|
||||
config, and store.
|
||||
name: Node name for graph identification.
|
||||
tags: Optional metadata tags.
|
||||
handle_tool_errors: Error handling configuration.
|
||||
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(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_)
|
||||
|
||||
if callable(tools):
|
||||
# Dynamic tool provider
|
||||
self._tool_provider_fn = tools
|
||||
self._static_resolver: Optional[ToolResolver] = None
|
||||
# Likely migrate to property and raise a RunTimeError
|
||||
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]] = {}
|
||||
else:
|
||||
# Static tools
|
||||
self._tool_provider_fn = None
|
||||
self._static_resolver = ToolResolver.from_tools(tools)
|
||||
self.tools_by_name = self._static_resolver.tools_by_name
|
||||
self.tool_to_state_args = self._static_resolver.tool_to_state_args
|
||||
self.tool_to_store_arg = self._static_resolver.tool_to_store_arg
|
||||
|
||||
def _get_resolver(
|
||||
self,
|
||||
input: Union[list[AnyMessage], dict[str, Any], BaseModel],
|
||||
config: Optional[RunnableConfig],
|
||||
store: Optional[BaseStore],
|
||||
) -> ToolResolver:
|
||||
"""Get the appropriate ToolResolver for this execution.
|
||||
|
||||
Args:
|
||||
input: The input to the tool node
|
||||
config: The runnable configuration
|
||||
store: The optional store instance
|
||||
|
||||
Returns:
|
||||
A ToolResolver containing the tools and metadata for this execution.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no tools are configured.
|
||||
"""
|
||||
if self._tool_provider_fn:
|
||||
# Dynamic tools compute fresh for each run
|
||||
# Note: config may be None during _parse_input,
|
||||
# but tool provider should handle this
|
||||
tools = self._tool_provider_fn(input, config, store)
|
||||
return ToolResolver.from_tools(tools)
|
||||
elif self._static_resolver:
|
||||
# Static tools - use cached resolver
|
||||
return self._static_resolver
|
||||
else:
|
||||
raise RuntimeError("ToolNode has no tools configured")
|
||||
|
||||
def _func(
|
||||
self,
|
||||
@@ -358,12 +446,16 @@ class ToolNode(RunnableCallable):
|
||||
*,
|
||||
store: Optional[BaseStore],
|
||||
) -> Any:
|
||||
tool_calls, input_type = self._parse_input(input, store)
|
||||
tool_calls, input_type = self._parse_input(input, store, config)
|
||||
resolver = self._get_resolver(input, config, store)
|
||||
config_list = get_config_list(config, len(tool_calls))
|
||||
input_types = [input_type] * len(tool_calls)
|
||||
with get_executor_for_config(config) as executor:
|
||||
outputs = [
|
||||
*executor.map(self._run_one, tool_calls, input_types, config_list)
|
||||
*executor.map(
|
||||
lambda args: self._run_one(*args),
|
||||
list(zip(tool_calls, input_types, config_list, repeat(resolver))),
|
||||
)
|
||||
]
|
||||
|
||||
return self._combine_tool_outputs(outputs, input_type)
|
||||
@@ -379,9 +471,10 @@ class ToolNode(RunnableCallable):
|
||||
*,
|
||||
store: Optional[BaseStore],
|
||||
) -> Any:
|
||||
tool_calls, input_type = self._parse_input(input, store)
|
||||
tool_calls, input_type = self._parse_input(input, store, config)
|
||||
resolver = self._get_resolver(input, config, store)
|
||||
outputs = await asyncio.gather(
|
||||
*(self._arun_one(call, input_type, config) for call in tool_calls)
|
||||
*(self._arun_one(call, input_type, config, resolver) for call in tool_calls)
|
||||
)
|
||||
|
||||
return self._combine_tool_outputs(outputs, input_type)
|
||||
@@ -435,20 +528,23 @@ class ToolNode(RunnableCallable):
|
||||
call: ToolCall,
|
||||
input_type: Literal["list", "dict", "tool_calls"],
|
||||
config: RunnableConfig,
|
||||
resolver: ToolResolver,
|
||||
) -> ToolMessage:
|
||||
if invalid_tool_message := self._validate_tool_call(call):
|
||||
if invalid_tool_message := self._validate_tool_call(call, resolver):
|
||||
return invalid_tool_message
|
||||
|
||||
try:
|
||||
input = {**call, **{"type": "tool_call"}}
|
||||
response = self.tools_by_name[call["name"]].invoke(input, config)
|
||||
response = resolver.tools_by_name[call["name"]].invoke(input, config)
|
||||
|
||||
# GraphInterrupt is a special exception that will always be raised.
|
||||
# It can be triggered in the following scenarios:
|
||||
# (1) a NodeInterrupt is raised inside a tool
|
||||
# (2) a NodeInterrupt is raised inside a graph node for a graph called as a tool
|
||||
# (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph called as a tool
|
||||
# (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture)
|
||||
# (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph
|
||||
# called as a tool
|
||||
# ---
|
||||
# (2) and (3) can happen in a "supervisor w/ tools" multi-agent architecture
|
||||
except GraphBubbleUp as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
@@ -490,20 +586,23 @@ class ToolNode(RunnableCallable):
|
||||
call: ToolCall,
|
||||
input_type: Literal["list", "dict", "tool_calls"],
|
||||
config: RunnableConfig,
|
||||
resolver: ToolResolver,
|
||||
) -> ToolMessage:
|
||||
if invalid_tool_message := self._validate_tool_call(call):
|
||||
if invalid_tool_message := self._validate_tool_call(call, resolver):
|
||||
return invalid_tool_message
|
||||
|
||||
try:
|
||||
input = {**call, **{"type": "tool_call"}}
|
||||
response = await self.tools_by_name[call["name"]].ainvoke(input, config)
|
||||
response = await resolver.tools_by_name[call["name"]].ainvoke(input, config)
|
||||
|
||||
# GraphInterrupt is a special exception that will always be raised.
|
||||
# It can be triggered in the following scenarios:
|
||||
# (1) a NodeInterrupt is raised inside a tool
|
||||
# (2) a NodeInterrupt is raised inside a graph node for a graph called as a tool
|
||||
# (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph called as a tool
|
||||
# (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture)
|
||||
# (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph
|
||||
# called as a tool
|
||||
# ---
|
||||
# (2) and (3) can happen in a "supervisor w/ tools" multi-agent architecture
|
||||
except GraphBubbleUp as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
@@ -549,7 +648,10 @@ class ToolNode(RunnableCallable):
|
||||
BaseModel,
|
||||
],
|
||||
store: Optional[BaseStore],
|
||||
config: RunnableConfig,
|
||||
) -> Tuple[list[ToolCall], Literal["list", "dict", "tool_calls"]]:
|
||||
"""Parse the input to extract tool calls and determine input type."""
|
||||
input_type: Literal["list", "dict", "tool_calls"]
|
||||
if isinstance(input, list):
|
||||
if isinstance(input[-1], dict) and input[-1].get("type") == "tool_call":
|
||||
input_type = "tool_calls"
|
||||
@@ -573,17 +675,22 @@ class ToolNode(RunnableCallable):
|
||||
except StopIteration:
|
||||
raise ValueError("No AIMessage found in input")
|
||||
|
||||
# For _parse_input, we need to compute the resolver to inject tool args
|
||||
# We pass None for config since we don't have it yet at this stage
|
||||
resolver = self._get_resolver(input, config=config, store=store)
|
||||
tool_calls = [
|
||||
self.inject_tool_args(call, input, store)
|
||||
self.inject_tool_args(call, input, store, resolver)
|
||||
for call in latest_ai_message.tool_calls
|
||||
]
|
||||
return tool_calls, input_type
|
||||
|
||||
def _validate_tool_call(self, call: ToolCall) -> Optional[ToolMessage]:
|
||||
if (requested_tool := call["name"]) not in self.tools_by_name:
|
||||
def _validate_tool_call(
|
||||
self, call: ToolCall, resolver: ToolResolver
|
||||
) -> Optional[ToolMessage]:
|
||||
if (requested_tool := call["name"]) not in resolver.tools_by_name:
|
||||
content = INVALID_TOOL_NAME_ERROR_TEMPLATE.format(
|
||||
requested_tool=requested_tool,
|
||||
available_tools=", ".join(self.tools_by_name.keys()),
|
||||
available_tools=", ".join(resolver.tools_by_name.keys()),
|
||||
)
|
||||
return ToolMessage(
|
||||
content, name=requested_tool, tool_call_id=call["id"], status="error"
|
||||
@@ -599,8 +706,9 @@ class ToolNode(RunnableCallable):
|
||||
dict[str, Any],
|
||||
BaseModel,
|
||||
],
|
||||
resolver: ToolResolver,
|
||||
) -> ToolCall:
|
||||
state_args = self.tool_to_state_args[tool_call["name"]]
|
||||
state_args = resolver.tool_to_state_args[tool_call["name"]]
|
||||
if state_args and isinstance(input, list):
|
||||
required_fields = list(state_args.values())
|
||||
if (
|
||||
@@ -637,9 +745,9 @@ class ToolNode(RunnableCallable):
|
||||
return tool_call
|
||||
|
||||
def _inject_store(
|
||||
self, tool_call: ToolCall, store: Optional[BaseStore]
|
||||
self, tool_call: ToolCall, store: Optional[BaseStore], resolver: ToolResolver
|
||||
) -> ToolCall:
|
||||
store_arg = self.tool_to_store_arg[tool_call["name"]]
|
||||
store_arg = resolver.tool_to_store_arg[tool_call["name"]]
|
||||
if not store_arg:
|
||||
return tool_call
|
||||
|
||||
@@ -664,6 +772,8 @@ class ToolNode(RunnableCallable):
|
||||
BaseModel,
|
||||
],
|
||||
store: Optional[BaseStore],
|
||||
# TODO(EUGENE): Potentially breaking change?
|
||||
resolver: Optional[ToolResolver] = None,
|
||||
) -> ToolCall:
|
||||
"""Inject graph state and store into tool call arguments.
|
||||
|
||||
@@ -683,6 +793,8 @@ class ToolNode(RunnableCallable):
|
||||
Can be a message list, state dictionary, or BaseModel instance.
|
||||
store: The persistent store instance to inject into tools requiring storage.
|
||||
Will be None if no store is configured for the graph.
|
||||
resolver: The ToolResolver instance containing metadata about available
|
||||
tools, including their state and store injection requirements.
|
||||
|
||||
Returns:
|
||||
A new ToolCall dictionary with the same structure as the input but with
|
||||
@@ -698,12 +810,17 @@ class ToolNode(RunnableCallable):
|
||||
The injection is performed on a copy of the tool call to avoid mutating
|
||||
the original.
|
||||
"""
|
||||
if tool_call["name"] not in self.tools_by_name:
|
||||
|
||||
resolver_ = resolver or self._static_resolver
|
||||
|
||||
if tool_call["name"] not in resolver_.tools_by_name:
|
||||
return tool_call
|
||||
|
||||
tool_call_copy: ToolCall = copy(tool_call)
|
||||
tool_call_with_state = self._inject_state(tool_call_copy, input)
|
||||
tool_call_with_store = self._inject_store(tool_call_with_state, store)
|
||||
tool_call_with_state = self._inject_state(tool_call_copy, input, resolver_)
|
||||
tool_call_with_store = self._inject_store(
|
||||
tool_call_with_state, store, resolver_
|
||||
)
|
||||
return tool_call_with_store
|
||||
|
||||
def _validate_tool_command(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from uuid import UUID
|
||||
|
||||
@@ -29,6 +30,55 @@ from tests.conftest_store import (
|
||||
|
||||
pytest.register_assert_rewrite("tests.memory_assert")
|
||||
|
||||
# Global variables for checkpointer and store configurations
|
||||
FAST_MODE = os.getenv("LANGGRAPH_TEST_FAST", "true").lower() in ("true", "1", "yes")
|
||||
|
||||
SYNC_CHECKPOINTER_PARAMS = (
|
||||
["memory"]
|
||||
if FAST_MODE
|
||||
else [
|
||||
"memory",
|
||||
"sqlite",
|
||||
"postgres",
|
||||
"postgres_pipe",
|
||||
"postgres_pool",
|
||||
]
|
||||
)
|
||||
|
||||
ASYNC_CHECKPOINTER_PARAMS = (
|
||||
["memory"]
|
||||
if FAST_MODE
|
||||
else [
|
||||
"memory",
|
||||
"sqlite_aio",
|
||||
"postgres_aio",
|
||||
"postgres_aio_pipe",
|
||||
"postgres_aio_pool",
|
||||
]
|
||||
)
|
||||
|
||||
SYNC_STORE_PARAMS = (
|
||||
["in_memory"]
|
||||
if FAST_MODE
|
||||
else [
|
||||
"in_memory",
|
||||
"postgres",
|
||||
"postgres_pipe",
|
||||
"postgres_pool",
|
||||
]
|
||||
)
|
||||
|
||||
ASYNC_STORE_PARAMS = (
|
||||
["in_memory"]
|
||||
if FAST_MODE
|
||||
else [
|
||||
"in_memory",
|
||||
"postgres_aio",
|
||||
"postgres_aio_pipe",
|
||||
"postgres_aio_pool",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
@@ -48,7 +98,7 @@ def deterministic_uuids(mocker: MockerFixture) -> MockerFixture:
|
||||
|
||||
@pytest.fixture(
|
||||
scope="function",
|
||||
params=["in_memory", "postgres", "postgres_pipe", "postgres_pool"],
|
||||
params=SYNC_STORE_PARAMS,
|
||||
)
|
||||
def sync_store(request: pytest.FixtureRequest) -> Iterator[BaseStore]:
|
||||
store_name = request.param
|
||||
@@ -72,7 +122,7 @@ def sync_store(request: pytest.FixtureRequest) -> Iterator[BaseStore]:
|
||||
|
||||
@pytest.fixture(
|
||||
scope="function",
|
||||
params=["in_memory", "postgres_aio", "postgres_aio_pipe", "postgres_aio_pool"],
|
||||
params=ASYNC_STORE_PARAMS,
|
||||
)
|
||||
async def async_store(request: pytest.FixtureRequest) -> AsyncIterator[BaseStore]:
|
||||
store_name = request.param
|
||||
@@ -96,13 +146,7 @@ async def async_store(request: pytest.FixtureRequest) -> AsyncIterator[BaseStore
|
||||
|
||||
@pytest.fixture(
|
||||
scope="function",
|
||||
params=[
|
||||
"memory",
|
||||
"sqlite",
|
||||
"postgres",
|
||||
"postgres_pipe",
|
||||
"postgres_pool",
|
||||
],
|
||||
params=SYNC_CHECKPOINTER_PARAMS,
|
||||
)
|
||||
def sync_checkpointer(
|
||||
request: pytest.FixtureRequest,
|
||||
@@ -129,13 +173,7 @@ def sync_checkpointer(
|
||||
|
||||
@pytest.fixture(
|
||||
scope="function",
|
||||
params=[
|
||||
"memory",
|
||||
"sqlite_aio",
|
||||
"postgres_aio",
|
||||
"postgres_aio_pipe",
|
||||
"postgres_aio_pool",
|
||||
],
|
||||
params=ASYNC_CHECKPOINTER_PARAMS,
|
||||
)
|
||||
async def async_checkpointer(
|
||||
request: pytest.FixtureRequest,
|
||||
|
||||
@@ -5,6 +5,7 @@ from functools import partial
|
||||
from typing import (
|
||||
Annotated,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Type,
|
||||
TypeVar,
|
||||
@@ -40,7 +41,7 @@ from langgraph.prebuilt.chat_agent_executor import (
|
||||
AgentState,
|
||||
AgentStatePydantic,
|
||||
StateSchemaType,
|
||||
_get_model,
|
||||
_get_underlying_model,
|
||||
_should_bind_tools,
|
||||
_validate_chat_history,
|
||||
)
|
||||
@@ -470,7 +471,7 @@ def test__infer_handled_types() -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
|
||||
def test_react_agent_with_structured_response(version: str) -> None:
|
||||
def test_react_agent_with_structured_response(version: Literal["v1", "v2"]) -> None:
|
||||
class WeatherResponse(BaseModel):
|
||||
temperature: float = Field(description="The temperature in fahrenheit")
|
||||
|
||||
@@ -1346,7 +1347,7 @@ def test_should_bind_tools(tool_style: str) -> None:
|
||||
|
||||
def test_get_model() -> None:
|
||||
model = FakeToolCallingModel(tool_calls=[])
|
||||
assert _get_model(model) == model
|
||||
assert _get_underlying_model(model) == model
|
||||
|
||||
@dec_tool
|
||||
def some_tool(some_val: int) -> str:
|
||||
@@ -1354,18 +1355,18 @@ def test_get_model() -> None:
|
||||
return "meow"
|
||||
|
||||
model_with_tools = model.bind_tools([some_tool])
|
||||
assert _get_model(model_with_tools) == model
|
||||
assert _get_underlying_model(model_with_tools) == model
|
||||
|
||||
seq = model | RunnableLambda(lambda message: message)
|
||||
assert _get_model(seq) == model
|
||||
assert _get_underlying_model(seq) == model
|
||||
|
||||
seq_with_tools = model.bind_tools([some_tool]) | RunnableLambda(
|
||||
lambda message: message
|
||||
)
|
||||
assert _get_model(seq_with_tools) == model
|
||||
assert _get_underlying_model(seq_with_tools) == model
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
_get_model(RunnableLambda(lambda message: message))
|
||||
_get_underlying_model(RunnableLambda(lambda message: message))
|
||||
|
||||
|
||||
def test_pre_model_hook() -> None:
|
||||
|
||||
@@ -1129,3 +1129,49 @@ def test_tool_node_parent_command_with_send():
|
||||
graph=Command.PARENT,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def test_tool_node_dynamic_tools_basic() -> None:
|
||||
"""Test basic dynamic tool functionality."""
|
||||
|
||||
@dec_tool()
|
||||
def answer_to_life() -> int:
|
||||
"""A very nice dynamic tool."""
|
||||
return 42
|
||||
|
||||
def tool_provider(state, config, store) -> list:
|
||||
"""Provide dynamic tools based on state or config."""
|
||||
return [answer_to_life]
|
||||
|
||||
tool_node = ToolNode(tool_provider)
|
||||
result = await tool_node.ainvoke(
|
||||
[
|
||||
{
|
||||
"name": "answer_to_life",
|
||||
"args": {},
|
||||
"id": "1",
|
||||
"type": "tool_call",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content="42",
|
||||
tool_call_id="1",
|
||||
name="answer_to_life",
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
# Test invoking a tool that's not defined
|
||||
with pytest.raises(ValueError):
|
||||
await tool_node.ainvoke(
|
||||
{
|
||||
"name": "not_available",
|
||||
"args": {},
|
||||
"id": "1",
|
||||
"type": "tool_call",
|
||||
}
|
||||
)
|
||||
|
||||
Generated
+1
-1
@@ -397,7 +397,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "2.0.22"
|
||||
version = "2.0.23"
|
||||
source = { editable = "../checkpoint-postgres" }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
|
||||
Reference in New Issue
Block a user