mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-11 03:08:37 +00:00
* refactor: thread app config through lead prompt * fix: honor explicit app config across runtime paths * style: format subagent executor tests * fix: thread resolved app config and guard subagents-only fallback Address two PR review findings: 1. _create_summarization_middleware passed the original (possibly None) app_config into create_chat_model, forcing the model factory back to ambient get_app_config() and risking config drift between the middleware's resolved view and the model's view. Pass the resolved AppConfig instance through end-to-end. 2. get_available_subagent_names accepted Any-typed config and forwarded it to is_host_bash_allowed, which reads ``.sandbox``. A SubagentsAppConfig (also accepted upstream as a sum-type input) has no ``.sandbox`` attribute and would be silently treated as "no sandbox configured", incorrectly disabling the bash subagent. Guard on hasattr and fall back to ambient lookup otherwise. Adds regression tests for both paths. * chore: simplify hasattr guard and tighten regression tests - Collapse if/else into ternary in get_available_subagent_names; hasattr(None, ...) is False so the explicit None check was redundant. - Drop comments that narrate the change rather than explain non-obvious WHY (test names already convey intent). - Replace stringly-typed sentinel "no-arg" in regression test with direct args tuple comparison. --------- Co-authored-by: greatmengqi <chenmengqi.0376@bytedance.com>
641 lines
22 KiB
Python
641 lines
22 KiB
Python
"""Tests for subagent per-agent skill configuration and custom subagent types.
|
|
|
|
Covers:
|
|
- SubagentConfig.skills field
|
|
- SubagentOverrideConfig.skills field
|
|
- CustomSubagentConfig model validation
|
|
- SubagentsAppConfig.custom_agents and get_skills_for()
|
|
- Registry: custom agent lookup, skills override, merged available names
|
|
- Skills filter passthrough in task_tool config assembly
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from deerflow.config.subagents_config import (
|
|
CustomSubagentConfig,
|
|
SubagentOverrideConfig,
|
|
SubagentsAppConfig,
|
|
get_subagents_app_config,
|
|
load_subagents_config_from_dict,
|
|
)
|
|
from deerflow.subagents.config import SubagentConfig
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _reset_subagents_config(**kwargs) -> None:
|
|
"""Reset global subagents config to a known state."""
|
|
load_subagents_config_from_dict(kwargs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SubagentConfig.skills field
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSubagentConfigSkills:
|
|
def test_default_skills_is_none(self):
|
|
config = SubagentConfig(name="test", description="test", system_prompt="test")
|
|
assert config.skills is None
|
|
|
|
def test_skills_whitelist(self):
|
|
config = SubagentConfig(
|
|
name="test",
|
|
description="test",
|
|
system_prompt="test",
|
|
skills=["data-analysis", "visualization"],
|
|
)
|
|
assert config.skills == ["data-analysis", "visualization"]
|
|
|
|
def test_skills_empty_list_means_no_skills(self):
|
|
config = SubagentConfig(
|
|
name="test",
|
|
description="test",
|
|
system_prompt="test",
|
|
skills=[],
|
|
)
|
|
assert config.skills == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SubagentOverrideConfig.skills field
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSubagentOverrideConfigSkills:
|
|
def test_default_skills_is_none(self):
|
|
override = SubagentOverrideConfig()
|
|
assert override.skills is None
|
|
|
|
def test_skills_whitelist(self):
|
|
override = SubagentOverrideConfig(skills=["web-search", "data-analysis"])
|
|
assert override.skills == ["web-search", "data-analysis"]
|
|
|
|
def test_skills_empty_list(self):
|
|
override = SubagentOverrideConfig(skills=[])
|
|
assert override.skills == []
|
|
|
|
def test_skills_coexists_with_other_fields(self):
|
|
override = SubagentOverrideConfig(
|
|
timeout_seconds=300,
|
|
model="gpt-5",
|
|
skills=["my-skill"],
|
|
)
|
|
assert override.timeout_seconds == 300
|
|
assert override.model == "gpt-5"
|
|
assert override.skills == ["my-skill"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CustomSubagentConfig model
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCustomSubagentConfig:
|
|
def test_minimal_valid(self):
|
|
config = CustomSubagentConfig(
|
|
description="A test agent",
|
|
system_prompt="You are a test agent.",
|
|
)
|
|
assert config.description == "A test agent"
|
|
assert config.system_prompt == "You are a test agent."
|
|
assert config.tools is None
|
|
assert config.disallowed_tools == ["task", "ask_clarification", "present_files"]
|
|
assert config.skills is None
|
|
assert config.model == "inherit"
|
|
assert config.max_turns == 50
|
|
assert config.timeout_seconds == 900
|
|
|
|
def test_full_configuration(self):
|
|
config = CustomSubagentConfig(
|
|
description="Data analysis specialist",
|
|
system_prompt="You are a data analysis subagent.",
|
|
tools=["bash", "read_file", "write_file"],
|
|
disallowed_tools=["task"],
|
|
skills=["data-analysis", "visualization"],
|
|
model="qwen3:32b",
|
|
max_turns=80,
|
|
timeout_seconds=600,
|
|
)
|
|
assert config.tools == ["bash", "read_file", "write_file"]
|
|
assert config.skills == ["data-analysis", "visualization"]
|
|
assert config.model == "qwen3:32b"
|
|
assert config.max_turns == 80
|
|
assert config.timeout_seconds == 600
|
|
|
|
def test_skills_empty_list_no_skills(self):
|
|
config = CustomSubagentConfig(
|
|
description="test",
|
|
system_prompt="test",
|
|
skills=[],
|
|
)
|
|
assert config.skills == []
|
|
|
|
def test_rejects_zero_max_turns(self):
|
|
with pytest.raises(ValueError):
|
|
CustomSubagentConfig(
|
|
description="test",
|
|
system_prompt="test",
|
|
max_turns=0,
|
|
)
|
|
|
|
def test_rejects_zero_timeout(self):
|
|
with pytest.raises(ValueError):
|
|
CustomSubagentConfig(
|
|
description="test",
|
|
system_prompt="test",
|
|
timeout_seconds=0,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SubagentsAppConfig.custom_agents and get_skills_for()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSubagentsAppConfigCustomAgents:
|
|
def test_default_custom_agents_empty(self):
|
|
config = SubagentsAppConfig()
|
|
assert config.custom_agents == {}
|
|
|
|
def test_custom_agents_loaded(self):
|
|
config = SubagentsAppConfig(
|
|
custom_agents={
|
|
"analysis": CustomSubagentConfig(
|
|
description="Analysis agent",
|
|
system_prompt="You analyze data.",
|
|
skills=["data-analysis"],
|
|
),
|
|
}
|
|
)
|
|
assert "analysis" in config.custom_agents
|
|
assert config.custom_agents["analysis"].skills == ["data-analysis"]
|
|
|
|
def test_multiple_custom_agents(self):
|
|
config = SubagentsAppConfig(
|
|
custom_agents={
|
|
"analysis": CustomSubagentConfig(
|
|
description="Analysis",
|
|
system_prompt="analyze",
|
|
skills=["data-analysis"],
|
|
),
|
|
"researcher": CustomSubagentConfig(
|
|
description="Research",
|
|
system_prompt="research",
|
|
skills=["web-search"],
|
|
),
|
|
}
|
|
)
|
|
assert len(config.custom_agents) == 2
|
|
|
|
|
|
class TestGetSkillsFor:
|
|
def test_returns_none_when_no_override(self):
|
|
config = SubagentsAppConfig()
|
|
assert config.get_skills_for("general-purpose") is None
|
|
assert config.get_skills_for("unknown") is None
|
|
|
|
def test_returns_skills_whitelist(self):
|
|
config = SubagentsAppConfig(
|
|
agents={
|
|
"general-purpose": SubagentOverrideConfig(skills=["web-search", "coding"]),
|
|
}
|
|
)
|
|
assert config.get_skills_for("general-purpose") == ["web-search", "coding"]
|
|
|
|
def test_returns_empty_list_for_no_skills(self):
|
|
config = SubagentsAppConfig(
|
|
agents={
|
|
"bash": SubagentOverrideConfig(skills=[]),
|
|
}
|
|
)
|
|
assert config.get_skills_for("bash") == []
|
|
|
|
def test_returns_none_for_unrelated_agent(self):
|
|
config = SubagentsAppConfig(
|
|
agents={
|
|
"bash": SubagentOverrideConfig(skills=["web-search"]),
|
|
}
|
|
)
|
|
assert config.get_skills_for("general-purpose") is None
|
|
|
|
def test_returns_none_when_skills_not_set(self):
|
|
config = SubagentsAppConfig(
|
|
agents={
|
|
"bash": SubagentOverrideConfig(timeout_seconds=300),
|
|
}
|
|
)
|
|
assert config.get_skills_for("bash") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# load_subagents_config_from_dict with skills and custom_agents
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoadSubagentsConfigWithSkills:
|
|
def teardown_method(self):
|
|
_reset_subagents_config()
|
|
|
|
def test_load_with_skills_override(self):
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"timeout_seconds": 900,
|
|
"agents": {
|
|
"general-purpose": {"skills": ["web-search", "data-analysis"]},
|
|
},
|
|
}
|
|
)
|
|
cfg = get_subagents_app_config()
|
|
assert cfg.get_skills_for("general-purpose") == ["web-search", "data-analysis"]
|
|
|
|
def test_load_with_empty_skills(self):
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"timeout_seconds": 900,
|
|
"agents": {
|
|
"bash": {"skills": []},
|
|
},
|
|
}
|
|
)
|
|
cfg = get_subagents_app_config()
|
|
assert cfg.get_skills_for("bash") == []
|
|
|
|
def test_load_with_custom_agents(self):
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"timeout_seconds": 900,
|
|
"custom_agents": {
|
|
"analysis": {
|
|
"description": "Data analysis specialist",
|
|
"system_prompt": "You are a data analysis subagent.",
|
|
"skills": ["data-analysis", "visualization"],
|
|
"tools": ["bash", "read_file"],
|
|
"max_turns": 80,
|
|
"timeout_seconds": 600,
|
|
},
|
|
},
|
|
}
|
|
)
|
|
cfg = get_subagents_app_config()
|
|
assert "analysis" in cfg.custom_agents
|
|
custom = cfg.custom_agents["analysis"]
|
|
assert custom.skills == ["data-analysis", "visualization"]
|
|
assert custom.tools == ["bash", "read_file"]
|
|
assert custom.max_turns == 80
|
|
assert custom.timeout_seconds == 600
|
|
|
|
def test_load_with_both_overrides_and_custom(self):
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"timeout_seconds": 900,
|
|
"agents": {
|
|
"general-purpose": {"skills": ["web-search"]},
|
|
},
|
|
"custom_agents": {
|
|
"analysis": {
|
|
"description": "Analysis",
|
|
"system_prompt": "Analyze.",
|
|
"skills": ["data-analysis"],
|
|
},
|
|
},
|
|
}
|
|
)
|
|
cfg = get_subagents_app_config()
|
|
assert cfg.get_skills_for("general-purpose") == ["web-search"]
|
|
assert cfg.custom_agents["analysis"].skills == ["data-analysis"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry: custom agent lookup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRegistryCustomAgentLookup:
|
|
def teardown_method(self):
|
|
_reset_subagents_config()
|
|
|
|
def test_custom_agent_found(self):
|
|
from deerflow.subagents.registry import get_subagent_config
|
|
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"custom_agents": {
|
|
"analysis": {
|
|
"description": "Data analysis specialist",
|
|
"system_prompt": "You are a data analysis subagent.",
|
|
"skills": ["data-analysis"],
|
|
"tools": ["bash", "read_file"],
|
|
"max_turns": 80,
|
|
"timeout_seconds": 600,
|
|
},
|
|
},
|
|
}
|
|
)
|
|
config = get_subagent_config("analysis")
|
|
assert config is not None
|
|
assert config.name == "analysis"
|
|
assert config.skills == ["data-analysis"]
|
|
assert config.tools == ["bash", "read_file"]
|
|
assert config.max_turns == 80
|
|
assert config.timeout_seconds == 600
|
|
assert config.model == "inherit"
|
|
|
|
def test_custom_agent_found_from_explicit_app_config_without_global_config(self, monkeypatch):
|
|
from deerflow.subagents.registry import get_subagent_config
|
|
|
|
def fail_get_subagents_app_config():
|
|
raise AssertionError("ambient get_subagents_app_config() must not be used when app_config is explicit")
|
|
|
|
monkeypatch.setattr("deerflow.config.subagents_config.get_subagents_app_config", fail_get_subagents_app_config)
|
|
|
|
app_config = SimpleNamespace(
|
|
subagents=SubagentsAppConfig(
|
|
custom_agents={
|
|
"analysis": CustomSubagentConfig(
|
|
description="Data analysis specialist",
|
|
system_prompt="You are a data analysis subagent.",
|
|
skills=["data-analysis"],
|
|
)
|
|
}
|
|
)
|
|
)
|
|
|
|
config = get_subagent_config("analysis", app_config=app_config)
|
|
|
|
assert config is not None
|
|
assert config.name == "analysis"
|
|
assert config.skills == ["data-analysis"]
|
|
|
|
def test_custom_agent_not_found(self):
|
|
from deerflow.subagents.registry import get_subagent_config
|
|
|
|
_reset_subagents_config()
|
|
assert get_subagent_config("nonexistent") is None
|
|
|
|
def test_get_available_subagent_names_falls_back_when_subagents_app_config_lacks_sandbox(self, monkeypatch):
|
|
from deerflow.subagents import registry as registry_module
|
|
from deerflow.subagents.registry import get_available_subagent_names
|
|
|
|
captured: dict[str, tuple] = {}
|
|
|
|
def fake_is_host_bash_allowed(*args, **kwargs):
|
|
captured["args"] = args
|
|
return True
|
|
|
|
monkeypatch.setattr(registry_module, "is_host_bash_allowed", fake_is_host_bash_allowed)
|
|
|
|
get_available_subagent_names(app_config=SubagentsAppConfig())
|
|
|
|
assert captured["args"] == ()
|
|
|
|
def test_builtin_takes_priority_over_custom(self):
|
|
"""If a custom agent has the same name as a builtin, builtin wins."""
|
|
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
|
|
from deerflow.subagents.registry import get_subagent_config
|
|
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"custom_agents": {
|
|
"general-purpose": {
|
|
"description": "Custom override attempt",
|
|
"system_prompt": "Should not be used",
|
|
},
|
|
},
|
|
}
|
|
)
|
|
config = get_subagent_config("general-purpose")
|
|
# Should get the builtin description, not the custom one
|
|
assert config.description == BUILTIN_SUBAGENTS["general-purpose"].description
|
|
|
|
def test_custom_agent_with_override(self):
|
|
"""Per-agent overrides also apply to custom agents."""
|
|
from deerflow.subagents.registry import get_subagent_config
|
|
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"custom_agents": {
|
|
"analysis": {
|
|
"description": "Analysis",
|
|
"system_prompt": "Analyze.",
|
|
"timeout_seconds": 600,
|
|
},
|
|
},
|
|
"agents": {
|
|
"analysis": {"timeout_seconds": 300, "skills": ["overridden-skill"]},
|
|
},
|
|
}
|
|
)
|
|
config = get_subagent_config("analysis")
|
|
assert config is not None
|
|
assert config.timeout_seconds == 300 # Override applied
|
|
assert config.skills == ["overridden-skill"] # Override applied
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry: skills override on builtin agents
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRegistrySkillsOverride:
|
|
def teardown_method(self):
|
|
_reset_subagents_config()
|
|
|
|
def test_skills_override_applied_to_builtin(self):
|
|
from deerflow.subagents.registry import get_subagent_config
|
|
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"agents": {
|
|
"general-purpose": {"skills": ["web-search", "data-analysis"]},
|
|
},
|
|
}
|
|
)
|
|
config = get_subagent_config("general-purpose")
|
|
assert config.skills == ["web-search", "data-analysis"]
|
|
|
|
def test_empty_skills_override(self):
|
|
from deerflow.subagents.registry import get_subagent_config
|
|
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"agents": {
|
|
"bash": {"skills": []},
|
|
},
|
|
}
|
|
)
|
|
config = get_subagent_config("bash")
|
|
assert config.skills == []
|
|
|
|
def test_no_skills_override_keeps_default(self):
|
|
from deerflow.subagents.registry import get_subagent_config
|
|
|
|
_reset_subagents_config()
|
|
config = get_subagent_config("general-purpose")
|
|
assert config.skills is None # Default: inherit all
|
|
|
|
def test_skills_override_does_not_mutate_builtin(self):
|
|
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
|
|
from deerflow.subagents.registry import get_subagent_config
|
|
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"agents": {
|
|
"general-purpose": {"skills": ["web-search"]},
|
|
},
|
|
}
|
|
)
|
|
_ = get_subagent_config("general-purpose")
|
|
assert BUILTIN_SUBAGENTS["general-purpose"].skills is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry: get_available_subagent_names merges custom types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRegistryAvailableNames:
|
|
def teardown_method(self):
|
|
_reset_subagents_config()
|
|
|
|
def test_includes_builtin_names(self):
|
|
from deerflow.subagents.registry import get_subagent_names
|
|
|
|
_reset_subagents_config()
|
|
names = get_subagent_names()
|
|
assert "general-purpose" in names
|
|
assert "bash" in names
|
|
|
|
def test_includes_custom_names(self):
|
|
from deerflow.subagents.registry import get_subagent_names
|
|
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"custom_agents": {
|
|
"analysis": {
|
|
"description": "Analysis",
|
|
"system_prompt": "Analyze.",
|
|
},
|
|
"researcher": {
|
|
"description": "Research",
|
|
"system_prompt": "Research.",
|
|
},
|
|
},
|
|
}
|
|
)
|
|
names = get_subagent_names()
|
|
assert "general-purpose" in names
|
|
assert "bash" in names
|
|
assert "analysis" in names
|
|
assert "researcher" in names
|
|
|
|
def test_no_duplicates_when_custom_name_matches_builtin(self):
|
|
from deerflow.subagents.registry import get_subagent_names
|
|
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"custom_agents": {
|
|
"general-purpose": {
|
|
"description": "Duplicate name",
|
|
"system_prompt": "test",
|
|
},
|
|
},
|
|
}
|
|
)
|
|
names = get_subagent_names()
|
|
assert names.count("general-purpose") == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry: list_subagents includes custom agents
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRegistryListSubagentsWithCustom:
|
|
def teardown_method(self):
|
|
_reset_subagents_config()
|
|
|
|
def test_list_includes_custom_agents(self):
|
|
from deerflow.subagents.registry import list_subagents
|
|
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"custom_agents": {
|
|
"analysis": {
|
|
"description": "Analysis",
|
|
"system_prompt": "Analyze.",
|
|
"skills": ["data-analysis"],
|
|
},
|
|
},
|
|
}
|
|
)
|
|
configs = list_subagents()
|
|
names = {c.name for c in configs}
|
|
assert "general-purpose" in names
|
|
assert "bash" in names
|
|
assert "analysis" in names
|
|
|
|
def test_list_custom_agent_has_correct_skills(self):
|
|
from deerflow.subagents.registry import list_subagents
|
|
|
|
load_subagents_config_from_dict(
|
|
{
|
|
"custom_agents": {
|
|
"analysis": {
|
|
"description": "Analysis",
|
|
"system_prompt": "Analyze.",
|
|
"skills": ["data-analysis", "visualization"],
|
|
},
|
|
},
|
|
}
|
|
)
|
|
by_name = {c.name: c for c in list_subagents()}
|
|
assert by_name["analysis"].skills == ["data-analysis", "visualization"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Skills filter passthrough: verify config.skills is used in task_tool assembly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkillsFilterPassthrough:
|
|
"""Test that SubagentConfig.skills is correctly passed to get_skills_prompt_section."""
|
|
|
|
def test_none_skills_passes_none_to_prompt(self):
|
|
"""When config.skills is None, available_skills=None should be passed (inherit all)."""
|
|
config = SubagentConfig(
|
|
name="test",
|
|
description="test",
|
|
system_prompt="test",
|
|
skills=None,
|
|
)
|
|
# Verify: set(None) would raise, so the code must check for None first
|
|
available = set(config.skills) if config.skills is not None else None
|
|
assert available is None
|
|
|
|
def test_empty_skills_passes_empty_set(self):
|
|
"""When config.skills is [], available_skills=set() should be passed (no skills)."""
|
|
config = SubagentConfig(
|
|
name="test",
|
|
description="test",
|
|
system_prompt="test",
|
|
skills=[],
|
|
)
|
|
available = set(config.skills) if config.skills is not None else None
|
|
assert available == set()
|
|
|
|
def test_skills_whitelist_passes_correct_set(self):
|
|
"""When config.skills has values, those should be passed as available_skills."""
|
|
config = SubagentConfig(
|
|
name="test",
|
|
description="test",
|
|
system_prompt="test",
|
|
skills=["data-analysis", "web-search"],
|
|
)
|
|
available = set(config.skills) if config.skills is not None else None
|
|
assert available == {"data-analysis", "web-search"}
|