deer-flow/backend/tests/test_subagent_model_length_capped.py
Tsai Yuan 03c95f5278
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>
2026-10-23 21:42:11 +08:00

96 lines
3.8 KiB
Python

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