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 Plan Mode with TodoList middleware
|
||||||
- [x] Add vision model support with ViewImageMiddleware
|
- [x] Add vision model support with ViewImageMiddleware
|
||||||
- [x] Skills system with SKILL.md format
|
- [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
|
## Planned Features
|
||||||
|
|
||||||
@@ -21,8 +22,7 @@
|
|||||||
- [ ] Support for more document formats in upload
|
- [ ] Support for more document formats in upload
|
||||||
- [ ] Skill marketplace / remote skill installation
|
- [ ] Skill marketplace / remote skill installation
|
||||||
- [ ] Optimize async concurrency in agent hot path (IM channels multi-task scenario)
|
- [ ] 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 `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
|
- 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
|
- Consider `asyncio.to_thread()` wrapper for remaining blocking file I/O
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ class SubagentOverrideConfig(BaseModel):
|
|||||||
ge=1,
|
ge=1,
|
||||||
description="Maximum turns for this subagent (None = use global or builtin default)",
|
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):
|
class SubagentsAppConfig(BaseModel):
|
||||||
@@ -54,6 +59,20 @@ class SubagentsAppConfig(BaseModel):
|
|||||||
return override.timeout_seconds
|
return override.timeout_seconds
|
||||||
return self.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:
|
def get_max_turns_for(self, agent_name: str, builtin_default: int) -> int:
|
||||||
"""Get the effective max_turns for a specific agent."""
|
"""Get the effective max_turns for a specific agent."""
|
||||||
override = self.agents.get(agent_name)
|
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")
|
parts.append(f"timeout={override.timeout_seconds}s")
|
||||||
if override.max_turns is not None:
|
if override.max_turns is not None:
|
||||||
parts.append(f"max_turns={override.max_turns}")
|
parts.append(f"max_turns={override.max_turns}")
|
||||||
|
if override.model is not None:
|
||||||
|
parts.append(f"model={override.model}")
|
||||||
if parts:
|
if parts:
|
||||||
overrides_summary[name] = ", ".join(parts)
|
overrides_summary[name] = ", ".join(parts)
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ def get_subagent_config(name: str) -> SubagentConfig | None:
|
|||||||
if config is None:
|
if config is None:
|
||||||
return 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
|
from deerflow.config.subagents_config import get_subagents_app_config
|
||||||
|
|
||||||
app_config = 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,
|
effective_max_turns,
|
||||||
)
|
)
|
||||||
overrides["max_turns"] = 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:
|
if overrides:
|
||||||
config = replace(config, **overrides)
|
config = replace(config, **overrides)
|
||||||
|
|
||||||
|
|||||||
@@ -50,11 +50,19 @@ class TestSubagentOverrideConfig:
|
|||||||
override = SubagentOverrideConfig()
|
override = SubagentOverrideConfig()
|
||||||
assert override.timeout_seconds is None
|
assert override.timeout_seconds is None
|
||||||
assert override.max_turns is None
|
assert override.max_turns is None
|
||||||
|
assert override.model is None
|
||||||
|
|
||||||
def test_explicit_value(self):
|
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.timeout_seconds == 300
|
||||||
assert override.max_turns == 42
|
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):
|
def test_rejects_zero(self):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
@@ -68,6 +76,13 @@ class TestSubagentOverrideConfig:
|
|||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
SubagentOverrideConfig(max_turns=-1)
|
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):
|
def test_minimum_valid_value(self):
|
||||||
override = SubagentOverrideConfig(timeout_seconds=1, max_turns=1)
|
override = SubagentOverrideConfig(timeout_seconds=1, max_turns=1)
|
||||||
assert override.timeout_seconds == 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("general-purpose", 100) == 200
|
||||||
assert config.get_max_turns_for("bash", 60) == 80
|
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
|
# 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("general-purpose", 100) == 100
|
||||||
assert cfg.get_max_turns_for("bash", 60) == 70
|
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):
|
def test_load_empty_dict_uses_defaults(self):
|
||||||
load_subagents_config_from_dict({})
|
load_subagents_config_from_dict({})
|
||||||
cfg = get_subagents_app_config()
|
cfg = get_subagents_app_config()
|
||||||
@@ -296,6 +363,97 @@ class TestRegistryGetSubagentConfig:
|
|||||||
assert gp_config.timeout_seconds == 900
|
assert gp_config.timeout_seconds == 900
|
||||||
assert gp_config.max_turns == 120
|
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):
|
def test_builtin_config_object_is_not_mutated(self):
|
||||||
"""Registry must return a new object, leaving the builtin default intact."""
|
"""Registry must return a new object, leaving the builtin default intact."""
|
||||||
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
|
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
|
||||||
|
|||||||
@@ -575,9 +575,14 @@ sandbox:
|
|||||||
# general-purpose:
|
# general-purpose:
|
||||||
# timeout_seconds: 1800 # 30 minutes for complex multi-step tasks
|
# timeout_seconds: 1800 # 30 minutes for complex multi-step tasks
|
||||||
# max_turns: 160
|
# max_turns: 160
|
||||||
|
# # model: qwen3:32b # Use a specific model (default: inherit from lead agent)
|
||||||
# bash:
|
# bash:
|
||||||
# timeout_seconds: 300 # 5 minutes for quick command execution
|
# timeout_seconds: 300 # 5 minutes for quick command execution
|
||||||
# max_turns: 80
|
# 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
|
# ACP Agents Configuration
|
||||||
|
|||||||
Reference in New Issue
Block a user