chore: port tool node improvements back to langgraph (#6321)

namespace decisions

```
langgraph.prebuilt
  ├── ToolRuntime  # new
# all of the other stuff that was already there

langgraph.prebuilt.tool_node
  ├── ToolNode
  ├── ToolCallRequest  # new
  ├── ToolRuntime  # new
  ├── InjectedState
  ├── InjectedStore
  ├── ToolCallWrapper
  ├── AsyncToolCallWrapper
  ├── tools_condition
```
```
langchain.tools
  ├── ToolRuntime  # now from langgraph.prebuilt
  ├── InjectedState  # now from langgraph.prebuilt
  ├── InjectedStore  # now from langgraph.prebuilt
  ├── ToolException
  ├── tool
  ├── BaseTool
  ├── InjectedToolArg
  ├── InjectedToolCallId
```
This commit is contained in:
Sydney Runkle
2025-10-29 09:58:06 -07:00
committed by GitHub
parent 41f8e61589
commit 4ac1c628ee
12 changed files with 4092 additions and 442 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.0.1"
version = "1.0.2"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
@@ -27,7 +27,7 @@ dependencies = [
"langchain-core>=0.1",
"langgraph-checkpoint>=2.1.0,<4.0.0",
"langgraph-sdk>=0.2.2,<0.3.0",
"langgraph-prebuilt>=1.0.0,<1.1.0",
"langgraph-prebuilt>=1.0.1,<1.1.0",
"xxhash>=3.5.0",
"pydantic>=2.7.4",
]
+4 -2
View File
@@ -1345,7 +1345,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.0.1"
version = "1.0.2"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1710,7 +1710,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.1"
version = "1.0.2"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -1732,6 +1732,7 @@ dev = [
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" },
{ name = "mypy" },
{ name = "psycopg-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -1750,6 +1751,7 @@ test = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" },
{ name = "psycopg-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -5,6 +5,7 @@ from langgraph.prebuilt.tool_node import (
InjectedState,
InjectedStore,
ToolNode,
ToolRuntime,
tools_condition,
)
from langgraph.prebuilt.tool_validator import ValidationNode
@@ -16,4 +17,5 @@ __all__ = [
"ValidationNode",
"InjectedState",
"InjectedStore",
"ToolRuntime",
]
@@ -44,7 +44,7 @@ from langgraph.warnings import LangGraphDeprecatedSinceV10
from pydantic import BaseModel
from typing_extensions import NotRequired, TypedDict, deprecated
from langgraph.prebuilt.tool_node import ToolNode
from langgraph.prebuilt.tool_node import ToolCallWithContext, ToolNode
StructuredResponse = dict | BaseModel
StructuredResponseSchema = dict | type[BaseModel]
@@ -826,11 +826,17 @@ def create_react_agent(
elif version == "v2":
if post_model_hook is not None:
return "post_model_hook"
tool_calls = [
tool_node.inject_tool_args(call, state, store) # type: ignore[arg-type]
return [
Send(
"tools",
ToolCallWithContext(
__type="tool_call_with_context",
tool_call=call,
state=state,
),
)
for call in last_message.tool_calls
]
return [Send("tools", [tool_call]) for tool_call in tool_calls]
# Define a new graph
workflow = StateGraph(
@@ -911,11 +917,17 @@ def create_react_agent(
]
if pending_tool_calls:
pending_tool_calls = [
tool_node.inject_tool_args(call, state, store) # type: ignore[arg-type]
return [
Send(
"tools",
ToolCallWithContext(
__type="tool_call_with_context",
tool_call=call,
state=state,
),
)
for call in pending_tool_calls
]
return [Send("tools", [tool_call]) for tool_call in pending_tool_calls]
elif isinstance(messages[-1], ToolMessage):
return entrypoint
elif response_format is not None:
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-prebuilt"
version = "1.0.1"
version = "1.0.2"
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
authors = []
requires-python = ">=3.10"
@@ -43,6 +43,7 @@ test = [
"langgraph-checkpoint-sqlite",
"langgraph-checkpoint-postgres",
"syrupy",
"psycopg-binary",
]
lint = [
"ruff",
File diff suppressed because it is too large Load Diff
+43 -34
View File
@@ -8,6 +8,7 @@ from typing import (
Literal,
TypeVar,
)
from unittest.mock import Mock
import pytest
from langchain_core.language_models import BaseChatModel
@@ -64,6 +65,29 @@ pytestmark = pytest.mark.anyio
REACT_TOOL_CALL_VERSIONS = ["v1", "v2"]
def _create_mock_runtime(store: BaseStore | None = None) -> Mock:
"""Create a mock Runtime object for testing ToolNode outside of graph context.
This helper is needed because ToolNode._func expects a Runtime parameter
which is injected by RunnableCallable from config["configurable"]["__pregel_runtime"].
When testing ToolNode directly (outside a graph), we need to provide this manually.
"""
mock_runtime = Mock()
mock_runtime.store = store
mock_runtime.context = None
mock_runtime.stream_writer = lambda *args, **kwargs: None
return mock_runtime
def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig:
"""Create a RunnableConfig with mock Runtime for testing ToolNode.
Returns:
RunnableConfig with __pregel_runtime in configurable dict.
"""
return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}}
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_no_prompt(sync_checkpointer: BaseCheckpointSaver, version: str) -> None:
model = FakeToolCallingModel()
@@ -327,7 +351,8 @@ def test_model_with_tools(tool_style: str, version: str, include_builtin: bool):
],
)
]
}
},
config=_create_config_with_runtime(),
)
tool_messages: ToolMessage = result["messages"][-2:]
for tool_message in tool_messages:
@@ -728,37 +753,13 @@ def test_tool_node_inject_state(schema_: type[T]) -> None:
"type": "tool_call",
}
msg = AIMessage("hi?", tool_calls=[tool_call])
result = node.invoke(schema_(**{"messages": [msg], "foo": "bar"}))
result = node.invoke(
schema_(**{"messages": [msg], "foo": "bar"}),
config=_create_config_with_runtime(),
)
tool_message = result["messages"][-1]
assert tool_message.content == "bar", f"Failed for tool={tool_name}"
if tool_name == "tool3":
failure_input = None
try:
failure_input = schema_(**{"messages": [msg], "notfoo": "bar"})
except Exception:
pass
if failure_input is not None:
with pytest.raises(KeyError):
node.invoke(failure_input)
with pytest.raises(ValueError):
node.invoke([msg])
else:
failure_input = None
try:
failure_input = schema_(**{"messages": [msg], "notfoo": "bar"})
except Exception:
# We'd get a validation error from pydantic state and wouldn't make it to the node
# anyway
pass
if failure_input is not None:
messages_ = node.invoke(failure_input)
tool_message = messages_["messages"][-1]
assert "KeyError" in tool_message.content
tool_message = node.invoke([msg])[-1]
assert "KeyError" in tool_message.content
tool_call = {
"name": "tool4",
"args": {"some_val": 1},
@@ -766,11 +767,13 @@ def test_tool_node_inject_state(schema_: type[T]) -> None:
"type": "tool_call",
}
msg = AIMessage("hi?", tool_calls=[tool_call])
result = node.invoke(schema_(**{"messages": [msg], "foo": ""}))
result = node.invoke(
schema_(**{"messages": [msg], "foo": ""}), config=_create_config_with_runtime()
)
tool_message = result["messages"][-1]
assert tool_message.content == "hi?"
result = node.invoke([msg])
result = node.invoke([msg], config=_create_config_with_runtime())
tool_message = result[-1]
assert tool_message.content == "hi?"
@@ -882,7 +885,9 @@ def test_tool_node_inject_store() -> None:
"type": "tool_call",
}
msg = AIMessage("hi?", tool_calls=[tool_call])
node_result = node.invoke({"messages": [msg]}, store=store)
node_result = node.invoke(
{"messages": [msg]}, config=_create_config_with_runtime(store=store)
)
graph_result = graph.invoke({"messages": [msg]})
for result in (node_result, graph_result):
result["messages"][-1]
@@ -898,7 +903,10 @@ def test_tool_node_inject_store() -> None:
"type": "tool_call",
}
msg = AIMessage("hi?", tool_calls=[tool_call])
node_result = node.invoke({"messages": [msg], "bar": "baz"}, store=store)
node_result = node.invoke(
{"messages": [msg], "bar": "baz"},
config=_create_config_with_runtime(store=store),
)
graph_result = graph.invoke({"messages": [msg], "bar": "baz"})
for result in (node_result, graph_result):
result["messages"][-1]
@@ -923,7 +931,8 @@ def test_tool_node_ensure_utf8() -> None:
tools = [get_day_list]
tool_calls = [ToolCall(name=get_day_list.name, args={"days": data}, id="test_id")]
outputs: list[ToolMessage] = ToolNode(tools).invoke(
[AIMessage(content="", tool_calls=tool_calls)]
[AIMessage(content="", tool_calls=tool_calls)],
config=_create_config_with_runtime(),
)
assert outputs[0].content == json.dumps(data, ensure_ascii=False)
+524 -69
View File
@@ -1,39 +1,92 @@
import contextlib
import dataclasses
import json
import sys
from functools import partial
from typing import (
Annotated,
Any,
NoReturn,
TypeVar,
)
from unittest.mock import Mock
import pytest
from langchain_core.messages import (
AIMessage,
AnyMessage,
HumanMessage,
RemoveMessage,
ToolCall,
ToolMessage,
)
from langchain_core.runnables.config import RunnableConfig
from langchain_core.tools import BaseTool, ToolException
from langchain_core.tools import tool as dec_tool
from langgraph.config import get_stream_writer
from langgraph.errors import GraphBubbleUp, GraphInterrupt
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from langgraph.types import Command, Send
from pydantic import BaseModel, ValidationError
from pydantic.v1 import ValidationError as ValidationErrorV1
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import TypedDict
from langgraph.prebuilt import ToolNode
from langgraph.prebuilt.tool_node import TOOL_CALL_ERROR_TEMPLATE
from langgraph.prebuilt import (
InjectedState,
InjectedStore,
ToolNode,
)
from langgraph.prebuilt.tool_node import (
TOOL_CALL_ERROR_TEMPLATE,
ToolInvocationError,
tools_condition,
)
from .messages import _AnyIdHumanMessage, _AnyIdToolMessage
from .model import FakeToolCallingModel
pytestmark = pytest.mark.anyio
def _create_mock_runtime(store: BaseStore | None = None) -> Mock:
"""Create a mock Runtime object for testing ToolNode outside of graph context.
This helper is needed because ToolNode._func expects a Runtime parameter
which is injected by RunnableCallable from config["configurable"]["__pregel_runtime"].
When testing ToolNode directly (outside a graph), we need to provide this manually.
"""
mock_runtime = Mock()
mock_runtime.store = store
mock_runtime.context = None
mock_runtime.stream_writer = lambda *args, **kwargs: None
return mock_runtime
def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig:
"""Create a RunnableConfig with mock Runtime for testing ToolNode.
Returns:
RunnableConfig with __pregel_runtime in configurable dict.
"""
return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}}
def tool1(some_val: int, some_other_val: str) -> str:
"""Tool 1 docstring."""
if some_val == 0:
raise ValueError("Test error")
msg = "Test error"
raise ValueError(msg)
return f"{some_val} - {some_other_val}"
async def tool2(some_val: int, some_other_val: str) -> str:
"""Tool 2 docstring."""
if some_val == 0:
raise ToolException("Test error")
msg = "Test error"
raise ToolException(msg)
return f"tool2: {some_val} - {some_other_val}"
@@ -53,15 +106,17 @@ async def tool4(some_val: int, some_other_val: str) -> str:
@dec_tool
def tool5(some_val: int):
def tool5(some_val: int) -> NoReturn:
"""Tool 5 docstring."""
raise ToolException("Test error")
msg = "Test error"
raise ToolException(msg)
tool5.handle_tool_error = "foo"
async def test_tool_node():
async def test_tool_node() -> None:
"""Test tool node."""
result = ToolNode([tool1]).invoke(
{
"messages": [
@@ -76,7 +131,8 @@ async def test_tool_node():
],
)
]
}
},
config=_create_config_with_runtime(),
)
tool_message: ToolMessage = result["messages"][-1]
@@ -98,7 +154,8 @@ async def test_tool_node():
],
)
]
}
},
config=_create_config_with_runtime(),
)
tool_message: ToolMessage = result2["messages"][-1]
@@ -120,7 +177,8 @@ async def test_tool_node():
],
)
]
}
},
config=_create_config_with_runtime(),
)
tool_message: ToolMessage = result3["messages"][-1]
assert tool_message.type == "tool"
@@ -145,7 +203,8 @@ async def test_tool_node():
],
)
]
}
},
config=_create_config_with_runtime(),
)
tool_message: ToolMessage = result4["messages"][-1]
assert tool_message.type == "tool"
@@ -153,7 +212,7 @@ async def test_tool_node():
assert tool_message.tool_call_id == "some 3"
async def test_tool_node_tool_call_input():
async def test_tool_node_tool_call_input() -> None:
# Single tool call
tool_call_1 = {
"name": "tool1",
@@ -161,7 +220,9 @@ async def test_tool_node_tool_call_input():
"id": "some 0",
"type": "tool_call",
}
result = ToolNode([tool1]).invoke([tool_call_1])
result = ToolNode([tool1]).invoke(
[tool_call_1], config=_create_config_with_runtime()
)
assert result["messages"] == [
ToolMessage(content="1 - foo", tool_call_id="some 0", name="tool1"),
]
@@ -173,7 +234,9 @@ async def test_tool_node_tool_call_input():
"id": "some 1",
"type": "tool_call",
}
result = ToolNode([tool1]).invoke([tool_call_1, tool_call_2])
result = ToolNode([tool1]).invoke(
[tool_call_1, tool_call_2], config=_create_config_with_runtime()
)
assert result["messages"] == [
ToolMessage(content="1 - foo", tool_call_id="some 0", name="tool1"),
ToolMessage(content="2 - bar", tool_call_id="some 1", name="tool1"),
@@ -182,7 +245,9 @@ async def test_tool_node_tool_call_input():
# Test with unknown tool
tool_call_3 = tool_call_1.copy()
tool_call_3["name"] = "tool2"
result = ToolNode([tool1]).invoke([tool_call_1, tool_call_3])
result = ToolNode([tool1]).invoke(
[tool_call_1, tool_call_3], config=_create_config_with_runtime()
)
assert result["messages"] == [
ToolMessage(content="1 - foo", tool_call_id="some 0", name="tool1"),
ToolMessage(
@@ -194,8 +259,58 @@ async def test_tool_node_tool_call_input():
]
async def test_tool_node_error_handling():
def handle_all(e: ValueError | ToolException | ValidationError):
def test_tool_node_error_handling_default_invocation() -> None:
tn = ToolNode([tool1])
result = tn.invoke(
{
"messages": [
AIMessage(
"hi?",
tool_calls=[
{
"name": "tool1",
"args": {"invalid": 0, "args": "foo"},
"id": "some id",
},
],
)
]
},
config=_create_config_with_runtime(),
)
assert all(m.type == "tool" for m in result["messages"])
assert all(m.status == "error" for m in result["messages"])
assert (
"Error invoking tool 'tool1' with kwargs {'invalid': 0, 'args': 'foo'} with error:\n"
in result["messages"][0].content
)
def test_tool_node_error_handling_default_exception() -> None:
tn = ToolNode([tool1])
with pytest.raises(ValueError):
tn.invoke(
{
"messages": [
AIMessage(
"hi?",
tool_calls=[
{
"name": "tool1",
"args": {"some_val": 0, "some_other_val": "foo"},
"id": "some id",
},
],
)
]
},
config=_create_config_with_runtime(),
)
async def test_tool_node_error_handling() -> None:
def handle_all(e: ValueError | ToolException | ToolInvocationError):
return TOOL_CALL_ERROR_TEMPLATE.format(error=repr(e))
# test catching all exceptions, via:
@@ -204,7 +319,7 @@ async def test_tool_node_error_handling():
# - passing a callable with all exceptions in the signature
for handle_tool_errors in (
True,
(ValueError, ToolException, ValidationError),
(ValueError, ToolException, ToolInvocationError),
handle_all,
):
result_error = await ToolNode(
@@ -233,34 +348,33 @@ async def test_tool_node_error_handling():
],
)
]
}
},
config=_create_config_with_runtime(),
)
assert all(m.type == "tool" for m in result_error["messages"])
assert all(m.status == "error" for m in result_error["messages"])
assert (
result_error["messages"][0].content
== f"Error: {repr(ValueError('Test error'))}\n Please fix your mistakes."
== f"Error: {ValueError('Test error')!r}\n Please fix your mistakes."
)
assert (
result_error["messages"][1].content
== f"Error: {repr(ToolException('Test error'))}\n Please fix your mistakes."
)
assert (
"ValidationError" in result_error["messages"][2].content
or "validation error" in result_error["messages"][2].content
== f"Error: {ToolException('Test error')!r}\n Please fix your mistakes."
)
# Check that the validation error contains the field name
assert "some_other_val" in result_error["messages"][2].content
assert result_error["messages"][0].tool_call_id == "some id"
assert result_error["messages"][1].tool_call_id == "some other id"
assert result_error["messages"][2].tool_call_id == "another id"
async def test_tool_node_error_handling_callable():
def handle_value_error(e: ValueError):
async def test_tool_node_error_handling_callable() -> None:
def handle_value_error(e: ValueError) -> str:
return "Value error"
def handle_tool_exception(e: ToolException):
def handle_tool_exception(e: ToolException) -> str:
return "Tool exception"
for handle_tool_errors in ("Value error", handle_value_error):
@@ -280,7 +394,8 @@ async def test_tool_node_error_handling_callable():
],
)
]
}
},
config=_create_config_with_runtime(),
)
tool_message: ToolMessage = result_error["messages"][-1]
assert tool_message.type == "tool"
@@ -313,7 +428,8 @@ async def test_tool_node_error_handling_callable():
],
)
]
}
},
config=_create_config_with_runtime(),
)
assert str(exc_info.value) == "Test error"
@@ -340,12 +456,13 @@ async def test_tool_node_error_handling_callable():
],
)
]
}
},
config=_create_config_with_runtime(),
)
assert str(exc_info.value) == "Test error"
async def test_tool_node_handle_tool_errors_false():
async def test_tool_node_handle_tool_errors_false() -> None:
with pytest.raises(ValueError) as exc_info:
ToolNode([tool1], handle_tool_errors=False).invoke(
{
@@ -361,7 +478,8 @@ async def test_tool_node_handle_tool_errors_false():
],
)
]
}
},
config=_create_config_with_runtime(),
)
assert str(exc_info.value) == "Test error"
@@ -381,13 +499,14 @@ async def test_tool_node_handle_tool_errors_false():
],
)
]
}
},
config=_create_config_with_runtime(),
)
assert str(exc_info.value) == "Test error"
# test validation errors get raised if handle_tool_errors is False
with pytest.raises((ValidationError, ValidationErrorV1)):
with pytest.raises(ToolInvocationError):
ToolNode([tool1], handle_tool_errors=False).invoke(
{
"messages": [
@@ -402,11 +521,12 @@ async def test_tool_node_handle_tool_errors_false():
],
)
]
}
},
config=_create_config_with_runtime(),
)
def test_tool_node_individual_tool_error_handling():
def test_tool_node_individual_tool_error_handling() -> None:
# test error handling on individual tools (and that it overrides overall error handling!)
result_individual_tool_error_handler = ToolNode(
[tool5], handle_tool_errors="bar"
@@ -424,7 +544,8 @@ def test_tool_node_individual_tool_error_handling():
],
)
]
}
},
config=_create_config_with_runtime(),
)
tool_message: ToolMessage = result_individual_tool_error_handler["messages"][-1]
@@ -434,7 +555,7 @@ def test_tool_node_individual_tool_error_handling():
assert tool_message.tool_call_id == "some 0"
def test_tool_node_incorrect_tool_name():
def test_tool_node_incorrect_tool_name() -> None:
result_incorrect_name = ToolNode([tool1, tool2]).invoke(
{
"messages": [
@@ -449,7 +570,8 @@ def test_tool_node_incorrect_tool_name():
],
)
]
}
},
config=_create_config_with_runtime(),
)
tool_message: ToolMessage = result_incorrect_name["messages"][-1]
@@ -462,12 +584,13 @@ def test_tool_node_incorrect_tool_name():
assert tool_message.tool_call_id == "some 0"
def test_tool_node_node_interrupt():
def test_tool_node_node_interrupt() -> None:
def tool_interrupt(some_val: int) -> None:
"""Tool docstring."""
raise GraphBubbleUp("foo")
msg = "foo"
raise GraphBubbleUp(msg)
def handle(e: GraphInterrupt):
def handle(e: GraphInterrupt) -> str:
return "handled"
for handle_tool_errors in (True, (GraphBubbleUp,), "handled", handle, False):
@@ -487,13 +610,14 @@ def test_tool_node_node_interrupt():
],
)
]
}
},
config=_create_config_with_runtime(),
)
assert exc_info.value == "foo"
@pytest.mark.parametrize("input_type", ["dict", "tool_calls"])
async def test_tool_node_command(input_type: str):
async def test_tool_node_command(input_type: str) -> None:
from langchain_core.tools.base import InjectedToolCallId
@dec_tool
@@ -578,7 +702,9 @@ async def test_tool_node_command(input_type: str):
input_ = {"messages": [AIMessage("", tool_calls=tool_calls)]}
elif input_type == "tool_calls":
input_ = tool_calls
result = ToolNode([add, transfer_to_bob]).invoke(input_)
result = ToolNode([add, transfer_to_bob]).invoke(
input_, config=_create_config_with_runtime()
)
assert result == [
{
@@ -616,7 +742,8 @@ async def test_tool_node_command(input_type: str):
"", tool_calls=[{"args": {}, "id": "1", "name": tool.name}]
)
]
}
},
config=_create_config_with_runtime(),
)
assert result == [
Command(
@@ -643,7 +770,8 @@ async def test_tool_node_command(input_type: str):
"", tool_calls=[{"args": {}, "id": "1", "name": tool.name}]
)
]
}
},
config=_create_config_with_runtime(),
)
assert result == [
Command(
@@ -673,7 +801,8 @@ async def test_tool_node_command(input_type: str):
],
)
]
}
},
config=_create_config_with_runtime(),
)
assert result == [
Command(
@@ -724,7 +853,8 @@ async def test_tool_node_command(input_type: str):
],
)
]
}
},
config=_create_config_with_runtime(),
)
# test validation (missing tool message in the update for current graph)
@@ -743,7 +873,8 @@ async def test_tool_node_command(input_type: str):
tool_calls=[{"args": {}, "id": "1", "name": "no_update_tool"}],
)
]
}
},
config=_create_config_with_runtime(),
)
# test validation (tool message with a wrong tool call ID)
@@ -770,7 +901,8 @@ async def test_tool_node_command(input_type: str):
],
)
]
}
},
config=_create_config_with_runtime(),
)
# test validation (missing tool message in the update for parent graph is OK)
@@ -789,11 +921,12 @@ async def test_tool_node_command(input_type: str):
],
)
]
}
},
config=_create_config_with_runtime(),
) == [Command(update={"messages": []}, graph=Command.PARENT)]
async def test_tool_node_command_list_input():
async def test_tool_node_command_list_input() -> None:
from langchain_core.tools.base import InjectedToolCallId
@dec_tool
@@ -871,7 +1004,8 @@ async def test_tool_node_command_list_input():
{"args": {}, "id": "2", "name": "transfer_to_bob"},
],
)
]
],
config=_create_config_with_runtime(),
)
assert result == [
@@ -900,7 +1034,8 @@ async def test_tool_node_command_list_input():
# test sync tools
for tool in [transfer_to_bob, custom_tool]:
result = ToolNode([tool]).invoke(
[AIMessage("", tool_calls=[{"args": {}, "id": "1", "name": tool.name}])]
[AIMessage("", tool_calls=[{"args": {}, "id": "1", "name": tool.name}])],
config=_create_config_with_runtime(),
)
assert result == [
Command(
@@ -919,7 +1054,8 @@ async def test_tool_node_command_list_input():
# test async tools
for tool in [async_transfer_to_bob, async_custom_tool]:
result = await ToolNode([tool]).ainvoke(
[AIMessage("", tool_calls=[{"args": {}, "id": "1", "name": tool.name}])]
[AIMessage("", tool_calls=[{"args": {}, "id": "1", "name": tool.name}])],
config=_create_config_with_runtime(),
)
assert result == [
Command(
@@ -945,7 +1081,8 @@ async def test_tool_node_command_list_input():
{"args": {}, "id": "2", "name": "custom_transfer_to_bob"},
],
)
]
],
config=_create_config_with_runtime(),
)
assert result == [
Command(
@@ -990,7 +1127,8 @@ async def test_tool_node_command_list_input():
"",
tool_calls=[{"args": {}, "id": "1", "name": "list_update_tool"}],
)
]
],
config=_create_config_with_runtime(),
)
# test validation (missing tool message in the update for current graph)
@@ -1007,7 +1145,8 @@ async def test_tool_node_command_list_input():
"",
tool_calls=[{"args": {}, "id": "1", "name": "no_update_tool"}],
)
]
],
config=_create_config_with_runtime(),
)
# test validation (tool message with a wrong tool call ID)
@@ -1026,7 +1165,8 @@ async def test_tool_node_command_list_input():
{"args": {}, "id": "1", "name": "mismatching_tool_call_id_tool"}
],
)
]
],
config=_create_config_with_runtime(),
)
# test validation (missing tool message in the update for parent graph is OK)
@@ -1041,11 +1181,12 @@ async def test_tool_node_command_list_input():
"",
tool_calls=[{"args": {}, "id": "1", "name": "node_update_parent_tool"}],
)
]
],
config=_create_config_with_runtime(),
) == [Command(update=[], graph=Command.PARENT)]
def test_tool_node_parent_command_with_send():
def test_tool_node_parent_command_with_send() -> None:
from langchain_core.tools.base import InjectedToolCallId
@dec_tool
@@ -1096,7 +1237,8 @@ def test_tool_node_parent_command_with_send():
]
result = ToolNode([transfer_to_alice, transfer_to_bob]).invoke(
[AIMessage("", tool_calls=tool_calls)]
[AIMessage("", tool_calls=tool_calls)],
config=_create_config_with_runtime(),
)
assert result == [
@@ -1132,7 +1274,7 @@ def test_tool_node_parent_command_with_send():
]
async def test_tool_node_command_remove_all_messages():
async def test_tool_node_command_remove_all_messages() -> None:
from langchain_core.tools.base import InjectedToolCallId
@dec_tool
@@ -1147,7 +1289,8 @@ async def test_tool_node_command_remove_all_messages():
"id": "tool_call_123",
}
result = await tool_node.ainvoke(
{"messages": [AIMessage(content="", tool_calls=[tool_call])]}
{"messages": [AIMessage(content="", tool_calls=[tool_call])]},
config=_create_config_with_runtime(),
)
assert isinstance(result, list)
@@ -1155,3 +1298,315 @@ async def test_tool_node_command_remove_all_messages():
command = result[0]
assert isinstance(command, Command)
assert command.update == {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]}
class _InjectStateSchema(TypedDict):
messages: list
foo: str
class _InjectedStatePydanticV2Schema(BaseModel):
messages: list
foo: str
@dataclasses.dataclass
class _InjectedStateDataclassSchema:
messages: list
foo: str
_INJECTED_STATE_SCHEMAS = [
_InjectStateSchema,
_InjectedStatePydanticV2Schema,
_InjectedStateDataclassSchema,
]
if sys.version_info < (3, 14):
class _InjectedStatePydanticSchema(BaseModelV1):
messages: list
foo: str
_INJECTED_STATE_SCHEMAS.append(_InjectedStatePydanticSchema)
T = TypeVar("T")
@pytest.mark.parametrize("schema_", _INJECTED_STATE_SCHEMAS)
def test_tool_node_inject_state(schema_: type[T]) -> None:
def tool1(some_val: int, state: Annotated[T, InjectedState]) -> str:
"""Tool 1 docstring."""
if isinstance(state, dict):
return state["foo"]
return state.foo
def tool2(some_val: int, state: Annotated[T, InjectedState()]) -> str:
"""Tool 2 docstring."""
if isinstance(state, dict):
return state["foo"]
return state.foo
def tool3(
some_val: int,
foo: Annotated[str, InjectedState("foo")],
msgs: Annotated[list[AnyMessage], InjectedState("messages")],
) -> str:
"""Tool 1 docstring."""
return foo
def tool4(
some_val: int, msgs: Annotated[list[AnyMessage], InjectedState("messages")]
) -> str:
"""Tool 1 docstring."""
return msgs[0].content
node = ToolNode([tool1, tool2, tool3, tool4], handle_tool_errors=True)
for tool_name in ("tool1", "tool2", "tool3"):
tool_call = {
"name": tool_name,
"args": {"some_val": 1},
"id": "some 0",
"type": "tool_call",
}
msg = AIMessage("hi?", tool_calls=[tool_call])
result = node.invoke(
schema_(messages=[msg], foo="bar"), config=_create_config_with_runtime()
)
tool_message = result["messages"][-1]
assert tool_message.content == "bar", f"Failed for tool={tool_name}"
if tool_name == "tool3":
failure_input = None
with contextlib.suppress(Exception):
failure_input = schema_(messages=[msg], notfoo="bar")
if failure_input is not None:
with pytest.raises(KeyError):
node.invoke(failure_input, config=_create_config_with_runtime())
with pytest.raises(ValueError):
node.invoke([msg], config=_create_config_with_runtime())
else:
failure_input = None
try:
failure_input = schema_(messages=[msg], notfoo="bar")
except Exception:
# We'd get a validation error from pydantic state and wouldn't make it to the node
# anyway
pass
if failure_input is not None:
messages_ = node.invoke(
failure_input, config=_create_config_with_runtime()
)
tool_message = messages_["messages"][-1]
assert "KeyError" in tool_message.content
tool_message = node.invoke([msg], config=_create_config_with_runtime())[
-1
]
assert "KeyError" in tool_message.content
tool_call = {
"name": "tool4",
"args": {"some_val": 1},
"id": "some 0",
"type": "tool_call",
}
msg = AIMessage("hi?", tool_calls=[tool_call])
result = node.invoke(
schema_(messages=[msg], foo=""), config=_create_config_with_runtime()
)
tool_message = result["messages"][-1]
assert tool_message.content == "hi?"
result = node.invoke([msg], config=_create_config_with_runtime())
tool_message = result[-1]
assert tool_message.content == "hi?"
def test_tool_node_inject_store() -> None:
store = InMemoryStore()
namespace = ("test",)
def tool1(some_val: int, store: Annotated[BaseStore, InjectedStore()]) -> str:
"""Tool 1 docstring."""
store_val = store.get(namespace, "test_key").value["foo"]
return f"Some val: {some_val}, store val: {store_val}"
def tool2(some_val: int, store: Annotated[BaseStore, InjectedStore()]) -> str:
"""Tool 2 docstring."""
store_val = store.get(namespace, "test_key").value["foo"]
return f"Some val: {some_val}, store val: {store_val}"
def tool3(
some_val: int,
bar: Annotated[str, InjectedState("bar")],
store: Annotated[BaseStore, InjectedStore()],
) -> str:
"""Tool 3 docstring."""
store_val = store.get(namespace, "test_key").value["foo"]
return f"Some val: {some_val}, store val: {store_val}, state val: {bar}"
node = ToolNode([tool1, tool2, tool3], handle_tool_errors=True)
store.put(namespace, "test_key", {"foo": "bar"})
class State(MessagesState):
bar: str
builder = StateGraph(State)
builder.add_node("tools", node)
builder.add_edge(START, "tools")
graph = builder.compile(store=store)
for tool_name in ("tool1", "tool2"):
tool_call = {
"name": tool_name,
"args": {"some_val": 1},
"id": "some 0",
"type": "tool_call",
}
msg = AIMessage("hi?", tool_calls=[tool_call])
node_result = node.invoke(
{"messages": [msg]}, config=_create_config_with_runtime(store=store)
)
graph_result = graph.invoke({"messages": [msg]})
for result in (node_result, graph_result):
result["messages"][-1]
tool_message = result["messages"][-1]
assert tool_message.content == "Some val: 1, store val: bar", (
f"Failed for tool={tool_name}"
)
tool_call = {
"name": "tool3",
"args": {"some_val": 1},
"id": "some 0",
"type": "tool_call",
}
msg = AIMessage("hi?", tool_calls=[tool_call])
node_result = node.invoke(
{"messages": [msg], "bar": "baz"},
config=_create_config_with_runtime(store=store),
)
graph_result = graph.invoke({"messages": [msg], "bar": "baz"})
for result in (node_result, graph_result):
result["messages"][-1]
tool_message = result["messages"][-1]
assert tool_message.content == "Some val: 1, store val: bar, state val: baz", (
f"Failed for tool={tool_name}"
)
# test injected store without passing store to compiled graph
failing_graph = builder.compile()
with pytest.raises(ValueError):
failing_graph.invoke({"messages": [msg], "bar": "baz"})
def test_tool_node_ensure_utf8() -> None:
@dec_tool
def get_day_list(days: list[str]) -> list[str]:
"""choose days"""
return days
data = ["星期一", "水曜日", "목요일", "Friday"]
tools = [get_day_list]
tool_calls = [ToolCall(name=get_day_list.name, args={"days": data}, id="test_id")]
outputs: list[ToolMessage] = ToolNode(tools).invoke(
[AIMessage(content="", tool_calls=tool_calls)],
config=_create_config_with_runtime(),
)
assert outputs[0].content == json.dumps(data, ensure_ascii=False)
def test_tool_node_messages_key() -> None:
@dec_tool
def add(a: int, b: int) -> int:
"""Adds a and b."""
return a + b
model = FakeToolCallingModel(
tool_calls=[[ToolCall(name=add.name, args={"a": 1, "b": 2}, id="test_id")]]
)
class State(TypedDict):
subgraph_messages: Annotated[list[AnyMessage], add_messages]
def call_model(state: State) -> dict[str, Any]:
response = model.invoke(state["subgraph_messages"])
model.tool_calls = []
return {"subgraph_messages": response}
builder = StateGraph(State)
builder.add_node("agent", call_model)
builder.add_node("tools", ToolNode([add], messages_key="subgraph_messages"))
builder.add_conditional_edges(
"agent", partial(tools_condition, messages_key="subgraph_messages")
)
builder.add_edge(START, "agent")
builder.add_edge("tools", "agent")
graph = builder.compile()
result = graph.invoke({"subgraph_messages": [HumanMessage(content="hi")]})
assert result["subgraph_messages"] == [
_AnyIdHumanMessage(content="hi"),
AIMessage(
content="hi",
id="0",
tool_calls=[ToolCall(name=add.name, args={"a": 1, "b": 2}, id="test_id")],
),
_AnyIdToolMessage(content="3", name=add.name, tool_call_id="test_id"),
AIMessage(content="hi-hi-3", id="1"),
]
def test_tool_node_stream_writer() -> None:
@dec_tool
def streaming_tool(x: int) -> str:
"""Do something with writer."""
my_writer = get_stream_writer()
for value in ["foo", "bar", "baz"]:
my_writer({"custom_tool_value": value})
return x
tool_node = ToolNode([streaming_tool])
graph = (
StateGraph(MessagesState)
.add_node("tools", tool_node)
.add_edge(START, "tools")
.compile()
)
tool_call = {
"name": "streaming_tool",
"args": {"x": 1},
"id": "1",
"type": "tool_call",
}
inputs = {
"messages": [AIMessage("", tool_calls=[tool_call])],
}
assert list(graph.stream(inputs, stream_mode="custom")) == [
{"custom_tool_value": "foo"},
{"custom_tool_value": "bar"},
{"custom_tool_value": "baz"},
]
assert list(graph.stream(inputs, stream_mode=["custom", "updates"])) == [
("custom", {"custom_tool_value": "foo"}),
("custom", {"custom_tool_value": "bar"}),
("custom", {"custom_tool_value": "baz"}),
(
"updates",
{
"tools": {
"messages": [
_AnyIdToolMessage(
content="1",
name="streaming_tool",
tool_call_id="1",
),
],
},
},
),
]
@@ -0,0 +1,578 @@
"""Test tool node interceptor handling of unregistered tools."""
from collections.abc import Awaitable, Callable
from unittest.mock import Mock
import pytest
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.runnables.config import RunnableConfig
from langchain_core.tools import tool as dec_tool
from langgraph.store.base import BaseStore
from langgraph.types import Command
from langgraph.prebuilt import ToolNode
from langgraph.prebuilt.tool_node import ToolCallRequest
pytestmark = pytest.mark.anyio
def _create_mock_runtime(store: BaseStore | None = None) -> Mock:
"""Create a mock Runtime object for testing ToolNode outside of graph context.
This helper is needed because ToolNode._func expects a Runtime parameter
which is injected by RunnableCallable from config["configurable"]["__pregel_runtime"].
When testing ToolNode directly (outside a graph), we need to provide this manually.
"""
mock_runtime = Mock()
mock_runtime.store = store
mock_runtime.context = None
mock_runtime.stream_writer = lambda *args, **kwargs: None
return mock_runtime
def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig:
"""Create a RunnableConfig with mock Runtime for testing ToolNode.
Returns:
RunnableConfig with __pregel_runtime in configurable dict.
"""
return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}}
@dec_tool
def registered_tool(x: int) -> str:
"""A registered tool."""
return f"Result: {x}"
def test_interceptor_can_handle_unregistered_tool_sync() -> None:
"""Test that interceptor can handle requests for unregistered tools (sync)."""
def interceptor(
request: ToolCallRequest,
execute: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
"""Intercept and handle unregistered tools."""
if request.tool_call["name"] == "unregistered_tool":
# Short-circuit without calling execute for unregistered tool
return ToolMessage(
content="Handled by interceptor",
tool_call_id=request.tool_call["id"],
name="unregistered_tool",
)
# Pass through for registered tools
return execute(request)
node = ToolNode([registered_tool], wrap_tool_call=interceptor)
# Test registered tool works normally
result = node.invoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "registered_tool",
"args": {"x": 42},
"id": "1",
"type": "tool_call",
}
],
)
],
config=_create_config_with_runtime(),
)
assert result[0].content == "Result: 42"
assert result[0].tool_call_id == "1"
# Test unregistered tool is intercepted and handled
result = node.invoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "unregistered_tool",
"args": {"x": 99},
"id": "2",
"type": "tool_call",
}
],
)
],
config=_create_config_with_runtime(),
)
assert result[0].content == "Handled by interceptor"
assert result[0].tool_call_id == "2"
assert result[0].name == "unregistered_tool"
async def test_interceptor_can_handle_unregistered_tool_async() -> None:
"""Test that interceptor can handle requests for unregistered tools (async)."""
async def async_interceptor(
request: ToolCallRequest,
execute: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
) -> ToolMessage | Command:
"""Intercept and handle unregistered tools."""
if request.tool_call["name"] == "unregistered_tool":
# Short-circuit without calling execute for unregistered tool
return ToolMessage(
content="Handled by async interceptor",
tool_call_id=request.tool_call["id"],
name="unregistered_tool",
)
# Pass through for registered tools
return await execute(request)
node = ToolNode([registered_tool], awrap_tool_call=async_interceptor)
# Test registered tool works normally
result = await node.ainvoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "registered_tool",
"args": {"x": 42},
"id": "1",
"type": "tool_call",
}
],
)
],
config=_create_config_with_runtime(),
)
assert result[0].content == "Result: 42"
assert result[0].tool_call_id == "1"
# Test unregistered tool is intercepted and handled
result = await node.ainvoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "unregistered_tool",
"args": {"x": 99},
"id": "2",
"type": "tool_call",
}
],
)
],
config=_create_config_with_runtime(),
)
assert result[0].content == "Handled by async interceptor"
assert result[0].tool_call_id == "2"
assert result[0].name == "unregistered_tool"
def test_unregistered_tool_error_when_interceptor_calls_execute() -> None:
"""Test that unregistered tools error if interceptor tries to execute them."""
def bad_interceptor(
request: ToolCallRequest,
execute: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
"""Interceptor that tries to execute unregistered tool."""
# This should fail validation when execute is called
return execute(request)
node = ToolNode([registered_tool], wrap_tool_call=bad_interceptor)
# Registered tool should still work
result = node.invoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "registered_tool",
"args": {"x": 42},
"id": "1",
"type": "tool_call",
}
],
)
],
config=_create_config_with_runtime(),
)
assert result[0].content == "Result: 42"
# Unregistered tool should error when interceptor calls execute
result = node.invoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "unregistered_tool",
"args": {"x": 99},
"id": "2",
"type": "tool_call",
}
],
)
],
config=_create_config_with_runtime(),
)
# Should get validation error message
assert result[0].status == "error"
assert "is not a valid tool" in result[0].content
assert result[0].tool_call_id == "2"
def test_interceptor_handles_mix_of_registered_and_unregistered() -> None:
"""Test interceptor handling mix of registered and unregistered tools."""
def selective_interceptor(
request: ToolCallRequest,
execute: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
"""Handle unregistered tools, pass through registered ones."""
if request.tool_call["name"] == "magic_tool":
return ToolMessage(
content=f"Magic result: {request.tool_call['args'].get('value', 0) * 2}",
tool_call_id=request.tool_call["id"],
name="magic_tool",
)
return execute(request)
node = ToolNode([registered_tool], wrap_tool_call=selective_interceptor)
# Test multiple tool calls - mix of registered and unregistered
result = node.invoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "registered_tool",
"args": {"x": 10},
"id": "1",
"type": "tool_call",
},
{
"name": "magic_tool",
"args": {"value": 5},
"id": "2",
"type": "tool_call",
},
{
"name": "registered_tool",
"args": {"x": 20},
"id": "3",
"type": "tool_call",
},
],
)
],
config=_create_config_with_runtime(),
)
# All tools should execute successfully
assert len(result) == 3
assert result[0].content == "Result: 10"
assert result[0].tool_call_id == "1"
assert result[1].content == "Magic result: 10"
assert result[1].tool_call_id == "2"
assert result[2].content == "Result: 20"
assert result[2].tool_call_id == "3"
def test_interceptor_command_for_unregistered_tool() -> None:
"""Test interceptor returning Command for unregistered tool."""
def command_interceptor(
request: ToolCallRequest,
execute: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
"""Return Command for unregistered tools."""
if request.tool_call["name"] == "routing_tool":
return Command(
update=[
ToolMessage(
content="Routing to special handler",
tool_call_id=request.tool_call["id"],
name="routing_tool",
)
],
goto="special_node",
)
return execute(request)
node = ToolNode([registered_tool], wrap_tool_call=command_interceptor)
result = node.invoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "routing_tool",
"args": {},
"id": "1",
"type": "tool_call",
}
],
)
],
config=_create_config_with_runtime(),
)
# Should get Command back
assert len(result) == 1
assert isinstance(result[0], Command)
assert result[0].goto == "special_node"
assert result[0].update is not None
assert len(result[0].update) == 1
assert result[0].update[0].content == "Routing to special handler"
def test_interceptor_exception_with_unregistered_tool() -> None:
"""Test that interceptor exceptions are caught by error handling."""
def failing_interceptor(
request: ToolCallRequest,
execute: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
"""Interceptor that throws exception for unregistered tools."""
if request.tool_call["name"] == "bad_tool":
msg = "Interceptor failed"
raise ValueError(msg)
return execute(request)
node = ToolNode(
[registered_tool], wrap_tool_call=failing_interceptor, handle_tool_errors=True
)
# Interceptor exception should be caught and converted to error message
result = node.invoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "bad_tool",
"args": {},
"id": "1",
"type": "tool_call",
}
],
)
],
config=_create_config_with_runtime(),
)
assert len(result) == 1
assert result[0].status == "error"
assert "Interceptor failed" in result[0].content
assert result[0].tool_call_id == "1"
# Test that exception is raised when handle_tool_errors is False
node_no_handling = ToolNode(
[registered_tool], wrap_tool_call=failing_interceptor, handle_tool_errors=False
)
with pytest.raises(ValueError, match="Interceptor failed"):
node_no_handling.invoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "bad_tool",
"args": {},
"id": "2",
"type": "tool_call",
}
],
)
],
config=_create_config_with_runtime(),
)
async def test_async_interceptor_exception_with_unregistered_tool() -> None:
"""Test that async interceptor exceptions are caught by error handling."""
async def failing_async_interceptor(
request: ToolCallRequest,
execute: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
) -> ToolMessage | Command:
"""Async interceptor that throws exception for unregistered tools."""
if request.tool_call["name"] == "bad_async_tool":
msg = "Async interceptor failed"
raise RuntimeError(msg)
return await execute(request)
node = ToolNode(
[registered_tool],
awrap_tool_call=failing_async_interceptor,
handle_tool_errors=True,
)
# Interceptor exception should be caught and converted to error message
result = await node.ainvoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "bad_async_tool",
"args": {},
"id": "1",
"type": "tool_call",
}
],
)
],
config=_create_config_with_runtime(),
)
assert len(result) == 1
assert result[0].status == "error"
assert "Async interceptor failed" in result[0].content
assert result[0].tool_call_id == "1"
# Test that exception is raised when handle_tool_errors is False
node_no_handling = ToolNode(
[registered_tool],
awrap_tool_call=failing_async_interceptor,
handle_tool_errors=False,
)
with pytest.raises(RuntimeError, match="Async interceptor failed"):
await node_no_handling.ainvoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "bad_async_tool",
"args": {},
"id": "2",
"type": "tool_call",
}
],
)
],
config=_create_config_with_runtime(),
)
def test_interceptor_with_dict_input_format() -> None:
"""Test that interceptor works with dict input format."""
def interceptor(
request: ToolCallRequest,
execute: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
"""Intercept unregistered tools with dict input."""
if request.tool_call["name"] == "dict_tool":
return ToolMessage(
content="Handled dict input",
tool_call_id=request.tool_call["id"],
name="dict_tool",
)
return execute(request)
node = ToolNode([registered_tool], wrap_tool_call=interceptor)
# Test with dict input format
result = node.invoke(
{
"messages": [
AIMessage(
"",
tool_calls=[
{
"name": "dict_tool",
"args": {"value": 5},
"id": "1",
"type": "tool_call",
}
],
)
]
},
config=_create_config_with_runtime(),
)
# Should return dict format output
assert isinstance(result, dict)
assert "messages" in result
assert len(result["messages"]) == 1
assert result["messages"][0].content == "Handled dict input"
assert result["messages"][0].tool_call_id == "1"
def test_interceptor_verifies_tool_is_none_for_unregistered() -> None:
"""Test that request.tool is None for unregistered tools."""
captured_requests: list[ToolCallRequest] = []
def capturing_interceptor(
request: ToolCallRequest,
execute: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
"""Capture request to verify tool field."""
captured_requests.append(request)
if request.tool is None:
# Tool is unregistered
return ToolMessage(
content=f"Unregistered: {request.tool_call['name']}",
tool_call_id=request.tool_call["id"],
name=request.tool_call["name"],
)
# Tool is registered
return execute(request)
node = ToolNode([registered_tool], wrap_tool_call=capturing_interceptor)
# Test unregistered tool
node.invoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "unknown_tool",
"args": {},
"id": "1",
"type": "tool_call",
}
],
)
],
config=_create_config_with_runtime(),
)
assert len(captured_requests) == 1
assert captured_requests[0].tool is None
assert captured_requests[0].tool_call["name"] == "unknown_tool"
# Clear and test registered tool
captured_requests.clear()
node.invoke(
[
AIMessage(
"",
tool_calls=[
{
"name": "registered_tool",
"args": {"x": 10},
"id": "2",
"type": "tool_call",
}
],
)
],
config=_create_config_with_runtime(),
)
assert len(captured_requests) == 1
assert captured_requests[0].tool is not None
assert captured_requests[0].tool.name == "registered_tool"
@@ -0,0 +1,470 @@
"""Unit tests for ValidationError filtering in ToolNode.
This module tests that validation errors are filtered to only include arguments
that the LLM controls. Injected arguments (InjectedState, InjectedStore,
ToolRuntime) are automatically provided by the system and should not appear in
validation error messages. This ensures the LLM receives focused, actionable
feedback about the parameters it can actually control, improving error correction
and reducing confusion from irrelevant system implementation details.
"""
from typing import Annotated
from unittest.mock import Mock
import pytest
from langchain_core.messages import AIMessage
from langchain_core.runnables.config import RunnableConfig
from langchain_core.tools import tool as dec_tool
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from langgraph.prebuilt import InjectedState, InjectedStore, ToolNode, ToolRuntime
from langgraph.prebuilt.tool_node import ToolInvocationError
pytestmark = pytest.mark.anyio
def _create_mock_runtime(store: BaseStore | None = None) -> Mock:
"""Create a mock Runtime object for testing ToolNode outside of graph context."""
mock_runtime = Mock()
mock_runtime.store = store
mock_runtime.context = None
mock_runtime.stream_writer = lambda *args, **kwargs: None
return mock_runtime
def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig:
"""Create a RunnableConfig with mock Runtime for testing ToolNode."""
return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}}
async def test_filter_injected_state_validation_errors() -> None:
"""Test that validation errors for InjectedState arguments are filtered out.
InjectedState parameters are not controlled by the LLM, so any validation
errors related to them should not appear in error messages. This ensures
the LLM receives only actionable feedback about its own tool call arguments.
"""
@dec_tool
def my_tool(
value: int,
state: Annotated[dict, InjectedState],
) -> str:
"""Tool that uses injected state.
Args:
value: An integer value.
state: The graph state (injected).
"""
return f"value={value}, messages={len(state.get('messages', []))}"
tool_node = ToolNode([my_tool])
# Call with invalid 'value' argument (should be int, not str)
result = await tool_node.ainvoke(
{
"messages": [
AIMessage(
"hi?",
tool_calls=[
{
"name": "my_tool",
"args": {"value": "not_an_int"}, # Invalid type
"id": "call_1",
"type": "tool_call",
}
],
)
]
},
config=_create_config_with_runtime(),
)
# Should get a ToolMessage with error
assert len(result["messages"]) == 1
tool_message = result["messages"][0]
assert tool_message.status == "error"
assert tool_message.tool_call_id == "call_1"
# Error should mention 'value' but NOT 'state' (which is injected)
assert "value" in tool_message.content
assert "state" not in tool_message.content.lower()
async def test_filter_injected_store_validation_errors() -> None:
"""Test that validation errors for InjectedStore arguments are filtered out.
InjectedStore parameters are not controlled by the LLM, so any validation
errors related to them should not appear in error messages. This keeps
error feedback focused on LLM-controllable parameters.
"""
@dec_tool
def my_tool(
key: str,
store: Annotated[BaseStore, InjectedStore()],
) -> str:
"""Tool that uses injected store.
Args:
key: A key to look up.
store: The persistent store (injected).
"""
return f"key={key}"
tool_node = ToolNode([my_tool])
# Call with invalid 'key' argument (missing required argument)
result = await tool_node.ainvoke(
{
"messages": [
AIMessage(
"hi?",
tool_calls=[
{
"name": "my_tool",
"args": {}, # Missing 'key'
"id": "call_1",
"type": "tool_call",
}
],
)
]
},
config=_create_config_with_runtime(store=InMemoryStore()),
)
# Should get a ToolMessage with error
assert len(result["messages"]) == 1
tool_message = result["messages"][0]
assert tool_message.status == "error"
# Error should mention 'key' is required
assert "key" in tool_message.content.lower()
# The error should be about 'key' field specifically (not about store field)
# Note: 'store' might appear in input_value representation, but the validation
# error itself should only be for 'key'
assert (
"field required" in tool_message.content.lower()
or "missing" in tool_message.content.lower()
)
async def test_filter_tool_runtime_validation_errors() -> None:
"""Test that validation errors for ToolRuntime arguments are filtered out.
ToolRuntime parameters are not controlled by the LLM, so any validation
errors related to them should not appear in error messages. This ensures
the LLM only sees errors for parameters it can fix.
"""
@dec_tool
def my_tool(
query: str,
runtime: ToolRuntime,
) -> str:
"""Tool that uses ToolRuntime.
Args:
query: A query string.
runtime: The tool runtime context (injected).
"""
return f"query={query}"
tool_node = ToolNode([my_tool])
# Call with invalid 'query' argument (wrong type)
result = await tool_node.ainvoke(
{
"messages": [
AIMessage(
"hi?",
tool_calls=[
{
"name": "my_tool",
"args": {"query": 123}, # Should be str, not int
"id": "call_1",
"type": "tool_call",
}
],
)
]
},
config=_create_config_with_runtime(),
)
# Should get a ToolMessage with error
assert len(result["messages"]) == 1
tool_message = result["messages"][0]
assert tool_message.status == "error"
# Error should mention 'query' but NOT 'runtime' (which is injected)
assert "query" in tool_message.content.lower()
assert "runtime" not in tool_message.content.lower()
async def test_filter_multiple_injected_args() -> None:
"""Test filtering when a tool has multiple injected arguments.
When a tool uses multiple injected parameters (state, store, runtime), none of
them should appear in validation error messages since they're all system-provided
and not controlled by the LLM. Only LLM-controllable parameter errors should appear.
"""
@dec_tool
def my_tool(
value: int,
state: Annotated[dict, InjectedState],
store: Annotated[BaseStore, InjectedStore()],
runtime: ToolRuntime,
) -> str:
"""Tool with multiple injected arguments.
Args:
value: An integer value.
state: The graph state (injected).
store: The persistent store (injected).
runtime: The tool runtime context (injected).
"""
return f"value={value}"
tool_node = ToolNode([my_tool])
# Call with invalid 'value' - injected args should be filtered from error
result = await tool_node.ainvoke(
{
"messages": [
AIMessage(
"hi?",
tool_calls=[
{
"name": "my_tool",
"args": {"value": "not_an_int"},
"id": "call_1",
"type": "tool_call",
}
],
)
]
},
config=_create_config_with_runtime(store=InMemoryStore()),
)
tool_message = result["messages"][0]
assert tool_message.status == "error"
# Only 'value' error should be reported
assert "value" in tool_message.content
# None of the injected args should appear in error
assert "state" not in tool_message.content.lower()
assert "store" not in tool_message.content.lower()
assert "runtime" not in tool_message.content.lower()
async def test_no_filtering_when_all_errors_are_model_args() -> None:
"""Test that validation errors for LLM-controlled arguments are preserved.
When validation fails for arguments the LLM controls, those errors should
be fully reported to help the LLM correct its tool calls. This ensures
the LLM receives complete feedback about all issues it can fix.
"""
@dec_tool
def my_tool(
value1: int,
value2: str,
state: Annotated[dict, InjectedState],
) -> str:
"""Tool with both regular and injected arguments.
Args:
value1: First value.
value2: Second value.
state: The graph state (injected).
"""
return f"value1={value1}, value2={value2}"
tool_node = ToolNode([my_tool])
# Call with invalid arguments for BOTH non-injected parameters
result = await tool_node.ainvoke(
{
"messages": [
AIMessage(
"hi?",
tool_calls=[
{
"name": "my_tool",
"args": {
"value1": "not_an_int", # Invalid
"value2": 456, # Invalid (should be str)
},
"id": "call_1",
"type": "tool_call",
}
],
)
]
},
config=_create_config_with_runtime(),
)
tool_message = result["messages"][0]
assert tool_message.status == "error"
# Both errors should be present
assert "value1" in tool_message.content
assert "value2" in tool_message.content
# Injected state should not appear
assert "state" not in tool_message.content.lower()
async def test_validation_error_with_no_injected_args() -> None:
"""Test that tools without injected arguments show all validation errors.
For tools that only have LLM-controlled parameters, all validation errors
should be reported since everything is under the LLM's control and can be
corrected by the LLM in subsequent tool calls.
"""
@dec_tool
def my_tool(value1: int, value2: str) -> str:
"""Regular tool without injected arguments.
Args:
value1: First value.
value2: Second value.
"""
return f"{value1} {value2}"
tool_node = ToolNode([my_tool])
result = await tool_node.ainvoke(
{
"messages": [
AIMessage(
"hi?",
tool_calls=[
{
"name": "my_tool",
"args": {"value1": "invalid", "value2": 123},
"id": "call_1",
"type": "tool_call",
}
],
)
]
},
config=_create_config_with_runtime(),
)
tool_message = result["messages"][0]
assert tool_message.status == "error"
# Both errors should be present since there are no injected args to filter
assert "value1" in tool_message.content
assert "value2" in tool_message.content
async def test_tool_invocation_error_without_handle_errors() -> None:
"""Test that ToolInvocationError contains only LLM-controlled parameter errors.
When handle_tool_errors is False, the raised ToolInvocationError should still
filter out system-injected arguments from the error details, ensuring that
error messages focus on what the LLM can control.
"""
@dec_tool
def my_tool(
value: int,
state: Annotated[dict, InjectedState],
) -> str:
"""Tool with injected state.
Args:
value: An integer value.
state: The graph state (injected).
"""
return f"value={value}"
tool_node = ToolNode([my_tool], handle_tool_errors=False)
# Should raise ToolInvocationError with filtered errors
with pytest.raises(ToolInvocationError) as exc_info:
await tool_node.ainvoke(
{
"messages": [
AIMessage(
"hi?",
tool_calls=[
{
"name": "my_tool",
"args": {"value": "not_an_int"},
"id": "call_1",
"type": "tool_call",
}
],
)
]
},
config=_create_config_with_runtime(),
)
error = exc_info.value
assert error.tool_name == "my_tool"
assert error.filtered_errors is not None
assert len(error.filtered_errors) > 0
# Filtered errors should only contain 'value' error, not 'state'
error_locs = [err["loc"] for err in error.filtered_errors]
assert any("value" in str(loc) for loc in error_locs)
assert not any("state" in str(loc) for loc in error_locs)
async def test_sync_tool_validation_error_filtering() -> None:
"""Test that error filtering works for sync tools.
Error filtering should work identically for both sync and async tool execution,
excluding injected arguments from validation error messages.
"""
@dec_tool
def my_tool(
value: int,
state: Annotated[dict, InjectedState],
) -> str:
"""Sync tool with injected state.
Args:
value: An integer value.
state: The graph state (injected).
"""
return f"value={value}"
tool_node = ToolNode([my_tool])
# Test sync invocation
result = tool_node.invoke(
{
"messages": [
AIMessage(
"hi?",
tool_calls=[
{
"name": "my_tool",
"args": {"value": "not_an_int"},
"id": "call_1",
"type": "tool_call",
}
],
)
]
},
config=_create_config_with_runtime(),
)
tool_message = result["messages"][0]
assert tool_message.status == "error"
assert "value" in tool_message.content
assert "state" not in tool_message.content.lower()
+58 -2
View File
@@ -246,7 +246,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.0.1"
version = "1.0.2"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -467,7 +467,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.1"
version = "1.0.2"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -483,6 +483,7 @@ dev = [
{ name = "langgraph-checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite" },
{ name = "mypy" },
{ name = "psycopg-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -501,6 +502,7 @@ test = [
{ name = "langgraph-checkpoint" },
{ name = "langgraph-checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite" },
{ name = "psycopg-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -523,6 +525,7 @@ dev = [
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" },
{ name = "mypy" },
{ name = "psycopg-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -541,6 +544,7 @@ test = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" },
{ name = "psycopg-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -828,6 +832,58 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/90/422ffbbeeb9418c795dae2a768db860401446af0c6768bc061ce22325f58/psycopg-3.2.10-py3-none-any.whl", hash = "sha256:ab5caf09a9ec42e314a21f5216dbcceac528e0e05142e42eea83a3b28b320ac3", size = 206586, upload-time = "2025-09-08T09:07:50.121Z" },
]
[[package]]
name = "psycopg-binary"
version = "3.2.11"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/96/9fe31ef61b311c697a98709a31b875d152e4f67924dd2cb94a4de0396d74/psycopg_binary-3.2.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f72146ad5b69ea177c2707578e5a4a9422b79e50d5a80992dabc5619b0929771", size = 4031016, upload-time = "2025-10-18T22:43:35.867Z" },
{ url = "https://files.pythonhosted.org/packages/55/fe/3ae6be34bfda1ba6dfd4e3b5c1d68bc51d4593399b5a10faaff68937c9a1/psycopg_binary-3.2.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b051aa1e67f0d03ccdb4503d716f22da56229896526f0aa721e5a199baa9e5d4", size = 4090430, upload-time = "2025-10-18T22:43:41.154Z" },
{ url = "https://files.pythonhosted.org/packages/fc/ea/7aa84f6bb64f94bfbe7d494d384a0d2bc66ba66e8607f5e9b515aa6af627/psycopg_binary-3.2.11-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49d76391b225f72dd63fcab87937ccf307ae0f093b5a382eeacf05f19a57c176", size = 4641307, upload-time = "2025-10-18T22:43:45.959Z" },
{ url = "https://files.pythonhosted.org/packages/9a/67/ef12ff8a530230824965668b44ccd58a88dae40511f7bbd125defb7972c4/psycopg_binary-3.2.11-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:58997db1aa48a1119e26c1c2f893d1c92339bd3be5d1f25334f22eaeaeeca90e", size = 4742204, upload-time = "2025-10-18T22:43:50.702Z" },
{ url = "https://files.pythonhosted.org/packages/f7/9c/8f35345fe22a0e5997cbfba0b7e1a58f26b290400cb9a6cb67e72e503331/psycopg_binary-3.2.11-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e3b6328bc2f3ca233f9a5f08d266089b96a534eca9ee4e45cb92d0a8d4629d9c", size = 4425352, upload-time = "2025-10-18T22:43:55.039Z" },
{ url = "https://files.pythonhosted.org/packages/f1/29/f0c585c6b48526f0ecf179e13ea2b6d8fed0dbba8c1a0d61da8ece149b0e/psycopg_binary-3.2.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bc571786a256a2fa2d8f13b5ecf714020b753bc76c2fa6d308e46751946dc31", size = 3885019, upload-time = "2025-10-18T22:43:59.256Z" },
{ url = "https://files.pythonhosted.org/packages/20/6d/a139e1c7e9840491d9ad3c837264a900ac95ba35e5f83fd5715b1ce7a729/psycopg_binary-3.2.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:766089fdaa8af1b5f7e2ec9fd7ad190c865e226b4fb0e7b1bd8dbcd62b5b923e", size = 3568192, upload-time = "2025-10-18T22:44:03.915Z" },
{ url = "https://files.pythonhosted.org/packages/74/ea/43a2b6fcfa816797dc6d2ac67e9cd09b3a7e4da0a29467a8b5940e7a1312/psycopg_binary-3.2.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5fb27dd9c52ae13cb4de90244207155b694f76a75a816115ead2d573f40e1e36", size = 3609300, upload-time = "2025-10-18T22:44:09.168Z" },
{ url = "https://files.pythonhosted.org/packages/77/d5/c9d46e626528a44b0feb881064e8018107b603ac683a658be3ee9ca00222/psycopg_binary-3.2.11-cp310-cp310-win_amd64.whl", hash = "sha256:3f32b09fba85d9e239229bdc5b6254420c02054f6954fe7fbd1ecf1ca93009ed", size = 2918105, upload-time = "2025-10-18T22:44:14.203Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c4/350473820759d7e599e68bd79c88d32376353ceb0f764db05de8f13ff421/psycopg_binary-3.2.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6688807ed07436c18e9946d01372bc80b9d20b7732cde27de9313e0860910c84", size = 4037740, upload-time = "2025-10-18T22:44:21.344Z" },
{ url = "https://files.pythonhosted.org/packages/50/e0/00bf3e207676bbe6e9f32c0f924f0e5be1efcd1a9fb2fd84d1c3d9958a96/psycopg_binary-3.2.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:478a68d50f34f6203642d245e2046d266c719ab4e593a1bb94c3be5f82e1aee1", size = 4098558, upload-time = "2025-10-18T22:44:26.948Z" },
{ url = "https://files.pythonhosted.org/packages/1e/db/bc1d22fe57b01fa76b02943e1034cb59070bf906e982ebc507d079998b5b/psycopg_binary-3.2.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e7575ca710277cc3e9257ff803a3e0e3cb7cc1b7851639cb783a7cd55ebfc815", size = 4646689, upload-time = "2025-10-18T22:44:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/e9/6e/b1234e784af5c999ca4bd2e3a8673c58e941926dc4a53b9196d00929f7c9/psycopg_binary-3.2.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:110a2036007230416fcc2c17bfe7aaa2c1fa9b6e9d21e2cd551523e3f6489759", size = 4749164, upload-time = "2025-10-18T22:44:39.529Z" },
{ url = "https://files.pythonhosted.org/packages/ee/77/98c2e6c683941e54560ef3449fbc97b7ca31318436576e0c9d92c1dc875d/psycopg_binary-3.2.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31f1d5630afa673c37a6327f8e3efa1f17d4e4e42972643b3478b52275233529", size = 4432473, upload-time = "2025-10-18T22:44:45.323Z" },
{ url = "https://files.pythonhosted.org/packages/94/1d/73e72427152c03f61b75c14642a7187b16be0e03480f7329ab5cf618fdac/psycopg_binary-3.2.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9f12a34bddaeffa7840a61163595ec0d70a9db855896865dcfbb731510014484", size = 3890114, upload-time = "2025-10-18T22:44:49.161Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ed/08a6b135ece52bb4024e19d03a294f002992d2f0c60fccdc35f245801d9c/psycopg_binary-3.2.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:82fe30afbdd66fbdad583b02baad5c15930a3dc8a3756d2ae15fc874e9be8ec8", size = 3571474, upload-time = "2025-10-18T22:44:52.476Z" },
{ url = "https://files.pythonhosted.org/packages/39/5c/b0c857cd0718b1a8af86a24e61deeb9643e9e4731f732a9b7cab280b1323/psycopg_binary-3.2.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:592fb928efe0674a7400af914bcf931eb5267d36237925947aaecf63bd9a91aa", size = 3613401, upload-time = "2025-10-18T22:44:56.473Z" },
{ url = "https://files.pythonhosted.org/packages/ec/29/437255bc149b132c63ab0279f8850648cd3ae524667f475b621a2a3d0d5b/psycopg_binary-3.2.11-cp311-cp311-win_amd64.whl", hash = "sha256:20d41bcd9ac289d44ac1f6151594f7883483b4ad14680a63e04b639dc90c3349", size = 2919850, upload-time = "2025-10-18T22:45:00.108Z" },
{ url = "https://files.pythonhosted.org/packages/f9/9e/58945c828b60820e5c192d04f238f1aa49de0fe5f3b9883e277f33c17c0a/psycopg_binary-3.2.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4cae9bdc482e36e825d5102a9f3010e729f33a4ca83fc8a1f439ba16eb61e1f1", size = 4019920, upload-time = "2025-10-18T22:45:05.023Z" },
{ url = "https://files.pythonhosted.org/packages/73/c4/ac7f600ae5d8fb7a89c2712163b642d88739b3bb4c8d0fb3178c084dc521/psycopg_binary-3.2.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:749d23fbfd642a7abfef5fc0f6ca185fa82a2c0f895e6eab42c3f2a5d88f6011", size = 4092123, upload-time = "2025-10-18T22:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/39/aa/866c8b2c83490f0d55c4a27d16c0b733744faac442adf181eb59d8d48a3d/psycopg_binary-3.2.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58d8f9f80ae79ba7f2a0509424939236220d7d66a4f8256ae999b882cc58065b", size = 4626894, upload-time = "2025-10-18T22:45:13.367Z" },
{ url = "https://files.pythonhosted.org/packages/17/a8/e7c1eba4ca230d510b76b3f8701321e0c21820953744db67ec7c8fb67537/psycopg_binary-3.2.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eab6959fade522e586b8ec37d3fe337ce10861965edef3292f52e66e36dc375d", size = 4719913, upload-time = "2025-10-18T22:45:19.523Z" },
{ url = "https://files.pythonhosted.org/packages/fc/5f/de0dea38cef6e050ff8e9acd0f7c5d956251fcfece5360973329eb10b84b/psycopg_binary-3.2.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe5e3648e855df4fba1d70c18aef18c9880ea8d123fdfae754c18787c8cb37b3", size = 4411018, upload-time = "2025-10-18T22:45:24.717Z" },
{ url = "https://files.pythonhosted.org/packages/8f/bf/2bbefb24e491f2fa4a7c627d14680429ca33092176eadae88fab4fbce8c6/psycopg_binary-3.2.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:30e2c114d26554ae677088de5d4133cc112344d7a233200fdbf4a2ca5754c7ec", size = 3861940, upload-time = "2025-10-18T22:45:28.624Z" },
{ url = "https://files.pythonhosted.org/packages/67/07/d68f78df7490fcd17eef7f138f96bf3398a961208262498cde7d30266481/psycopg_binary-3.2.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e3f5887019dfb094c60e7026968ca3a964ca16305807ba5e43f9a78483767d5f", size = 3534831, upload-time = "2025-10-18T22:45:32.089Z" },
{ url = "https://files.pythonhosted.org/packages/d0/18/fc5a881ca3d8b40b8e37a396bf14176b8439a7e4b1a29848af325009f955/psycopg_binary-3.2.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9b4b0fc4e774063ae64c92cc57e2b10160150de68c96d71743218159d953869d", size = 3583559, upload-time = "2025-10-18T22:45:36.438Z" },
{ url = "https://files.pythonhosted.org/packages/c0/98/c4418b609ffea80907861ddb01c043af860b179cb8fb41905ad2f0a4f400/psycopg_binary-3.2.11-cp312-cp312-win_amd64.whl", hash = "sha256:9bdc762600fcc8e4ad3224734a4e70cc226207fd8f2de47c36b115efeed01782", size = 2910294, upload-time = "2025-10-18T22:45:40.135Z" },
{ url = "https://files.pythonhosted.org/packages/f2/93/9cea78ed3b279909f0fd6c2badb24b2361b93c875d6a7c921e26f6254044/psycopg_binary-3.2.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47f6cf8a1d02d25238bdb8741ac641ff0ec22b1c6ff6a2acd057d0da5c712842", size = 4017939, upload-time = "2025-10-18T22:45:45.114Z" },
{ url = "https://files.pythonhosted.org/packages/58/86/fc9925f500b2c140c0bb8c1f8fcd04f8c45c76d4852e87baf4c75182de8c/psycopg_binary-3.2.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91268f04380964a5e767f8102d05f1e23312ddbe848de1a9514b08b3fc57d354", size = 4090150, upload-time = "2025-10-18T22:45:50.214Z" },
{ url = "https://files.pythonhosted.org/packages/4e/10/752b698da1ca9e6c5f15d8798cb637c3615315fd2da17eee4a90cf20ee08/psycopg_binary-3.2.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:199f88a05dd22133eab2deb30348ef7a70c23d706c8e63fdc904234163c63517", size = 4625597, upload-time = "2025-10-18T22:45:54.638Z" },
{ url = "https://files.pythonhosted.org/packages/0a/9f/b578545c3c23484f4e234282d97ab24632a1d3cbfec64209786872e7cc8f/psycopg_binary-3.2.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7b3c5474dbad63bcccb8d14d4d4c7c19f1dc6f8e8c1914cbc771d261cf8eddca", size = 4720326, upload-time = "2025-10-18T22:45:59.266Z" },
{ url = "https://files.pythonhosted.org/packages/43/3b/ba548d3fe65a7d4c96e568c2188e4b665802e3cba41664945ed95d16eae9/psycopg_binary-3.2.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:581358e770a4536e546841b78fd0fe318added4a82443bf22d0bbe3109cf9582", size = 4411647, upload-time = "2025-10-18T22:46:04.009Z" },
{ url = "https://files.pythonhosted.org/packages/26/65/559ab485b198600e7ff70d70786ae5c89d63475ca01d43a7dda0d7c91386/psycopg_binary-3.2.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54a30f00a51b9043048b3e7ee806ffd31fc5fbd02a20f0e69d21306ff33dc473", size = 3863037, upload-time = "2025-10-18T22:46:08.469Z" },
{ url = "https://files.pythonhosted.org/packages/8c/29/05d0b48c8bef147e8216a36a1263a309a6240dcc09a56f5b8174fa6216d2/psycopg_binary-3.2.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2a438fad4cc081b018431fde0e791b6d50201526edf39522a85164f606c39ddb", size = 3536975, upload-time = "2025-10-18T22:46:12.982Z" },
{ url = "https://files.pythonhosted.org/packages/d4/75/304e133d3ab1a49602616192edb81f603ed574f79966449105f2e200999d/psycopg_binary-3.2.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f5e7415b5d0f58edf2708842c66605092df67f3821161d861b09695fc326c4de", size = 3586213, upload-time = "2025-10-18T22:46:19.523Z" },
{ url = "https://files.pythonhosted.org/packages/c0/10/c47cce42fa3c37d439e1400eaa5eeb2ce53dc3abc84d52c8a8a9e544d945/psycopg_binary-3.2.11-cp313-cp313-win_amd64.whl", hash = "sha256:6b9632c42f76d5349e7dd50025cff02688eb760b258e891ad2c6428e7e4917d5", size = 2912997, upload-time = "2025-10-18T22:46:24.978Z" },
{ url = "https://files.pythonhosted.org/packages/85/13/728b4763ef76a688737acebfcb5ab8696b024adc49a69c86081392b0e5ba/psycopg_binary-3.2.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:260738ae222b41dbefd0d84cb2e150a112f90b41688630f57fdac487ab6d6f38", size = 4016962, upload-time = "2025-10-18T22:46:29.207Z" },
{ url = "https://files.pythonhosted.org/packages/9f/0f/6180149621a907c5b60a2fae87d6ee10cc13e8c9f58d8250c310634ced04/psycopg_binary-3.2.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c594c199869099c59c85b9f4423370b6212491fb929e7fcda0da1768761a2c2c", size = 4090614, upload-time = "2025-10-18T22:46:33.073Z" },
{ url = "https://files.pythonhosted.org/packages/f8/97/cce19bdef510b698c9036d5573b941b539ffcaa7602450da559c8a62e0c3/psycopg_binary-3.2.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5768a9e7d393b2edd3a28de5a6d5850d054a016ed711f7044a9072f19f5e50d5", size = 4629749, upload-time = "2025-10-18T22:46:37.415Z" },
{ url = "https://files.pythonhosted.org/packages/93/9d/9bff18989fb2bf05d18c1431dd8bec4a1d90141beb11fc45d3269947ddf3/psycopg_binary-3.2.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:27eb6367350b75fef882c40cd6f748bfd976db2f8651f7511956f11efc15154f", size = 4724035, upload-time = "2025-10-18T22:46:42.568Z" },
{ url = "https://files.pythonhosted.org/packages/08/e5/39b930323428596990367b7953197730213d3d9d07bcedcad1d026608178/psycopg_binary-3.2.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa2aa5094dc962967ca0978c035b3ef90329b802501ef12a088d3bac6a55598e", size = 4411419, upload-time = "2025-10-18T22:46:47.745Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9c/97c25438d1e51ddc6a7f67990b4c59f94bc515114ada864804ccee27ef1b/psycopg_binary-3.2.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7744b4ed1f3b76fe37de7e9ef98014482fe74b6d3dfe1026cc4cfb4b4404e74f", size = 3867844, upload-time = "2025-10-18T22:46:53.328Z" },
{ url = "https://files.pythonhosted.org/packages/91/51/8c1e291cf4aa9982666f71a886aa782d990aa16853a42de545a0a9a871ef/psycopg_binary-3.2.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5f6f948ff1cd252003ff534d7b50a2b25453b4212b283a7514ff8751bdb68c37", size = 3541539, upload-time = "2025-10-18T22:46:58.993Z" },
{ url = "https://files.pythonhosted.org/packages/57/0a/e25edcdfa1111bfc5c95668b7469b5a957b40ce10cc81383688d65564826/psycopg_binary-3.2.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3bd2c8fb1dec6f93383fbaa561591fa3d676e079f9cb9889af17c3020a19715f", size = 3588090, upload-time = "2025-10-18T22:47:04.105Z" },
{ url = "https://files.pythonhosted.org/packages/a3/aa/f8c2f4b4c13d5680a20e5bfcd61f9e154bce26e7a2c70cb0abeade088d61/psycopg_binary-3.2.11-cp314-cp314-win_amd64.whl", hash = "sha256:c45f61202e5691090a697e599997eaffa3ec298209743caa4fd346145acabafe", size = 3006049, upload-time = "2025-10-18T22:47:07.923Z" },
]
[[package]]
name = "psycopg-pool"
version = "3.2.6"