mirror of
https://github.com/hwchase17/langchain.git
synced 2026-07-11 18:18:51 +00:00
fix(langchain): propagate interrupts through ToolRetryMiddleware (#38722)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user