"""Unit tests for tool call interceptor in ToolNode.""" from collections.abc import Callable from unittest.mock import Mock import pytest from langchain_core.messages import AIMessage, ToolCall, ToolMessage from langchain_core.runnables import RunnableConfig from langchain_core.tools import tool from langgraph.store.base import BaseStore from langgraph.types import Command from langgraph.prebuilt.tool_node import ( ToolCallRequest, ToolNode, ) pytestmark = pytest.mark.anyio def _create_mock_runtime(store: BaseStore | None = None) -> Mock: mock_runtime = Mock() mock_runtime.store = store mock_runtime.context = None mock_runtime.stream_writer = lambda _: None return mock_runtime def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig: return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}} @tool def add(a: int, b: int) -> int: """Add two numbers.""" return a + b @tool def failing_tool(a: int) -> int: """A tool that always fails.""" msg = f"This tool always fails (input: {a})" raise ValueError(msg) @tool def command_tool(goto: str) -> Command: """A tool that returns a Command.""" return Command(goto=goto) def test_passthrough_handler() -> None: """Test a simple passthrough handler that doesn't modify anything.""" def passthrough_handler( request: ToolCallRequest, execute: Callable[[ToolCallRequest], ToolMessage | Command], ) -> ToolMessage | Command: """Simple passthrough handler.""" return execute(request) tool_node = ToolNode([add], wrap_tool_call=passthrough_handler) result = tool_node.invoke( { "messages": [ AIMessage( "adding", tool_calls=[ { "name": "add", "args": {"a": 1, "b": 2}, "id": "call_1", } ], ) ] }, config=_create_config_with_runtime(), ) tool_message = result["messages"][-1] assert isinstance(tool_message, ToolMessage) assert tool_message.content == "3" assert tool_message.tool_call_id == "call_1" assert tool_message.status != "error" async def test_passthrough_handler_async() -> None: """Test passthrough handler with async tool.""" def passthrough_handler( request: ToolCallRequest, execute: Callable[[ToolCallRequest], ToolMessage | Command], ) -> ToolMessage | Command: """Simple passthrough handler.""" return execute(request) tool_node = ToolNode([add], wrap_tool_call=passthrough_handler) result = await tool_node.ainvoke( { "messages": [ AIMessage( "adding", tool_calls=[ { "name": "add", "args": {"a": 2, "b": 3}, "id": "call_2", } ], ) ] }, config=_create_config_with_runtime(), ) tool_message = result["messages"][-1] assert isinstance(tool_message, ToolMessage) assert tool_message.content == "5" assert tool_message.tool_call_id == "call_2" def test_modify_arguments() -> None: """Test handler that modifies tool arguments before execution.""" def modify_args_handler( request: ToolCallRequest, execute: Callable[[ToolCallRequest], ToolMessage | Command], ) -> ToolMessage | Command: """Handler that doubles the input arguments.""" # Modify the arguments 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. """ import functools 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): # noqa: ARG001 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