Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions libs/prebuilt/langgraph/prebuilt/tool_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,20 @@ async def _execute_tool_async(
# (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture)
except GraphBubbleUp:
raise
except asyncio.CancelledError:
# Handle CancelledError separately since it inherits from BaseException, not Exception
if self._handle_tool_errors:
content = _handle_tool_error(
Exception("Tool execution was cancelled"),
flag=self._handle_tool_errors,
)
return ToolMessage(
content=content,
name=call["name"],
tool_call_id=call["id"],
status="error",
)
raise
except Exception as e:
# Determine which exception types are handled
handled_types: tuple[type[Exception], ...]
Expand Down
64 changes: 64 additions & 0 deletions libs/prebuilt/tests/test_tool_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -1902,3 +1902,67 @@ def get_info(rt: ToolRuntime[MyContext]):
assert tool_message.type == "tool"
assert tool_message.content == "test_info"
assert tool_message.tool_call_id == "call_1"


async def test_tool_node_handles_cancelled_error() -> None:
"""Test that ToolNode handles asyncio.CancelledError raised within tools when handle_tool_errors=True.

This replicates the scenario from issue #6726 where a tool raises CancelledError
(e.g., from a cancelled underlying operation) and we want the ToolNode to convert
it to an error ToolMessage rather than letting it propagate.
"""
import asyncio

@dec_tool
async def tool_that_cancels(query: str) -> str:
"""A tool that simulates cancellation during execution."""
# Simulate a scenario where the tool's work is cancelled
# For example, an internal HTTP request that times out and gets cancelled
raise asyncio.CancelledError("Simulated cancellation")

# Test with handle_tool_errors=True
tool_node = ToolNode([tool_that_cancels], handle_tool_errors=True)

ai_message = AIMessage(
"",
tool_calls=[
{"id": "call_1", "name": "tool_that_cancels", "args": {"query": "test"}}
],
)
state = {"messages": [ai_message]}
config = _create_config_with_runtime()

# Should not raise, should return ToolMessage with error
result = await tool_node.ainvoke(state, config)
tool_message = result["messages"][-1]

assert isinstance(tool_message, ToolMessage)
assert tool_message.tool_call_id == "call_1"
assert tool_message.status == "error"
assert "cancel" in tool_message.content.lower()


async def test_tool_node_raises_cancelled_error_when_not_handled() -> None:
"""Test that ToolNode raises CancelledError when handle_tool_errors=False."""
import asyncio

@dec_tool
async def tool_that_cancels(query: str) -> str:
"""A tool that simulates cancellation during execution."""
raise asyncio.CancelledError("Simulated cancellation")

# Test with handle_tool_errors=False
tool_node = ToolNode([tool_that_cancels], handle_tool_errors=False)

ai_message = AIMessage(
"",
tool_calls=[
{"id": "call_1", "name": "tool_that_cancels", "args": {"query": "test"}}
],
)
state = {"messages": [ai_message]}
config = _create_config_with_runtime()

# Should raise CancelledError
with pytest.raises(asyncio.CancelledError):
await tool_node.ainvoke(state, config)