mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-11 23:38:44 +00:00
* fix(subagents): surface turn-budget cap as MAX_TURNS_REACHED with partial result (#3875) Phase 2 of #3875. When a subagent exhausts its turn budget (recursion_limit == max_turns), LangGraph raises GraphRecursionError from agent.astream. The generic except Exception in _aexecute misclassified it as FAILED and discarded the partial work already streamed into final_state, so the lead could not tell 'broken subagent' from 'out of budget' and got an empty failure. Catch GraphRecursionError specifically (before the generic handler) and set a distinct SubagentStatus.MAX_TURNS_REACHED terminal status, recovering the partial result from the last streamed chunk via a shared _extract_final_result helper (refactored out of the normal-completion path so both paths render content identically). Extend the cross-language status contract so the new value travels on additional_kwargs.subagent_status: a capped run is result-bearing, so make_subagent_additional_kwargs / read_subagent_result_metadata carry subagent_result_brief + subagent_result_sha256 (the recovered work, like completed) AND the cap notice on subagent_error -- the one status that carries both. task_tool.py returns it via the shared _task_result_command; the delegation ledger prefers the partial result_brief and renders model-facing guidance (reuse / retry tighter / raise max_turns). Frontend collapses max_turns_reached to the failed pill with the cap notice on error. No agent-loop, runner, or persistence behavior touched; default max_turns is unchanged. * refactor(subagents): consolidate content-stringify onto shared helper Address review feedback on #3949 (willem-bd, copilot-pull-request-reviewer): - executor.py: drop the private `_stringify_message_content` — a third near-duplicate of `utils/messages.py::message_content_to_text`. `_extract_final_result` now delegates to that canonical helper; the "No response generated" sentinel is pushed down to the consumer (the shared helper returns "" for no-text, matching every other call site). - task_tool.py: align the live `task_failed` event's error string with the canonical "Reached max_turns=N" used by the logger, the structured `error=`, and the executor (was "Reached max turns (N)"). Behavior for real AIMessage content is unchanged; only atypical edge inputs (consecutive bare-string list items; empty content) now match the canonical helper that every other call site already uses. `extract_response_text` is intentionally left as-is: it filters by OpenAI content-block `type`, a different shape with many callers and its own tests. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
167 lines
6.8 KiB
Python
167 lines
6.8 KiB
Python
"""Contract tests for ``deerflow.subagents.status_contract``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from deerflow.subagents.status_contract import (
|
|
SUBAGENT_ERROR_KEY,
|
|
SUBAGENT_METADATA_TEXT_MAX_CHARS,
|
|
SUBAGENT_RESULT_BRIEF_KEY,
|
|
SUBAGENT_RESULT_SHA256_KEY,
|
|
SUBAGENT_STATUS_KEY,
|
|
SUBAGENT_STATUS_VALUES,
|
|
_bound_metadata_text,
|
|
format_subagent_result_message,
|
|
make_subagent_additional_kwargs,
|
|
read_subagent_result_metadata,
|
|
)
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
_CONTRACT_PATH = _REPO_ROOT / "contracts" / "subagent_status_contract.json"
|
|
|
|
|
|
def _load_contract() -> dict:
|
|
return json.loads(_CONTRACT_PATH.read_text(encoding="utf-8"))
|
|
|
|
|
|
def test_contract_file_exists():
|
|
assert _CONTRACT_PATH.is_file(), f"missing shared fixture: {_CONTRACT_PATH}"
|
|
|
|
|
|
def test_status_values_match_contract():
|
|
"""Backend status enum stays aligned with the contract document."""
|
|
contract = _load_contract()
|
|
assert set(SUBAGENT_STATUS_VALUES) == set(contract["valid_status_values"])
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_includes_status():
|
|
kwargs = make_subagent_additional_kwargs("completed")
|
|
assert kwargs == {SUBAGENT_STATUS_KEY: "completed"}
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_includes_error_when_present():
|
|
kwargs = make_subagent_additional_kwargs("failed", error="boom")
|
|
assert kwargs == {SUBAGENT_STATUS_KEY: "failed", SUBAGENT_ERROR_KEY: "boom"}
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_includes_bounded_result_metadata():
|
|
kwargs = make_subagent_additional_kwargs("completed", result="done")
|
|
assert kwargs[SUBAGENT_STATUS_KEY] == "completed"
|
|
assert kwargs[SUBAGENT_RESULT_BRIEF_KEY] == "done"
|
|
assert len(kwargs[SUBAGENT_RESULT_SHA256_KEY]) == 64
|
|
assert SUBAGENT_ERROR_KEY not in kwargs
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_bounds_large_result_metadata():
|
|
huge = "x" * (SUBAGENT_METADATA_TEXT_MAX_CHARS + 5000)
|
|
kwargs = make_subagent_additional_kwargs("completed", result=huge)
|
|
assert len(kwargs[SUBAGENT_RESULT_BRIEF_KEY]) <= SUBAGENT_METADATA_TEXT_MAX_CHARS
|
|
assert kwargs[SUBAGENT_RESULT_BRIEF_KEY] != huge
|
|
assert len(kwargs[SUBAGENT_RESULT_SHA256_KEY]) == 64
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_max_turns_reached_carries_result_and_error():
|
|
"""#3875 Phase 2: a turn-capped run is result-bearing — the partial work
|
|
the executor recovered must travel on ``subagent_result_brief`` / ``sha256``
|
|
(so the delegation ledger and card keep it) AND the cap notice must travel
|
|
on ``subagent_error``. This is the one status that carries both."""
|
|
kwargs = make_subagent_additional_kwargs("max_turns_reached", result="investigated 3 of 5 sources", error="Reached max_turns=150")
|
|
assert kwargs[SUBAGENT_STATUS_KEY] == "max_turns_reached"
|
|
assert kwargs[SUBAGENT_RESULT_BRIEF_KEY] == "investigated 3 of 5 sources"
|
|
assert len(kwargs[SUBAGENT_RESULT_SHA256_KEY]) == 64
|
|
assert kwargs[SUBAGENT_ERROR_KEY] == "Reached max_turns=150"
|
|
|
|
|
|
def test_format_subagent_result_message_max_turns_reached_leads_with_partial_result():
|
|
"""The model-visible text leads with the recovered partial result and
|
|
names the cap; the metadata error carries the cap reason only."""
|
|
content, metadata_error = format_subagent_result_message("max_turns_reached", result="investigated 3 of 5 sources", error="Reached max_turns=150")
|
|
assert content.startswith("Task reached max turns")
|
|
assert "investigated 3 of 5 sources" in content
|
|
assert metadata_error == "Reached max_turns=150"
|
|
|
|
|
|
def test_format_subagent_result_message_max_turns_reached_uses_sentinel_when_no_partial():
|
|
content, _metadata_error = format_subagent_result_message("max_turns_reached", result=None, error="Reached max_turns=150")
|
|
assert "No partial result was produced" in content
|
|
|
|
|
|
def test_bound_metadata_text_respects_small_caps():
|
|
text = "A" * 100
|
|
|
|
assert _bound_metadata_text(text, cap=0) == ""
|
|
assert _bound_metadata_text(text, cap=1) == "A"
|
|
assert len(_bound_metadata_text(text, cap=15)) <= 15
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_omits_blank_error():
|
|
"""Empty / whitespace error must not leak as ``subagent_error: ""``."""
|
|
assert make_subagent_additional_kwargs("failed", error="") == {SUBAGENT_STATUS_KEY: "failed"}
|
|
assert make_subagent_additional_kwargs("failed", error=" ") == {SUBAGENT_STATUS_KEY: "failed"}
|
|
assert make_subagent_additional_kwargs("failed", error=None) == {SUBAGENT_STATUS_KEY: "failed"}
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_bounds_large_error_metadata():
|
|
huge = "boom " * 2000
|
|
kwargs = make_subagent_additional_kwargs("failed", error=huge)
|
|
assert kwargs[SUBAGENT_STATUS_KEY] == "failed"
|
|
assert len(kwargs[SUBAGENT_ERROR_KEY]) <= SUBAGENT_METADATA_TEXT_MAX_CHARS
|
|
assert SUBAGENT_RESULT_BRIEF_KEY not in kwargs
|
|
|
|
|
|
def test_read_subagent_result_metadata_returns_bounded_payload():
|
|
parsed = read_subagent_result_metadata(
|
|
{
|
|
SUBAGENT_STATUS_KEY: "completed",
|
|
SUBAGENT_RESULT_BRIEF_KEY: "structured",
|
|
SUBAGENT_RESULT_SHA256_KEY: "a" * 64,
|
|
SUBAGENT_ERROR_KEY: "ignored",
|
|
}
|
|
)
|
|
assert parsed == {
|
|
"status": "completed",
|
|
"result_brief": "structured",
|
|
"result_sha256": "a" * 64,
|
|
}
|
|
|
|
|
|
def test_read_subagent_result_metadata_max_turns_reached_reads_result_brief_and_error():
|
|
"""A turn-capped result carries both result metadata and the cap error;
|
|
the reader must surface both so the delegation ledger can prefer the
|
|
partial result and still expose the cap reason."""
|
|
parsed = read_subagent_result_metadata(
|
|
{
|
|
SUBAGENT_STATUS_KEY: "max_turns_reached",
|
|
SUBAGENT_RESULT_BRIEF_KEY: "investigated 3 of 5 sources",
|
|
SUBAGENT_RESULT_SHA256_KEY: "a" * 64,
|
|
SUBAGENT_ERROR_KEY: "Reached max_turns=150",
|
|
}
|
|
)
|
|
assert parsed == {
|
|
"status": "max_turns_reached",
|
|
"result_brief": "investigated 3 of 5 sources",
|
|
"result_sha256": "a" * 64,
|
|
"error": "Reached max_turns=150",
|
|
}
|
|
|
|
|
|
def test_read_subagent_result_metadata_rejects_unknown_status():
|
|
assert read_subagent_result_metadata({SUBAGENT_STATUS_KEY: "future"}) is None
|
|
|
|
|
|
def test_read_subagent_result_metadata_rejects_non_hex_sha256():
|
|
"""A 64-char value that is not a lowercase hex digest must be dropped."""
|
|
base = {SUBAGENT_STATUS_KEY: "completed", SUBAGENT_RESULT_BRIEF_KEY: "structured"}
|
|
for bad_hash in ("z" * 64, "A" * 64, "a" * 63, "a" * 65, ("a" * 63) + " "):
|
|
parsed = read_subagent_result_metadata({**base, SUBAGENT_RESULT_SHA256_KEY: bad_hash})
|
|
assert parsed == {"status": "completed", "result_brief": "structured"}, bad_hash
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_rejects_unknown_status():
|
|
import pytest
|
|
|
|
with pytest.raises(ValueError, match="invalid subagent status"):
|
|
make_subagent_additional_kwargs("garbage") # type: ignore[arg-type]
|