mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-20 07:01:03 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 979a461af5 | |||
| ac04f2704f |
@@ -11,6 +11,7 @@
|
||||
- [x] Add Plan Mode with TodoList middleware
|
||||
- [x] Add vision model support with ViewImageMiddleware
|
||||
- [x] Skills system with SKILL.md format
|
||||
- [x] Replace `time.sleep(5)` with `asyncio.sleep()` in `packages/harness/deerflow/tools/builtins/task_tool.py` (subagent polling)
|
||||
|
||||
## Planned Features
|
||||
|
||||
@@ -21,8 +22,7 @@
|
||||
- [ ] Support for more document formats in upload
|
||||
- [ ] Skill marketplace / remote skill installation
|
||||
- [ ] Optimize async concurrency in agent hot path (IM channels multi-task scenario)
|
||||
- Replace `time.sleep(5)` with `asyncio.sleep()` in `packages/harness/deerflow/tools/builtins/task_tool.py` (subagent polling)
|
||||
- Replace `subprocess.run()` with `asyncio.create_subprocess_shell()` in `packages/harness/deerflow/sandbox/local/local_sandbox.py`
|
||||
- [ ] Replace `subprocess.run()` with `asyncio.create_subprocess_shell()` in `packages/harness/deerflow/sandbox/local/local_sandbox.py`
|
||||
- Replace sync `requests` with `httpx.AsyncClient` in community tools (tavily, jina_ai, firecrawl, infoquest, image_search)
|
||||
- Replace sync `model.invoke()` with async `model.ainvoke()` in title_middleware and memory updater
|
||||
- Consider `asyncio.to_thread()` wrapper for remaining blocking file I/O
|
||||
|
||||
@@ -20,6 +20,11 @@ class SubagentOverrideConfig(BaseModel):
|
||||
ge=1,
|
||||
description="Maximum turns for this subagent (None = use global or builtin default)",
|
||||
)
|
||||
model: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
description="Model name for this subagent (None = inherit from parent agent)",
|
||||
)
|
||||
|
||||
|
||||
class SubagentsAppConfig(BaseModel):
|
||||
@@ -54,6 +59,20 @@ class SubagentsAppConfig(BaseModel):
|
||||
return override.timeout_seconds
|
||||
return self.timeout_seconds
|
||||
|
||||
def get_model_for(self, agent_name: str) -> str | None:
|
||||
"""Get the model override for a specific agent.
|
||||
|
||||
Args:
|
||||
agent_name: The name of the subagent.
|
||||
|
||||
Returns:
|
||||
Model name if overridden, None otherwise (subagent will inherit parent model).
|
||||
"""
|
||||
override = self.agents.get(agent_name)
|
||||
if override is not None and override.model is not None:
|
||||
return override.model
|
||||
return None
|
||||
|
||||
def get_max_turns_for(self, agent_name: str, builtin_default: int) -> int:
|
||||
"""Get the effective max_turns for a specific agent."""
|
||||
override = self.agents.get(agent_name)
|
||||
@@ -84,6 +103,8 @@ def load_subagents_config_from_dict(config_dict: dict) -> None:
|
||||
parts.append(f"timeout={override.timeout_seconds}s")
|
||||
if override.max_turns is not None:
|
||||
parts.append(f"max_turns={override.max_turns}")
|
||||
if override.model is not None:
|
||||
parts.append(f"model={override.model}")
|
||||
if parts:
|
||||
overrides_summary[name] = ", ".join(parts)
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ def get_subagent_config(name: str) -> SubagentConfig | None:
|
||||
if config is None:
|
||||
return None
|
||||
|
||||
# Apply timeout override from config.yaml (lazy import to avoid circular deps)
|
||||
# Apply runtime overrides (timeout, max_turns, model) from config.yaml
|
||||
# Lazy import to avoid circular deps.
|
||||
from deerflow.config.subagents_config import get_subagents_app_config
|
||||
|
||||
app_config = get_subagents_app_config()
|
||||
@@ -47,6 +48,15 @@ def get_subagent_config(name: str) -> SubagentConfig | None:
|
||||
effective_max_turns,
|
||||
)
|
||||
overrides["max_turns"] = effective_max_turns
|
||||
effective_model = app_config.get_model_for(name)
|
||||
if effective_model is not None and effective_model != config.model:
|
||||
logger.debug(
|
||||
"Subagent '%s': model overridden by config.yaml (%s -> %s)",
|
||||
name,
|
||||
config.model,
|
||||
effective_model,
|
||||
)
|
||||
overrides["model"] = effective_model
|
||||
if overrides:
|
||||
config = replace(config, **overrides)
|
||||
|
||||
|
||||
@@ -50,11 +50,19 @@ class TestSubagentOverrideConfig:
|
||||
override = SubagentOverrideConfig()
|
||||
assert override.timeout_seconds is None
|
||||
assert override.max_turns is None
|
||||
assert override.model is None
|
||||
|
||||
def test_explicit_value(self):
|
||||
override = SubagentOverrideConfig(timeout_seconds=300, max_turns=42)
|
||||
override = SubagentOverrideConfig(timeout_seconds=300, max_turns=42, model="gpt-5.4")
|
||||
assert override.timeout_seconds == 300
|
||||
assert override.max_turns == 42
|
||||
assert override.model == "gpt-5.4"
|
||||
|
||||
def test_model_accepts_any_non_empty_string(self):
|
||||
"""Model name is a free-form non-empty string; cross-reference validation
|
||||
against the `models:` section happens at registry lookup time."""
|
||||
override = SubagentOverrideConfig(model="any-arbitrary-model-name")
|
||||
assert override.model == "any-arbitrary-model-name"
|
||||
|
||||
def test_rejects_zero(self):
|
||||
with pytest.raises(ValueError):
|
||||
@@ -68,6 +76,13 @@ class TestSubagentOverrideConfig:
|
||||
with pytest.raises(ValueError):
|
||||
SubagentOverrideConfig(max_turns=-1)
|
||||
|
||||
def test_rejects_empty_model(self):
|
||||
"""Empty-string model would silently bypass the `is not None` check and
|
||||
reach `create_chat_model(name="")` as a runtime error. Reject at load time
|
||||
instead, symmetric with the `ge=1` guard on timeout_seconds / max_turns."""
|
||||
with pytest.raises(ValueError):
|
||||
SubagentOverrideConfig(model="")
|
||||
|
||||
def test_minimum_valid_value(self):
|
||||
override = SubagentOverrideConfig(timeout_seconds=1, max_turns=1)
|
||||
assert override.timeout_seconds == 1
|
||||
@@ -165,6 +180,42 @@ class TestRuntimeResolution:
|
||||
assert config.get_max_turns_for("general-purpose", 100) == 200
|
||||
assert config.get_max_turns_for("bash", 60) == 80
|
||||
|
||||
def test_get_model_for_returns_none_when_no_override(self):
|
||||
"""No per-agent model override -> returns None so callers fall back to builtin/parent."""
|
||||
config = SubagentsAppConfig(timeout_seconds=900)
|
||||
assert config.get_model_for("general-purpose") is None
|
||||
assert config.get_model_for("bash") is None
|
||||
assert config.get_model_for("unknown-agent") is None
|
||||
|
||||
def test_get_model_for_returns_override_when_set(self):
|
||||
config = SubagentsAppConfig(
|
||||
timeout_seconds=900,
|
||||
agents={
|
||||
"general-purpose": SubagentOverrideConfig(model="qwen3.5-35b-a3b"),
|
||||
"bash": SubagentOverrideConfig(model="gpt-5.4"),
|
||||
},
|
||||
)
|
||||
assert config.get_model_for("general-purpose") == "qwen3.5-35b-a3b"
|
||||
assert config.get_model_for("bash") == "gpt-5.4"
|
||||
|
||||
def test_get_model_for_returns_none_for_omitted_agent(self):
|
||||
"""An agent not listed in overrides returns None even when other agents have model overrides."""
|
||||
config = SubagentsAppConfig(
|
||||
timeout_seconds=900,
|
||||
agents={"bash": SubagentOverrideConfig(model="gpt-5.4")},
|
||||
)
|
||||
assert config.get_model_for("general-purpose") is None
|
||||
|
||||
def test_get_model_for_handles_explicit_none(self):
|
||||
"""Explicit model=None in the override is equivalent to no override."""
|
||||
config = SubagentsAppConfig(
|
||||
timeout_seconds=900,
|
||||
agents={"bash": SubagentOverrideConfig(timeout_seconds=300, model=None)},
|
||||
)
|
||||
assert config.get_model_for("bash") is None
|
||||
# Timeout override is still applied even when model is None.
|
||||
assert config.get_timeout_for("bash") == 300
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_subagents_config_from_dict / get_subagents_app_config singleton
|
||||
@@ -211,6 +262,22 @@ class TestLoadSubagentsConfig:
|
||||
assert cfg.get_max_turns_for("general-purpose", 100) == 100
|
||||
assert cfg.get_max_turns_for("bash", 60) == 70
|
||||
|
||||
def test_load_with_model_overrides(self):
|
||||
load_subagents_config_from_dict(
|
||||
{
|
||||
"timeout_seconds": 900,
|
||||
"agents": {
|
||||
"general-purpose": {"model": "qwen3.5-35b-a3b"},
|
||||
"bash": {"model": "gpt-5.4", "timeout_seconds": 300},
|
||||
},
|
||||
}
|
||||
)
|
||||
cfg = get_subagents_app_config()
|
||||
assert cfg.get_model_for("general-purpose") == "qwen3.5-35b-a3b"
|
||||
assert cfg.get_model_for("bash") == "gpt-5.4"
|
||||
# Other override fields on the same agent must still load correctly.
|
||||
assert cfg.get_timeout_for("bash") == 300
|
||||
|
||||
def test_load_empty_dict_uses_defaults(self):
|
||||
load_subagents_config_from_dict({})
|
||||
cfg = get_subagents_app_config()
|
||||
@@ -296,6 +363,97 @@ class TestRegistryGetSubagentConfig:
|
||||
assert gp_config.timeout_seconds == 900
|
||||
assert gp_config.max_turns == 120
|
||||
|
||||
def test_per_agent_model_override_applied(self):
|
||||
from deerflow.subagents.registry import get_subagent_config
|
||||
|
||||
load_subagents_config_from_dict(
|
||||
{
|
||||
"timeout_seconds": 900,
|
||||
"agents": {"bash": {"model": "gpt-5.4-mini"}},
|
||||
}
|
||||
)
|
||||
bash_config = get_subagent_config("bash")
|
||||
assert bash_config.model == "gpt-5.4-mini"
|
||||
|
||||
def test_omitted_model_keeps_builtin_value(self):
|
||||
"""When config.yaml has no `model` field for an agent, the builtin default must be preserved."""
|
||||
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
|
||||
from deerflow.subagents.registry import get_subagent_config
|
||||
|
||||
builtin_bash_model = BUILTIN_SUBAGENTS["bash"].model
|
||||
load_subagents_config_from_dict(
|
||||
{
|
||||
"timeout_seconds": 900,
|
||||
"agents": {"bash": {"timeout_seconds": 300}},
|
||||
}
|
||||
)
|
||||
bash_config = get_subagent_config("bash")
|
||||
assert bash_config.model == builtin_bash_model
|
||||
|
||||
def test_explicit_null_model_keeps_builtin_value(self):
|
||||
"""An explicit `model: null` in config.yaml is equivalent to omission — builtin wins."""
|
||||
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
|
||||
from deerflow.subagents.registry import get_subagent_config
|
||||
|
||||
builtin_bash_model = BUILTIN_SUBAGENTS["bash"].model
|
||||
load_subagents_config_from_dict(
|
||||
{
|
||||
"timeout_seconds": 900,
|
||||
"agents": {"bash": {"model": None}},
|
||||
}
|
||||
)
|
||||
bash_config = get_subagent_config("bash")
|
||||
assert bash_config.model == builtin_bash_model
|
||||
|
||||
def test_model_override_does_not_affect_other_agents(self):
|
||||
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
|
||||
from deerflow.subagents.registry import get_subagent_config
|
||||
|
||||
builtin_gp_model = BUILTIN_SUBAGENTS["general-purpose"].model
|
||||
load_subagents_config_from_dict(
|
||||
{
|
||||
"timeout_seconds": 900,
|
||||
"agents": {"bash": {"model": "gpt-5.4"}},
|
||||
}
|
||||
)
|
||||
gp_config = get_subagent_config("general-purpose")
|
||||
assert gp_config.model == builtin_gp_model
|
||||
|
||||
def test_model_override_preserves_other_fields(self):
|
||||
"""Applying a model override must leave timeout_seconds / max_turns / name intact."""
|
||||
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
|
||||
from deerflow.subagents.registry import get_subagent_config
|
||||
|
||||
original = BUILTIN_SUBAGENTS["bash"]
|
||||
load_subagents_config_from_dict(
|
||||
{
|
||||
"timeout_seconds": 900,
|
||||
"agents": {"bash": {"model": "gpt-5.4-mini"}},
|
||||
}
|
||||
)
|
||||
overridden = get_subagent_config("bash")
|
||||
assert overridden.model == "gpt-5.4-mini"
|
||||
assert overridden.name == original.name
|
||||
assert overridden.description == original.description
|
||||
# No timeout / max_turns override was set, so they use global default / builtin.
|
||||
assert overridden.timeout_seconds == 900
|
||||
assert overridden.max_turns == original.max_turns
|
||||
|
||||
def test_model_override_does_not_mutate_builtin(self):
|
||||
"""Registry must return a new object, leaving the builtin default intact."""
|
||||
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
|
||||
from deerflow.subagents.registry import get_subagent_config
|
||||
|
||||
original_bash_model = BUILTIN_SUBAGENTS["bash"].model
|
||||
load_subagents_config_from_dict(
|
||||
{
|
||||
"timeout_seconds": 900,
|
||||
"agents": {"bash": {"model": "gpt-5.4-mini"}},
|
||||
}
|
||||
)
|
||||
_ = get_subagent_config("bash")
|
||||
assert BUILTIN_SUBAGENTS["bash"].model == original_bash_model
|
||||
|
||||
def test_builtin_config_object_is_not_mutated(self):
|
||||
"""Registry must return a new object, leaving the builtin default intact."""
|
||||
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
|
||||
|
||||
@@ -575,9 +575,14 @@ sandbox:
|
||||
# general-purpose:
|
||||
# timeout_seconds: 1800 # 30 minutes for complex multi-step tasks
|
||||
# max_turns: 160
|
||||
# # model: qwen3:32b # Use a specific model (default: inherit from lead agent)
|
||||
# bash:
|
||||
# timeout_seconds: 300 # 5 minutes for quick command execution
|
||||
# max_turns: 80
|
||||
#
|
||||
# # Model override: by default, subagents inherit the lead agent's model.
|
||||
# # Set `model` to use a different model (e.g., a local Ollama model for cost savings).
|
||||
# # The model name must match a name defined in the `models:` section above.
|
||||
|
||||
# ============================================================================
|
||||
# ACP Agents Configuration
|
||||
|
||||
Reference in New Issue
Block a user