黄云龙 126fc9ea81
fix(subagents): clamp subagent limit consistently with MIN_SUBAGENT_LIMIT (#4081)
* fix(subagents): align prompt and middleware subagent limit; allow min of 1

SubagentLimitMiddleware clamped max_concurrent to [2, 4] internally, but
agent.py and client.py fed the raw config value into the system prompt, so
a user-configured 1 (or 5) produced a prompt that disagreed with the
enforced middleware limit. Lower MIN_SUBAGENT_LIMIT to 1 and clamp the raw
config value with _clamp_subagent_limit() at both the agent factory and the
embedded client so the prompt and middleware see the same value.

* fix: remove unused imports MAX_CONCURRENT_SUBAGENT_CALLS, MIN_CONCURRENT_SUBAGENT_CALLS, clamp_subagent_concurrency

* fix: harmonize clamp range [1,4] across middleware, config, and prompt path; fix lint

- Changed MIN_CONCURRENT_SUBAGENT_CALLS from 2 to 1 so prompt.py's
  clamp_subagent_concurrency and the middleware's _clamp_subagent_limit
  both clamp to [1,4] — eliminating the divergence where the prompt told
  the model 'max 2 task calls' but the middleware enforced 1.
- Applied _clamp_subagent_limit at build_middlewares (agent.py:360) so
  all 3 construction sites (agent.py:360, agent.py:450, client.py:259)
  consistently clamp the config-resolved limit.
- Derived MIN_SUBAGENT_LIMIT / MAX_SUBAGENT_LIMIT from
  MIN_CONCURRENT_SUBAGENT_CALLS / MAX_CONCURRENT_SUBAGENT_CALLS so the
  two module-level definitions stay in sync.
- Added TestConfigParity.test_prompt_path_and_middleware_clamp_agree
  regression test.
- Fixed lint.

* fix(lint): add missing imports for MIN_CONCURRENT_SUBAGENT_CALLS and MAX_CONCURRENT_SUBAGENT_CALLS

* docs+test: update AGENTS.md clamp range to 1-4; add prompt/middleware parity regression test

- backend/AGENTS.md still documented the old [2,4] clamp in two places;
  updated to [1,4] to match MIN_CONCURRENT_SUBAGENT_CALLS = 1.
- Added test_apply_prompt_template_single_subagent_limit_matches_middleware:
  renders the real system prompt with max_concurrent_subagents=1 and asserts
  the advertised HARD LIMITS value equals SubagentLimitMiddleware's enforced
  max_concurrent — the end-to-end check that would have caught the [1,4] vs
  [2,4] prompt-path divergence flagged in review.

* refactor: simplify per review — restore clamp delegation, drop redundant call-site clamps

Per willem-bd's review, reduce the PR to the one behavioral change plus
docs/tests:

- _clamp_subagent_limit delegates to clamp_subagent_concurrency again
  instead of inlining a byte-identical copy; with a single source of
  truth the TestConfigParity sync-check class is unnecessary — dropped.
- Revert the call-site clamps in agent.py (build_middlewares,
  _make_lead_agent) and client.py (_ensure_agent) to main: both
  downstream consumers (SubagentLimitMiddleware.__init__ and the prompt
  path) already clamp internally, and the cross-module private import
  of _clamp_subagent_limit goes away with them.
- Keep MIN_CONCURRENT_SUBAGENT_CALLS = 1 (the fix), the [1, 4]
  docstring updates, the AGENTS.md range corrections, and the
  end-to-end prompt/middleware parity test for single-subagent mode
  (docstring reworded: on main a configured 1 was bumped to 2 by both
  paths — there was no divergence to fix, just a silently raised floor).

* test: fix stale comment referencing reverted agent.py/client.py call-site clamps

---------

Co-authored-by: nankingjing <nankingjing@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-24 21:56:11 +08:00

300 lines
12 KiB
Python

"""Configuration for the subagent system loaded from config.yaml."""
import logging
from pydantic import BaseModel, Field
from deerflow.config.token_budget_config import TokenBudgetConfig
logger = logging.getLogger(__name__)
DEFAULT_MAX_TOTAL_SUBAGENTS_PER_RUN = 6
MIN_TOTAL_SUBAGENTS_PER_RUN = 1
MAX_TOTAL_SUBAGENTS_PER_RUN = 50
MIN_CONCURRENT_SUBAGENT_CALLS = 1
MAX_CONCURRENT_SUBAGENT_CALLS = 4
def clamp_subagent_concurrency(value: int) -> int:
"""Clamp per-response task call concurrency to the enforced middleware range."""
return max(MIN_CONCURRENT_SUBAGENT_CALLS, min(MAX_CONCURRENT_SUBAGENT_CALLS, value))
def clamp_total_subagents_per_run(value: int) -> int:
"""Clamp per-run task delegation totals to the enforced middleware range."""
return max(MIN_TOTAL_SUBAGENTS_PER_RUN, min(MAX_TOTAL_SUBAGENTS_PER_RUN, value))
def default_subagent_token_budget(*, summarization_enabled: bool = False) -> TokenBudgetConfig:
"""Default per-run token budget for subagents (#3875 Phase 2 → Phase 3 coupling).
Enabled by default so the pathological-token-burn backstop actually
engages (per umbrella #3857 point 4 — backstops must engage, not just
exist). ``max_tokens`` is **coupled to whether subagent summarization is
on** (#3875 Phase 3 review point):
- ``summarization_enabled=True`` (Phase 3 compacts the running context
before it reaches pathological size): **1M** — tighter ceiling still
covers legitimate deep research while catching degenerate runs earlier.
- ``summarization_enabled=False``: **2M** — the Phase 2 ceiling. Phase 2's
own docstring noted legitimate deep-research runs (``max_turns=150``,
no summarization) "can genuinely accumulate >1M cumulative input," so a
1M ceiling without compaction would prematurely cap them. Keeping 2M
here preserves that headroom; the tighter 1M only applies when the
compaction that justifies it is actually running.
The model-level ``default_factory`` (``SubagentsAppConfig.token_budget``)
cannot read ``summarization.enabled`` (a sibling top-level field), so it
falls back to the 2M no-compaction default; the builder
(``build_subagent_runtime_middlewares``) recomputes via
``get_token_budget_for(..., summarization_enabled=...)`` so the live value
reflects the actual switch. A user-set ``token_budget`` (global or
per-agent) always wins regardless of the switch. Flagged tunable.
"""
max_tokens = 1_000_000 if summarization_enabled else 2_000_000
return TokenBudgetConfig(enabled=True, max_tokens=max_tokens, warn_threshold=0.7)
class SubagentOverrideConfig(BaseModel):
"""Per-agent configuration overrides."""
timeout_seconds: int | None = Field(
default=None,
ge=1,
description="Timeout in seconds for this subagent (None = use global default)",
)
max_turns: int | None = Field(
default=None,
ge=1,
description="Maximum turns for this subagent (None = use global or builtin default)",
)
model: str | None = Field(
default=None,
min_length=1,
description="Model name for this subagent (None = inherit from parent agent)",
)
skills: list[str] | None = Field(
default=None,
description="Skill names whitelist for this subagent (None = inherit all enabled skills, [] = no skills)",
)
token_budget: TokenBudgetConfig | None = Field(
default=None,
description="Per-run token budget override for this subagent (None = use the global subagents.token_budget default). Symmetric with timeout_seconds/max_turns.",
)
class CustomSubagentConfig(BaseModel):
"""User-defined subagent type declared in config.yaml."""
description: str = Field(
description="When the lead agent should delegate to this subagent",
)
system_prompt: str = Field(
description="System prompt that guides the subagent's behavior",
)
tools: list[str] | None = Field(
default=None,
description="Tool names whitelist (None = inherit all tools from parent)",
)
disallowed_tools: list[str] | None = Field(
default_factory=lambda: ["task", "ask_clarification", "present_files"],
description="Tool names to deny",
)
skills: list[str] | None = Field(
default=None,
description="Skill names whitelist (None = inherit all enabled skills, [] = no skills)",
)
model: str = Field(
default="inherit",
description="Model to use - 'inherit' uses parent's model",
)
max_turns: int = Field(
default=50,
ge=1,
description="Maximum number of agent turns before stopping",
)
timeout_seconds: int = Field(
default=900,
ge=1,
description="Maximum execution time in seconds",
)
class SubagentsAppConfig(BaseModel):
"""Configuration for the subagent system."""
timeout_seconds: int = Field(
default=1800,
ge=1,
description="Default timeout in seconds for built-in subagents (default: 1800 = 30 minutes); custom agents use their own timeout_seconds unless given a per-agent override",
)
max_turns: int | None = Field(
default=None,
ge=1,
description="Optional default max-turn override for all subagents (None = keep builtin defaults)",
)
max_total_per_run: int = Field(
default=DEFAULT_MAX_TOTAL_SUBAGENTS_PER_RUN,
ge=MIN_TOTAL_SUBAGENTS_PER_RUN,
le=MAX_TOTAL_SUBAGENTS_PER_RUN,
description="Default total number of subagent delegations allowed in one lead-agent run. This is a deterministic backstop against repeated legal-sized task batches. Valid range: 1-50.",
)
token_budget: TokenBudgetConfig = Field(
default_factory=default_subagent_token_budget,
description="Default per-run token budget for subagents — a cost-ceiling backstop that engages by default (#3875 Phase 2). Set enabled: false to disable, or override per agent via agents.<name>.token_budget.",
)
agents: dict[str, SubagentOverrideConfig] = Field(
default_factory=dict,
description="Per-agent configuration overrides keyed by agent name",
)
custom_agents: dict[str, CustomSubagentConfig] = Field(
default_factory=dict,
description="User-defined subagent types keyed by agent name",
)
# True when ``token_budget`` was NOT explicitly provided by the user, i.e.
# the field fell back to its default_factory. ``get_token_budget_for`` uses
# this to decide whether the ceiling may be re-coupled to
# ``summarization.enabled`` (#3875 Phase 3): a user-set budget is always
# respected as-is. Set by ``__init__`` from ``model_fields_set`` and
# preserved across the app-config reload path (which drops a default
# ``token_budget`` before re-constructing — see
# ``load_subagents_config_from_dict``).
_token_budget_is_default: bool = True
def __init__(self, **data):
super().__init__(**data)
self._token_budget_is_default = "token_budget" not in self.model_fields_set
def get_timeout_for(self, agent_name: str) -> int:
"""Get the effective timeout for a specific agent.
Args:
agent_name: The name of the subagent.
Returns:
The timeout in seconds, using per-agent override if set, otherwise global default.
"""
override = self.agents.get(agent_name)
if override is not None and override.timeout_seconds is not None:
return override.timeout_seconds
return self.timeout_seconds
def get_model_for(self, agent_name: str) -> str | None:
"""Get the model override for a specific agent.
Args:
agent_name: The name of the subagent.
Returns:
Model name if overridden, None otherwise (subagent will inherit parent model).
"""
override = self.agents.get(agent_name)
if override is not None and override.model is not None:
return override.model
return None
def get_max_turns_for(self, agent_name: str, builtin_default: int) -> int:
"""Get the effective max_turns for a specific agent."""
override = self.agents.get(agent_name)
if override is not None and override.max_turns is not None:
return override.max_turns
if self.max_turns is not None:
return self.max_turns
return builtin_default
def get_skills_for(self, agent_name: str) -> list[str] | None:
"""Get the skills override for a specific agent.
Args:
agent_name: The name of the subagent.
Returns:
Skill names whitelist if overridden, None otherwise (subagent will inherit all enabled skills).
"""
override = self.agents.get(agent_name)
if override is not None and override.skills is not None:
return override.skills
return None
def get_token_budget_for(
self,
agent_name: str,
*,
summarization_enabled: bool = False,
) -> TokenBudgetConfig:
"""Get the effective token-budget config for a specific agent.
Unlike ``max_turns``/``timeout_seconds`` (which keep a custom agent's
own value), the token budget is a safety backstop that must engage for
every subagent unless explicitly disabled — so the per-agent override
wins when set, otherwise the global default applies to built-in AND
custom agents alike (#3875 Phase 2 / umbrella #3857 point 4).
``summarization_enabled`` couples the DEFAULT ceiling to whether
subagent summarization is on (#3875 Phase 3 review): 1M when
compaction is running, 2M otherwise. It ONLY affects the default —
any explicitly configured ``token_budget`` (global or per-agent)
wins regardless, so a deployment that pinned a value is never
silently changed by flipping the summarization switch.
"""
override = self.agents.get(agent_name)
if override is not None and override.token_budget is not None:
return override.token_budget
# Only recompute when the caller is using the default (no explicit
# global token_budget was set). A user-set global is respected as-is.
if self._token_budget_is_default:
return default_subagent_token_budget(summarization_enabled=summarization_enabled)
return self.token_budget
_subagents_config: SubagentsAppConfig = SubagentsAppConfig()
def get_subagents_app_config() -> SubagentsAppConfig:
"""Get the current subagents configuration."""
return _subagents_config
def load_subagents_config_from_dict(config_dict: dict) -> None:
"""Load subagents configuration from a dictionary."""
global _subagents_config
# The app-config reload path (app_config.py) round-trips via
# ``config.subagents.model_dump()``, which serializes a default
# ``token_budget`` into the dict. Re-constructing from that dict would make
# ``model_fields_set`` contain ``token_budget`` and flip
# ``_token_budget_is_default`` to False — breaking the
# summarization-coupled recompute in ``get_token_budget_for`` (#3875 Phase
# 3). Drop the key when its value still equals the no-compaction default so
# the default_factory fires on reconstruction and the "user did not set
# this" signal is preserved.
tb = config_dict.get("token_budget")
if tb is not None and tb == default_subagent_token_budget(summarization_enabled=False).model_dump():
config_dict = {k: v for k, v in config_dict.items() if k != "token_budget"}
_subagents_config = SubagentsAppConfig(**config_dict)
overrides_summary = {}
for name, override in _subagents_config.agents.items():
parts = []
if override.timeout_seconds is not None:
parts.append(f"timeout={override.timeout_seconds}s")
if override.max_turns is not None:
parts.append(f"max_turns={override.max_turns}")
if override.model is not None:
parts.append(f"model={override.model}")
if override.skills is not None:
parts.append(f"skills={override.skills}")
if parts:
overrides_summary[name] = ", ".join(parts)
custom_agents_names = list(_subagents_config.custom_agents.keys())
if overrides_summary or custom_agents_names:
logger.info(
"Subagents config loaded: default timeout=%ss, default max_turns=%s, per-agent overrides=%s, custom_agents=%s",
_subagents_config.timeout_seconds,
_subagents_config.max_turns,
overrides_summary or "none",
custom_agents_names or "none",
)