mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
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:
parent
e9161ff148
commit
80e031dcc6
2 changed files with 73 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue