From fca0a977a6a68338e2770664cd3e1912497ab5d8 Mon Sep 17 00:00:00 2001 From: ccurme Date: Wed, 8 Jul 2026 11:16:42 -0400 Subject: [PATCH] fix(langchain): propagate interrupts through ToolRetryMiddleware (#38722) --- .../langchain/agents/middleware/tool_retry.py | 9 ++ .../implementations/test_tool_retry.py | 106 +++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/tool_retry.py b/libs/langchain_v1/langchain/agents/middleware/tool_retry.py index bfb55bd9a4a..735cfd1ca96 100644 --- a/libs/langchain_v1/langchain/agents/middleware/tool_retry.py +++ b/libs/langchain_v1/langchain/agents/middleware/tool_retry.py @@ -8,6 +8,7 @@ import warnings from typing import TYPE_CHECKING, Any from langchain_core.messages import ToolMessage +from langgraph.errors import GraphBubbleUp from langchain.agents.middleware._retry import ( OnFailure, @@ -314,6 +315,10 @@ class ToolRetryMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, Respo for attempt in range(self.max_retries + 1): try: return handler(request) + except GraphBubbleUp: + # Control-flow signals (interrupts, parent commands) must + # propagate, not be retried or converted to error messages. + raise except Exception as exc: attempts_made = attempt + 1 # attempt is 0-indexed @@ -372,6 +377,10 @@ class ToolRetryMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, Respo for attempt in range(self.max_retries + 1): try: return await handler(request) + except GraphBubbleUp: + # Control-flow signals (interrupts, parent commands) must + # propagate, not be retried or converted to error messages. + raise except Exception as exc: attempts_made = attempt + 1 # attempt is 0-indexed diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_retry.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_retry.py index 3f3744a0243..a1cf61d99e2 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_retry.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_retry.py @@ -8,8 +8,9 @@ import pytest from langchain_core.messages import HumanMessage, ToolCall, ToolMessage from langchain_core.tools import tool from langgraph.checkpoint.memory import InMemorySaver +from langgraph.errors import ParentCommand from langgraph.prebuilt.tool_node import ToolCallRequest -from langgraph.types import Command +from langgraph.types import Command, interrupt from langchain.agents.factory import create_agent from langchain.agents.middleware._retry import calculate_delay @@ -31,6 +32,13 @@ def failing_tool(value: str) -> str: raise ValueError(msg) +@tool +def interrupting_tool(value: str) -> str: + """Tool that pauses for human input.""" + answer = interrupt(f"Approve {value}?") + return f"Human said: {answer}" + + class TemporaryFailureTool: """Tool that fails a certain number of times before succeeding.""" @@ -1010,3 +1018,99 @@ def test_tool_retry_deprecated_return_message_behavior() -> None: assert "3 attempts" in tool_messages[0].content assert "ValueError" in tool_messages[0].content assert tool_messages[0].status == "error" + + +def test_tool_retry_does_not_swallow_interrupt() -> None: + """A tool that calls interrupt() must surface the interrupt, not retry it.""" + model = FakeToolCallingModel( + tool_calls=[ + [ToolCall(name="interrupting_tool", args={"value": "test"}, id="1")], + [], + ] + ) + + agent = create_agent( + model=model, + tools=[interrupting_tool], + middleware=[ToolRetryMiddleware(max_retries=2, initial_delay=0.01, jitter=False)], + checkpointer=InMemorySaver(), + ) + + result = agent.invoke( + {"messages": [HumanMessage("Use interrupting tool")]}, + {"configurable": {"thread_id": "test"}}, + ) + + # The interrupt must bubble up, not be retried and swallowed into an error message. + assert "__interrupt__" in result + assert [m for m in result["messages"] if isinstance(m, ToolMessage)] == [] + + # Resuming completes normally. + final = agent.invoke(Command(resume="approved"), {"configurable": {"thread_id": "test"}}) + assert "__interrupt__" not in final + tool_messages = [m for m in final["messages"] if isinstance(m, ToolMessage)] + assert len(tool_messages) == 1 + assert "Human said: approved" in tool_messages[0].content + + +def test_tool_retry_parallel_interrupt_with_successful_sibling() -> None: + """In a parallel batch, one tool's interrupt bubbles up while a sibling succeeds.""" + model = FakeToolCallingModel( + tool_calls=[ + [ + ToolCall(name="interrupting_tool", args={"value": "a"}, id="1"), + ToolCall(name="working_tool", args={"value": "b"}, id="2"), + ], + [], + ] + ) + + agent = create_agent( + model=model, + tools=[interrupting_tool, working_tool], + middleware=[ToolRetryMiddleware(max_retries=2, initial_delay=0.01, jitter=False)], + checkpointer=InMemorySaver(), + ) + + result = agent.invoke( + {"messages": [HumanMessage("Use both tools")]}, + {"configurable": {"thread_id": "test"}}, + ) + + # The interrupt bubbles up; the sibling still executes and is checkpointed. + assert "__interrupt__" in result + tool_messages = {m.tool_call_id: m for m in result["messages"] if isinstance(m, ToolMessage)} + assert "1" not in tool_messages # interrupted tool has no result yet + assert tool_messages["2"].content == "Success: b" + assert tool_messages["2"].status != "error" + + # Resuming completes the interrupted tool without re-running the sibling. + final = agent.invoke(Command(resume="approved"), {"configurable": {"thread_id": "test"}}) + assert "__interrupt__" not in final + final_messages = {m.tool_call_id: m for m in final["messages"] if isinstance(m, ToolMessage)} + assert final_messages["1"].content == "Human said: approved" + assert final_messages["2"].content == "Success: b" + + +def test_tool_retry_reraises_graph_bubble_up() -> None: + """GraphBubbleUp signals (e.g. ParentCommand) must propagate, not be retried.""" + middleware = ToolRetryMiddleware(max_retries=3, initial_delay=0.01, jitter=False) + + calls = 0 + + def handler(request: ToolCallRequest) -> ToolMessage: # noqa: ARG001 + nonlocal calls + calls += 1 + raise ParentCommand(Command(goto="some_node")) + + request = ToolCallRequest( + tool_call=ToolCall(name="working_tool", args={"value": "x"}, id="1"), + tool=working_tool, + state={"messages": []}, + runtime=None, # type: ignore[arg-type] + ) + + with pytest.raises(ParentCommand): + middleware.wrap_tool_call(request, handler) + + assert calls == 1 # bubbled up on first raise, not retried