mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-17 21:25:46 +02:00
Follow-up to #8540, which turned on `PLC0415` (import-outside-top-level) for checkpoint-postgres and checkpoint-sqlite. This does the remaining six packages: checkpoint, checkpoint-conformance, langgraph, prebuilt, cli, sdk-py. Scoped to tests, per @sydney-runkle's call on #8540: library code is exempted with `per-file-ignores`, since it still has deferred imports nobody has reviewed and mixing that in would make this hard to read. ## What changed Function-level imports across 56 test files moved to module level. Nine could not move and carry an explicit `# noqa: PLC0415` with a reason: | File | Why it stays local | |---|---| | `libs/langgraph/tests/test_deprecation.py` (4) | the import has to run inside `pytest.warns` for the warning to be observed | | `libs/langgraph/tests/test_serde_allowlist.py` | try/except guard, skips when langchain_core is absent | | `libs/langgraph/tests/test_delta_channel_benchmark.py` | optional psycopg probe | | `libs/checkpoint/tests/test_conformance_delta.py` (3) | protected by a module-level `pytest.importorskip`; hoisting past the guard turns a skip into a collection error | That last one is the trap: an import moved above `pytest.importorskip` silently defeats the guard. I hit it locally and it turned the skip into a `ModuleNotFoundError` at collection. Every file with an `importorskip` or `except ImportError` was checked by hand for this. ## Verification `make lint` and `make test` in each of the six: | Package | Tests | |---|---| | checkpoint | 156 passed, 17 skipped | | checkpoint-conformance | 1 passed | | langgraph | 1968 passed, 4 skipped | | prebuilt | 284 passed | | cli | 336 passed | | sdk-py | 493 passed | Also confirmed the rule actually fires: a throwaway test file with a function-level import is flagged in all six packages, and the source exemption holds.
1474 lines
46 KiB
Python
1474 lines
46 KiB
Python
"""Unit tests for tool call interceptor in ToolNode."""
|
|
|
|
import functools
|
|
from collections.abc import Callable
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage, ToolCall, ToolMessage
|
|
from langchain_core.runnables import RunnableConfig
|
|
from langchain_core.tools import tool
|
|
from langgraph.store.base import BaseStore
|
|
from langgraph.types import Command
|
|
|
|
from langgraph.prebuilt.tool_node import (
|
|
ToolCallRequest,
|
|
ToolNode,
|
|
)
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
def _create_mock_runtime(store: BaseStore | None = None) -> Mock:
|
|
mock_runtime = Mock()
|
|
mock_runtime.store = store
|
|
mock_runtime.context = None
|
|
mock_runtime.stream_writer = lambda _: None
|
|
return mock_runtime
|
|
|
|
|
|
def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig:
|
|
return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}}
|
|
|
|
|
|
@tool
|
|
def add(a: int, b: int) -> int:
|
|
"""Add two numbers."""
|
|
return a + b
|
|
|
|
|
|
@tool
|
|
def failing_tool(a: int) -> int:
|
|
"""A tool that always fails."""
|
|
msg = f"This tool always fails (input: {a})"
|
|
raise ValueError(msg)
|
|
|
|
|
|
@tool
|
|
def command_tool(goto: str) -> Command:
|
|
"""A tool that returns a Command."""
|
|
return Command(goto=goto)
|
|
|
|
|
|
def test_passthrough_handler() -> None:
|
|
"""Test a simple passthrough handler that doesn't modify anything."""
|
|
|
|
def passthrough_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Simple passthrough handler."""
|
|
return execute(request)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=passthrough_handler)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_1",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
tool_message = result["messages"][-1]
|
|
assert isinstance(tool_message, ToolMessage)
|
|
assert tool_message.content == "3"
|
|
assert tool_message.tool_call_id == "call_1"
|
|
assert tool_message.status != "error"
|
|
|
|
|
|
async def test_passthrough_handler_async() -> None:
|
|
"""Test passthrough handler with async tool."""
|
|
|
|
def passthrough_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Simple passthrough handler."""
|
|
return execute(request)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=passthrough_handler)
|
|
|
|
result = await tool_node.ainvoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 2, "b": 3},
|
|
"id": "call_2",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
tool_message = result["messages"][-1]
|
|
assert isinstance(tool_message, ToolMessage)
|
|
assert tool_message.content == "5"
|
|
assert tool_message.tool_call_id == "call_2"
|
|
|
|
|
|
def test_modify_arguments() -> None:
|
|
"""Test handler that modifies tool arguments before execution."""
|
|
|
|
def modify_args_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that doubles the input arguments."""
|
|
# Modify the arguments using override method
|
|
modified_call = {
|
|
**request.tool_call,
|
|
"args": {
|
|
**request.tool_call["args"],
|
|
"a": request.tool_call["args"]["a"] * 2,
|
|
"b": request.tool_call["args"]["b"] * 2,
|
|
},
|
|
}
|
|
modified_request = request.override(tool_call=modified_call)
|
|
return execute(modified_request)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=modify_args_handler)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_3",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
tool_message = result["messages"][-1]
|
|
assert isinstance(tool_message, ToolMessage)
|
|
# Original args were (1, 2), doubled to (2, 4), so result is 6
|
|
assert tool_message.content == "6"
|
|
|
|
|
|
def test_handler_validation_no_return() -> None:
|
|
"""Test that handler must return a result."""
|
|
|
|
def handler_with_explicit_none(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that executes and returns result."""
|
|
return execute(request)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=handler_with_explicit_none)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_6",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
assert isinstance(result, dict)
|
|
messages = result["messages"]
|
|
assert len(messages) == 1
|
|
assert isinstance(messages[0], ToolMessage)
|
|
assert messages[0].content == "3"
|
|
|
|
|
|
def test_handler_validation_no_yield() -> None:
|
|
"""Test that handler that doesn't call execute returns None (bad behavior)."""
|
|
|
|
def bad_handler(
|
|
_request: ToolCallRequest,
|
|
_execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that doesn't call execute - will cause type error."""
|
|
# Don't call execute, just return None (invalid)
|
|
return None # type: ignore[return-value]
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=bad_handler)
|
|
|
|
# This will return None wrapped in messages
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_7",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
# Result contains None in messages (bad handler behavior)
|
|
assert isinstance(result, dict)
|
|
assert result["messages"][0] is None
|
|
|
|
|
|
def test_handler_with_handle_tool_errors_true() -> None:
|
|
"""Test that handle_tool_errors=True works with on_tool_call handler."""
|
|
|
|
def passthrough_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Simple passthrough handler."""
|
|
message = execute(request)
|
|
# When handle_tool_errors=True, errors should be converted to error messages
|
|
assert isinstance(message, ToolMessage)
|
|
assert message.status == "error"
|
|
return message
|
|
|
|
tool_node = ToolNode(
|
|
[failing_tool], wrap_tool_call=passthrough_handler, handle_tool_errors=True
|
|
)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"failing",
|
|
tool_calls=[
|
|
{
|
|
"name": "failing_tool",
|
|
"args": {"a": 1},
|
|
"id": "call_9",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
tool_message = result["messages"][-1]
|
|
assert isinstance(tool_message, ToolMessage)
|
|
assert tool_message.status == "error"
|
|
|
|
|
|
def test_multiple_tool_calls_with_handler() -> None:
|
|
"""Test handler with multiple tool calls in one message."""
|
|
call_count = 0
|
|
|
|
def counting_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that counts calls."""
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return execute(request)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=counting_handler)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding multiple",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_10",
|
|
},
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 3, "b": 4},
|
|
"id": "call_11",
|
|
},
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 5, "b": 6},
|
|
"id": "call_12",
|
|
},
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
# Handler should be called once for each tool call
|
|
assert call_count == 3
|
|
|
|
# Verify all results
|
|
messages = result["messages"]
|
|
assert len(messages) == 3
|
|
assert all(isinstance(m, ToolMessage) for m in messages)
|
|
assert messages[0].content == "3"
|
|
assert messages[1].content == "7"
|
|
assert messages[2].content == "11"
|
|
|
|
|
|
def test_tool_call_request_dataclass() -> None:
|
|
"""Test ToolCallRequest dataclass."""
|
|
tool_call: ToolCall = {"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"}
|
|
state: dict = {"messages": []}
|
|
runtime = None
|
|
|
|
request = ToolCallRequest(
|
|
tool_call=tool_call, tool=add, state=state, runtime=runtime
|
|
) # type: ignore[arg-type]
|
|
|
|
assert request.tool_call == tool_call
|
|
assert request.tool == add
|
|
assert request.state == state
|
|
assert request.runtime is None
|
|
assert request.tool_call["name"] == "add"
|
|
|
|
|
|
async def test_handler_with_async_execution() -> None:
|
|
"""Test handler works correctly with async tool execution."""
|
|
|
|
@tool
|
|
def async_add(a: int, b: int) -> int:
|
|
"""Async add two numbers."""
|
|
return a + b
|
|
|
|
def modifying_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that modifies arguments."""
|
|
# Add 10 to both arguments using override method
|
|
modified_call = {
|
|
**request.tool_call,
|
|
"args": {
|
|
**request.tool_call["args"],
|
|
"a": request.tool_call["args"]["a"] + 10,
|
|
"b": request.tool_call["args"]["b"] + 10,
|
|
},
|
|
}
|
|
modified_request = request.override(tool_call=modified_call)
|
|
return execute(modified_request)
|
|
|
|
tool_node = ToolNode([async_add], wrap_tool_call=modifying_handler)
|
|
|
|
result = await tool_node.ainvoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "async_add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_13",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
tool_message = result["messages"][-1]
|
|
assert isinstance(tool_message, ToolMessage)
|
|
# Original: 1 + 2 = 3, with modifications: 11 + 12 = 23
|
|
assert tool_message.content == "23"
|
|
|
|
|
|
def test_short_circuit_with_tool_message() -> None:
|
|
"""Test handler that returns ToolMessage to short-circuit tool execution."""
|
|
|
|
def short_circuit_handler(
|
|
request: ToolCallRequest,
|
|
_execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that returns cached result without executing tool."""
|
|
# Return a ToolMessage directly instead of calling execute
|
|
return ToolMessage(
|
|
content="cached_result",
|
|
tool_call_id=request.tool_call["id"],
|
|
name=request.tool_call["name"],
|
|
)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=short_circuit_handler)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_16",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
tool_message = result["messages"][-1]
|
|
assert isinstance(tool_message, ToolMessage)
|
|
assert tool_message.content == "cached_result"
|
|
assert tool_message.tool_call_id == "call_16"
|
|
assert tool_message.name == "add"
|
|
|
|
|
|
async def test_short_circuit_with_tool_message_async() -> None:
|
|
"""Test async handler that returns ToolMessage to short-circuit tool execution."""
|
|
|
|
def short_circuit_handler(
|
|
request: ToolCallRequest,
|
|
_execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that returns cached result without executing tool."""
|
|
return ToolMessage(
|
|
content="async_cached_result",
|
|
tool_call_id=request.tool_call["id"],
|
|
name=request.tool_call["name"],
|
|
)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=short_circuit_handler)
|
|
|
|
result = await tool_node.ainvoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 2, "b": 3},
|
|
"id": "call_17",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
tool_message = result["messages"][-1]
|
|
assert isinstance(tool_message, ToolMessage)
|
|
assert tool_message.content == "async_cached_result"
|
|
assert tool_message.tool_call_id == "call_17"
|
|
|
|
|
|
def test_conditional_short_circuit() -> None:
|
|
"""Test handler that conditionally short-circuits based on request."""
|
|
call_count = {"count": 0}
|
|
|
|
def conditional_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that caches even numbers, executes odd."""
|
|
call_count["count"] += 1
|
|
a = request.tool_call["args"]["a"]
|
|
|
|
if a % 2 == 0:
|
|
# Even: use cached result
|
|
return ToolMessage(
|
|
content=f"cached_{a}",
|
|
tool_call_id=request.tool_call["id"],
|
|
name=request.tool_call["name"],
|
|
)
|
|
# Odd: execute normally
|
|
return execute(request)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=conditional_handler)
|
|
|
|
# Test with even number (should be cached)
|
|
result1 = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 2, "b": 3},
|
|
"id": "call_18",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
tool_message1 = result1["messages"][-1]
|
|
assert tool_message1.content == "cached_2"
|
|
|
|
# Test with odd number (should execute)
|
|
result2 = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 3, "b": 4},
|
|
"id": "call_19",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
tool_message2 = result2["messages"][-1]
|
|
assert tool_message2.content == "7" # Actual execution: 3 + 4
|
|
|
|
|
|
def test_direct_return_tool_message() -> None:
|
|
"""Test handler that returns ToolMessage directly without calling execute."""
|
|
|
|
def direct_return_handler(
|
|
request: ToolCallRequest,
|
|
_execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that returns ToolMessage directly."""
|
|
# Return ToolMessage directly instead of calling execute
|
|
return ToolMessage(
|
|
content="direct_return",
|
|
tool_call_id=request.tool_call["id"],
|
|
name=request.tool_call["name"],
|
|
)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=direct_return_handler)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_21",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
tool_message = result["messages"][-1]
|
|
assert isinstance(tool_message, ToolMessage)
|
|
assert tool_message.content == "direct_return"
|
|
assert tool_message.tool_call_id == "call_21"
|
|
assert tool_message.name == "add"
|
|
|
|
|
|
async def test_direct_return_tool_message_async() -> None:
|
|
"""Test async handler that returns ToolMessage directly without calling execute."""
|
|
|
|
def direct_return_handler(
|
|
request: ToolCallRequest,
|
|
_execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that returns ToolMessage directly."""
|
|
return ToolMessage(
|
|
content="async_direct_return",
|
|
tool_call_id=request.tool_call["id"],
|
|
name=request.tool_call["name"],
|
|
)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=direct_return_handler)
|
|
|
|
result = await tool_node.ainvoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 2, "b": 3},
|
|
"id": "call_22",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
tool_message = result["messages"][-1]
|
|
assert isinstance(tool_message, ToolMessage)
|
|
assert tool_message.content == "async_direct_return"
|
|
assert tool_message.tool_call_id == "call_22"
|
|
|
|
|
|
def test_conditional_direct_return() -> None:
|
|
"""Test handler that conditionally returns ToolMessage directly or executes tool."""
|
|
|
|
def conditional_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that returns cached or executes based on condition."""
|
|
a = request.tool_call["args"]["a"]
|
|
|
|
if a == 0:
|
|
# Return ToolMessage directly for zero
|
|
return ToolMessage(
|
|
content="zero_cached",
|
|
tool_call_id=request.tool_call["id"],
|
|
name=request.tool_call["name"],
|
|
)
|
|
# Execute tool normally
|
|
return execute(request)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=conditional_handler)
|
|
|
|
# Test with zero (should return directly)
|
|
result1 = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 0, "b": 5},
|
|
"id": "call_23",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
tool_message1 = result1["messages"][-1]
|
|
assert tool_message1.content == "zero_cached"
|
|
|
|
# Test with non-zero (should execute)
|
|
result2 = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 3, "b": 4},
|
|
"id": "call_24",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
tool_message2 = result2["messages"][-1]
|
|
assert tool_message2.content == "7" # Actual execution: 3 + 4
|
|
|
|
|
|
def test_handler_can_throw_exception() -> None:
|
|
"""Test that a handler can throw an exception to signal error."""
|
|
|
|
def throwing_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that throws an exception after receiving response."""
|
|
response = execute(request)
|
|
# Check response and throw if invalid
|
|
if isinstance(response, ToolMessage):
|
|
msg = "Handler rejected the response"
|
|
raise TypeError(msg)
|
|
return response
|
|
|
|
tool_node = ToolNode(
|
|
[add], wrap_tool_call=throwing_handler, handle_tool_errors=True
|
|
)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_exc_1",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
# Should get error message due to handle_tool_errors=True
|
|
messages = result["messages"]
|
|
assert len(messages) == 1
|
|
assert isinstance(messages[0], ToolMessage)
|
|
assert messages[0].status == "error"
|
|
assert "Handler rejected the response" in messages[0].content
|
|
|
|
|
|
def test_handler_throw_without_handle_errors() -> None:
|
|
"""Test that exception propagates when handle_tool_errors=False."""
|
|
|
|
def throwing_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that throws an exception."""
|
|
execute(request)
|
|
msg = "Handler error"
|
|
raise ValueError(msg)
|
|
|
|
tool_node = ToolNode(
|
|
[add], wrap_tool_call=throwing_handler, handle_tool_errors=False
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Handler error"):
|
|
tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_exc_2",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
|
|
def test_retry_middleware_with_exception() -> None:
|
|
"""Test retry middleware pattern that can call execute multiple times."""
|
|
attempt_count = {"count": 0}
|
|
|
|
def retry_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that can retry by calling execute multiple times."""
|
|
max_retries = 3
|
|
|
|
for _attempt in range(max_retries):
|
|
attempt_count["count"] += 1
|
|
response = execute(request)
|
|
|
|
# Simulate checking for retriable errors
|
|
# In real use case, would check response.status or content
|
|
if isinstance(response, ToolMessage):
|
|
# For this test, just succeed immediately
|
|
return response
|
|
|
|
# If we exhausted retries, return last response
|
|
return response
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=retry_handler)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_exc_3",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
# Should succeed after 1 attempt
|
|
assert attempt_count["count"] == 1
|
|
messages = result["messages"]
|
|
assert len(messages) == 1
|
|
assert isinstance(messages[0], ToolMessage)
|
|
assert messages[0].content == "3"
|
|
|
|
|
|
async def test_async_handler_can_throw_exception() -> None:
|
|
"""Test that async execution also supports exception throwing."""
|
|
|
|
def throwing_handler(
|
|
_request: ToolCallRequest,
|
|
_execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that throws an exception before calling execute."""
|
|
# Throw exception before executing (to avoid async/await complications)
|
|
msg = "Async handler rejected the request"
|
|
raise ValueError(msg)
|
|
|
|
tool_node = ToolNode(
|
|
[add], wrap_tool_call=throwing_handler, handle_tool_errors=True
|
|
)
|
|
|
|
result = await tool_node.ainvoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_exc_4",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
# Should get error message due to handle_tool_errors=True
|
|
messages = result["messages"]
|
|
assert len(messages) == 1
|
|
assert isinstance(messages[0], ToolMessage)
|
|
assert messages[0].status == "error"
|
|
assert "Async handler rejected the request" in messages[0].content
|
|
|
|
|
|
def test_handler_cannot_yield_multiple_tool_messages() -> None:
|
|
"""Test that handler can only return once (not applicable to handler pattern)."""
|
|
# With handler pattern, you can only return once by definition
|
|
# This test is no longer relevant - handlers naturally return once
|
|
# Keep test for compatibility but with simple passthrough
|
|
|
|
def single_return_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that returns once (as all handlers do)."""
|
|
return execute(request)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=single_return_handler)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_multi_1",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
# Should succeed - handlers can only return once
|
|
assert isinstance(result, dict)
|
|
assert len(result["messages"]) == 1
|
|
|
|
|
|
def test_handler_cannot_yield_request_after_tool_message() -> None:
|
|
"""Test that handler pattern doesn't allow multiple returns (not applicable)."""
|
|
# With handler pattern, you can only return once
|
|
# This test is no longer relevant
|
|
|
|
def single_return_handler(
|
|
request: ToolCallRequest,
|
|
_execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that returns cached result."""
|
|
# Return cached result (short-circuit)
|
|
return ToolMessage("cached", tool_call_id=request.tool_call["id"], name="add")
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=single_return_handler)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_confused_1",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
# Should succeed with cached result
|
|
assert isinstance(result, dict)
|
|
assert result["messages"][0].content == "cached"
|
|
|
|
|
|
def test_handler_can_short_circuit_with_command() -> None:
|
|
"""Test that handler can short-circuit by returning Command."""
|
|
|
|
def command_handler(
|
|
_request: ToolCallRequest,
|
|
_execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that short-circuits with Command."""
|
|
# Short-circuit with Command instead of executing tool
|
|
return Command(goto="end")
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=command_handler)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_cmd_1",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
# Should get Command in result list
|
|
assert isinstance(result, list)
|
|
assert len(result) == 1
|
|
assert isinstance(result[0], Command)
|
|
assert result[0].goto == "end"
|
|
|
|
|
|
def test_handler_cannot_yield_multiple_commands() -> None:
|
|
"""Test that handler can only return once (not applicable to handler pattern)."""
|
|
# With handler pattern, you can only return once
|
|
# This test is no longer relevant
|
|
|
|
def single_command_handler(
|
|
_request: ToolCallRequest,
|
|
_execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that returns Command once."""
|
|
return Command(goto="step1")
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=single_command_handler)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_multicmd_1",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
# Should succeed - handlers naturally return once
|
|
assert isinstance(result, list)
|
|
assert len(result) == 1
|
|
assert isinstance(result[0], Command)
|
|
assert result[0].goto == "step1"
|
|
|
|
|
|
def test_handler_cannot_yield_request_after_command() -> None:
|
|
"""Test that handler can only return once (not applicable to handler pattern)."""
|
|
# With handler pattern, you can only return once
|
|
# This test is no longer relevant
|
|
|
|
def command_handler(
|
|
_request: ToolCallRequest,
|
|
_execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that returns Command."""
|
|
return Command(goto="somewhere")
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=command_handler)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"adding",
|
|
tool_calls=[
|
|
{
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_cmdreq_1",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
# Should succeed with Command
|
|
assert isinstance(result, list)
|
|
assert len(result) == 1
|
|
assert isinstance(result[0], Command)
|
|
assert result[0].goto == "somewhere"
|
|
|
|
|
|
def test_tool_returning_command_sent_to_handler() -> None:
|
|
"""Test that when tool returns Command, it's sent to handler."""
|
|
received_commands = []
|
|
|
|
def command_inspector_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that inspects Command returned by tool."""
|
|
result = execute(request)
|
|
# Should receive Command from tool
|
|
if isinstance(result, Command):
|
|
received_commands.append(result)
|
|
return result
|
|
|
|
tool_node = ToolNode([command_tool], wrap_tool_call=command_inspector_handler)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"navigating",
|
|
tool_calls=[
|
|
{
|
|
"name": "command_tool",
|
|
"args": {"goto": "next_step"},
|
|
"id": "call_cmdtool_1",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
# Handler should have received the Command
|
|
assert len(received_commands) == 1
|
|
assert received_commands[0].goto == "next_step"
|
|
|
|
# Final result should be the Command in result list
|
|
assert isinstance(result, list)
|
|
assert len(result) == 1
|
|
assert isinstance(result[0], Command)
|
|
assert result[0].goto == "next_step"
|
|
|
|
|
|
def test_handler_can_modify_command_from_tool() -> None:
|
|
"""Test that handler can inspect and modify Command from tool."""
|
|
|
|
def command_modifier_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that modifies Command returned by tool."""
|
|
result = execute(request)
|
|
# Modify the Command
|
|
if isinstance(result, Command):
|
|
return Command(goto=f"modified_{result.goto}")
|
|
return result
|
|
|
|
tool_node = ToolNode([command_tool], wrap_tool_call=command_modifier_handler)
|
|
|
|
result = tool_node.invoke(
|
|
{
|
|
"messages": [
|
|
AIMessage(
|
|
"navigating",
|
|
tool_calls=[
|
|
{
|
|
"name": "command_tool",
|
|
"args": {"goto": "original"},
|
|
"id": "call_cmdmod_1",
|
|
}
|
|
],
|
|
)
|
|
]
|
|
},
|
|
config=_create_config_with_runtime(),
|
|
)
|
|
|
|
# Final result should be the modified Command in result list
|
|
assert isinstance(result, list)
|
|
assert len(result) == 1
|
|
assert isinstance(result[0], Command)
|
|
assert result[0].goto == "modified_original"
|
|
|
|
|
|
def test_state_extraction_with_dict_input() -> None:
|
|
"""Test that state is correctly passed when input is a dict."""
|
|
state_seen = []
|
|
|
|
def state_inspector_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that records the state it receives."""
|
|
state_seen.append(request.state)
|
|
return execute(request)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
|
|
|
|
input_state = {
|
|
"messages": [
|
|
AIMessage(
|
|
"test",
|
|
tool_calls=[{"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"}],
|
|
)
|
|
],
|
|
"other_field": "value",
|
|
}
|
|
|
|
tool_node.invoke(input_state, config=_create_config_with_runtime())
|
|
|
|
# State should be the dict we passed in
|
|
assert len(state_seen) == 1
|
|
assert state_seen[0] == input_state
|
|
assert isinstance(state_seen[0], dict)
|
|
assert "messages" in state_seen[0]
|
|
assert "other_field" in state_seen[0]
|
|
assert "__type" not in state_seen[0]
|
|
|
|
|
|
def test_state_extraction_with_list_input() -> None:
|
|
"""Test that state is correctly passed when input is a list."""
|
|
state_seen = []
|
|
|
|
def state_inspector_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that records the state it receives."""
|
|
state_seen.append(request.state)
|
|
return execute(request)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
|
|
|
|
input_state = [
|
|
AIMessage(
|
|
"test",
|
|
tool_calls=[{"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"}],
|
|
)
|
|
]
|
|
|
|
tool_node.invoke(input_state, config=_create_config_with_runtime())
|
|
|
|
# State should be the list we passed in
|
|
assert len(state_seen) == 1
|
|
assert state_seen[0] == input_state
|
|
assert isinstance(state_seen[0], list)
|
|
|
|
|
|
def test_state_extraction_with_tool_call_with_context() -> None:
|
|
"""Test that state is correctly extracted from ToolCallWithContext.
|
|
|
|
This tests the scenario where ToolNode is invoked via the Send API in
|
|
create_agent, which wraps the tool call with additional context including
|
|
the graph state.
|
|
"""
|
|
state_seen = []
|
|
|
|
def state_inspector_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that records the state it receives."""
|
|
state_seen.append(request.state)
|
|
return execute(request)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
|
|
|
|
# Simulate ToolCallWithContext as used by create_agent with Send API
|
|
actual_state = {
|
|
"messages": [AIMessage("test")],
|
|
"thread_model_call_count": 1,
|
|
"run_model_call_count": 1,
|
|
"custom_field": "custom_value",
|
|
}
|
|
|
|
tool_call_with_context = {
|
|
"__type": "tool_call_with_context",
|
|
"tool_call": {
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_1",
|
|
"type": "tool_call",
|
|
},
|
|
"state": actual_state,
|
|
}
|
|
|
|
tool_node.invoke(tool_call_with_context, config=_create_config_with_runtime())
|
|
|
|
# State should be the extracted state from ToolCallWithContext, not the wrapper
|
|
assert len(state_seen) == 1
|
|
assert state_seen[0] == actual_state
|
|
assert isinstance(state_seen[0], dict)
|
|
assert "messages" in state_seen[0]
|
|
assert "thread_model_call_count" in state_seen[0]
|
|
assert "custom_field" in state_seen[0]
|
|
# Most importantly, __type should NOT be in the extracted state
|
|
assert "__type" not in state_seen[0]
|
|
# And tool_call should not be in the state
|
|
assert "tool_call" not in state_seen[0]
|
|
|
|
|
|
async def test_state_extraction_with_tool_call_with_context_async() -> None:
|
|
"""Test that state is correctly extracted from ToolCallWithContext in async mode."""
|
|
state_seen = []
|
|
|
|
def state_inspector_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
"""Handler that records the state it receives."""
|
|
state_seen.append(request.state)
|
|
return execute(request)
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
|
|
|
|
# Simulate ToolCallWithContext as used by create_agent with Send API
|
|
actual_state = {
|
|
"messages": [AIMessage("test")],
|
|
"thread_model_call_count": 1,
|
|
"run_model_call_count": 1,
|
|
}
|
|
|
|
tool_call_with_context = {
|
|
"__type": "tool_call_with_context",
|
|
"tool_call": {
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_1",
|
|
"type": "tool_call",
|
|
},
|
|
"state": actual_state,
|
|
}
|
|
|
|
await tool_node.ainvoke(
|
|
tool_call_with_context, config=_create_config_with_runtime()
|
|
)
|
|
|
|
# State should be the extracted state from ToolCallWithContext
|
|
assert len(state_seen) == 1
|
|
assert state_seen[0] == actual_state
|
|
assert "__type" not in state_seen[0]
|
|
assert "tool_call" not in state_seen[0]
|
|
|
|
|
|
def _config_with_channel_read(
|
|
channel_values: dict[str, object],
|
|
store: BaseStore | None = None,
|
|
) -> RunnableConfig:
|
|
"""Build a config that mimics `CONFIG_KEY_READ` as Pregel installs it.
|
|
|
|
Pregel always installs a `functools.partial(local_read, scratchpad,
|
|
channels, managed, task)`, and `ToolNode` introspects that partial to
|
|
learn channel names. The stub matches the shape: partial whose second and
|
|
third positional args are `channels` and `managed` mappings.
|
|
"""
|
|
|
|
channels_stub = {k: None for k in channel_values}
|
|
managed_stub: dict[str, object] = {}
|
|
|
|
# Shape matches pregel's real partial:
|
|
# functools.partial(local_read, scratchpad, channels, managed, task)
|
|
def _read(scratchpad, channels, managed, task, select, fresh):
|
|
if isinstance(select, str):
|
|
return channel_values[select]
|
|
return {k: channel_values[k] for k in select if k in channel_values}
|
|
|
|
read = functools.partial(_read, None, channels_stub, managed_stub, None)
|
|
cfg = _create_config_with_runtime(store)
|
|
cfg["configurable"]["__pregel_read"] = read
|
|
return cfg
|
|
|
|
|
|
def test_list_form_send_hydrates_state_from_channel_read() -> None:
|
|
"""Send('tools', [tool_call]) with no inlined state should hydrate
|
|
ToolRuntime.state from CONFIG_KEY_READ (full state read)."""
|
|
state_seen = []
|
|
|
|
def state_inspector_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
state_seen.append(request.state)
|
|
return execute(request)
|
|
|
|
channel_values = {
|
|
"messages": [AIMessage("from channels")],
|
|
"files": {"/a.md": "body"},
|
|
}
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
|
|
|
|
tool_call: ToolCall = {
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_1",
|
|
"type": "tool_call",
|
|
}
|
|
|
|
tool_node.invoke([tool_call], config=_config_with_channel_read(channel_values))
|
|
|
|
assert len(state_seen) == 1
|
|
got = state_seen[0]
|
|
assert got == channel_values
|
|
assert "messages" in got and "files" in got
|
|
|
|
|
|
async def test_list_form_send_hydrates_state_async() -> None:
|
|
state_seen = []
|
|
|
|
def state_inspector_handler(
|
|
request: ToolCallRequest,
|
|
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
) -> ToolMessage | Command:
|
|
state_seen.append(request.state)
|
|
return execute(request)
|
|
|
|
channel_values = {"messages": [AIMessage("from channels")], "files": {}}
|
|
|
|
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
|
|
|
|
tool_call: ToolCall = {
|
|
"name": "add",
|
|
"args": {"a": 1, "b": 2},
|
|
"id": "call_1",
|
|
"type": "tool_call",
|
|
}
|
|
|
|
await tool_node.ainvoke(
|
|
[tool_call], config=_config_with_channel_read(channel_values)
|
|
)
|
|
|
|
assert len(state_seen) == 1
|
|
assert state_seen[0] == channel_values
|
|
|
|
|
|
def test_tool_call_request_is_frozen() -> None:
|
|
"""Test that ToolCallRequest raises deprecation warnings on direct attribute reassignment."""
|
|
tool_call: ToolCall = {"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"}
|
|
state: dict = {"messages": []}
|
|
runtime = None
|
|
|
|
request = ToolCallRequest(
|
|
tool_call=tool_call, tool=add, state=state, runtime=runtime
|
|
) # type: ignore[arg-type]
|
|
|
|
# Test that direct attribute reassignment raises DeprecationWarning
|
|
with pytest.warns(
|
|
DeprecationWarning,
|
|
match="Setting attribute 'tool_call' on ToolCallRequest is deprecated",
|
|
):
|
|
request.tool_call = {"name": "other", "args": {}, "id": "call_2"} # type: ignore[misc]
|
|
|
|
with pytest.warns(
|
|
DeprecationWarning,
|
|
match="Setting attribute 'tool' on ToolCallRequest is deprecated",
|
|
):
|
|
request.tool = None # type: ignore[misc]
|
|
|
|
with pytest.warns(
|
|
DeprecationWarning,
|
|
match="Setting attribute 'state' on ToolCallRequest is deprecated",
|
|
):
|
|
request.state = {} # type: ignore[misc]
|
|
|
|
with pytest.warns(
|
|
DeprecationWarning,
|
|
match="Setting attribute 'runtime' on ToolCallRequest is deprecated",
|
|
):
|
|
request.runtime = None # type: ignore[misc]
|
|
|
|
# Test that override method works correctly
|
|
new_tool_call: ToolCall = {
|
|
"name": "multiply",
|
|
"args": {"x": 5, "y": 10},
|
|
"id": "call_3",
|
|
}
|
|
|
|
# Original request should be unchanged (note: it was modified by the warnings tests above)
|
|
# So we create a fresh request to test override properly
|
|
fresh_request = ToolCallRequest(
|
|
tool_call=tool_call, tool=add, state=state, runtime=runtime
|
|
) # type: ignore[arg-type]
|
|
fresh_new_request = fresh_request.override(tool_call=new_tool_call)
|
|
|
|
# Original request should be unchanged
|
|
assert fresh_request.tool_call == tool_call
|
|
assert fresh_request.tool_call["name"] == "add"
|
|
|
|
# New request should have the updated tool_call
|
|
assert fresh_new_request.tool_call == new_tool_call
|
|
assert fresh_new_request.tool_call["name"] == "multiply"
|
|
assert fresh_new_request.tool == add # Other fields should remain the same
|
|
assert fresh_new_request.state == state
|
|
assert fresh_new_request.runtime is None
|