fix(agents): treat a null max_total_subagents override as unset (#6088)

* fix(agents): treat a null max_total_subagents override as unset

An API caller sending "max_total_subagents": null in the run context
failed that run with a TypeError: the key was present, so
dict.get(key, default) returned None and building SubagentLimitMiddleware
compared None with an int.

Resolve the per-run delegation cap through one helper,
effective_total_subagents_per_run, which treats None as "use
subagents.max_total_per_run" and clamps to 1-50. The lead agent's
middleware and assembly paths, DeerFlowClient, and the system prompt all
use it, so the extension host policy and release policy now report the
enforced cap instead of an out-of-range request.

* docs(changelog): reference #6088 in the null max_total_subagents entry
This commit is contained in:
Hyeonsang Cho 2026-09-30 17:10:29 +09:00 committed by GitHub
parent d0e4d3525c
commit af67738521
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 186 additions and 17 deletions

View File

@ -341,6 +341,15 @@ This release closes that milestone with **181 merged pull requests**.
while the memory and database stores return an empty result. JSONL reads and
deletes now treat such an ID as an unknown run; writes still reject it.
([#6070])
- **agents:** A run started with `"max_total_subagents": null` in its context
now uses the configured `subagents.max_total_per_run` instead of failing with
a `TypeError`. The key was present, so `dict.get(key, default)` returned
`None`, and building `SubagentLimitMiddleware` crashed that run with an
internal error (the web UI never sends the key; API and embedded-client
callers could). The Gateway lead agent, `DeerFlowClient`, and the system
prompt now resolve the cap through one helper that treats `null` as unset and
clamps to 1-50, so the extension-facing host policy and the release policy
also report the enforced cap rather than an out-of-range request. ([#6088])
- **scheduler:** Fixed-hour cron tasks no longer fire twice on the daylight-saving
fall-back day. `croniter` returns both occurrences of an ambiguous wall-clock
hour (the first with `fold=0`, the second with `fold=1`). For tasks where
@ -6284,4 +6293,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6066]: https://github.com/bytedance/deer-flow/pull/6066
[#6069]: https://github.com/bytedance/deer-flow/pull/6069
[#6070]: https://github.com/bytedance/deer-flow/pull/6070
[#6088]: https://github.com/bytedance/deer-flow/pull/6088

View File

@ -343,6 +343,13 @@
`run_events.backend: jsonl` 下,`run.1` 这类 ID 会抛出 `ValueError`,而内存与
数据库存储返回空结果。现在 JSONL 的读取与删除把这类 ID 视为不存在的 run,写入
仍会拒绝它。([#6070])
- **智能体:** 运行上下文中带有 `"max_total_subagents": null` 时,现在会使用配置的
`subagents.max_total_per_run`,而不是抛出 `TypeError`。该键存在时
`dict.get(key, default)` 会返回 `None`,构建 `SubagentLimitMiddleware` 时该次运行
便以内部错误失败(Web UI 从不发送该键,但 API 与嵌入式客户端调用方可能发送)。
Gateway lead agent、`DeerFlowClient` 与系统提示词现在通过同一个辅助函数解析上限:
`null` 视为未设置,并限制在 1-50,因此面向扩展的 host policy 与 release policy
报告的也是实际执行的上限,而非超出范围的请求值。([#6088])
- **调度器:** 固定小时的 cron 任务在夏令时回退(DST fall-back)当天不再重复运行两次。
`croniter` 会返回模糊本地时间的两个实例(首个为 `fold=0`,第二个为 `fold=1`)。
对于分和时字段不包含通配符的固定任务,现在会跳过第二个重复实例(`fold=1`),保持每天只运行一次
@ -5329,3 +5336,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6066]: https://github.com/bytedance/deer-flow/pull/6066
[#6069]: https://github.com/bytedance/deer-flow/pull/6069
[#6070]: https://github.com/bytedance/deer-flow/pull/6070
[#6088]: https://github.com/bytedance/deer-flow/pull/6088

View File

@ -45,7 +45,7 @@ same resulting prompt. Empty overlays preserve bytes.
- `is_plan_mode` - Enable TodoList middleware
- `subagent_enabled` - Enable task delegation tool
- `max_concurrent_subagents` - Per-response `task` call concurrency limit (clamped by `SubagentLimitMiddleware`)
- `max_total_subagents` - Optional per-run total delegation cap override (falls back to `subagents.max_total_per_run`, clamped to 1-50)
- `max_total_subagents` - Per-run total delegation cap override (unset/`null` uses `subagents.max_total_per_run`, clamped to 1-50)
Gateway and `DeerFlowClient.stream()` always provide the runtime `run_id`; custom
graph integrations must do the same. If it is absent, enforcement deliberately
counts the thread's full delegation ledger (fail-restrictive) and emits a warning.

View File

@ -60,8 +60,8 @@ from deerflow.config.agents_config import load_agent_config, validate_agent_name
from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.config.memory_config import should_use_memory_tools
from deerflow.config.subagents_config import (
DEFAULT_MAX_TOTAL_SUBAGENTS_PER_RUN,
effective_subagent_concurrency,
effective_total_subagents_per_run,
)
from deerflow.models import create_chat_model
from deerflow.models.reasoning import resolve_reasoning_contract, resolve_reasoning_request
@ -120,11 +120,6 @@ def unwrap_agent_graph(agent_result: Any) -> Any:
return agent_result.graph if isinstance(agent_result, LeadAgentAssembly) else agent_result
def _default_max_total_subagents(app_config: object) -> int:
subagents_config = getattr(app_config, "subagents", None)
return getattr(subagents_config, "max_total_per_run", DEFAULT_MAX_TOTAL_SUBAGENTS_PER_RUN)
def _subagent_release_policy(
app_config: AppConfig,
*,
@ -708,7 +703,7 @@ def build_middlewares(
resolved_app_config,
execution_capacity=subagent_execution_capacity,
)
max_total_subagents = cfg.get("max_total_subagents", _default_max_total_subagents(resolved_app_config))
max_total_subagents = effective_total_subagents_per_run(cfg.get("max_total_subagents"), resolved_app_config)
effective_max_subagents_per_run = max_total_subagents
middlewares.append(SubagentLimitMiddleware(max_concurrent=max_concurrent_subagents, max_total=max_total_subagents))
@ -959,7 +954,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
resolved_app_config,
execution_capacity=subagent_execution_capacity,
)
max_total_subagents = cfg.get("max_total_subagents", _default_max_total_subagents(resolved_app_config))
max_total_subagents = effective_total_subagents_per_run(cfg.get("max_total_subagents"), resolved_app_config)
is_bootstrap = cfg.get("is_bootstrap", False)
interaction_policy = resolve_run_interaction_policy(config)
non_interactive = not interaction_policy.allows_clarification

View File

@ -16,6 +16,7 @@ from deerflow.config.subagents_config import (
clamp_subagent_concurrency,
clamp_total_subagents_per_run,
effective_subagent_concurrency,
effective_total_subagents_per_run,
)
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage
@ -1039,11 +1040,7 @@ def apply_prompt_template(
execution_capacity=subagent_execution_capacity,
)
)
total = max_total_subagents
if total is None:
subagents_config = getattr(app_config, "subagents", None) if app_config is not None else None
total = getattr(subagents_config, "max_total_per_run", DEFAULT_MAX_TOTAL_SUBAGENTS_PER_RUN)
total = clamp_total_subagents_per_run(total)
total = effective_total_subagents_per_run(max_total_subagents, app_config)
if subagent_enabled:
from deerflow.subagents.batch_runtime import is_subagent_batch_runtime_available

View File

@ -381,7 +381,7 @@ class DeerFlowClient:
model_name = self._app_config.models[0].name
model_name = _authorize_model_name(model_name, context=cfg, app_config=self._app_config)
subagent_enabled = cfg.get("subagent_enabled", False)
from deerflow.config.subagents_config import effective_subagent_concurrency
from deerflow.config.subagents_config import effective_subagent_concurrency, effective_total_subagents_per_run
# Lightweight integrations and older tests may construct a client via
# ``__new__`` and inject only ``_app_config``. Production clients keep
@ -397,7 +397,7 @@ class DeerFlowClient:
self._app_config,
execution_capacity=subagent_execution_capacity,
)
max_total_subagents = cfg.get("max_total_subagents", self._app_config.subagents.max_total_per_run)
max_total_subagents = effective_total_subagents_per_run(cfg.get("max_total_subagents"), self._app_config)
tools = self._get_tools(model_name=model_name, subagent_enabled=subagent_enabled, mcp_plugins=mcp_plugins)

View File

@ -42,6 +42,19 @@ def clamp_total_subagents_per_run(value: int) -> int:
return max(MIN_TOTAL_SUBAGENTS_PER_RUN, min(MAX_TOTAL_SUBAGENTS_PER_RUN, value))
def effective_total_subagents_per_run(value: int | None, app_config: object) -> int:
"""Resolve one per-run delegation cap for prompt, middleware, and policy.
``None`` means the run did not choose a cap, so the configured
``subagents.max_total_per_run`` applies. That includes an explicit
``null`` from API callers, which ``dict.get(key, default)`` would pass
through unchanged.
"""
subagents = getattr(app_config, "subagents", None)
requested = getattr(subagents, "max_total_per_run", DEFAULT_MAX_TOTAL_SUBAGENTS_PER_RUN) if value is None else value
return clamp_total_subagents_per_run(int(requested))
def default_subagent_token_budget(*, summarization_enabled: bool = False) -> TokenBudgetConfig:
"""Default per-run token budget for subagents (#3875 Phase 2 → Phase 3 coupling).

View File

@ -1875,6 +1875,25 @@ class TestEnsureAgent:
assert mock_create_agent.call_count == 2
def test_null_subagent_total_limit_falls_back_to_app_config(self, client, mock_app_config):
"""An explicit ``null`` cap means "unset", not a value to hand to the clamp."""
mock_app_config.subagents.max_total_per_run = 4
config = client._get_runnable_config("t1")
config["configurable"].update({"subagent_enabled": True, "max_total_subagents": None})
with (
patch("deerflow.client.create_chat_model"),
patch("deerflow.client.create_agent", return_value=MagicMock()),
patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt") as mock_apply_prompt,
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
):
client._ensure_agent(config)
assert mock_apply_prompt.call_args.kwargs["max_total_subagents"] == 4
def test_deferred_skill_discovery_wired_when_enabled(self, client, mock_app_config):
"""When skills.deferred_discovery=True, skill_names reaches apply_prompt_template
(parity with agent.py — config flag must not be a silent no-op on the embedded path)."""

View File

@ -422,3 +422,19 @@ def test_lead_build_context_projects_the_effective_delegation_override():
assert capture.ctx is not None
assert capture.ctx.policy.max_subagents_per_run == 3
@pytest.mark.parametrize(("requested", "projected"), [(None, 7), (99, 50)])
def test_lead_build_context_projects_the_enforced_delegation_cap(requested, projected):
capture = _CtxCapture()
_lead_stack(
extensions=_extensions_with_contributor(capture),
app_config=_policy_config(),
configurable={
"subagent_enabled": True,
"max_total_subagents": requested,
},
)
assert capture.ctx is not None
assert capture.ctx.policy.max_subagents_per_run == projected

View File

@ -2071,6 +2071,28 @@ def test_merge_run_context_overrides_forwards_subagent_total_limit():
assert config["context"]["max_total_subagents"] == 8
def test_null_subagent_total_limit_from_api_context_builds_with_configured_cap():
# The Gateway forwards an explicit ``null`` verbatim; the lead agent must
# read it as "unset" rather than fail the run while building its stack.
from app.gateway.services import build_run_config, merge_run_context_overrides
from deerflow.agents.lead_agent.agent import build_middlewares
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.subagents_config import SubagentsAppConfig
app_config = AppConfig(
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
subagents=SubagentsAppConfig(max_total_per_run=7),
)
config = build_run_config("thread-1", None, None)
merge_run_context_overrides(config, {"subagent_enabled": True, "max_total_subagents": None})
middlewares = build_middlewares(config, model_name=None, app_config=app_config)
limit = next(m for m in middlewares if isinstance(m, SubagentLimitMiddleware))
assert limit.max_total == 7
def test_merge_run_context_overrides_noop_for_empty_context():
from app.gateway.services import build_run_config, merge_run_context_overrides

View File

@ -1180,6 +1180,58 @@ def test_build_middlewares_allows_runtime_subagent_total_limit_override(monkeypa
assert limit.max_total == 5
def test_build_middlewares_falls_back_to_app_config_for_null_subagent_total_limit(monkeypatch):
# API clients may send ``"max_total_subagents": null``; the key is present,
# so ``dict.get(key, default)`` alone would hand ``None`` to the middleware.
app_config = _make_app_config(
[_make_model("safe-model", supports_thinking=False)],
loop_detection=LoopDetectionConfig(enabled=False),
)
app_config.subagents = SubagentsAppConfig(max_total_per_run=7)
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
middlewares = lead_agent_module.build_middlewares(
{
"configurable": {
"is_plan_mode": False,
"subagent_enabled": True,
"max_total_subagents": None,
}
},
model_name="safe-model",
app_config=app_config,
)
limit = next(m for m in middlewares if isinstance(m, SubagentLimitMiddleware))
assert limit.max_total == 7
def test_make_lead_agent_falls_back_to_app_config_for_null_subagent_total_limit(monkeypatch):
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
app_config.subagents = SubagentsAppConfig(max_total_per_run=7)
import deerflow.tools as tools_module
monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: [])
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda config, model_name, agent_name=None, **kwargs: [])
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: object())
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
prompt_calls: list[dict] = []
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", lambda **kwargs: prompt_calls.append(kwargs) or "system prompt")
lead_agent_module._make_lead_agent(
{"configurable": {"model_name": "safe-model", "subagent_enabled": True, "max_total_subagents": None}},
app_config=app_config,
)
assert prompt_calls[0]["max_total_subagents"] == 7
def test_build_middlewares_rejects_invalid_configured_extension_middleware(monkeypatch):
app_config = _make_app_config(
[_make_model("safe-model", supports_thinking=False)],

View File

@ -323,6 +323,34 @@ def test_apply_prompt_template_clamps_subagent_limits_to_enforced_bounds(monkeyp
assert "MAXIMUM 50 `task` CALLS PER RUN" in prompt
def test_apply_prompt_template_uses_configured_subagent_total_when_unset(monkeypatch):
explicit_config = SimpleNamespace(
sandbox=SimpleNamespace(
use="deerflow.sandbox.local:LocalSandboxProvider",
allow_host_bash=False,
mounts=[],
),
subagents=SubagentsAppConfig(max_total_per_run=7),
subagent_runtime=SimpleNamespace(max_running=4),
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
skill_evolution=SimpleNamespace(enabled=False),
tool_search=SimpleNamespace(enabled=False),
memory=SimpleNamespace(enabled=False, injection_enabled=True, max_injection_tokens=2000),
acp_agents={},
)
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda app_config=None: SimpleNamespace(load_skills=lambda enabled_only=True: []))
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None, **kwargs: "")
prompt = prompt_module.apply_prompt_template(
subagent_enabled=True,
max_total_subagents=None,
app_config=explicit_config,
)
assert "MAXIMUM 7 `task` CALLS PER RUN" in prompt
def test_apply_prompt_template_prefers_startup_execution_capacity_after_reload(monkeypatch):
explicit_config = SimpleNamespace(
sandbox=SimpleNamespace(

View File

@ -7,7 +7,7 @@ from deerflow.config.app_config import AppConfig
from deerflow.config.reload_boundary import STARTUP_ONLY_FIELDS, STARTUP_ONLY_PREFIX
from deerflow.config.subagent_batches_config import SubagentBatchesConfig
from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig
from deerflow.config.subagents_config import effective_subagent_concurrency
from deerflow.config.subagents_config import SubagentsAppConfig, effective_subagent_concurrency, effective_total_subagents_per_run
def test_subagent_runtime_defaults_are_safe_and_bounded() -> None:
@ -56,3 +56,12 @@ def test_ordinary_task_limit_uses_frozen_execution_capacity_after_reload() -> No
reloaded_config = SimpleNamespace(subagent_runtime=SubagentRuntimeConfig(max_running=12))
assert effective_subagent_concurrency(None, reloaded_config, execution_capacity=3) == 3
assert effective_subagent_concurrency(10, reloaded_config, execution_capacity=3) == 3
def test_per_run_delegation_cap_treats_explicit_null_as_unset() -> None:
config = SimpleNamespace(subagents=SubagentsAppConfig(max_total_per_run=7))
assert effective_total_subagents_per_run(None, config) == 7
assert effective_total_subagents_per_run(5, config) == 5
assert effective_total_subagents_per_run(99, config) == 50
assert effective_total_subagents_per_run(0, config) == 1
assert effective_total_subagents_per_run(None, SimpleNamespace()) == 6