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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Tsai Yuan 2026-10-23 21:42:11 +08:00 committed by GitHub
parent 6e240b4bc3
commit 03c95f5278
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 275 additions and 43 deletions

View File

@ -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.

View File

@ -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`.

View File

@ -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:

View File

@ -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

View File

@ -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).

View File

@ -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

View File

@ -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`` /

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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."""

View File

@ -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

View File

@ -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"]
}

View File

@ -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()])`.

View File

@ -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()])`.

View File

@ -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.

View File

@ -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.

View File

@ -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 |

View File

@ -75,7 +75,7 @@ Canonical acceptance criteria: `file:<path> exists`, `file:<path> 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 |

View File

@ -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

View File

@ -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.

View File

@ -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()])`。

View File

@ -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()])`。

View File

@ -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 用量、回执、验收)是加法式的,新增字段不需要升级契约版本。

View File

@ -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)始终优先,切换摘要开关不会悄悄改动你固定的值。

View File

@ -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 |

View File

@ -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` 加上述五个终态 |

View File

@ -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`。
### 失败的判定依据

View File

@ -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`。处理办法同上。

View File

@ -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,

View File

@ -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).
*/

View File

@ -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 });
},
);
}
});