From 03c95f5278aef895201e6ff7498422bd42d96949 Mon Sep 17 00:00:00 2001 From: Tsai Yuan <136825049+nothingyuancando@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:42:11 +0800 Subject: [PATCH] fix(subagents): report model output length caps (#7355) * fix(subagents): report model_length_capped when a subagent hits the output limit ModelLengthFinishReasonMiddleware only ran on the lead chain. A subagent whose response was cut at the provider output limit (finish_reason=length, stop_reason=max_tokens, MAX_TOKENS, Responses max_output_tokens) reached the lead as a clean `completed`, and a tool call truncated at that boundary still executed with partial arguments. Register the same middleware on the subagent chain, in the lead's relative position (after configured extensions, before Safety). It now also records each detected cap per context run_id and exposes `consume_stop_reason`, so the executor's existing duck-typed guard collection reports `stop_reason=model_length_capped`. Add the value to the subagent status contract (Python enum, contract fixture, frontend whitelist) with the label "model output length limit", and update the subagent docs. Tests cover the middleware channel, chain placement, the contract, and an end-to-end executor run with a fake model. Co-Authored-By: Claude Code * test(tasks): cover cap reasons on completed and failed runs Extend the existing shared-contract round-trip matrix to preserve both terminal statuses alongside every supported stop reason, including model_length_capped and loop_capped. --------- Co-authored-by: huangzz <110116@nd.com> Co-authored-by: Claude Code Co-authored-by: Willem Jiang --- backend/CONTRIBUTING.md | 4 +- .../deerflow/agents/middlewares/AGENTS.md | 2 +- .../model_length_finish_reason_middleware.py | 28 ++++++ .../tool_error_handling_middleware.py | 11 +++ .../harness/deerflow/subagents/AGENTS.md | 2 +- .../harness/deerflow/subagents/executor.py | 14 ++- .../deerflow/subagents/status_contract.py | 6 +- .../tests/test_middleware_documentation.py | 11 ++- ...t_model_length_finish_reason_middleware.py | 33 +++++++ .../test_subagent_model_length_capped.py | 95 +++++++++++++++++++ .../tests/test_subagent_status_contract.py | 12 +++ .../test_tool_error_handling_middleware.py | 26 ++++- contracts/subagent_status_contract.json | 2 +- .../src/content/en/harness/customization.mdx | 2 +- .../src/content/en/harness/middlewares.mdx | 2 +- .../en/harness/subagents/developers.mdx | 2 +- .../content/en/harness/subagents/limits.mdx | 3 +- .../en/harness/subagents/observability.mdx | 2 +- .../en/harness/subagents/reference.mdx | 2 +- .../content/en/harness/subagents/results.mdx | 5 +- .../en/harness/subagents/troubleshooting.mdx | 6 ++ .../src/content/zh/harness/customization.mdx | 2 +- .../src/content/zh/harness/middlewares.mdx | 2 +- .../zh/harness/subagents/developers.mdx | 2 +- .../content/zh/harness/subagents/limits.mdx | 3 +- .../zh/harness/subagents/observability.mdx | 2 +- .../zh/harness/subagents/reference.mdx | 2 +- .../content/zh/harness/subagents/results.mdx | 5 +- .../zh/harness/subagents/troubleshooting.mdx | 6 ++ frontend/src/core/tasks/subtask-result.ts | 3 +- frontend/src/core/tasks/types.ts | 2 +- .../unit/core/tasks/subtask-result.test.ts | 19 ++-- 32 files changed, 275 insertions(+), 43 deletions(-) create mode 100644 backend/tests/test_subagent_model_length_capped.py diff --git a/backend/CONTRIBUTING.md b/backend/CONTRIBUTING.md index de97191426..cda9fa7004 100644 --- a/backend/CONTRIBUTING.md +++ b/backend/CONTRIBUTING.md @@ -316,8 +316,8 @@ extensions: Configured middleware runs after the built-in middleware and optional loop/token guards. On the lead-agent pipeline, it runs before the terminal-response, model-length, safety, and clarification tail; subagents have no -terminal-response, model-length, or clarification stage, so configured middleware is -followed by the optional safety guard, `DurableContextMiddleware`, optional +terminal-response or clarification stage, so configured middleware is followed +by the model-length guard, the optional safety guard, `DurableContextMiddleware`, optional `SummarizationMiddleware`, then `SubagentDateContextMiddleware` and `SystemMessageCoalescingMiddleware`. Treat middleware class paths as trusted operator configuration because loading one executes Python code. diff --git a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md index a92ff376fa..a6df8f6ffd 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md @@ -155,7 +155,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../ 34. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before config-declared extensions and the terminal-response/safety/clarification tail 35. **Configured extension middlewares** - `extensions.middlewares` in `config.yaml` or `extensions_config.json` optionally accepts `module.path:ClassName` strings or `{class, kwargs}` objects. `deerflow.reflection.resolve_class` loads `AgentMiddleware` classes; import, class, and constructor errors fail agent creation. `kwargs` must be JSON-compatible; YAML dates/timestamps become ISO strings. Order: built-ins/custom and loop/token guards → extensions → terminal-response/safety/clarification tail. Subagents share the list before their safety tail; separate lead/subagent lists are unsupported. Trusted operator config only: paths instantiate arbitrary code. Gateway skill/MCP toggles preserve it in raw JSON; adding an API write path requires explicit trust-boundary review. 36. **TerminalResponseMiddleware** - When a provider returns an empty terminal `AIMessage` after tool execution, injects a hidden recovery prompt and retries the model once; a second empty response is replaced in checkpoint state by a visible error fallback marked for the run worker, so the run finishes as an error instead of a silent success -37. **ModelLengthFinishReasonMiddleware** - Responses: same-container `status=incomplete` + `incomplete_details.reason=max_output_tokens`. Suppress calls via shared cloning after clearing invalid calls; keep non-call blocks/metadata. Stamp cap markers; add notice. +37. **ModelLengthFinishReasonMiddleware** - Responses: same-container `status=incomplete` + `incomplete_details.reason=max_output_tokens`. Suppress calls via shared cloning after clearing invalid calls; keep non-call blocks/metadata. Stamp cap markers; add notice. Also registered on subagent chains (before Safety), where `consume_stop_reason` surfaces `model_length_capped` to the executor. 38. **SafetyFinishReasonMiddleware** - *(optional, if `safety_finish_reason.enabled`)* Suppresses tool execution when the provider safety-terminated the response (e.g. `finish_reason=content_filter`); registered after terminal-response/custom/configured middlewares so LangChain's reverse-order `after_model` dispatch runs it first 39. **ClarificationMiddleware** - Intercepts `ask_clarification`, writes a readable `ToolMessage.content` fallback plus a structured `ToolMessage.artifact.human_input` payload, and interrupts via `Command(goto=END)` (must be last). `after_model` drops same-turn sibling tool calls so they cannot run before the user answers; a malformed `ask_clarification` parked on `invalid_tool_calls` is the same stop signal. `disable_clarification` runs keep the siblings. Payloads are versioned — legacy `free_text`/`choice_with_other` stay `version: 1`; the v2 `form` mode (from `fields`) is `version: 2` so older frontends reject it and fall back to plain text. Field normalization is deterministic and lives in the middleware (it short-circuits before tool execution, so tool-arg typing gives no runtime validation), and it is atomic: any structurally broken entry — non-dict, bad/duplicate name, a name colliding with a JS `Object.prototype` member (`__proto__`/`constructor`), or exceeding the caps (16 fields / 24 options per field / 200 chars per text / `MAX_FORM_SERIALIZED_BYTES` = 16KB UTF-8, the per-item caps alone admitting forms whose IM text fallback overruns channel limits) — degrades the whole form to the legacy option/free-text modes, so a card never renders "complete" while missing a field. Benign issues degrade locally (unknown types — incl. unhashable JSON like `type: []` — and option-less selects become `text`); options are trimmed/deduped with blanks dropped (form- and top-level) since the frontend rejects blank labels. XML-to-dict option payloads are recursively flattened from dict/list containers in source order, scalar leaves kept, residual XML tags stripped before that trimming. Checkboxes are booleans defaulting to "no"; `required` on one means consent semantics. The response protocol is unchanged (v1 `text`/`option`): form cards submit a text summary as `response_kind: "text"`, so journal persistence needs no new allowlist entries. `RunJournal` reconciles visible, unpersisted `ToolMessage`s for current-run lead-agent calls at the next lead-agent `on_chat_model_start` after consumption, or at successful root `on_chain_end`. This preserves any middleware short-circuit, including blocked writes, across later model errors and checkpoint compaction (#4666). `_remember_current_run_tool_calls` excludes subagents; their results stay in `subagent.step`. Human Input Card replies are `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden sources (currently `ask_clarification`) as `llm.human.input`. diff --git a/backend/packages/harness/deerflow/agents/middlewares/model_length_finish_reason_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/model_length_finish_reason_middleware.py index 7152f390cc..8a085d4912 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/model_length_finish_reason_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/model_length_finish_reason_middleware.py @@ -10,11 +10,21 @@ drops tool calls that may have been truncated at the output boundary before they can execute. The stamped ``model_length_termination`` marker tells downstream guards (notably ``TodoMiddleware``) to stand down rather than re-engage a capped turn. + +Stop-reason surfacing for subagents: + The lead worker reads ``stop_reason`` from ``runtime.context``. The subagent + executor cannot, so each detected cap is also recorded per context + ``run_id`` (``None`` included, exactly as the executor passes it back) and + exposed via :meth:`consume_stop_reason`, the same duck-typed channel + ``TokenBudgetMiddleware`` and ``LoopDetectionMiddleware`` use. A capped + subagent then reaches the lead as ``completed`` + + ``stop_reason=model_length_capped`` instead of a clean ``completed``. """ from __future__ import annotations import logging +import threading from typing import Any, override from langchain.agents import AgentState @@ -22,6 +32,7 @@ from langchain.agents.middleware import AgentMiddleware from langchain_core.messages import AIMessage from langgraph.runtime import Runtime +from deerflow.agents.middlewares._bounded_dict import BoundedDict from deerflow.agents.middlewares.model_length_termination_detectors import ( ModelLengthTermination, ModelLengthTerminationDetector, @@ -72,6 +83,20 @@ class ModelLengthFinishReasonMiddleware(AgentMiddleware[AgentState]): def __init__(self, detectors: list[ModelLengthTerminationDetector] | None = None) -> None: super().__init__() self._detectors: list[ModelLengthTerminationDetector] = list(detectors) if detectors else default_detectors() + # Consumed by the subagent executor after the run returns; bounded + # because the lead chain's shared instance never consumes it. + self._lock = threading.Lock() + self._stop_reason: BoundedDict[str | None, str] = BoundedDict(1000) + + def consume_stop_reason(self, run_id: str | None) -> str | None: + """Pop and return ``"model_length_capped"`` if a cap was detected for this run. + + The subagent executor calls this after the run returns, alongside the + token-budget and loop-detection guards, so a length-capped completion + carries the cap to the lead. + """ + with self._lock: + return self._stop_reason.pop(run_id, None) def release_policy_parameters(self) -> dict[str, object]: from deerflow_extension_api import canonical_hash @@ -106,6 +131,9 @@ class ModelLengthFinishReasonMiddleware(AgentMiddleware[AgentState]): thread_id = ctx.get("thread_id") if isinstance(ctx, dict) else None run_id = ctx.get("run_id") if isinstance(ctx, dict) else None stamped_stop_reason = False + if isinstance(ctx, dict) and "run_id" in ctx: + with self._lock: + self._stop_reason[run_id] = MODEL_LENGTH_CAPPED_STOP_REASON if isinstance(ctx, dict): # Preserve any earlier cap reason carried across hidden continuation turns. if "stop_reason" not in ctx: 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 ebfb0bc009..aa174b39bc 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 @@ -701,6 +701,17 @@ def build_subagent_runtime_middlewares( middlewares.extend(load_configured_extension_middlewares(app_config)) + # Same provider output-length guard the lead agent uses. A subagent answer + # cut off at the model's output limit would otherwise reach the lead as a + # clean ``completed``, and a tool call truncated at that boundary would + # still execute. The middleware drops such tool calls and exposes + # ``consume_stop_reason`` so the executor marks the result + # ``model_length_capped``. Registered before Safety, as on the lead chain, + # so reverse-order after_model dispatch runs Safety first. + from deerflow.agents.middlewares.model_length_finish_reason_middleware import ModelLengthFinishReasonMiddleware + + middlewares.append(ModelLengthFinishReasonMiddleware()) + # 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/packages/harness/deerflow/subagents/AGENTS.md b/backend/packages/harness/deerflow/subagents/AGENTS.md index aff08ca84c..40edaaa595 100644 --- a/backend/packages/harness/deerflow/subagents/AGENTS.md +++ b/backend/packages/harness/deerflow/subagents/AGENTS.md @@ -114,7 +114,7 @@ through additive `stop_reason`, never a new status enum. `_aexecute` duck-types every middleware's per-run `consume_stop_reason(run_id)` and uses the first non-`None` reason. Usable final answers or turn-cap partials -remain `completed` with `token_capped|turn_capped|loop_capped`; a turn cap without +remain `completed` with `token_capped|turn_capped|loop_capped|model_length_capped`; a turn cap without usable work is `failed` with `turn_capped`. `SubagentResult.stop_reason` flows through `_task_result_command`, `format_subagent_result_message` (capped labels), and `make_subagent_additional_kwargs` (`subagent_stop_reason` alongside status). diff --git a/backend/packages/harness/deerflow/subagents/executor.py b/backend/packages/harness/deerflow/subagents/executor.py index 679a186eaa..e180de70c3 100644 --- a/backend/packages/harness/deerflow/subagents/executor.py +++ b/backend/packages/harness/deerflow/subagents/executor.py @@ -1022,9 +1022,10 @@ class SubagentExecutor: # this exact set so a subagent cannot activate an undisclosed skill. self._available_skill_names: set[str] = set() # Guard middlewares that expose ``consume_stop_reason`` (currently - # ``TokenBudgetMiddleware`` and ``LoopDetectionMiddleware``), captured in - # ``_create_agent`` so ``_aexecute`` can read each after the run and - # surface whichever cap fired (token_capped / loop_capped) to the lead + # ``TokenBudgetMiddleware``, ``LoopDetectionMiddleware`` and + # ``ModelLengthFinishReasonMiddleware``), captured in ``_create_agent`` + # so ``_aexecute`` can read each after the run and surface whichever + # cap fired (token_capped / loop_capped / model_length_capped) to the lead # (#3875 Phase 2). Collected as a list — every guard must be checked, # not just the first — because the v2 contract advertises more than one # cap reason. @@ -1169,7 +1170,8 @@ class SubagentExecutor: self.config.name, ) # Collect every guard middleware that exposes ``consume_stop_reason`` - # (TokenBudgetMiddleware, LoopDetectionMiddleware) so _aexecute can read + # (TokenBudgetMiddleware, LoopDetectionMiddleware, + # ModelLengthFinishReasonMiddleware) so _aexecute can read # each after the run and surface whichever cap fired. Duck-typed # (``hasattr``) so this file needs no import of the middleware classes; # a list (not ``next(...)``) so every guard is checked and a later one @@ -1328,7 +1330,9 @@ class SubagentExecutor: Checks every guard middleware that exposes ``consume_stop_reason`` (collected in :meth:`_create_agent`) and returns the first non-``None`` reason — ``"token_capped"`` when the token-budget hard stop fired, - ``"loop_capped"`` when loop detection forced a stop, otherwise ``None``. + ``"loop_capped"`` when loop detection forced a stop, + ``"model_length_capped"`` when the provider cut the response at its + output limit, otherwise ``None``. Each guard's cap does not raise (the run still completes with a final answer), so this is how the executor learns a completion was actually capped. Typically at most one guard fires per run, but checking all of diff --git a/backend/packages/harness/deerflow/subagents/status_contract.py b/backend/packages/harness/deerflow/subagents/status_contract.py index d47c79911d..964586d72e 100644 --- a/backend/packages/harness/deerflow/subagents/status_contract.py +++ b/backend/packages/harness/deerflow/subagents/status_contract.py @@ -7,7 +7,7 @@ consumers read the structured facts carried inside - ``subagent_status``: one of ``SUBAGENT_STATUS_VALUES``. - ``subagent_stop_reason`` (optional): when a guardrail cap ended the run early, one of ``SUBAGENT_STOP_REASON_VALUES`` (``token_capped`` / - ``turn_capped`` / ``loop_capped``). Additive (#3875 Phase 2): a capped run + ``turn_capped`` / ``loop_capped`` / ``model_length_capped``). Additive (#3875 Phase 2): a capped run that still produced a final answer stays ``status=completed`` and carries the cap here; a capped run with no usable output is ``status=failed`` + ``stop_reason``. Old frontends ignore the unknown field. @@ -90,12 +90,13 @@ SUBAGENT_STATUS_VALUES: tuple[SubagentStatusValue, ...] = ( #: Why a guardrail cap ended a run early. Carried on the additive #: ``subagent_stop_reason`` field, never as a status enum value. -SubagentStopReasonValue = Literal["token_capped", "turn_capped", "loop_capped"] +SubagentStopReasonValue = Literal["token_capped", "turn_capped", "loop_capped", "model_length_capped"] SUBAGENT_STOP_REASON_VALUES: tuple[SubagentStopReasonValue, ...] = ( "token_capped", "turn_capped", "loop_capped", + "model_length_capped", ) #: Human-readable label folded into the model-visible result text when a cap @@ -104,6 +105,7 @@ _STOP_REASON_LABELS: dict[SubagentStopReasonValue, str] = { "token_capped": "token budget", "turn_capped": "turn budget", "loop_capped": "repeated tool-call loop", + "model_length_capped": "model output length limit", } #: Statuses that carry a recoverable result in ``subagent_result_brief`` / diff --git a/backend/tests/test_middleware_documentation.py b/backend/tests/test_middleware_documentation.py index 524ab5214e..7d22465f0e 100644 --- a/backend/tests/test_middleware_documentation.py +++ b/backend/tests/test_middleware_documentation.py @@ -88,9 +88,9 @@ def test_lifecycle_return_contract_is_explicit(path: Path) -> None: def test_middleware_placement_scope_is_explicit(path: Path) -> None: content = (REPO_ROOT / path).read_text(encoding="utf-8") marker = ( - "对于主 Agent 链,它位于终态响应、模型长度、安全和澄清尾部之前;子 Agent 链没有终态响应、模型长度或澄清阶段" + "对于主 Agent 链,它位于终态响应、模型长度、安全和澄清尾部之前;子 Agent 链没有终态响应或澄清阶段" if "/zh/" in path.as_posix() - else "On the lead-agent pipeline, it runs before the terminal-response, model-length, safety, and clarification tail; subagents have no terminal-response, model-length, or clarification stage" + else "On the lead-agent pipeline, it runs before the terminal-response, model-length, safety, and clarification tail; subagents have no terminal-response or clarification stage" ) assert marker in " ".join(content.split()) @@ -128,9 +128,12 @@ def test_middleware_order_includes_configured_extension_tail(path: Path) -> None def test_subagent_summarization_optionality_is_explicit(path: Path) -> None: content = " ".join((REPO_ROOT / path).read_text(encoding="utf-8").split()) marker = ( - "因此配置中间件之后会继续执行可选的安全防护、`DurableContextMiddleware`、可选的 `SummarizationMiddleware`,随后是 `SubagentDateContextMiddleware` 和 `SystemMessageCoalescingMiddleware`。" + "因此配置中间件之后会继续执行模型长度防护、可选的安全防护、`DurableContextMiddleware`、可选的 `SummarizationMiddleware`,随后是 `SubagentDateContextMiddleware` 和 `SystemMessageCoalescingMiddleware`。" if "/zh/" in path.as_posix() - else "so configured middleware is followed by the optional safety guard, `DurableContextMiddleware`, optional `SummarizationMiddleware`, then `SubagentDateContextMiddleware` and `SystemMessageCoalescingMiddleware`." + else ( + "so configured middleware is followed by the model-length guard, the optional safety guard, `DurableContextMiddleware`, " + "optional `SummarizationMiddleware`, then `SubagentDateContextMiddleware` and `SystemMessageCoalescingMiddleware`." + ) ) assert marker in content diff --git a/backend/tests/test_model_length_finish_reason_middleware.py b/backend/tests/test_model_length_finish_reason_middleware.py index e022205f42..0128b70101 100644 --- a/backend/tests/test_model_length_finish_reason_middleware.py +++ b/backend/tests/test_model_length_finish_reason_middleware.py @@ -341,3 +341,36 @@ def test_non_ai_last_message_passes_through(): assert mw._apply({"messages": [HumanMessage(content="hello")]}, runtime) is None assert "stop_reason" not in runtime.context + + +def test_consume_stop_reason_returns_model_length_capped_once_per_run(): + mw = ModelLengthFinishReasonMiddleware() + msg = AIMessage(content="partial", response_metadata={"finish_reason": "length"}) + + mw._apply({"messages": [msg]}, _runtime("run-a")) + + assert mw.consume_stop_reason("run-b") is None + assert mw.consume_stop_reason("run-a") == MODEL_LENGTH_CAPPED_STOP_REASON + assert mw.consume_stop_reason("run-a") is None + + +def test_consume_stop_reason_keys_by_context_run_id_including_none(): + # The subagent executor consumes with its raw run_id, which is None when + # the parent run has none; the middleware must key the cap the same way. + mw = ModelLengthFinishReasonMiddleware() + runtime = MagicMock() + runtime.context = {"thread_id": "thread-1", "run_id": None} + msg = AIMessage(content="", tool_calls=[{"name": "write_file", "args": {}, "id": "call-1"}], response_metadata={"finish_reason": "length"}) + + assert mw._apply({"messages": [msg]}, runtime) is not None + + assert mw.consume_stop_reason(None) == MODEL_LENGTH_CAPPED_STOP_REASON + + +def test_consume_stop_reason_is_empty_for_clean_response(): + mw = ModelLengthFinishReasonMiddleware() + msg = AIMessage(content="done", response_metadata={"finish_reason": "stop"}) + + mw._apply({"messages": [msg]}, _runtime("run-a")) + + assert mw.consume_stop_reason("run-a") is None diff --git a/backend/tests/test_subagent_model_length_capped.py b/backend/tests/test_subagent_model_length_capped.py new file mode 100644 index 0000000000..0a9d61140f --- /dev/null +++ b/backend/tests/test_subagent_model_length_capped.py @@ -0,0 +1,95 @@ +"""A subagent response cut at the provider output limit must not look like a clean completion.""" + +import importlib.util +import sys +from pathlib import Path + +import pytest +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage +from langchain_core.tools import StructuredTool + +from deerflow.config.app_config import AppConfig +from deerflow.subagents.config import SubagentConfig +from deerflow.subagents.status_contract import format_subagent_result_message + + +@pytest.fixture +def executor_module(monkeypatch, tmp_path): + # conftest installs an executor stub. Load the production module separately + # so the real subagent middleware chain runs. + path = Path(__file__).parents[1] / "packages/harness/deerflow/subagents/executor.py" + spec = importlib.util.spec_from_file_location("_model_length_capped_executor", path) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, spec.name, module) + spec.loader.exec_module(module) + monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path)) + yield module + module._shutdown_isolated_subagent_loop() + + +class RecordingModel(GenericFakeChatModel): + def bind_tools(self, tools, **kwargs): + return self + + +def _executor(executor_module, monkeypatch, responses, tools): + model = RecordingModel(messages=iter(responses)) + monkeypatch.setattr(executor_module, "create_chat_model", lambda **kwargs: model) + app_config = AppConfig.model_validate( + { + "models": [{"name": "offline", "use": "langchain_openai:ChatOpenAI", "model": "offline"}], + "sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, + "summarization": {"enabled": False}, + } + ) + return executor_module.SubagentExecutor( + config=SubagentConfig(name="writer", description="Offline writer", skills=[]), + tools=tools, + parent_model="offline", + app_config=app_config, + thread_id="length-thread", + user_id="length-user", + ) + + +@pytest.mark.asyncio +async def test_length_capped_answer_completes_with_model_length_capped(executor_module, monkeypatch): + responses = [AIMessage(content="## Draft\n\nSection 1 covers the", response_metadata={"finish_reason": "length"})] + executor = _executor(executor_module, monkeypatch, responses, tools=[]) + + result = await executor._aexecute("Write the long draft") + + assert result.status == executor_module.SubagentStatus.COMPLETED, result.error + assert result.stop_reason == "model_length_capped" + assert "Section 1 covers the" in result.result + content, _ = format_subagent_result_message(result.status.value, result=result.result, stop_reason=result.stop_reason) + assert content.startswith("Task Succeeded (capped: model output length limit)") + + +@pytest.mark.asyncio +async def test_length_capped_tool_call_is_dropped_not_executed(executor_module, monkeypatch): + executed = [] + + def write_report(content: str) -> str: + """Write the report.""" + executed.append(content) + return "written" + + tool = StructuredTool.from_function(write_report, name="write_report") + responses = [ + AIMessage( + content="", + tool_calls=[{"name": "write_report", "args": {"content": "# Report\n\nhalf a sent"}, "id": "call-1"}], + response_metadata={"finish_reason": "length"}, + ), + AIMessage(content="unreachable: the capped turn must end the run"), + ] + executor = _executor(executor_module, monkeypatch, responses, tools=[tool]) + + result = await executor._aexecute("Write the report to a file") + + assert executed == [] + assert result.status == executor_module.SubagentStatus.COMPLETED, result.error + assert result.stop_reason == "model_length_capped" + assert "output limit" in result.result diff --git a/backend/tests/test_subagent_status_contract.py b/backend/tests/test_subagent_status_contract.py index d5c3056f08..a4bfb4ae0a 100644 --- a/backend/tests/test_subagent_status_contract.py +++ b/backend/tests/test_subagent_status_contract.py @@ -112,6 +112,18 @@ def test_format_subagent_result_message_completed_with_stop_reason_notes_the_cap assert metadata_error is None +def test_format_subagent_result_message_notes_model_length_cap(): + """A subagent whose final answer was cut at the provider output limit is + ``completed`` + ``model_length_capped``; the lead sees the cap in the text + and in the structured metadata.""" + content, metadata_error = format_subagent_result_message("completed", result="## Draft - Section 1 covers the", stop_reason="model_length_capped") + assert content.startswith("Task Succeeded (capped: model output length limit)") + assert metadata_error is None + kwargs = make_subagent_additional_kwargs("completed", result="## Draft", stop_reason="model_length_capped") + assert kwargs[SUBAGENT_STOP_REASON_KEY] == "model_length_capped" + assert read_subagent_result_metadata(kwargs)["stop_reason"] == "model_length_capped" + + def test_format_subagent_result_message_failed_with_stop_reason_notes_the_cap(): """A turn-capped run with no usable output is ``failed`` + ``turn_capped``; the cap note distinguishes "out of budget" from a broken subagent.""" diff --git a/backend/tests/test_tool_error_handling_middleware.py b/backend/tests/test_tool_error_handling_middleware.py index 7e1593b802..de8006f846 100644 --- a/backend/tests/test_tool_error_handling_middleware.py +++ b/backend/tests/test_tool_error_handling_middleware.py @@ -213,6 +213,7 @@ def test_build_subagent_runtime_middlewares_threads_app_config_to_llm_middleware # + 1 ReadBeforeWriteMiddleware + 1 LoopDetectionMiddleware # + 1 TokenBudgetMiddleware (subagents.token_budget enabled by default, #3875 Phase 2) # + 1 SkillActivationMiddleware (allowed-tools is metadata, no policy wrapper) + # + 1 ModelLengthFinishReasonMiddleware # + 1 SafetyFinishReasonMiddleware + 1 DurableContextMiddleware # + 1 SubagentDateContextMiddleware # + 1 SystemMessageCoalescingMiddleware + 1 ToolReceiptMiddleware @@ -231,7 +232,7 @@ def test_build_subagent_runtime_middlewares_threads_app_config_to_llm_middleware from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware from deerflow.agents.middlewares.tool_receipt_middleware import ToolReceiptMiddleware - assert len(middlewares) == 22 + assert len(middlewares) == 23 assert isinstance(middlewares[0], FakeMiddleware) # InputSanitizationMiddleware stub assert isinstance(middlewares[1], KnowledgeScopeMiddleware) assert isinstance(middlewares[2], ToolOutputBudgetMiddleware) @@ -814,6 +815,29 @@ def test_subagent_runtime_middlewares_inject_configured_extension_middlewares(mo assert extension_idx < safety_idx +def test_subagent_runtime_middlewares_attach_model_length_guard_before_safety_finish(monkeypatch): + """Subagents must carry the lead's output-length guard so a response cut + off at the model's output limit drops its (possibly truncated) tool calls + and the executor can report ``stop_reason=model_length_capped`` through + the duck-typed ``consume_stop_reason`` channel. Placement mirrors the lead + chain: after configured extensions, before SafetyFinishReasonMiddleware.""" + from deerflow.agents.middlewares.model_length_finish_reason_middleware import ModelLengthFinishReasonMiddleware + from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware + + app_config = _make_app_config() + app_config.extensions = ExtensionsConfig(middlewares=[f"{__name__}:ConfiguredSubagentMiddleware"]) + _stub_runtime_middleware_imports(monkeypatch) + + middlewares = build_subagent_runtime_middlewares(app_config=app_config, model_name="test-model") + + length_idx = [i for i, m in enumerate(middlewares) if isinstance(m, ModelLengthFinishReasonMiddleware)] + assert len(length_idx) == 1 + extension_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, ConfiguredSubagentMiddleware)) + safety_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, SafetyFinishReasonMiddleware)) + assert extension_idx < length_idx[0] < safety_idx + assert middlewares[length_idx[0]] in [m for m in middlewares if hasattr(m, "consume_stop_reason")] + + def test_subagent_runtime_middlewares_place_mcp_routing_before_deferred_filter(monkeypatch): from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware from deerflow.agents.middlewares.mcp_routing_middleware import McpRoutingMiddleware diff --git a/contracts/subagent_status_contract.json b/contracts/subagent_status_contract.json index ddc423a556..f60f02a566 100644 --- a/contracts/subagent_status_contract.json +++ b/contracts/subagent_status_contract.json @@ -2,5 +2,5 @@ "version": 2, "description": "Cross-language contract fixture for the structured subagent status field. Task result text is display content only and is not part of this wire contract. The optional subagent_stop_reason field (v2, #3875 Phase 2) carries why a guardrail cap ended a run early; it is ignored by older consumers that only read subagent_status.", "valid_status_values": ["completed", "failed", "cancelled", "timed_out", "polling_timed_out"], - "valid_stop_reason_values": ["token_capped", "turn_capped", "loop_capped"] + "valid_stop_reason_values": ["token_capped", "turn_capped", "loop_capped", "model_length_capped"] } diff --git a/frontend/src/content/en/harness/customization.mdx b/frontend/src/content/en/harness/customization.mdx index f4485afeaa..3df030b752 100644 --- a/frontend/src/content/en/harness/customization.mdx +++ b/frontend/src/content/en/harness/customization.mdx @@ -55,7 +55,7 @@ extensions: max_tool_calls: 5 ``` -Configured middleware is loaded after the built-in middleware and optional loop/token guards. On the lead-agent pipeline, it runs before the terminal-response, model-length, safety, and clarification tail; subagents have no terminal-response, model-length, or clarification stage, so configured middleware is followed by the optional safety guard, `DurableContextMiddleware`, optional `SummarizationMiddleware`, then `SubagentDateContextMiddleware` and `SystemMessageCoalescingMiddleware`. Treat these class paths as trusted configuration because loading one executes Python code. Embedded callers can instead use `DeerFlowClient(middlewares=[AuditMiddleware()])`, which builds the full lead-agent chain and places middleware before its terminal-response, model-length, safety, and clarification tail. `create_deerflow_agent(extra_middleware=[AuditMiddleware()])` instead builds a smaller feature-based lead-agent chain; unanchored extras are placed immediately before `ClarificationMiddleware` (anchored extras follow their `@Next`/`@Prev` placement). Neither API forwards middleware to subagents. +Configured middleware is loaded after the built-in middleware and optional loop/token guards. On the lead-agent pipeline, it runs before the terminal-response, model-length, safety, and clarification tail; subagents have no terminal-response or clarification stage, so configured middleware is followed by the model-length guard, the optional safety guard, `DurableContextMiddleware`, optional `SummarizationMiddleware`, then `SubagentDateContextMiddleware` and `SystemMessageCoalescingMiddleware`. Treat these class paths as trusted configuration because loading one executes Python code. Embedded callers can instead use `DeerFlowClient(middlewares=[AuditMiddleware()])`, which builds the full lead-agent chain and places middleware before its terminal-response, model-length, safety, and clarification tail. `create_deerflow_agent(extra_middleware=[AuditMiddleware()])` instead builds a smaller feature-based lead-agent chain; unanchored extras are placed immediately before `ClarificationMiddleware` (anchored extras follow their `@Next`/`@Prev` placement). Neither API forwards middleware to subagents. Choose the registration path by ownership and placement. The fixed-slot (not deprecated) `extensions.middlewares` list is accepted in `config.yaml` and `extensions_config.json` (`config.yaml` wins) and applies to both lead and subagent pipelines. Packaged extensions registered through the top-level `plugins:` list contribute middleware at semantic extension points. Contributor code that needs committed, programmatic lead-only wiring can use `build_middlewares(..., custom_middlewares=[AuditMiddleware()])`. diff --git a/frontend/src/content/en/harness/middlewares.mdx b/frontend/src/content/en/harness/middlewares.mdx index 51a95a0e99..ca37881919 100644 --- a/frontend/src/content/en/harness/middlewares.mdx +++ b/frontend/src/content/en/harness/middlewares.mdx @@ -267,6 +267,6 @@ extensions: max_tool_calls: 5 ``` -Configured middleware runs after the built-in middleware and optional loop/token guards. On the lead-agent pipeline, it runs before the terminal-response, model-length, safety, and clarification tail; subagents have no terminal-response, model-length, or clarification stage, so configured middleware is followed by the optional safety guard, `DurableContextMiddleware`, optional `SummarizationMiddleware`, then `SubagentDateContextMiddleware` and `SystemMessageCoalescingMiddleware`. Treat middleware paths as trusted configuration because loading one executes Python code. Embedded callers can instead use `DeerFlowClient(middlewares=[...])`, which builds the full lead-agent chain and places middleware before its terminal-response, model-length, safety, and clarification tail. `create_deerflow_agent(extra_middleware=[...])` instead builds a smaller feature-based lead-agent chain; unanchored extras are placed immediately before `ClarificationMiddleware` (anchored extras follow their `@Next`/`@Prev` placement). Neither API forwards middleware to subagents. +Configured middleware runs after the built-in middleware and optional loop/token guards. On the lead-agent pipeline, it runs before the terminal-response, model-length, safety, and clarification tail; subagents have no terminal-response or clarification stage, so configured middleware is followed by the model-length guard, the optional safety guard, `DurableContextMiddleware`, optional `SummarizationMiddleware`, then `SubagentDateContextMiddleware` and `SystemMessageCoalescingMiddleware`. Treat middleware paths as trusted configuration because loading one executes Python code. Embedded callers can instead use `DeerFlowClient(middlewares=[...])`, which builds the full lead-agent chain and places middleware before its terminal-response, model-length, safety, and clarification tail. `create_deerflow_agent(extra_middleware=[...])` instead builds a smaller feature-based lead-agent chain; unanchored extras are placed immediately before `ClarificationMiddleware` (anchored extras follow their `@Next`/`@Prev` placement). Neither API forwards middleware to subagents. Choose the registration path by ownership and placement. The fixed-slot (not deprecated) `extensions.middlewares` list is accepted in `config.yaml` and `extensions_config.json` (`config.yaml` wins) and applies to both lead and subagent pipelines. Packaged extensions registered through the top-level `plugins:` list contribute middleware at semantic extension points. Contributor code that needs committed, programmatic lead-only wiring can use `build_middlewares(..., custom_middlewares=[MyMiddleware()])`. diff --git a/frontend/src/content/en/harness/subagents/developers.mdx b/frontend/src/content/en/harness/subagents/developers.mdx index 616d4c9e61..558df4a1fc 100644 --- a/frontend/src/content/en/harness/subagents/developers.mdx +++ b/frontend/src/content/en/harness/subagents/developers.mdx @@ -46,7 +46,7 @@ Public exports: `deerflow.agents` provides `create_deerflow_agent`, `RuntimeFeat `contracts/subagent_status_contract.json` is the fixture shared by backend and frontend (version 2): - Valid `subagent_status`: `completed`, `failed`, `cancelled`, `timed_out`, `polling_timed_out`. -- Valid `subagent_stop_reason`: `token_capped`, `turn_capped`, `loop_capped`. +- Valid `subagent_stop_reason`: `token_capped`, `turn_capped`, `loop_capped`, `model_length_capped`. - The tool result text is display content, not part of the contract. The backend writes metadata with `make_subagent_additional_kwargs` and reads it with `read_subagent_result_metadata`. The writer raises `ValueError` on an invalid status; the reader returns `None` for an unknown one and maps the legacy `max_turns_reached` to `turn_capped`. The frontend's `parseSubtaskResult` reads structured fields first and falls back to text prefixes only when no structured metadata exists at all. Additive fields (model name, token usage, receipts, acceptance) do not require a contract version bump. diff --git a/frontend/src/content/en/harness/subagents/limits.mdx b/frontend/src/content/en/harness/subagents/limits.mdx index 79fad9e4cb..b9b9c3338c 100644 --- a/frontend/src/content/en/harness/subagents/limits.mdx +++ b/frontend/src/content/en/harness/subagents/limits.mdx @@ -60,10 +60,11 @@ The timeout is wall-clock. Once it elapses the subagent is cancelled with status ## Runaway guards -The subagent middleware chain mirrors three Lead Agent guards: +The subagent middleware chain mirrors four Lead Agent guards: - **Loop detection** (`LoopDetectionMiddleware`): breaks a subagent that repeats the same tool call without progress. Subagents have no `task`, so only the tool-loop heuristic can fire. A hard stop marks the result `completed` + `subagent_stop_reason=loop_capped`. Controlled by the `loop_detection` config, with `tool_freq_overrides` for per-tool thresholds. - **Token budget** (`TokenBudgetMiddleware`): tracks the run's cumulative tokens against `subagents.token_budget`. At the hard-stop threshold it strips the current turn's tool calls and forces a final answer, marking the result `completed` + `token_capped`. Reaching `warn_threshold` injects a one-time budget warning into the subagent's next model call and logs it at INFO level. +- **Output length** (`ModelLengthFinishReasonMiddleware`): when the provider stops a response at its output limit (`finish_reason=length`, `stop_reason=max_tokens`, `MAX_TOKENS`, or Responses `max_output_tokens`), tool calls in that response are dropped because their arguments may be truncated, and the result is marked `completed` + `model_length_capped`. - **Summarization** (`DeerFlowSummarizationMiddleware`): compacts long subagent transcripts under the same `summarization.enabled` switch as the Lead Agent, and by default summarizes with the subagent's own model. Compaction preserves the subagent's system prompt (role, report contract, acceptance note, skill index, deferred tool catalog) and the latest user message. `DurableContextMiddleware` sits before summarization and re-injects `summary_text` into later requests, so a compacted history never starts with an assistant message. The default token ceiling is coupled to the summarization switch: 1,000,000 when compaction is on, 2,000,000 when off. An explicit `subagents.token_budget.max_tokens` (global or per agent) always wins, so flipping summarization never silently changes a value you pinned. diff --git a/frontend/src/content/en/harness/subagents/observability.mdx b/frontend/src/content/en/harness/subagents/observability.mdx index bcf49aa1bf..65e03b99e5 100644 --- a/frontend/src/content/en/harness/subagents/observability.mdx +++ b/frontend/src/content/en/harness/subagents/observability.mdx @@ -63,7 +63,7 @@ The terminal `ToolMessage` of a `task` carries these keys in `additional_kwargs` | Key | Meaning | | ----------------------------- | ---------------------------------------------------------- | | `subagent_status` | One of the five terminal statuses | -| `subagent_stop_reason` | `token_capped` / `turn_capped` / `loop_capped`, optional | +| `subagent_stop_reason` | `token_capped` / `turn_capped` / `loop_capped` / `model_length_capped`, optional | | `subagent_error` | Error text for non-`completed` results, up to 2,000 characters | | `subagent_result_brief` | Result brief for `completed`, up to 2,000 characters | | `subagent_result_sha256` | SHA-256 of the full result | diff --git a/frontend/src/content/en/harness/subagents/reference.mdx b/frontend/src/content/en/harness/subagents/reference.mdx index 4a6cdc1dbe..22e1360acf 100644 --- a/frontend/src/content/en/harness/subagents/reference.mdx +++ b/frontend/src/content/en/harness/subagents/reference.mdx @@ -75,7 +75,7 @@ Canonical acceptance criteria: `file: exists`, `file: non-empty`, `f | Field | Values | | --------------------------- | --------------------------------------------------------------------------------------- | | `subagent_status` | `completed`, `failed`, `cancelled`, `timed_out`, `polling_timed_out` | -| `subagent_stop_reason` | `token_capped`, `turn_capped`, `loop_capped` | +| `subagent_stop_reason` | `token_capped`, `turn_capped`, `loop_capped`, `model_length_capped` | | Acceptance leaf | Booleans `checked` and `holds`; rendered as `holds` / `does not hold` / `UNVERIFIED` | | Receipt verification | `resolved`, `failed`, `unknown`, `no_citation_claims` | | Delegation ledger status | `in_progress` plus the five terminal statuses | diff --git a/frontend/src/content/en/harness/subagents/results.mdx b/frontend/src/content/en/harness/subagents/results.mdx index f12c46e69a..13f92611a7 100644 --- a/frontend/src/content/en/harness/subagents/results.mdx +++ b/frontend/src/content/en/harness/subagents/results.mdx @@ -29,15 +29,16 @@ A subagent's report is its **self-report**. DeerFlow layers three kinds of check ### Cap reasons: stop_reason -Three guards can end a run early. The run usually still counts as `completed`; when no usable output exists it is `failed` and keeps the same `stop_reason`: +Four guards can end a run early. The run usually still counts as `completed`; when no usable output exists it is `failed` and keeps the same `stop_reason`: | `subagent_stop_reason` | Trigger | Result text | | ---------------------- | -------------------------------- | -------------------------------------------------------------------- | | `turn_capped` | `max_turns` exhausted | `Task Succeeded (capped: turn budget). Result: ...` | | `token_capped` | `token_budget` hard stop reached | `Task Succeeded (capped: token budget). Result: ...` | | `loop_capped` | Loop-detection hard stop | `Task Succeeded (capped: repeated tool-call loop). Result: ...` | +| `model_length_capped` | Provider output-length limit | `Task Succeeded (capped: model output length limit). Result: ...` | -A capped report is a **partial result**: the tool calls of the final turn were stripped and the model was forced to answer. When the turn budget runs out with no usable text at all, the status is `failed` and the text reads `Task failed (capped: turn budget). Error: Reached max_turns=N`. The legacy `max_turns_reached` status maps to `turn_capped` on read. +A capped report is a **partial result**: the tool calls of the final turn were stripped and the model was forced to answer. For `model_length_capped`, the provider cut the final response at its output limit (`finish_reason=length` or an equivalent signal); any tool call in that response is dropped instead of executed. When the turn budget runs out with no usable text at all, the status is `failed` and the text reads `Task failed (capped: turn budget). Error: Reached max_turns=N`. The legacy `max_turns_reached` status maps to `turn_capped` on read. ### How failure is decided diff --git a/frontend/src/content/en/harness/subagents/troubleshooting.mdx b/frontend/src/content/en/harness/subagents/troubleshooting.mdx index 0083ca5412..d646a5f699 100644 --- a/frontend/src/content/en/harness/subagents/troubleshooting.mdx +++ b/frontend/src/content/en/harness/subagents/troubleshooting.mdx @@ -33,6 +33,12 @@ Expected. A failed subagent is an ordinary tool result; the parent run does not **Fix**: first check the step timeline for the same tool being called over and over. If more budget is genuinely needed, adjust `subagents.token_budget.max_tokens` or `loop_detection.tool_freq_overrides`. Note that the default token ceiling is coupled to `summarization.enabled`; an explicit value is not. +### The result is marked `model_length_capped` + +**Cause**: the provider stopped the subagent's response at the model's output limit, so the answer is incomplete and any tool call in that response was dropped. + +**Fix**: raise the output limit (`max_tokens`) of the model the subagent uses, or ask for shorter output, for example by splitting the task or writing long content to a file in several steps. + ### Status `failed` with the error `Reached max_turns=N` The turns ran out and the last assistant message had no usable text; the result reads `Task failed (capped: turn budget). Error: Reached max_turns=N`. Same fix as above. diff --git a/frontend/src/content/zh/harness/customization.mdx b/frontend/src/content/zh/harness/customization.mdx index 1916e64a91..4cbb55ad12 100644 --- a/frontend/src/content/zh/harness/customization.mdx +++ b/frontend/src/content/zh/harness/customization.mdx @@ -54,7 +54,7 @@ extensions: max_tool_calls: 5 ``` -配置的中间件会在内置中间件及可选的循环/token 保护之后加载。对于主 Agent 链,它位于终态响应、模型长度、安全和澄清尾部之前;子 Agent 链没有终态响应、模型长度或澄清阶段,因此配置中间件之后会继续执行可选的安全防护、`DurableContextMiddleware`、可选的 `SummarizationMiddleware`,随后是 `SubagentDateContextMiddleware` 和 `SystemMessageCoalescingMiddleware`。中间件路径会执行 Python 代码,因此应视为可信配置。嵌入式调用方也可以使用 `DeerFlowClient(middlewares=[AuditMiddleware()])`;它构建完整的主 Agent 链,并将中间件放在其终态响应、模型长度、安全和澄清尾部之前。`create_deerflow_agent(extra_middleware=[AuditMiddleware()])` 则构建较小的按功能组装的主 Agent 链;未锚定的额外中间件会紧接在 `ClarificationMiddleware` 之前放置(锚定中间件遵循其 `@Next`/`@Prev` 位置)。两个 API 均不会将中间件转发给子 Agent。 +配置的中间件会在内置中间件及可选的循环/token 保护之后加载。对于主 Agent 链,它位于终态响应、模型长度、安全和澄清尾部之前;子 Agent 链没有终态响应或澄清阶段,因此配置中间件之后会继续执行模型长度防护、可选的安全防护、`DurableContextMiddleware`、可选的 `SummarizationMiddleware`,随后是 `SubagentDateContextMiddleware` 和 `SystemMessageCoalescingMiddleware`。中间件路径会执行 Python 代码,因此应视为可信配置。嵌入式调用方也可以使用 `DeerFlowClient(middlewares=[AuditMiddleware()])`;它构建完整的主 Agent 链,并将中间件放在其终态响应、模型长度、安全和澄清尾部之前。`create_deerflow_agent(extra_middleware=[AuditMiddleware()])` 则构建较小的按功能组装的主 Agent 链;未锚定的额外中间件会紧接在 `ClarificationMiddleware` 之前放置(锚定中间件遵循其 `@Next`/`@Prev` 位置)。两个 API 均不会将中间件转发给子 Agent。 应根据配置的所有者和插入位置选择注册方式。固定槽位(并未弃用)`extensions.middlewares` 可写在 `config.yaml` 或 `extensions_config.json` 中(前者优先),并同时作用于主 Agent 和子 Agent。通过顶层 `plugins:` 注册的打包扩展可在语义化扩展点贡献中间件。若贡献代码需要可提交、可编程且仅作用于主 Agent 的接线,请使用 `build_middlewares(..., custom_middlewares=[AuditMiddleware()])`。 diff --git a/frontend/src/content/zh/harness/middlewares.mdx b/frontend/src/content/zh/harness/middlewares.mdx index 2552614b95..3212fd685f 100644 --- a/frontend/src/content/zh/harness/middlewares.mdx +++ b/frontend/src/content/zh/harness/middlewares.mdx @@ -250,6 +250,6 @@ extensions: max_tool_calls: 5 ``` -配置的中间件会在内置中间件及可选的循环/token 保护之后加载。对于主 Agent 链,它位于终态响应、模型长度、安全和澄清尾部之前;子 Agent 链没有终态响应、模型长度或澄清阶段,因此配置中间件之后会继续执行可选的安全防护、`DurableContextMiddleware`、可选的 `SummarizationMiddleware`,随后是 `SubagentDateContextMiddleware` 和 `SystemMessageCoalescingMiddleware`。中间件路径会执行 Python 代码,因此应视为可信配置。嵌入式调用方也可以使用 `DeerFlowClient(middlewares=[...])`;它构建完整的主 Agent 链,并将中间件放在其终态响应、模型长度、安全和澄清尾部之前。`create_deerflow_agent(extra_middleware=[...])` 则构建较小的按功能组装的主 Agent 链;未锚定的额外中间件会紧接在 `ClarificationMiddleware` 之前放置(锚定中间件遵循其 `@Next`/`@Prev` 位置)。两个 API 均不会将中间件转发给子 Agent。 +配置的中间件会在内置中间件及可选的循环/token 保护之后加载。对于主 Agent 链,它位于终态响应、模型长度、安全和澄清尾部之前;子 Agent 链没有终态响应或澄清阶段,因此配置中间件之后会继续执行模型长度防护、可选的安全防护、`DurableContextMiddleware`、可选的 `SummarizationMiddleware`,随后是 `SubagentDateContextMiddleware` 和 `SystemMessageCoalescingMiddleware`。中间件路径会执行 Python 代码,因此应视为可信配置。嵌入式调用方也可以使用 `DeerFlowClient(middlewares=[...])`;它构建完整的主 Agent 链,并将中间件放在其终态响应、模型长度、安全和澄清尾部之前。`create_deerflow_agent(extra_middleware=[...])` 则构建较小的按功能组装的主 Agent 链;未锚定的额外中间件会紧接在 `ClarificationMiddleware` 之前放置(锚定中间件遵循其 `@Next`/`@Prev` 位置)。两个 API 均不会将中间件转发给子 Agent。 应根据配置的所有者和插入位置选择注册方式。固定槽位(并未弃用)`extensions.middlewares` 可写在 `config.yaml` 或 `extensions_config.json` 中(前者优先),并同时作用于主 Agent 和子 Agent。通过顶层 `plugins:` 注册的打包扩展可在语义化扩展点贡献中间件。若贡献代码需要可提交、可编程且仅作用于主 Agent 的接线,请使用 `build_middlewares(..., custom_middlewares=[MyMiddleware()])`。 diff --git a/frontend/src/content/zh/harness/subagents/developers.mdx b/frontend/src/content/zh/harness/subagents/developers.mdx index dd3cbf4723..ee456ce0bf 100644 --- a/frontend/src/content/zh/harness/subagents/developers.mdx +++ b/frontend/src/content/zh/harness/subagents/developers.mdx @@ -46,7 +46,7 @@ LangGraph Server 的入口必须是模块级的具体函数,harness 提供 `ma `contracts/subagent_status_contract.json` 是前后端共享的固定文件(版本 2): - 合法 `subagent_status`:`completed`、`failed`、`cancelled`、`timed_out`、`polling_timed_out`。 -- 合法 `subagent_stop_reason`:`token_capped`、`turn_capped`、`loop_capped`。 +- 合法 `subagent_stop_reason`:`token_capped`、`turn_capped`、`loop_capped`、`model_length_capped`。 - 工具结果正文只是展示内容,不属于契约。 后端用 `make_subagent_additional_kwargs` 写元数据、`read_subagent_result_metadata` 读取;写入时非法状态值会抛 `ValueError`,读取时遇到未知状态返回 `None`,并把旧的 `max_turns_reached` 映射为 `turn_capped`。前端的 `parseSubtaskResult` 先读结构化字段,只有完全没有结构化元数据时才回退到文本前缀。附加字段(模型名、token 用量、回执、验收)是加法式的,新增字段不需要升级契约版本。 diff --git a/frontend/src/content/zh/harness/subagents/limits.mdx b/frontend/src/content/zh/harness/subagents/limits.mdx index 13b208dde8..3f3d2ffcc3 100644 --- a/frontend/src/content/zh/harness/subagents/limits.mdx +++ b/frontend/src/content/zh/harness/subagents/limits.mdx @@ -60,10 +60,11 @@ recursion_limit = max_turns × (每轮节点数) + (每次调用一次性节点 ## 失控行为防护 -子 Agent 中间件链镜像了 Lead Agent 的三道防护: +子 Agent 中间件链镜像了 Lead Agent 的四道防护: - **循环检测** `LoopDetectionMiddleware`:打断不取得进展却反复调用同一工具的循环。子 Agent 没有 `task`,所以只有工具循环启发式会触发。硬停止把结果标记为 `completed` + `subagent_stop_reason=loop_capped`。由 `loop_detection` 配置控制,可以用 `tool_freq_overrides` 按工具名单独设阈值。 - **token 预算** `TokenBudgetMiddleware`:按 `subagents.token_budget` 计算单次运行的累计 token。到达硬停止阈值时剥离当前轮的工具调用、强制产出最终答案,结果标记 `completed` + `token_capped`。到达 `warn_threshold` 比例时,会在子 Agent 的下一次模型调用中注入一次预算警告,并记一条 INFO 日志。 +- **输出长度** `ModelLengthFinishReasonMiddleware`:provider 因输出长度上限停止回答时(`finish_reason=length`、`stop_reason=max_tokens`、`MAX_TOKENS` 或 Responses 的 `max_output_tokens`),该回答里的工具调用参数可能不完整,会被丢弃,结果标记 `completed` + `model_length_capped`。 - **摘要压缩** `DeerFlowSummarizationMiddleware`:用与 Lead Agent 相同的 `summarization.enabled` 开关压缩过长的子 Agent 记录,摘要模型默认就是子 Agent 自己在用的模型。压缩时子 Agent 的系统提示(角色、报告契约、验收说明、技能索引、延迟工具目录)和最新用户消息会被保留;`DurableContextMiddleware` 排在压缩之前,把 `summary_text` 回注到后续请求,避免压缩后出现以助手消息开头的历史。 token 预算的默认上限与摘要开关联动:压缩开启时 1,000,000,关闭时 2,000,000。显式设置的 `subagents.token_budget.max_tokens`(全局或按 Agent)始终优先,切换摘要开关不会悄悄改动你固定的值。 diff --git a/frontend/src/content/zh/harness/subagents/observability.mdx b/frontend/src/content/zh/harness/subagents/observability.mdx index e38e16d0cf..96742fc936 100644 --- a/frontend/src/content/zh/harness/subagents/observability.mdx +++ b/frontend/src/content/zh/harness/subagents/observability.mdx @@ -63,7 +63,7 @@ GET /api/threads/{thread_id}/runs/{run_id}/events?event_types=subagent.step&task | 键 | 说明 | | ----------------------------- | ------------------------------------------------------- | | `subagent_status` | 五个终态之一 | -| `subagent_stop_reason` | `token_capped` / `turn_capped` / `loop_capped`,可缺省 | +| `subagent_stop_reason` | `token_capped` / `turn_capped` / `loop_capped` / `model_length_capped`,可缺省 | | `subagent_error` | 非 `completed` 时的错误文本,最多 2,000 字符 | | `subagent_result_brief` | `completed` 时的结果摘要,最多 2,000 字符 | | `subagent_result_sha256` | 完整结果的 SHA-256 | diff --git a/frontend/src/content/zh/harness/subagents/reference.mdx b/frontend/src/content/zh/harness/subagents/reference.mdx index 1b52f506fa..55f08a2267 100644 --- a/frontend/src/content/zh/harness/subagents/reference.mdx +++ b/frontend/src/content/zh/harness/subagents/reference.mdx @@ -75,7 +75,7 @@ invoke_acp_agent(...) # 外部 ACP Agent,见子 Agent 目录 | 字段 | 取值 | | ------------------------- | --------------------------------------------------------------------- | | `subagent_status` | `completed`、`failed`、`cancelled`、`timed_out`、`polling_timed_out` | -| `subagent_stop_reason` | `token_capped`、`turn_capped`、`loop_capped` | +| `subagent_stop_reason` | `token_capped`、`turn_capped`、`loop_capped`、`model_length_capped` | | 验收 leaf | `checked` 与 `holds` 两个布尔;渲染为 `holds` / `does not hold` / `UNVERIFIED` | | 回执核对 | `resolved`、`failed`、`unknown`、`no_citation_claims` | | 委派账本状态 | `in_progress` 加上述五个终态 | diff --git a/frontend/src/content/zh/harness/subagents/results.mdx b/frontend/src/content/zh/harness/subagents/results.mdx index 373d932993..1fa3954e31 100644 --- a/frontend/src/content/zh/harness/subagents/results.mdx +++ b/frontend/src/content/zh/harness/subagents/results.mdx @@ -28,15 +28,16 @@ import { Callout } from "nextra/components"; ### 截断原因 stop_reason -三道防护可以提前结束一次运行。运行通常仍是 `completed`;没有可用输出时为 `failed`,并保留同样的 `stop_reason`: +四道防护可以提前结束一次运行。运行通常仍是 `completed`;没有可用输出时为 `failed`,并保留同样的 `stop_reason`: | `subagent_stop_reason` | 触发 | 结果正文 | | ---------------------- | ---------------------------- | ---------------------------------------------------- | | `turn_capped` | 用尽 `max_turns` | `Task Succeeded (capped: turn budget). Result: ...` | | `token_capped` | 触达 `token_budget` 硬停止 | `Task Succeeded (capped: token budget). Result: ...` | | `loop_capped` | 循环检测硬停止 | `Task Succeeded (capped: repeated tool-call loop). Result: ...` | +| `model_length_capped` | provider 输出长度上限 | `Task Succeeded (capped: model output length limit). Result: ...` | -被截断意味着报告是**部分结果**:最后一轮的工具调用被剥离,模型被迫给出最终答案。轮次用尽时如果连一段可用文本都没有,状态是 `failed`,正文为 `Task failed (capped: turn budget). Error: Reached max_turns=N`。旧版本的 `max_turns_reached` 状态在读取时映射为 `turn_capped`。 +被截断意味着报告是**部分结果**:最后一轮的工具调用被剥离,模型被迫给出最终答案。`model_length_capped` 表示 provider 在输出长度上限处截断了最后一次回答(`finish_reason=length` 或等价信号),该回答里的工具调用会被丢弃而不是执行。轮次用尽时如果连一段可用文本都没有,状态是 `failed`,正文为 `Task failed (capped: turn budget). Error: Reached max_turns=N`。旧版本的 `max_turns_reached` 状态在读取时映射为 `turn_capped`。 ### 失败的判定依据 diff --git a/frontend/src/content/zh/harness/subagents/troubleshooting.mdx b/frontend/src/content/zh/harness/subagents/troubleshooting.mdx index 474f31e03c..f8a8f4fd2d 100644 --- a/frontend/src/content/zh/harness/subagents/troubleshooting.mdx +++ b/frontend/src/content/zh/harness/subagents/troubleshooting.mdx @@ -33,6 +33,12 @@ import { Callout } from "nextra/components"; **处理**:先看步骤时间线里是不是同一个工具被反复调用。确实需要更多预算时调整 `subagents.token_budget.max_tokens` 或 `loop_detection.tool_freq_overrides`。注意 token 预算的默认值与 `summarization.enabled` 联动,显式设置的值不受影响。 +### 结果标记 `model_length_capped` + +**原因**:provider 在模型输出长度上限处停止了子 Agent 的回答,答案不完整,该回答里的工具调用已被丢弃。 + +**处理**:调大子 Agent 所用模型的输出上限(`max_tokens`),或者要求更短的输出,例如拆分任务、把长内容分几步写入文件。 + ### 状态是 `failed`,错误为 `Reached max_turns=N` 轮次用尽且最后一条助手消息没有可用文本,结果正文为 `Task failed (capped: turn budget). Error: Reached max_turns=N`。处理办法同上。 diff --git a/frontend/src/core/tasks/subtask-result.ts b/frontend/src/core/tasks/subtask-result.ts index 0680192b0b..3d717c5f5f 100644 --- a/frontend/src/core/tasks/subtask-result.ts +++ b/frontend/src/core/tasks/subtask-result.ts @@ -14,7 +14,7 @@ export interface SubtaskResultUpdate { usage?: Subtask["usage"]; /** * Why a guardrail cap ended the run early (``token_capped`` / ``turn_capped`` - * / ``loop_capped``), when the backend stamps ``subagent_stop_reason``. A + * / ``loop_capped`` / ``model_length_capped``), when the backend stamps ``subagent_stop_reason``. A * capped run keeps a normal pill status — ``completed`` when it produced a * final answer, ``failed`` when it did not — so this field is the only * signal that distinguishes "finished" from "capped" (#3875 Phase 2). @@ -54,6 +54,7 @@ const SUBAGENT_STOP_REASON_VALUES = [ "token_capped", "turn_capped", "loop_capped", + "model_length_capped", ] as const; const STRUCTURED_SUBAGENT_KEYS = [ SUBAGENT_STATUS_KEY, diff --git a/frontend/src/core/tasks/types.ts b/frontend/src/core/tasks/types.ts index dee3ccf226..d1ddad3d0d 100644 --- a/frontend/src/core/tasks/types.ts +++ b/frontend/src/core/tasks/types.ts @@ -25,7 +25,7 @@ export interface Subtask { error?: string; /** * Why a guardrail cap ended the run early (``token_capped`` / ``turn_capped`` - * / ``loop_capped``), or ``undefined`` for a clean run. The pill status stays + * / ``loop_capped`` / ``model_length_capped``), or ``undefined`` for a clean run. The pill status stays * normal (``completed``/``failed``); this carries the cap detail so a future * badge can show "capped" without parsing result text (#3875 Phase 2). */ diff --git a/frontend/tests/unit/core/tasks/subtask-result.test.ts b/frontend/tests/unit/core/tasks/subtask-result.test.ts index 8af7c4a59f..0590140be3 100644 --- a/frontend/tests/unit/core/tasks/subtask-result.test.ts +++ b/frontend/tests/unit/core/tasks/subtask-result.test.ts @@ -359,13 +359,16 @@ describe("parseSubtaskResult — shared contract fixture", () => { } for (const stopReason of CONTRACT.valid_stop_reason_values) { - it(`carries stop_reason through unchanged: ${stopReason}`, () => { - const parsed = parseSubtaskResult("ignored content", { - [SUBAGENT_STATUS_KEY]: "completed", - [SUBAGENT_RESULT_BRIEF_KEY]: "partial work", - [SUBAGENT_STOP_REASON_KEY]: stopReason, - }); - expect(parsed.stopReason).toBe(stopReason); - }); + it.each(["completed", "failed"])( + `carries stop_reason ${stopReason} through unchanged for %s`, + (status) => { + const parsed = parseSubtaskResult("ignored content", { + [SUBAGENT_STATUS_KEY]: status, + [SUBAGENT_RESULT_BRIEF_KEY]: "partial work", + [SUBAGENT_STOP_REASON_KEY]: stopReason, + }); + expect(parsed).toMatchObject({ status, stopReason }); + }, + ); } });