interrupt() calls in parallel tools generate identical IDs, making multi-interrupt resume impossible #1107

Closed
opened 2026-02-20 17:43:06 -05:00 by yindo · 4 comments
Owner

Originally created by @AbhinaavRamesh on GitHub (Dec 25, 2025).

Checked other resources

  • This is a bug, not a usage question. For questions, please use the LangChain Forum (https://forum.langchain.com/).
  • I added a clear and detailed title that summarizes the issue.
  • I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example).
  • I included a self-contained, minimal example that demonstrates the issue INCLUDING all the relevant imports. The code run AS IS to reproduce the issue.

Example Code

import asyncio
from typing import Annotated, List
from uuid import uuid4

from langchain_core.messages import AnyMessage, HumanMessage, AIMessage
from langchain_core.tools import tool
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, START, StateGraph, add_messages
from langgraph.prebuilt import ToolNode
from langgraph.types import interrupt, Command
from typing_extensions import TypedDict


@tool
def create_circle(shape_id: str, color: str) -> str:
    """Create a circle on the canvas."""
    response = interrupt({"tool": "create_circle", "shapeId": shape_id, "color": color})
    return f"Created circle {shape_id}. Response: {response}"


@tool
def create_square(shape_id: str, color: str) -> str:
    """Create a square on the canvas."""
    response = interrupt({"tool": "create_square", "shapeId": shape_id, "color": color})
    return f"Created square {shape_id}. Response: {response}"


@tool
def create_triangle(shape_id: str, color: str) -> str:
    """Create a triangle on the canvas."""
    response = interrupt({"tool": "create_triangle", "shapeId": shape_id, "color": color})
    return f"Created triangle {shape_id}. Response: {response}"


class State(TypedDict):
    messages: Annotated[List[AnyMessage], add_messages]


tools = [create_circle, create_square, create_triangle]


async def agent_node(state: State):
    """Simulates an LLM returning multiple parallel tool calls."""
    return {"messages": [AIMessage(
        content="I'll create three shapes for you.",
        tool_calls=[
            {"id": "call_1", "name": "create_circle", "args": {"shape_id": "c1", "color": "red"}},
            {"id": "call_2", "name": "create_square", "args": {"shape_id": "s1", "color": "blue"}},
            {"id": "call_3", "name": "create_triangle", "args": {"shape_id": "t1", "color": "green"}},
        ],
    )]}


def router(state: State) -> str:
    last_message = state["messages"][-1]
    if hasattr(last_message, "tool_calls") and last_message.tool_calls:
        return "tools"
    return END


async def main():
    print("=" * 60)
    print("Bug Demo: Parallel Interrupts Have Identical IDs")
    print("=" * 60)

    graph = StateGraph(State)
    graph.add_node("agent", agent_node)
    graph.add_node("tools", ToolNode(tools=tools))
    graph.add_edge(START, "agent")
    graph.add_conditional_edges("agent", router, ["tools", END])
    graph.add_edge("tools", "agent")

    checkpointer = MemorySaver()
    workflow = graph.compile(checkpointer=checkpointer)

    config = {"configurable": {"thread_id": str(uuid4())}}

    # First invocation - triggers interrupts
    print("\n--- First Invocation ---")
    await workflow.ainvoke(
        {"messages": [HumanMessage(content="Create 3 shapes")]},
        config=config
    )

    # Check state for interrupts
    state = await workflow.aget_state(config)
    all_interrupts = []
    for task in state.tasks:
        if task.interrupts:
            all_interrupts.extend(task.interrupts)

    print(f"\nInterrupts collected: {len(all_interrupts)}")
    for i, intr in enumerate(all_interrupts):
        print(f"  {i}: id={intr.id}, tool={intr.value.get('tool')}")

    # Show the problem: all IDs are identical
    unique_ids = set(intr.id for intr in all_interrupts)
    print(f"\nUnique IDs: {len(unique_ids)} (expected: 3)")

    if len(unique_ids) == 1:
        print("\n!!! BUG: All 3 interrupts have the SAME ID !!!")
        print("This makes it impossible to resume each tool with its own value.")

    # Attempt resume - this fails because dict keys collide
    print("\n--- Attempting Resume ---")
    resume_data = {
        intr.id: {"approved": True, "tool": intr.value["tool"]}
        for intr in all_interrupts
    }
    print(f"Resume dict has {len(resume_data)} entries (expected: 3, actual: 1 due to ID collision)")


if __name__ == "__main__":
    asyncio.run(main())

Error Message and Stack Trace (if applicable)

No exception will be present, this is an issue in resuming since the IDs remain same:

============================================================
Bug Demo: Parallel Interrupts Have Identical IDs
============================================================

--- First Invocation ---

Interrupts collected: 3
  0: id=1b09852344aeb4aee6776a24d09c3f9e, tool=create_circle
  1: id=1b09852344aeb4aee6776a24d09c3f9e, tool=create_square
  2: id=1b09852344aeb4aee6776a24d09c3f9e, tool=create_triangle

Unique IDs: 1 (expected: 3)

!!! BUG: All 3 interrupts have the SAME ID !!!
This makes it impossible to resume each tool with its own value.

--- Attempting Resume ---
Resume dict has 1 entries (expected: 3, actual: 1 due to ID collision)

Description

What I'm trying to do

Execute multiple tools in parallel where each tool calls interrupt() to get human approval, then resume all tools with their respective approval responses.

What I expect to happen

  1. All 3 tools execute in parallel and call interrupt()
  2. Each interrupt gets a unique ID so I can match resume values to specific tools
  3. I can resume with Command(resume={id1: response1, id2: response2, id3: response3})
  4. Each tool receives its corresponding response

What actually happens

  1. All 3 tools execute and call interrupt() (this works)
  2. All 3 interrupts get the SAME ID (this is the bug)
  3. When building the resume dict, all 3 entries collapse into 1 due to key collision
  4. Only one tool can be resumed; the other two are stuck

Root Cause Analysis

The issue is in langgraph/types.py in the Interrupt.from_ns() method:

@classmethod
def from_ns(cls, value: Any, ns: str) -> Interrupt:
    return cls(value=value, id=xxh3_128_hexdigest(ns.encode()))

The interrupt ID is generated by hashing only the checkpoint namespace (ns). When multiple tools run in parallel within the same ToolNode, they share the same namespace, so they all get identical IDs.

The interrupt() function (lines) does track an interrupt counter via scratchpad.interrupt_counter(), but this counter is not included in the ID hash.

Suggested Fix

Include the interrupt index in the ID generation:

@classmethod
def from_ns(cls, value: Any, ns: str, idx: int = 0) -> Interrupt:
    return cls(value=value, id=xxh3_128_hexdigest(f"{ns}:{idx}".encode()))

And update the interrupt() function to pass the index:

raise GraphInterrupt(
    (
        Interrupt.from_ns(
            value=value,
            ns=conf[CONFIG_KEY_CHECKPOINT_NS],
            idx=idx,  # Add this
        ),
    )
)

System Info

System Information
------------------
OS: Darwin
OS Version: Darwin Kernel Version 25.1.0
Python Version: 3.13.x / 3.14.x

Package Information
-------------------
langchain_core: 0.3.x
langgraph: 0.2.x
langgraph_sdk: 0.3.x
Originally created by @AbhinaavRamesh on GitHub (Dec 25, 2025). ### Checked other resources - [x] This is a bug, not a usage question. For questions, please use the LangChain Forum (https://forum.langchain.com/). - [x] I added a clear and detailed title that summarizes the issue. - [x] I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example). - [x] I included a self-contained, minimal example that demonstrates the issue INCLUDING all the relevant imports. The code run AS IS to reproduce the issue. ### Example Code ```python import asyncio from typing import Annotated, List from uuid import uuid4 from langchain_core.messages import AnyMessage, HumanMessage, AIMessage from langchain_core.tools import tool from langgraph.checkpoint.memory import MemorySaver from langgraph.graph import END, START, StateGraph, add_messages from langgraph.prebuilt import ToolNode from langgraph.types import interrupt, Command from typing_extensions import TypedDict @tool def create_circle(shape_id: str, color: str) -> str: """Create a circle on the canvas.""" response = interrupt({"tool": "create_circle", "shapeId": shape_id, "color": color}) return f"Created circle {shape_id}. Response: {response}" @tool def create_square(shape_id: str, color: str) -> str: """Create a square on the canvas.""" response = interrupt({"tool": "create_square", "shapeId": shape_id, "color": color}) return f"Created square {shape_id}. Response: {response}" @tool def create_triangle(shape_id: str, color: str) -> str: """Create a triangle on the canvas.""" response = interrupt({"tool": "create_triangle", "shapeId": shape_id, "color": color}) return f"Created triangle {shape_id}. Response: {response}" class State(TypedDict): messages: Annotated[List[AnyMessage], add_messages] tools = [create_circle, create_square, create_triangle] async def agent_node(state: State): """Simulates an LLM returning multiple parallel tool calls.""" return {"messages": [AIMessage( content="I'll create three shapes for you.", tool_calls=[ {"id": "call_1", "name": "create_circle", "args": {"shape_id": "c1", "color": "red"}}, {"id": "call_2", "name": "create_square", "args": {"shape_id": "s1", "color": "blue"}}, {"id": "call_3", "name": "create_triangle", "args": {"shape_id": "t1", "color": "green"}}, ], )]} def router(state: State) -> str: last_message = state["messages"][-1] if hasattr(last_message, "tool_calls") and last_message.tool_calls: return "tools" return END async def main(): print("=" * 60) print("Bug Demo: Parallel Interrupts Have Identical IDs") print("=" * 60) graph = StateGraph(State) graph.add_node("agent", agent_node) graph.add_node("tools", ToolNode(tools=tools)) graph.add_edge(START, "agent") graph.add_conditional_edges("agent", router, ["tools", END]) graph.add_edge("tools", "agent") checkpointer = MemorySaver() workflow = graph.compile(checkpointer=checkpointer) config = {"configurable": {"thread_id": str(uuid4())}} # First invocation - triggers interrupts print("\n--- First Invocation ---") await workflow.ainvoke( {"messages": [HumanMessage(content="Create 3 shapes")]}, config=config ) # Check state for interrupts state = await workflow.aget_state(config) all_interrupts = [] for task in state.tasks: if task.interrupts: all_interrupts.extend(task.interrupts) print(f"\nInterrupts collected: {len(all_interrupts)}") for i, intr in enumerate(all_interrupts): print(f" {i}: id={intr.id}, tool={intr.value.get('tool')}") # Show the problem: all IDs are identical unique_ids = set(intr.id for intr in all_interrupts) print(f"\nUnique IDs: {len(unique_ids)} (expected: 3)") if len(unique_ids) == 1: print("\n!!! BUG: All 3 interrupts have the SAME ID !!!") print("This makes it impossible to resume each tool with its own value.") # Attempt resume - this fails because dict keys collide print("\n--- Attempting Resume ---") resume_data = { intr.id: {"approved": True, "tool": intr.value["tool"]} for intr in all_interrupts } print(f"Resume dict has {len(resume_data)} entries (expected: 3, actual: 1 due to ID collision)") if __name__ == "__main__": asyncio.run(main()) ``` ### Error Message and Stack Trace (if applicable) ```shell No exception will be present, this is an issue in resuming since the IDs remain same: ============================================================ Bug Demo: Parallel Interrupts Have Identical IDs ============================================================ --- First Invocation --- Interrupts collected: 3 0: id=1b09852344aeb4aee6776a24d09c3f9e, tool=create_circle 1: id=1b09852344aeb4aee6776a24d09c3f9e, tool=create_square 2: id=1b09852344aeb4aee6776a24d09c3f9e, tool=create_triangle Unique IDs: 1 (expected: 3) !!! BUG: All 3 interrupts have the SAME ID !!! This makes it impossible to resume each tool with its own value. --- Attempting Resume --- Resume dict has 1 entries (expected: 3, actual: 1 due to ID collision) ``` ### Description ### What I'm trying to do Execute multiple tools in parallel where each tool calls `interrupt()` to get human approval, then resume all tools with their respective approval responses. ### What I expect to happen 1. All 3 tools execute in parallel and call `interrupt()` 2. Each interrupt gets a **unique ID** so I can match resume values to specific tools 3. I can resume with `Command(resume={id1: response1, id2: response2, id3: response3})` 4. Each tool receives its corresponding response ### What actually happens 1. All 3 tools execute and call `interrupt()` (this works) 2. **All 3 interrupts get the SAME ID** (this is the bug) 3. When building the resume dict, all 3 entries collapse into 1 due to key collision 4. Only one tool can be resumed; the other two are stuck ### Root Cause Analysis The issue is in `langgraph/types.py` in the `Interrupt.from_ns()` method: ```python @classmethod def from_ns(cls, value: Any, ns: str) -> Interrupt: return cls(value=value, id=xxh3_128_hexdigest(ns.encode())) ``` The interrupt ID is generated by hashing only the checkpoint namespace (`ns`). When multiple tools run in parallel within the same `ToolNode`, they share the same namespace, so they all get identical IDs. The `interrupt()` function ([lines](https://github.com/langchain-ai/langgraph/blob/main/libs/langgraph/langgraph/types.py#L516-L539)) does track an interrupt counter via `scratchpad.interrupt_counter()`, but this counter is **not included in the ID hash**. ### Suggested Fix Include the interrupt index in the ID generation: ```python @classmethod def from_ns(cls, value: Any, ns: str, idx: int = 0) -> Interrupt: return cls(value=value, id=xxh3_128_hexdigest(f"{ns}:{idx}".encode())) ``` And update the `interrupt()` function to pass the index: ```python raise GraphInterrupt( ( Interrupt.from_ns( value=value, ns=conf[CONFIG_KEY_CHECKPOINT_NS], idx=idx, # Add this ), ) ) ``` ### System Info ``` System Information ------------------ OS: Darwin OS Version: Darwin Kernel Version 25.1.0 Python Version: 3.13.x / 3.14.x Package Information ------------------- langchain_core: 0.3.x langgraph: 0.2.x langgraph_sdk: 0.3.x ```
yindo added the bugpending labels 2026-02-20 17:43:06 -05:00
yindo closed this issue 2026-02-20 17:43:06 -05:00
Author
Owner

@dominic-ideagen commented on GitHub (Dec 29, 2025):

I checked the PR for the fix. Just try to understand even with this fix, because the tools will still run in the same node Tool Node meaning when resume it, it will run the three tools again or it will just resume the tool that get interrupt?

If I'm not wrong, below is langgraph current behavior:

Tool 1 interrupt resume, Tool 2 and Tool 3 haven resume = Tool 1 will run.
Next,
Tool 2 interrupt resume, Tool 3 haven resume = Tool 1 and Tool 2 will run. (Meaning tool 1 run again)
Next,
Tool3 interrupt resume = Tool 1, 2, 3 will run.

Note that they are concurrent so may not follow sequence.

With this change will it change the current behavior meaning I no need to maintain idempotent between all tools in the ToolNode?

@dominic-ideagen commented on GitHub (Dec 29, 2025): I checked the PR for the fix. Just try to understand even with this fix, because the tools will still run in the same node `Tool Node` meaning when resume it, it will run the three tools again or it will just resume the tool that get interrupt? If I'm not wrong, below is langgraph current behavior: > Tool 1 interrupt resume, Tool 2 and Tool 3 haven resume = Tool 1 will run. Next, Tool 2 interrupt resume, Tool 3 haven resume = Tool 1 and Tool 2 will run. (Meaning tool 1 run again) Next, Tool3 interrupt resume = Tool 1, 2, 3 will run. Note that they are concurrent so may not follow sequence. With this change will it change the current behavior meaning I no need to maintain idempotent between all tools in the ToolNode?
Author
Owner

@AbhinaavRamesh commented on GitHub (Dec 31, 2025):

@dominic-ideagen Great question! To clarify the behavior:

This fix does NOT change the execution behavior of the ToolNode. When you resume, the node still re-runs from the start, and all tools within it will re-execute. This is by design - LangGraph's interrupt/resume mechanism works at the node level, not the individual tool level.

What this fix does:

  • Each interrupt() call now gets a unique ID in the format {namespace_hash}:{index}
  • This allows you to provide different resume values for each interrupt using Command(resume={id1: val1, id2: val2, id3: val3})
  • The resume values are matched to interrupts by their index within the node

Example of the current behavior (unchanged by this fix):

  1. First invocation: All 3 tools start, each calls interrupt(), graph pauses with 3 interrupts
  2. You resume with values for all 3 interrupts: Command(resume={id1: val1, id2: val2, id3: val3})
  3. The node re-runs, each tool's interrupt() call returns its corresponding value (val1, val2, val3)
  4. All 3 tools complete

You still need idempotency if your tools have side effects that shouldn't repeat. The fix only ensures you can provide different resume values for each parallel interrupt - it doesn't prevent re-execution.

For truly independent tool resumption (where only the unfinished tools run), you would need to structure your graph differently, using separate nodes or subgraphs with their own checkpointing.

@AbhinaavRamesh commented on GitHub (Dec 31, 2025): @dominic-ideagen Great question! To clarify the behavior: **This fix does NOT change the execution behavior of the ToolNode.** When you resume, the node still re-runs from the start, and all tools within it will re-execute. This is by design - LangGraph's interrupt/resume mechanism works at the node level, not the individual tool level. **What this fix does:** - Each `interrupt()` call now gets a unique ID in the format `{namespace_hash}:{index}` - This allows you to provide different resume values for each interrupt using `Command(resume={id1: val1, id2: val2, id3: val3})` - The resume values are matched to interrupts by their index within the node **Example of the current behavior (unchanged by this fix):** 1. First invocation: All 3 tools start, each calls `interrupt()`, graph pauses with 3 interrupts 2. You resume with values for all 3 interrupts: `Command(resume={id1: val1, id2: val2, id3: val3})` 3. The node re-runs, each tool's `interrupt()` call returns its corresponding value (`val1`, `val2`, `val3`) 4. All 3 tools complete **You still need idempotency** if your tools have side effects that shouldn't repeat. The fix only ensures you can provide different resume values for each parallel interrupt - it doesn't prevent re-execution. For truly independent tool resumption (where only the unfinished tools run), you would need to structure your graph differently, using separate nodes or subgraphs with their own checkpointing.
Author
Owner

@AbhinaavRamesh commented on GitHub (Dec 31, 2025):

I updated the previous PR with some changes to account for this:
https://github.com/langchain-ai/langgraph/pull/6627

@AbhinaavRamesh commented on GitHub (Dec 31, 2025): I updated the previous PR with some changes to account for this: https://github.com/langchain-ai/langgraph/pull/6627
Author
Owner

@sydney-runkle commented on GitHub (Jan 12, 2026):

Hiya! Closing as a dupe of https://github.com/langchain-ai/langgraph/issues/6533, w/ solution here: https://github.com/langchain-ai/langgraph/issues/6533#issuecomment-3739779037

@sydney-runkle commented on GitHub (Jan 12, 2026): Hiya! Closing as a dupe of https://github.com/langchain-ai/langgraph/issues/6533, w/ solution here: https://github.com/langchain-ai/langgraph/issues/6533#issuecomment-3739779037
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/langgraph#1107