Hyeonsang Cho 53798b44cd
fix(subagents): scale max_turns into the graph's super-step budget (#5485)
* fix(subagents): scale max_turns into the graph's super-step budget

max_turns was handed to LangGraph as recursion_limit, but the two count
different things. recursion_limit counts super-steps, one per graph node,
and create_agent compiles a node for every middleware lifecycle hook, so
one turn costs before_model + model + after_model + tools nodes — seven to
eight through the subagent chain. The built-in general-purpose agent's
max_turns=150 therefore bought about 18 tool-using turns before failing as
turn_capped, and every middleware added to the chain shrank the effective
budget again.

Resolve the limit from the chain each subagent was actually assembled with
(subagents/turn_budget.py) instead of passing the turn count through, so
raising max_turns buys the turns it names.

No config keys or defaults changed; existing max_turns values now grant
their full budget, bounded as before by subagents.timeout_seconds and
subagents.token_budget.

* fix(subagents): warn when a counted hook can jump the agent loop

Review follow-up. The per-turn cost is a flat multiplier over the straight
before_model -> model -> tools loop. A hook that declares can_jump_to and
returns {"jump_to": ...} re-enters the loop without traversing tools,
spending another before_model + model + after_model pass that buys no tool
result, so the resolved limit becomes a lower bound rather than an exact
budget — silently re-creating the short budget this translation fixes.

Measured against a compiled graph: with one jumping after_model hook,
three tool turns need the resolved limit plus one jump pass, and the run
raises GraphRecursionError at the resolved limit.

How often a jump fires is data-dependent and unbounded, so it cannot be
folded into the arithmetic. find_jumping_hooks reports the condition off
the same __can_jump_to__ attribute the factory reads, and the executor
warns when a counted hook declares one. Nothing in today's subagent chain
does, so this changes no budget.

* fix(subagents): detect jumps declared on agent-level hooks too

Review follow-up. find_jumping_hooks exempted before_agent/after_agent on
the grounds that a jump out of them lands in the loop the budget already
pays for. That does not hold on langchain 1.3.14:

- after_agent jumps re-enter the loop after it finished, and the hook runs
  again on the next exit, so the extra passes are unbounded. Even
  jump_to "end" is routed to exit_node, the head of the after_agent chain,
  so it reruns the chain; destinations are no safe filter.
- a before_agent hook that stages a tool call and jumps to tools runs a
  tools step no model turn paid for. It is O(1), but the resolved limit
  has zero headroom, so one step caps the last turn.

The detector now scans every hook pair the factory wires jump edges for.
The compiled-graph pin is parametrized over after_model->model,
after_agent->model, after_agent->end and before_agent->tools, each raising
GraphRecursionError at the resolved limit and completing once the jump's
cost is added. No middleware in the subagent chain declares a jump on any
hook, so this changes no budget.
2026-09-17 09:05:25 +08:00

68 lines
2.7 KiB
Python

"""Subagent configuration definitions."""
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig
@dataclass
class SubagentConfig:
"""Configuration for a subagent.
Attributes:
name: Unique identifier for the subagent.
description: When Claude should delegate to this subagent.
system_prompt: The system prompt that guides the subagent's behavior.
tools: Optional list of tool names to allow. If None, inherits all tools.
disallowed_tools: Optional list of tool names to deny.
skills: Optional list of skill names to make discoverable and activatable.
If None, all enabled skills are available. If empty, skills are
disabled for this subagent. Skill bodies and their allowed-tools
policies take effect only after activation/loading at runtime.
model: Model to use - 'inherit' uses parent's model.
max_turns: Maximum agent turns — model call plus the tools it runs —
before stopping. Built-in agents use the value set here
(general-purpose=150, bash=60) unless the global
``subagents.max_turns`` is set. ``turn_budget.py`` converts this
into the LangGraph ``recursion_limit`` that buys that many turns
through the assembled middleware chain; it is not passed through as
a super-step count.
timeout_seconds: Bare fallback execution-time cap. For built-in agents the
effective limit is the global ``subagents.timeout_seconds`` (default
1800 = 30 min), layered on by the registry; this 900 only applies
when no differing global value exists.
"""
name: str
description: str
system_prompt: str | None = None
tools: list[str] | None = None
disallowed_tools: list[str] | None = field(default_factory=lambda: ["task"])
skills: list[str] | None = None
model: str = "inherit"
max_turns: int = 50
timeout_seconds: int = 900
def _default_model_name(app_config: "AppConfig") -> str:
if not app_config.models:
raise ValueError("No chat models are configured. Please configure at least one model in config.yaml.")
return app_config.models[0].name
def resolve_subagent_model_name(config: SubagentConfig, parent_model: str | None, *, app_config: "AppConfig | None" = None) -> str:
"""Resolve the effective model name a subagent should use."""
if config.model != "inherit":
return config.model
if parent_model is not None:
return parent_model
if app_config is None:
from deerflow.config import get_app_config
app_config = get_app_config()
return _default_model_name(app_config)