From af677385210012bcd6d9d9c161df37ddb8cd39eb Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Wed, 30 Sep 2026 17:10:29 +0900 Subject: [PATCH] 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 --- CHANGELOG.md | 10 ++++ CHANGELOG_zh.md | 8 +++ .../harness/deerflow/agents/AGENTS.md | 2 +- .../deerflow/agents/lead_agent/agent.py | 11 ++-- .../deerflow/agents/lead_agent/prompt.py | 7 +-- backend/packages/harness/deerflow/client.py | 4 +- .../deerflow/config/subagents_config.py | 13 +++++ backend/tests/test_client.py | 19 +++++++ backend/tests/test_extension_stack_wiring.py | 16 ++++++ backend/tests/test_gateway_services.py | 22 ++++++++ .../tests/test_lead_agent_model_resolution.py | 52 +++++++++++++++++++ backend/tests/test_lead_agent_prompt.py | 28 ++++++++++ .../tests/test_subagent_capacity_config.py | 11 +++- 13 files changed, 186 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eafafa558f..c82ccb46d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 029b1ef0f0..f9cd63e177 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -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 diff --git a/backend/packages/harness/deerflow/agents/AGENTS.md b/backend/packages/harness/deerflow/agents/AGENTS.md index 5911b8b428..ffe83eb07a 100644 --- a/backend/packages/harness/deerflow/agents/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/AGENTS.md @@ -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. diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index 8fe6882af8..94243315d9 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -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 diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index eecd5f0db7..314c6034f6 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -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 diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index f081192a28..337320dee0 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -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) diff --git a/backend/packages/harness/deerflow/config/subagents_config.py b/backend/packages/harness/deerflow/config/subagents_config.py index e101da7e95..ff46d5347a 100644 --- a/backend/packages/harness/deerflow/config/subagents_config.py +++ b/backend/packages/harness/deerflow/config/subagents_config.py @@ -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). diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index 9e243b1573..9e8a5683ed 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -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).""" diff --git a/backend/tests/test_extension_stack_wiring.py b/backend/tests/test_extension_stack_wiring.py index a909ca21d0..e21ba4f337 100644 --- a/backend/tests/test_extension_stack_wiring.py +++ b/backend/tests/test_extension_stack_wiring.py @@ -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 diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py index 7d0f00d9e2..e1b07c446e 100644 --- a/backend/tests/test_gateway_services.py +++ b/backend/tests/test_gateway_services.py @@ -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 diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index 9d142e7d40..47a77a2833 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -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)], diff --git a/backend/tests/test_lead_agent_prompt.py b/backend/tests/test_lead_agent_prompt.py index 5f05551921..2831477d50 100644 --- a/backend/tests/test_lead_agent_prompt.py +++ b/backend/tests/test_lead_agent_prompt.py @@ -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( diff --git a/backend/tests/test_subagent_capacity_config.py b/backend/tests/test_subagent_capacity_config.py index b0e0388fd3..1954a1bd27 100644 --- a/backend/tests/test_subagent_capacity_config.py +++ b/backend/tests/test_subagent_capacity_config.py @@ -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