diff --git a/backend/packages/harness/deerflow/config/subagents_config.py b/backend/packages/harness/deerflow/config/subagents_config.py index 2611fe8fb..f2c650709 100644 --- a/backend/packages/harness/deerflow/config/subagents_config.py +++ b/backend/packages/harness/deerflow/config/subagents_config.py @@ -15,6 +15,11 @@ class SubagentOverrideConfig(BaseModel): ge=1, description="Timeout in seconds for this subagent (None = use global default)", ) + max_turns: int | None = Field( + default=None, + ge=1, + description="Maximum turns for this subagent (None = use global or builtin default)", + ) class SubagentsAppConfig(BaseModel): @@ -25,6 +30,11 @@ class SubagentsAppConfig(BaseModel): ge=1, description="Default timeout in seconds for all subagents (default: 900 = 15 minutes)", ) + max_turns: int | None = Field( + default=None, + ge=1, + description="Optional default max-turn override for all subagents (None = keep builtin defaults)", + ) agents: dict[str, SubagentOverrideConfig] = Field( default_factory=dict, description="Per-agent configuration overrides keyed by agent name", @@ -44,6 +54,15 @@ class SubagentsAppConfig(BaseModel): return override.timeout_seconds return self.timeout_seconds + 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) + if override is not None and override.max_turns is not None: + return override.max_turns + if self.max_turns is not None: + return self.max_turns + return builtin_default + _subagents_config: SubagentsAppConfig = SubagentsAppConfig() @@ -58,8 +77,26 @@ def load_subagents_config_from_dict(config_dict: dict) -> None: global _subagents_config _subagents_config = SubagentsAppConfig(**config_dict) - overrides_summary = {name: f"{override.timeout_seconds}s" for name, override in _subagents_config.agents.items() if override.timeout_seconds is not None} + overrides_summary = {} + for name, override in _subagents_config.agents.items(): + parts = [] + if override.timeout_seconds is not 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 parts: + overrides_summary[name] = ", ".join(parts) + if overrides_summary: - logger.info(f"Subagents config loaded: default timeout={_subagents_config.timeout_seconds}s, per-agent overrides={overrides_summary}") + logger.info( + "Subagents config loaded: default timeout=%ss, default max_turns=%s, per-agent overrides=%s", + _subagents_config.timeout_seconds, + _subagents_config.max_turns, + overrides_summary, + ) else: - logger.info(f"Subagents config loaded: default timeout={_subagents_config.timeout_seconds}s, no per-agent overrides") + logger.info( + "Subagents config loaded: default timeout=%ss, default max_turns=%s, no per-agent overrides", + _subagents_config.timeout_seconds, + _subagents_config.max_turns, + ) diff --git a/backend/packages/harness/deerflow/subagents/builtins/bash_agent.py b/backend/packages/harness/deerflow/subagents/builtins/bash_agent.py index 409594efa..094ec65e7 100644 --- a/backend/packages/harness/deerflow/subagents/builtins/bash_agent.py +++ b/backend/packages/harness/deerflow/subagents/builtins/bash_agent.py @@ -43,5 +43,5 @@ You have access to the sandbox environment: tools=["bash", "ls", "read_file", "write_file", "str_replace"], # Sandbox tools only disallowed_tools=["task", "ask_clarification", "present_files"], model="inherit", - max_turns=30, + max_turns=60, ) diff --git a/backend/packages/harness/deerflow/subagents/builtins/general_purpose.py b/backend/packages/harness/deerflow/subagents/builtins/general_purpose.py index 45f1b9fa2..d09d1a00b 100644 --- a/backend/packages/harness/deerflow/subagents/builtins/general_purpose.py +++ b/backend/packages/harness/deerflow/subagents/builtins/general_purpose.py @@ -44,5 +44,5 @@ You have access to the same sandbox environment as the parent agent: tools=None, # Inherit all tools from parent disallowed_tools=["task", "ask_clarification", "present_files"], # Prevent nesting and clarification model="inherit", - max_turns=50, + max_turns=100, ) diff --git a/backend/packages/harness/deerflow/subagents/registry.py b/backend/packages/harness/deerflow/subagents/registry.py index 61da0e453..0192ee7da 100644 --- a/backend/packages/harness/deerflow/subagents/registry.py +++ b/backend/packages/harness/deerflow/subagents/registry.py @@ -28,9 +28,27 @@ def get_subagent_config(name: str) -> SubagentConfig | None: app_config = get_subagents_app_config() effective_timeout = app_config.get_timeout_for(name) + effective_max_turns = app_config.get_max_turns_for(name, config.max_turns) + + overrides = {} if effective_timeout != config.timeout_seconds: - logger.debug(f"Subagent '{name}': timeout overridden by config.yaml ({config.timeout_seconds}s -> {effective_timeout}s)") - config = replace(config, timeout_seconds=effective_timeout) + logger.debug( + "Subagent '%s': timeout overridden by config.yaml (%ss -> %ss)", + name, + config.timeout_seconds, + effective_timeout, + ) + overrides["timeout_seconds"] = effective_timeout + if effective_max_turns != config.max_turns: + logger.debug( + "Subagent '%s': max_turns overridden by config.yaml (%s -> %s)", + name, + config.max_turns, + effective_max_turns, + ) + overrides["max_turns"] = effective_max_turns + if overrides: + config = replace(config, **overrides) return config diff --git a/backend/tests/test_subagent_timeout_config.py b/backend/tests/test_subagent_timeout_config.py index 9edd971a0..50722cc97 100644 --- a/backend/tests/test_subagent_timeout_config.py +++ b/backend/tests/test_subagent_timeout_config.py @@ -1,8 +1,8 @@ -"""Tests for subagent timeout configuration. +"""Tests for subagent runtime configuration. Covers: - SubagentsAppConfig / SubagentOverrideConfig model validation and defaults -- get_timeout_for() resolution logic (global vs per-agent) +- get_timeout_for() / get_max_turns_for() resolution logic - load_subagents_config_from_dict() and get_subagents_app_config() singleton - registry.get_subagent_config() applies config overrides - registry.list_subagents() applies overrides for all agents @@ -24,9 +24,20 @@ from deerflow.subagents.config import SubagentConfig # --------------------------------------------------------------------------- -def _reset_subagents_config(timeout_seconds: int = 900, agents: dict | None = None) -> None: +def _reset_subagents_config( + timeout_seconds: int = 900, + *, + max_turns: int | None = None, + agents: dict | None = None, +) -> None: """Reset global subagents config to a known state.""" - load_subagents_config_from_dict({"timeout_seconds": timeout_seconds, "agents": agents or {}}) + load_subagents_config_from_dict( + { + "timeout_seconds": timeout_seconds, + "max_turns": max_turns, + "agents": agents or {}, + } + ) # --------------------------------------------------------------------------- @@ -38,22 +49,29 @@ class TestSubagentOverrideConfig: def test_default_is_none(self): override = SubagentOverrideConfig() assert override.timeout_seconds is None + assert override.max_turns is None def test_explicit_value(self): - override = SubagentOverrideConfig(timeout_seconds=300) + override = SubagentOverrideConfig(timeout_seconds=300, max_turns=42) assert override.timeout_seconds == 300 + assert override.max_turns == 42 def test_rejects_zero(self): with pytest.raises(ValueError): SubagentOverrideConfig(timeout_seconds=0) + with pytest.raises(ValueError): + SubagentOverrideConfig(max_turns=0) def test_rejects_negative(self): with pytest.raises(ValueError): SubagentOverrideConfig(timeout_seconds=-1) + with pytest.raises(ValueError): + SubagentOverrideConfig(max_turns=-1) def test_minimum_valid_value(self): - override = SubagentOverrideConfig(timeout_seconds=1) + override = SubagentOverrideConfig(timeout_seconds=1, max_turns=1) assert override.timeout_seconds == 1 + assert override.max_turns == 1 # --------------------------------------------------------------------------- @@ -66,66 +84,86 @@ class TestSubagentsAppConfigDefaults: config = SubagentsAppConfig() assert config.timeout_seconds == 900 + def test_default_max_turns_override_is_none(self): + config = SubagentsAppConfig() + assert config.max_turns is None + def test_default_agents_empty(self): config = SubagentsAppConfig() assert config.agents == {} - def test_custom_global_timeout(self): - config = SubagentsAppConfig(timeout_seconds=1800) + def test_custom_global_runtime_overrides(self): + config = SubagentsAppConfig(timeout_seconds=1800, max_turns=120) assert config.timeout_seconds == 1800 + assert config.max_turns == 120 def test_rejects_zero_timeout(self): with pytest.raises(ValueError): SubagentsAppConfig(timeout_seconds=0) + with pytest.raises(ValueError): + SubagentsAppConfig(max_turns=0) def test_rejects_negative_timeout(self): with pytest.raises(ValueError): SubagentsAppConfig(timeout_seconds=-60) + with pytest.raises(ValueError): + SubagentsAppConfig(max_turns=-60) # --------------------------------------------------------------------------- -# SubagentsAppConfig.get_timeout_for() +# SubagentsAppConfig resolution helpers # --------------------------------------------------------------------------- -class TestGetTimeoutFor: +class TestRuntimeResolution: def test_returns_global_default_when_no_override(self): config = SubagentsAppConfig(timeout_seconds=600) assert config.get_timeout_for("general-purpose") == 600 assert config.get_timeout_for("bash") == 600 assert config.get_timeout_for("unknown-agent") == 600 + assert config.get_max_turns_for("general-purpose", 100) == 100 + assert config.get_max_turns_for("bash", 60) == 60 def test_returns_per_agent_override_when_set(self): config = SubagentsAppConfig( timeout_seconds=900, - agents={"bash": SubagentOverrideConfig(timeout_seconds=300)}, + max_turns=120, + agents={"bash": SubagentOverrideConfig(timeout_seconds=300, max_turns=80)}, ) assert config.get_timeout_for("bash") == 300 + assert config.get_max_turns_for("bash", 60) == 80 def test_other_agents_still_use_global_default(self): config = SubagentsAppConfig( timeout_seconds=900, - agents={"bash": SubagentOverrideConfig(timeout_seconds=300)}, + max_turns=140, + agents={"bash": SubagentOverrideConfig(timeout_seconds=300, max_turns=80)}, ) assert config.get_timeout_for("general-purpose") == 900 + assert config.get_max_turns_for("general-purpose", 100) == 140 def test_agent_with_none_override_falls_back_to_global(self): config = SubagentsAppConfig( timeout_seconds=900, - agents={"general-purpose": SubagentOverrideConfig(timeout_seconds=None)}, + max_turns=150, + agents={"general-purpose": SubagentOverrideConfig(timeout_seconds=None, max_turns=None)}, ) assert config.get_timeout_for("general-purpose") == 900 + assert config.get_max_turns_for("general-purpose", 100) == 150 def test_multiple_per_agent_overrides(self): config = SubagentsAppConfig( timeout_seconds=900, + max_turns=120, agents={ - "general-purpose": SubagentOverrideConfig(timeout_seconds=1800), - "bash": SubagentOverrideConfig(timeout_seconds=120), + "general-purpose": SubagentOverrideConfig(timeout_seconds=1800, max_turns=200), + "bash": SubagentOverrideConfig(timeout_seconds=120, max_turns=80), }, ) assert config.get_timeout_for("general-purpose") == 1800 assert config.get_timeout_for("bash") == 120 + assert config.get_max_turns_for("general-purpose", 100) == 200 + assert config.get_max_turns_for("bash", 60) == 80 # --------------------------------------------------------------------------- @@ -139,54 +177,63 @@ class TestLoadSubagentsConfig: _reset_subagents_config() def test_load_global_timeout(self): - load_subagents_config_from_dict({"timeout_seconds": 300}) + load_subagents_config_from_dict({"timeout_seconds": 300, "max_turns": 120}) assert get_subagents_app_config().timeout_seconds == 300 + assert get_subagents_app_config().max_turns == 120 def test_load_with_per_agent_overrides(self): load_subagents_config_from_dict( { "timeout_seconds": 900, + "max_turns": 120, "agents": { - "general-purpose": {"timeout_seconds": 1800}, - "bash": {"timeout_seconds": 60}, + "general-purpose": {"timeout_seconds": 1800, "max_turns": 200}, + "bash": {"timeout_seconds": 60, "max_turns": 80}, }, } ) cfg = get_subagents_app_config() assert cfg.get_timeout_for("general-purpose") == 1800 assert cfg.get_timeout_for("bash") == 60 + assert cfg.get_max_turns_for("general-purpose", 100) == 200 + assert cfg.get_max_turns_for("bash", 60) == 80 def test_load_partial_override(self): load_subagents_config_from_dict( { "timeout_seconds": 600, - "agents": {"bash": {"timeout_seconds": 120}}, + "agents": {"bash": {"timeout_seconds": 120, "max_turns": 70}}, } ) cfg = get_subagents_app_config() assert cfg.get_timeout_for("general-purpose") == 600 assert cfg.get_timeout_for("bash") == 120 + assert cfg.get_max_turns_for("general-purpose", 100) == 100 + assert cfg.get_max_turns_for("bash", 60) == 70 def test_load_empty_dict_uses_defaults(self): load_subagents_config_from_dict({}) cfg = get_subagents_app_config() assert cfg.timeout_seconds == 900 + assert cfg.max_turns is None assert cfg.agents == {} def test_load_replaces_previous_config(self): - load_subagents_config_from_dict({"timeout_seconds": 100}) + load_subagents_config_from_dict({"timeout_seconds": 100, "max_turns": 90}) assert get_subagents_app_config().timeout_seconds == 100 + assert get_subagents_app_config().max_turns == 90 - load_subagents_config_from_dict({"timeout_seconds": 200}) + load_subagents_config_from_dict({"timeout_seconds": 200, "max_turns": 110}) assert get_subagents_app_config().timeout_seconds == 200 + assert get_subagents_app_config().max_turns == 110 def test_singleton_returns_same_instance_between_calls(self): - load_subagents_config_from_dict({"timeout_seconds": 777}) + load_subagents_config_from_dict({"timeout_seconds": 777, "max_turns": 123}) assert get_subagents_app_config() is get_subagents_app_config() # --------------------------------------------------------------------------- -# registry.get_subagent_config – timeout override applied +# registry.get_subagent_config – runtime overrides applied # --------------------------------------------------------------------------- @@ -211,25 +258,29 @@ class TestRegistryGetSubagentConfig: _reset_subagents_config(timeout_seconds=900) config = get_subagent_config("general-purpose") assert config.timeout_seconds == 900 + assert config.max_turns == 100 def test_global_timeout_override_applied(self): from deerflow.subagents.registry import get_subagent_config - _reset_subagents_config(timeout_seconds=1800) + _reset_subagents_config(timeout_seconds=1800, max_turns=140) config = get_subagent_config("general-purpose") assert config.timeout_seconds == 1800 + assert config.max_turns == 140 - def test_per_agent_timeout_override_applied(self): + def test_per_agent_runtime_override_applied(self): from deerflow.subagents.registry import get_subagent_config load_subagents_config_from_dict( { "timeout_seconds": 900, - "agents": {"bash": {"timeout_seconds": 120}}, + "max_turns": 120, + "agents": {"bash": {"timeout_seconds": 120, "max_turns": 80}}, } ) bash_config = get_subagent_config("bash") assert bash_config.timeout_seconds == 120 + assert bash_config.max_turns == 80 def test_per_agent_override_does_not_affect_other_agents(self): from deerflow.subagents.registry import get_subagent_config @@ -237,11 +288,13 @@ class TestRegistryGetSubagentConfig: load_subagents_config_from_dict( { "timeout_seconds": 900, - "agents": {"bash": {"timeout_seconds": 120}}, + "max_turns": 120, + "agents": {"bash": {"timeout_seconds": 120, "max_turns": 80}}, } ) gp_config = get_subagent_config("general-purpose") assert gp_config.timeout_seconds == 900 + assert gp_config.max_turns == 120 def test_builtin_config_object_is_not_mutated(self): """Registry must return a new object, leaving the builtin default intact.""" @@ -249,24 +302,27 @@ class TestRegistryGetSubagentConfig: from deerflow.subagents.registry import get_subagent_config original_timeout = BUILTIN_SUBAGENTS["bash"].timeout_seconds - load_subagents_config_from_dict({"timeout_seconds": 42}) + original_max_turns = BUILTIN_SUBAGENTS["bash"].max_turns + load_subagents_config_from_dict({"timeout_seconds": 42, "max_turns": 88}) returned = get_subagent_config("bash") assert returned.timeout_seconds == 42 + assert returned.max_turns == 88 assert BUILTIN_SUBAGENTS["bash"].timeout_seconds == original_timeout + assert BUILTIN_SUBAGENTS["bash"].max_turns == original_max_turns def test_config_preserves_other_fields(self): - """Applying timeout override must not change other SubagentConfig fields.""" + """Applying runtime overrides must not change other SubagentConfig fields.""" from deerflow.subagents.builtins import BUILTIN_SUBAGENTS from deerflow.subagents.registry import get_subagent_config - _reset_subagents_config(timeout_seconds=300) + _reset_subagents_config(timeout_seconds=300, max_turns=140) original = BUILTIN_SUBAGENTS["general-purpose"] overridden = get_subagent_config("general-purpose") assert overridden.name == original.name assert overridden.description == original.description - assert overridden.max_turns == original.max_turns + assert overridden.max_turns == 140 assert overridden.model == original.model assert overridden.tools == original.tools assert overridden.disallowed_tools == original.disallowed_tools @@ -291,9 +347,10 @@ class TestRegistryListSubagents: def test_all_returned_configs_get_global_override(self): from deerflow.subagents.registry import list_subagents - _reset_subagents_config(timeout_seconds=123) + _reset_subagents_config(timeout_seconds=123, max_turns=77) for cfg in list_subagents(): assert cfg.timeout_seconds == 123, f"{cfg.name} has wrong timeout" + assert cfg.max_turns == 77, f"{cfg.name} has wrong max_turns" def test_per_agent_overrides_reflected_in_list(self): from deerflow.subagents.registry import list_subagents @@ -301,15 +358,18 @@ class TestRegistryListSubagents: load_subagents_config_from_dict( { "timeout_seconds": 900, + "max_turns": 120, "agents": { - "general-purpose": {"timeout_seconds": 1800}, - "bash": {"timeout_seconds": 60}, + "general-purpose": {"timeout_seconds": 1800, "max_turns": 200}, + "bash": {"timeout_seconds": 60, "max_turns": 80}, }, } ) by_name = {cfg.name: cfg for cfg in list_subagents()} assert by_name["general-purpose"].timeout_seconds == 1800 assert by_name["bash"].timeout_seconds == 60 + assert by_name["general-purpose"].max_turns == 200 + assert by_name["bash"].max_turns == 80 # --------------------------------------------------------------------------- diff --git a/config.example.yaml b/config.example.yaml index f68a574e5..d6f382591 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -456,13 +456,17 @@ sandbox: # subagents: # # Default timeout in seconds for all subagents (default: 900 = 15 minutes) # timeout_seconds: 900 +# # Optional global max-turn override for all subagents +# # max_turns: 120 # -# # Optional per-agent timeout overrides +# # Optional per-agent overrides # agents: # general-purpose: # timeout_seconds: 1800 # 30 minutes for complex multi-step tasks +# max_turns: 160 # bash: # timeout_seconds: 300 # 5 minutes for quick command execution +# max_turns: 80 # ============================================================================ # ACP Agents Configuration