diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py index 8e2bae352..d6adced3e 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py @@ -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 diff --git a/backend/tests/test_tool_error_handling_middleware.py b/backend/tests/test_tool_error_handling_middleware.py index 922cf7cc8..065f0e3f1 100644 --- a/backend/tests/test_tool_error_handling_middleware.py +++ b/backend/tests/test_tool_error_handling_middleware.py @@ -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.