Skip to content

fix(prebuilt): handle asyncio.CancelledError in ToolNode#6737

Open
Saakshi Gupta (saakshigupta2002) wants to merge 1 commit intolangchain-ai:mainfrom
saakshigupta2002:fix/handle-cancelled-error-6726
Open

fix(prebuilt): handle asyncio.CancelledError in ToolNode#6737
Saakshi Gupta (saakshigupta2002) wants to merge 1 commit intolangchain-ai:mainfrom
saakshigupta2002:fix/handle-cancelled-error-6726

Conversation

@saakshigupta2002
Copy link
Copy Markdown

Pull Request: fix(prebuilt): handle asyncio.CancelledError in ToolNode

Summary

This PR fixes issue #6726 where asyncio.CancelledError is not caught by the ToolNode error handling mechanism, even when handle_tool_errors=True is set.

Problem

When a tool execution is cancelled via asyncio.CancelledError, the ToolNode does not create an error ToolMessage. This leaves the message history in an invalid state where an AIMessage has tool_calls without corresponding ToolMessages, causing INVALID_CHAT_HISTORY errors on subsequent LLM calls.

Root Cause

asyncio.CancelledError inherits from BaseException, not Exception. The existing error handling in ToolNode._execute_tool_async() and _arun_one() uses except Exception as e:, which doesn't catch CancelledError.

Solution

  • Added explicit except asyncio.CancelledError: handlers before the except Exception: blocks
  • When handle_tool_errors=True, a ToolMessage with status="error" is returned
  • When handle_tool_errors=False, the exception is re-raised as expected
  • Added a new constant TOOL_CANCELLED_ERROR_TEMPLATE for consistent error messages

Changes

libs/prebuilt/langgraph/prebuilt/tool_node.py

  1. Added constant (line 118):

    TOOL_CANCELLED_ERROR_TEMPLATE = "Tool execution was cancelled."
  2. Modified _execute_tool_async - Added CancelledError handler before GraphBubbleUp:

    except asyncio.CancelledError:
        if self._handle_tool_errors:
            return ToolMessage(
                content=TOOL_CANCELLED_ERROR_TEMPLATE,
                name=call["name"],
                tool_call_id=call["id"],
                status="error",
            )
        raise
  3. Modified _arun_one - Added CancelledError handler for wrapper cancellation:

    except asyncio.CancelledError:
        if self._handle_tool_errors:
            return ToolMessage(
                content=TOOL_CANCELLED_ERROR_TEMPLATE,
                name=tool_request.tool_call["name"],
                tool_call_id=tool_request.tool_call["id"],
                status="error",
            )
        raise

libs/prebuilt/tests/test_tool_node.py

Added three test cases:

  1. test_tool_node_cancelled_error_handled - Verifies that a ToolMessage with error status is returned when handle_tool_errors=True and a tool is cancelled

  2. test_tool_node_cancelled_error_not_handled - Verifies that CancelledError is raised when handle_tool_errors=False

  3. test_tool_node_cancelled_error_in_wrapper - Verifies that CancelledError raised in awrap_tool_call is handled properly

Test Plan

Reproduction Script (from issue)

import asyncio
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.tools import tool
from langgraph.prebuilt import ToolNode

@tool
async def slow_tool(query: str) -> str:
    """A tool that takes time to complete."""
    await asyncio.sleep(10)
    return f"Result: {query}"

async def test_cancelled_error():
    tool_node = ToolNode(tools=[slow_tool], handle_tool_errors=True)

    ai_message = AIMessage(
        content="",
        tool_calls=[{"id": "call_1", "name": "slow_tool", "args": {"query": "test"}}]
    )
    state = {"messages": [HumanMessage(content="test"), ai_message]}
    config = {"configurable": {"thread_id": "test"}}

    task = asyncio.create_task(tool_node.ainvoke(state, config))
    await asyncio.sleep(0.1)
    task.cancel()

    try:
        result = await task
        print("Got result:", result)  # Now returns ToolMessage with error status
    except asyncio.CancelledError:
        print("CancelledError raised - no ToolMessage created!")  # This no longer happens

asyncio.run(test_cancelled_error())

Before/After Comparison

Scenario Before After
handle_tool_errors=True, tool cancelled CancelledError raised, no ToolMessage ToolMessage with status="error" returned
handle_tool_errors=False, tool cancelled CancelledError raised CancelledError raised (unchanged)
Message history validity Invalid (AIMessage with tool_calls, no ToolMessage) Valid (matching ToolMessage created)

Related Issues

Checklist

  • Code follows the project's coding style
  • Changes are covered by tests
  • All existing tests pass
  • Documentation is updated (docstrings in code)
  • Commit message follows conventional commits format

When a tool execution is cancelled via asyncio.CancelledError, the
ToolNode now creates an error ToolMessage when handle_tool_errors=True.

Previously, CancelledError would bypass error handling because it
inherits from BaseException, not Exception. This left the message
history in an invalid state where an AIMessage has tool_calls without
corresponding ToolMessages, causing INVALID_CHAT_HISTORY errors.

Changes:
- Add TOOL_CANCELLED_ERROR_TEMPLATE constant for cancellation messages
- Add CancelledError handler in _execute_tool_async method
- Add CancelledError handler in _arun_one method for wrapper cancellation
- Add test cases for cancellation handling

Fixes langchain-ai#6726
@mdrxy Mason Daugherty (mdrxy) added the bypass-issue-check Maintainer override: skip issue-link enforcement label Mar 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bypass-issue-check Maintainer override: skip issue-link enforcement external

Projects

None yet

Development

Successfully merging this pull request may close these issues.

handle_tool_errors=True does not catch asyncio.CancelledError, causing INVALID_CHAT_HISTORY

2 participants