fix(langchain): propagate interrupts through ToolRetryMiddleware (#38722)

This commit is contained in:
ccurme
2026-07-08 11:16:42 -04:00
committed by GitHub
parent 874bada42f
commit fca0a977a6
2 changed files with 114 additions and 1 deletions

View File

@@ -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

View File

@@ -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