fix(subagents): inherit LoopDetectionMiddleware to break tool loops (#3875) (#3931)

Subagents build their runtime chain via build_subagent_runtime_middlewares,
which attached none of the lead's runaway guards — no LoopDetectionMiddleware.
A degenerate subagent tool loop therefore ran unchecked until max_turns,
re-sending a growing context each turn (the #3875 4.4M-token burn).

Append LoopDetectionMiddleware.from_config(app_config.loop_detection) to the
subagent chain, gated by the existing loop_detection.enabled config (default
on) — no new config field. Registered before SafetyFinishReasonMiddleware to
match the lead's after-model ordering (the safety middleware requires placement
after LoopDetection). Subagents disallow task, so only the tool-loop heuristic
can fire here — no recursive-delegation false positives to worry about.

Phase 1 of #3875 (smallest blast radius). Phase 2 adds a deterministic
turn/token budget with a lead-visible stop reason; Phase 3 defers summarization.

Tests: enabled/disabled wiring + before-SafetyFinish ordering, mirroring the
lead chain's coverage.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
hataa 2026-07-04 16:29:49 +08:00 committed by GitHub
parent e9161ff148
commit 80e031dcc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 73 additions and 3 deletions

View file

@ -272,6 +272,26 @@ def build_subagent_runtime_middlewares(
middlewares.append(DeferredToolFilterMiddleware(deferred_setup.deferred_names, deferred_setup.catalog_hash))
# LoopDetectionMiddleware — subagents inherit none of the lead's runaway
# guards today (see #3875): with no loop detection a degenerate subagent tool
# loop runs unchecked until ``max_turns``, re-sending a growing context each
# turn (the reported 4.4M-token burn). Mirror the lead chain so the loop is
# detected and broken. Subagents disallow ``task``, so only the tool-loop
# heuristic can fire here — no recursive-delegation path to false-positive on.
# Registered before SafetyFinishReasonMiddleware (earlier in the list).
# LangChain dispatches after_model hooks in REVERSE registration order, so
# SafetyFinishReasonMiddleware (appended below) executes first and strips
# safety-terminated tool_calls; LoopDetectionMiddleware then accounts on the
# cleaned message. This is the placement SafetyFinishReasonMiddleware's
# docstring requires ("register after LoopDetection") and mirrors the lead
# chain (``lead_agent/agent.py``). Phase 1 of #3875; a deterministic
# turn/token budget with lead-visible stop reason is Phase 2.
loop_detection_config = app_config.loop_detection
if loop_detection_config.enabled:
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
middlewares.append(LoopDetectionMiddleware.from_config(loop_detection_config))
# Same provider safety-termination guard the lead agent uses — subagents
# are equally exposed to truncated tool_calls returned with
# finish_reason=content_filter (and friends), and the bad call would then

View file

@ -144,12 +144,12 @@ def test_build_subagent_runtime_middlewares_threads_app_config_to_llm_middleware
assert captured["app_config"] is app_config
# 8 baseline (InputSanitization, ToolOutputBudget, ThreadData, Sandbox,
# DanglingToolCall, LLMErrorHandling, SandboxAudit, ToolErrorHandling)
# + 1 ReadBeforeWriteMiddleware + 1 SafetyFinishReasonMiddleware (both
# enabled by default).
# + 1 ReadBeforeWriteMiddleware + 1 LoopDetectionMiddleware
# + 1 SafetyFinishReasonMiddleware (all enabled by default).
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware
assert len(middlewares) == 10
assert len(middlewares) == 11
assert isinstance(middlewares[0], FakeMiddleware) # InputSanitizationMiddleware stub
assert isinstance(middlewares[1], ToolOutputBudgetMiddleware)
assert any(isinstance(m, ToolErrorHandlingMiddleware) for m in middlewares)
@ -472,6 +472,56 @@ def test_subagent_runtime_middlewares_skip_deferred_filter_without_names(monkeyp
assert not any(isinstance(m, DeferredToolFilterMiddleware) for m in middlewares)
def test_subagent_runtime_middlewares_attach_loop_detection_when_enabled(monkeypatch):
"""Subagents must inherit the lead's LoopDetectionMiddleware so a degenerate
tool loop is broken instead of burning tokens until ``max_turns`` (#3875).
``loop_detection.enabled`` defaults to True, so the default subagent chain
carries the guard. Phase 1 of #3875."""
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
app_config = _make_app_config()
_stub_runtime_middleware_imports(monkeypatch)
middlewares = build_subagent_runtime_middlewares(app_config=app_config, model_name="test-model")
loop = [m for m in middlewares if isinstance(m, LoopDetectionMiddleware)]
assert len(loop) == 1
def test_subagent_runtime_middlewares_omit_loop_detection_when_disabled(monkeypatch):
"""``loop_detection.enabled=False`` must drop the guard from the subagent
chain, mirroring the lead's gate (``lead_agent/agent.py``)."""
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.config.loop_detection_config import LoopDetectionConfig
app_config = _make_app_config().model_copy(update={"loop_detection": LoopDetectionConfig(enabled=False)})
_stub_runtime_middleware_imports(monkeypatch)
middlewares = build_subagent_runtime_middlewares(app_config=app_config, model_name="test-model")
assert not any(isinstance(m, LoopDetectionMiddleware) for m in middlewares)
def test_subagent_runtime_middlewares_place_loop_detection_before_safety_finish(monkeypatch):
"""LoopDetectionMiddleware must be registered before SafetyFinishReasonMiddleware
(earlier in the middleware list). LangChain dispatches after_model hooks in
reverse registration order, so SafetyFinishReasonMiddleware (registered
later) executes first the placement its docstring requires and the lead
chain (``lead_agent/agent.py``) uses. The assertion pins registration order,
not execution order."""
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
app_config = _make_app_config()
_stub_runtime_middleware_imports(monkeypatch)
middlewares = build_subagent_runtime_middlewares(app_config=app_config, model_name="test-model")
loop_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, LoopDetectionMiddleware))
safety_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, SafetyFinishReasonMiddleware))
assert loop_idx < safety_idx
def test_lead_runtime_chain_finds_historical_uploads_under_lazy_init_false(tmp_path, monkeypatch):
"""Integration anchor for the ThreadData → Uploads ordering.