mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
d0e4d3525c
commit
af67738521
10
CHANGELOG.md
10
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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
@ -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)."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)],
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user