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