Compare commits

...
Author SHA1 Message Date
Sydney Runkle 8cb23c9ccc first pass at patch 2025-11-06 11:09:06 -05:00
2 changed files with 112 additions and 0 deletions
@@ -891,6 +891,9 @@ class ToolNode(RunnableCallable):
return self._validate_tool_command(response, request.tool_call, input_type)
if isinstance(response, ToolMessage):
response.content = cast("str | list", msg_content_output(response.content))
# Enrich ToolMessage with name if not set (e.g., from fallback handlers)
if response.name is None:
response.name = call["name"]
return response
msg = f"Tool {call['name']} returned unexpected type: {type(response)}"
@@ -1048,6 +1051,9 @@ class ToolNode(RunnableCallable):
return self._validate_tool_command(response, request.tool_call, input_type)
if isinstance(response, ToolMessage):
response.content = cast("str | list", msg_content_output(response.content))
# Enrich ToolMessage with name if not set (e.g., from fallback handlers)
if response.name is None:
response.name = call["name"]
return response
msg = f"Tool {call['name']} returned unexpected type: {type(response)}"
+106
View File
@@ -0,0 +1,106 @@
"""Test to reproduce the tool error fallback issue.
When using ToolNode(...).with_fallbacks(...) with an error handler that returns
ToolMessage objects, the messages are losing:
1. The `name` field (becomes `None` instead of the tool name)
2. The `status` field (becomes `success` instead of `error`)
"""
from unittest.mock import Mock
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.runnables import RunnableLambda
from langchain_core.runnables.config import RunnableConfig
from langchain_core.tools import tool
from langgraph.prebuilt import ToolNode
def _create_mock_runtime():
"""Create a mock Runtime object for testing ToolNode outside of graph context."""
mock_runtime = Mock()
mock_runtime.store = None
mock_runtime.context = None
mock_runtime.stream_writer = lambda *args, **kwargs: None
return mock_runtime
def _create_config_with_runtime() -> RunnableConfig:
"""Create a RunnableConfig with mock Runtime for testing ToolNode."""
return {"configurable": {"__pregel_runtime": _create_mock_runtime()}}
@tool
def failing_tool(x: int) -> str:
"""A tool that always fails."""
raise RuntimeError("This tool always fails!")
def handle_tool_error(state) -> dict:
"""Error handler that returns ToolMessages."""
print(f"handle_tool_error called with state: {state}")
print(f"State type: {type(state)}")
print(f"State keys: {state.keys() if isinstance(state, dict) else 'N/A'}")
error = state.get("error")
print(f"Error: {error}")
tool_calls = state["messages"][-1].tool_calls
print(f"Tool calls: {tool_calls}")
return {
"messages": [
ToolMessage(
content=f"Error: {repr(error)}\n please fix your mistakes.",
tool_call_id=tc["id"],
)
for tc in tool_calls
]
}
fallback_runnable = RunnableLambda(handle_tool_error)
def test_tool_error_with_fallbacks():
"""Test that ToolMessages from fallback handlers preserve name and status."""
# Create a ToolNode with a fallback
tool_node = ToolNode([failing_tool], handle_tool_errors=True).with_fallbacks(
[fallback_runnable],
)
# Create an AI message with a tool call
messages = [
AIMessage(
content="",
tool_calls=[
{
"name": "failing_tool",
"args": {"x": 1},
"id": "call_123",
"type": "tool_call",
}
],
)
]
# Invoke the tool node
result = tool_node.invoke({"messages": messages}, config=_create_config_with_runtime())
print("Result:", result)
print("Result type:", type(result))
print("Result keys:", result.keys() if isinstance(result, dict) else "N/A")
print("\nTool messages:")
for msg in result["messages"]:
if isinstance(msg, ToolMessage):
print(f" - content: {msg.content[:50]}...")
print(f" name: {msg.name}")
print(f" tool_call_id: {msg.tool_call_id}")
print(f" status: {msg.status}")
print()
# Check expectations
assert msg.name == "failing_tool", f"Expected name='failing_tool', got {msg.name}"
assert msg.status == "error", f"Expected status='error', got {msg.status}"
print("✓ Test passed!")
if __name__ == "__main__":
test_tool_error_with_fallbacks()