mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-02 22:02:13 +08:00
feat(subagents): make subagent timeout configurable via config.yaml (#897)
* feat(subagents): make subagent timeout configurable via config.yaml - Add SubagentsAppConfig supporting global and per-agent timeout_seconds - Load subagents config section in AppConfig.from_file() - Registry now applies config.yaml overrides without mutating builtin defaults - Polling safety-net in task_tool is now dynamic (execution timeout + 60s buffer) - Document subagents section in config.example.yaml - Add make test command and enforce TDD policy in CLAUDE.md - Add 38 unit tests covering config validation, timeout resolution, registry override behavior, and polling timeout formula Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(subagents): add logging for subagent timeout config and execution - Log loaded timeout config (global default + per-agent overrides) on startup - Log debug message in registry when config.yaml overrides a builtin timeout - Include timeout in executor's async execution start log - Log effective timeout and polling limit when a task is dispatched - Fix UnboundLocalError: move max_poll_count assignment before logger.info Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci(backend): add lint step and run all unit tests via Makefile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix lint --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
6
.github/workflows/backend-unit-tests.yml
vendored
6
.github/workflows/backend-unit-tests.yml
vendored
@@ -30,6 +30,10 @@ jobs:
|
||||
working-directory: backend
|
||||
run: uv sync --group dev
|
||||
|
||||
- name: Lint backend
|
||||
working-directory: backend
|
||||
run: make lint
|
||||
|
||||
- name: Run unit tests of backend
|
||||
working-directory: backend
|
||||
run: uv run pytest tests/test_provisioner_kubeconfig.py tests/test_docker_sandbox_mode_detection.py
|
||||
run: make test
|
||||
|
||||
@@ -82,9 +82,9 @@ make stop # Stop all services
|
||||
make install # Install backend dependencies
|
||||
make dev # Run LangGraph server only (port 2024)
|
||||
make gateway # Run Gateway API only (port 8001)
|
||||
make test # Run all backend tests
|
||||
make lint # Lint with ruff
|
||||
make format # Format code with ruff
|
||||
uv run pytest # Run backend tests
|
||||
```
|
||||
|
||||
Regression tests related to Docker/provisioner behavior:
|
||||
@@ -293,6 +293,24 @@ Both can be modified at runtime via Gateway API endpoints.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Test-Driven Development (TDD) — MANDATORY
|
||||
|
||||
**Every new feature or bug fix MUST be accompanied by unit tests. No exceptions.**
|
||||
|
||||
- Write tests in `backend/tests/` following the existing naming convention `test_<feature>.py`
|
||||
- Run the full suite before and after your change: `make test`
|
||||
- Tests must pass before a feature is considered complete
|
||||
- For lightweight config/utility modules, prefer pure unit tests with no external dependencies
|
||||
- If a module causes circular import issues in tests, add a `sys.modules` mock in `tests/conftest.py` (see existing example for `src.subagents.executor`)
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
make test
|
||||
|
||||
# Run a specific test file
|
||||
PYTHONPATH=. uv run pytest tests/test_<feature>.py -v
|
||||
```
|
||||
|
||||
### Running the Full Application
|
||||
|
||||
From the **project root** directory:
|
||||
|
||||
@@ -7,6 +7,9 @@ dev:
|
||||
gateway:
|
||||
uv run uvicorn src.gateway.app:app --host 0.0.0.0 --port 8001
|
||||
|
||||
test:
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
|
||||
lint:
|
||||
uvx ruff check .
|
||||
|
||||
|
||||
@@ -245,17 +245,19 @@ def make_lead_agent(config: RunnableConfig):
|
||||
subagent_enabled = config.get("configurable", {}).get("subagent_enabled", False)
|
||||
max_concurrent_subagents = config.get("configurable", {}).get("max_concurrent_subagents", 3)
|
||||
print(f"thinking_enabled: {thinking_enabled}, model_name: {model_name}, is_plan_mode: {is_plan_mode}, subagent_enabled: {subagent_enabled}, max_concurrent_subagents: {max_concurrent_subagents}")
|
||||
|
||||
|
||||
# Inject run metadata for LangSmith trace tagging
|
||||
if "metadata" not in config:
|
||||
config["metadata"] = {}
|
||||
config["metadata"].update({
|
||||
"model_name": model_name or "default",
|
||||
"thinking_enabled": thinking_enabled,
|
||||
"is_plan_mode": is_plan_mode,
|
||||
"subagent_enabled": subagent_enabled,
|
||||
})
|
||||
|
||||
config["metadata"].update(
|
||||
{
|
||||
"model_name": model_name or "default",
|
||||
"thinking_enabled": thinking_enabled,
|
||||
"is_plan_mode": is_plan_mode,
|
||||
"subagent_enabled": subagent_enabled,
|
||||
}
|
||||
)
|
||||
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled),
|
||||
tools=get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled),
|
||||
|
||||
@@ -18,7 +18,6 @@ Architecture:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from src.config.memory_config import load_memory_config_from_dict
|
||||
from src.config.model_config import ModelConfig
|
||||
from src.config.sandbox_config import SandboxConfig
|
||||
from src.config.skills_config import SkillsConfig
|
||||
from src.config.subagents_config import load_subagents_config_from_dict
|
||||
from src.config.summarization_config import load_summarization_config_from_dict
|
||||
from src.config.title_config import load_title_config_from_dict
|
||||
from src.config.tool_config import ToolConfig, ToolGroupConfig
|
||||
@@ -87,6 +88,10 @@ class AppConfig(BaseModel):
|
||||
if "memory" in config_data:
|
||||
load_memory_config_from_dict(config_data["memory"])
|
||||
|
||||
# Load subagents config if present
|
||||
if "subagents" in config_data:
|
||||
load_subagents_config_from_dict(config_data["subagents"])
|
||||
|
||||
# Load extensions config separately (it's in a different file)
|
||||
extensions_config = ExtensionsConfig.from_file()
|
||||
config_data["extensions"] = extensions_config.model_dump()
|
||||
|
||||
65
backend/src/config/subagents_config.py
Normal file
65
backend/src/config/subagents_config.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Configuration for the subagent system loaded from config.yaml."""
|
||||
|
||||
import logging
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubagentOverrideConfig(BaseModel):
|
||||
"""Per-agent configuration overrides."""
|
||||
|
||||
timeout_seconds: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
description="Timeout in seconds for this subagent (None = use global default)",
|
||||
)
|
||||
|
||||
|
||||
class SubagentsAppConfig(BaseModel):
|
||||
"""Configuration for the subagent system."""
|
||||
|
||||
timeout_seconds: int = Field(
|
||||
default=900,
|
||||
ge=1,
|
||||
description="Default timeout in seconds for all subagents (default: 900 = 15 minutes)",
|
||||
)
|
||||
agents: dict[str, SubagentOverrideConfig] = Field(
|
||||
default_factory=dict,
|
||||
description="Per-agent configuration overrides keyed by agent name",
|
||||
)
|
||||
|
||||
def get_timeout_for(self, agent_name: str) -> int:
|
||||
"""Get the effective timeout for a specific agent.
|
||||
|
||||
Args:
|
||||
agent_name: The name of the subagent.
|
||||
|
||||
Returns:
|
||||
The timeout in seconds, using per-agent override if set, otherwise global default.
|
||||
"""
|
||||
override = self.agents.get(agent_name)
|
||||
if override is not None and override.timeout_seconds is not None:
|
||||
return override.timeout_seconds
|
||||
return self.timeout_seconds
|
||||
|
||||
|
||||
_subagents_config: SubagentsAppConfig = SubagentsAppConfig()
|
||||
|
||||
|
||||
def get_subagents_app_config() -> SubagentsAppConfig:
|
||||
"""Get the current subagents configuration."""
|
||||
return _subagents_config
|
||||
|
||||
|
||||
def load_subagents_config_from_dict(config_dict: dict) -> None:
|
||||
"""Load subagents configuration from a dictionary."""
|
||||
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}
|
||||
if overrides_summary:
|
||||
logger.info(f"Subagents config loaded: default timeout={_subagents_config.timeout_seconds}s, per-agent overrides={overrides_summary}")
|
||||
else:
|
||||
logger.info(f"Subagents config loaded: default timeout={_subagents_config.timeout_seconds}s, no per-agent overrides")
|
||||
@@ -1,11 +1,13 @@
|
||||
import logging
|
||||
import os
|
||||
from pydantic import BaseModel, Field
|
||||
import threading
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_config_lock = threading.Lock()
|
||||
|
||||
|
||||
class TracingConfig(BaseModel):
|
||||
"""Configuration for LangSmith tracing."""
|
||||
|
||||
@@ -41,11 +43,11 @@ def get_tracing_config() -> TracingConfig:
|
||||
endpoint=os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com"),
|
||||
)
|
||||
return _tracing_config
|
||||
|
||||
|
||||
|
||||
def is_tracing_enabled() -> bool:
|
||||
"""Check if LangSmith tracing is enabled and configured.
|
||||
Returns:
|
||||
True if tracing is enabled and has an API key.
|
||||
"""
|
||||
return get_tracing_config().is_configured
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
|
||||
from langchain.chat_models import BaseChatModel
|
||||
|
||||
from src.config import get_app_config, get_tracing_config, is_tracing_enabled
|
||||
@@ -6,6 +7,7 @@ from src.reflection import resolve_class
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, **kwargs) -> BaseChatModel:
|
||||
"""Create a chat model instance from the config.
|
||||
|
||||
@@ -50,9 +52,7 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
|
||||
)
|
||||
existing_callbacks = model_instance.callbacks or []
|
||||
model_instance.callbacks = [*existing_callbacks, tracer]
|
||||
logger.debug(
|
||||
f"LangSmith tracing attached to model '{name}' (project='{tracing_config.project}')"
|
||||
)
|
||||
logger.debug(f"LangSmith tracing attached to model '{name}' (project='{tracing_config.project}')")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to attach LangSmith tracing to model '{name}': {e}")
|
||||
return model_instance
|
||||
|
||||
@@ -343,7 +343,7 @@ class SubagentExecutor:
|
||||
status=SubagentStatus.PENDING,
|
||||
)
|
||||
|
||||
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} starting async execution, task_id={task_id}")
|
||||
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} starting async execution, task_id={task_id}, timeout={self.config.timeout_seconds}s")
|
||||
|
||||
with _background_tasks_lock:
|
||||
_background_tasks[task_id] = result
|
||||
|
||||
@@ -1,28 +1,46 @@
|
||||
"""Subagent registry for managing available subagents."""
|
||||
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
|
||||
from src.subagents.builtins import BUILTIN_SUBAGENTS
|
||||
from src.subagents.config import SubagentConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_subagent_config(name: str) -> SubagentConfig | None:
|
||||
"""Get a subagent configuration by name.
|
||||
"""Get a subagent configuration by name, with config.yaml overrides applied.
|
||||
|
||||
Args:
|
||||
name: The name of the subagent.
|
||||
|
||||
Returns:
|
||||
SubagentConfig if found, None otherwise.
|
||||
SubagentConfig if found (with any config.yaml overrides applied), None otherwise.
|
||||
"""
|
||||
return BUILTIN_SUBAGENTS.get(name)
|
||||
config = BUILTIN_SUBAGENTS.get(name)
|
||||
if config is None:
|
||||
return None
|
||||
|
||||
# Apply timeout override from config.yaml (lazy import to avoid circular deps)
|
||||
from src.config.subagents_config import get_subagents_app_config
|
||||
|
||||
app_config = get_subagents_app_config()
|
||||
effective_timeout = app_config.get_timeout_for(name)
|
||||
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)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def list_subagents() -> list[SubagentConfig]:
|
||||
"""List all available subagent configurations.
|
||||
"""List all available subagent configurations (with config.yaml overrides applied).
|
||||
|
||||
Returns:
|
||||
List of all registered SubagentConfig instances.
|
||||
"""
|
||||
return list(BUILTIN_SUBAGENTS.values())
|
||||
return [get_subagent_config(name) for name in BUILTIN_SUBAGENTS]
|
||||
|
||||
|
||||
def get_subagent_names() -> list[str]:
|
||||
|
||||
@@ -115,12 +115,15 @@ def task_tool(
|
||||
# Start background execution (always async to prevent blocking)
|
||||
# Use tool_call_id as task_id for better traceability
|
||||
task_id = executor.execute_async(prompt, task_id=tool_call_id)
|
||||
logger.info(f"[trace={trace_id}] Started background task {task_id}, polling for completion...")
|
||||
|
||||
# Poll for task completion in backend (removes need for LLM to poll)
|
||||
poll_count = 0
|
||||
last_status = None
|
||||
last_message_count = 0 # Track how many AI messages we've already sent
|
||||
# Polling timeout: execution timeout + 60s buffer, checked every 5s
|
||||
max_poll_count = (config.timeout_seconds + 60) // 5
|
||||
|
||||
logger.info(f"[trace={trace_id}] Started background task {task_id} (subagent={subagent_type}, timeout={config.timeout_seconds}s, polling_limit={max_poll_count} polls)")
|
||||
|
||||
writer = get_stream_writer()
|
||||
# Send Task Started message'
|
||||
@@ -176,9 +179,10 @@ def task_tool(
|
||||
poll_count += 1
|
||||
|
||||
# Polling timeout as a safety net (in case thread pool timeout doesn't work)
|
||||
# Set to 16 minutes (longer than the default 15-minute thread pool timeout)
|
||||
# Set to execution timeout + 60s buffer, in 5s poll intervals
|
||||
# This catches edge cases where the background task gets stuck
|
||||
if poll_count > 192: # 192 * 5s = 16 minutes
|
||||
if poll_count > max_poll_count:
|
||||
timeout_minutes = config.timeout_seconds // 60
|
||||
logger.error(f"[trace={trace_id}] Task {task_id} polling timed out after {poll_count} polls (should have been caught by thread pool timeout)")
|
||||
writer({"type": "task_timed_out", "task_id": task_id})
|
||||
return f"Task polling timed out after 16 minutes. This may indicate the background task is stuck. Status: {result.status.value}"
|
||||
return f"Task polling timed out after {timeout_minutes} minutes. This may indicate the background task is stuck. Status: {result.status.value}"
|
||||
|
||||
33
backend/tests/conftest.py
Normal file
33
backend/tests/conftest.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Test configuration for the backend test suite.
|
||||
|
||||
Sets up sys.path and pre-mocks modules that would cause circular import
|
||||
issues when unit-testing lightweight config/registry code in isolation.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Make 'src' importable from any working directory
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# Break the circular import chain that exists in production code:
|
||||
# src.subagents.__init__
|
||||
# -> .executor (SubagentExecutor, SubagentResult)
|
||||
# -> src.agents.thread_state
|
||||
# -> src.agents.__init__
|
||||
# -> lead_agent.agent
|
||||
# -> subagent_limit_middleware
|
||||
# -> src.subagents.executor <-- circular!
|
||||
#
|
||||
# By injecting a mock for src.subagents.executor *before* any test module
|
||||
# triggers the import, __init__.py's "from .executor import ..." succeeds
|
||||
# immediately without running the real executor module.
|
||||
_executor_mock = MagicMock()
|
||||
_executor_mock.SubagentExecutor = MagicMock
|
||||
_executor_mock.SubagentResult = MagicMock
|
||||
_executor_mock.SubagentStatus = MagicMock
|
||||
_executor_mock.MAX_CONCURRENT_SUBAGENTS = 3
|
||||
_executor_mock.get_background_task_result = MagicMock()
|
||||
|
||||
sys.modules["src.subagents.executor"] = _executor_mock
|
||||
@@ -16,11 +16,7 @@ def _detect_mode_with_config(config_content: str) -> str:
|
||||
tmp_root = Path(tmpdir)
|
||||
(tmp_root / "config.yaml").write_text(config_content)
|
||||
|
||||
command = (
|
||||
f"source '{SCRIPT_PATH}' && "
|
||||
f"PROJECT_ROOT='{tmp_root}' && "
|
||||
"detect_sandbox_mode"
|
||||
)
|
||||
command = f"source '{SCRIPT_PATH}' && PROJECT_ROOT='{tmp_root}' && detect_sandbox_mode"
|
||||
|
||||
output = subprocess.check_output(
|
||||
["bash", "-lc", command],
|
||||
@@ -33,11 +29,7 @@ def _detect_mode_with_config(config_content: str) -> str:
|
||||
def test_detect_mode_defaults_to_local_when_config_missing():
|
||||
"""No config file should default to local mode."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
command = (
|
||||
f"source '{SCRIPT_PATH}' && "
|
||||
f"PROJECT_ROOT='{tmpdir}' && "
|
||||
"detect_sandbox_mode"
|
||||
)
|
||||
command = f"source '{SCRIPT_PATH}' && PROJECT_ROOT='{tmpdir}' && detect_sandbox_mode"
|
||||
output = subprocess.check_output(["bash", "-lc", command], text=True).strip()
|
||||
|
||||
assert output == "local"
|
||||
|
||||
@@ -90,9 +90,7 @@ def test_init_k8s_client_uses_file_kubeconfig(tmp_path, monkeypatch):
|
||||
assert result == "core-v1"
|
||||
|
||||
|
||||
def test_init_k8s_client_falls_back_to_incluster_when_missing(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
def test_init_k8s_client_falls_back_to_incluster_when_missing(tmp_path, monkeypatch):
|
||||
"""When kubeconfig file is missing, in-cluster config should be attempted."""
|
||||
provisioner_module = _load_provisioner_module()
|
||||
missing_path = tmp_path / "missing-config"
|
||||
|
||||
354
backend/tests/test_subagent_timeout_config.py
Normal file
354
backend/tests/test_subagent_timeout_config.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""Tests for subagent timeout configuration.
|
||||
|
||||
Covers:
|
||||
- SubagentsAppConfig / SubagentOverrideConfig model validation and defaults
|
||||
- get_timeout_for() resolution logic (global vs per-agent)
|
||||
- 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
|
||||
- Polling timeout calculation in task_tool is consistent with config
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config.subagents_config import (
|
||||
SubagentOverrideConfig,
|
||||
SubagentsAppConfig,
|
||||
get_subagents_app_config,
|
||||
load_subagents_config_from_dict,
|
||||
)
|
||||
from src.subagents.config import SubagentConfig
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _reset_subagents_config(timeout_seconds: int = 900, 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 {}})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SubagentOverrideConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSubagentOverrideConfig:
|
||||
def test_default_is_none(self):
|
||||
override = SubagentOverrideConfig()
|
||||
assert override.timeout_seconds is None
|
||||
|
||||
def test_explicit_value(self):
|
||||
override = SubagentOverrideConfig(timeout_seconds=300)
|
||||
assert override.timeout_seconds == 300
|
||||
|
||||
def test_rejects_zero(self):
|
||||
with pytest.raises(ValueError):
|
||||
SubagentOverrideConfig(timeout_seconds=0)
|
||||
|
||||
def test_rejects_negative(self):
|
||||
with pytest.raises(ValueError):
|
||||
SubagentOverrideConfig(timeout_seconds=-1)
|
||||
|
||||
def test_minimum_valid_value(self):
|
||||
override = SubagentOverrideConfig(timeout_seconds=1)
|
||||
assert override.timeout_seconds == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SubagentsAppConfig – defaults and validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSubagentsAppConfigDefaults:
|
||||
def test_default_timeout(self):
|
||||
config = SubagentsAppConfig()
|
||||
assert config.timeout_seconds == 900
|
||||
|
||||
def test_default_agents_empty(self):
|
||||
config = SubagentsAppConfig()
|
||||
assert config.agents == {}
|
||||
|
||||
def test_custom_global_timeout(self):
|
||||
config = SubagentsAppConfig(timeout_seconds=1800)
|
||||
assert config.timeout_seconds == 1800
|
||||
|
||||
def test_rejects_zero_timeout(self):
|
||||
with pytest.raises(ValueError):
|
||||
SubagentsAppConfig(timeout_seconds=0)
|
||||
|
||||
def test_rejects_negative_timeout(self):
|
||||
with pytest.raises(ValueError):
|
||||
SubagentsAppConfig(timeout_seconds=-60)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SubagentsAppConfig.get_timeout_for()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTimeoutFor:
|
||||
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
|
||||
|
||||
def test_returns_per_agent_override_when_set(self):
|
||||
config = SubagentsAppConfig(
|
||||
timeout_seconds=900,
|
||||
agents={"bash": SubagentOverrideConfig(timeout_seconds=300)},
|
||||
)
|
||||
assert config.get_timeout_for("bash") == 300
|
||||
|
||||
def test_other_agents_still_use_global_default(self):
|
||||
config = SubagentsAppConfig(
|
||||
timeout_seconds=900,
|
||||
agents={"bash": SubagentOverrideConfig(timeout_seconds=300)},
|
||||
)
|
||||
assert config.get_timeout_for("general-purpose") == 900
|
||||
|
||||
def test_agent_with_none_override_falls_back_to_global(self):
|
||||
config = SubagentsAppConfig(
|
||||
timeout_seconds=900,
|
||||
agents={"general-purpose": SubagentOverrideConfig(timeout_seconds=None)},
|
||||
)
|
||||
assert config.get_timeout_for("general-purpose") == 900
|
||||
|
||||
def test_multiple_per_agent_overrides(self):
|
||||
config = SubagentsAppConfig(
|
||||
timeout_seconds=900,
|
||||
agents={
|
||||
"general-purpose": SubagentOverrideConfig(timeout_seconds=1800),
|
||||
"bash": SubagentOverrideConfig(timeout_seconds=120),
|
||||
},
|
||||
)
|
||||
assert config.get_timeout_for("general-purpose") == 1800
|
||||
assert config.get_timeout_for("bash") == 120
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_subagents_config_from_dict / get_subagents_app_config singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadSubagentsConfig:
|
||||
def teardown_method(self):
|
||||
"""Restore defaults after each test."""
|
||||
_reset_subagents_config()
|
||||
|
||||
def test_load_global_timeout(self):
|
||||
load_subagents_config_from_dict({"timeout_seconds": 300})
|
||||
assert get_subagents_app_config().timeout_seconds == 300
|
||||
|
||||
def test_load_with_per_agent_overrides(self):
|
||||
load_subagents_config_from_dict(
|
||||
{
|
||||
"timeout_seconds": 900,
|
||||
"agents": {
|
||||
"general-purpose": {"timeout_seconds": 1800},
|
||||
"bash": {"timeout_seconds": 60},
|
||||
},
|
||||
}
|
||||
)
|
||||
cfg = get_subagents_app_config()
|
||||
assert cfg.get_timeout_for("general-purpose") == 1800
|
||||
assert cfg.get_timeout_for("bash") == 60
|
||||
|
||||
def test_load_partial_override(self):
|
||||
load_subagents_config_from_dict(
|
||||
{
|
||||
"timeout_seconds": 600,
|
||||
"agents": {"bash": {"timeout_seconds": 120}},
|
||||
}
|
||||
)
|
||||
cfg = get_subagents_app_config()
|
||||
assert cfg.get_timeout_for("general-purpose") == 600
|
||||
assert cfg.get_timeout_for("bash") == 120
|
||||
|
||||
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.agents == {}
|
||||
|
||||
def test_load_replaces_previous_config(self):
|
||||
load_subagents_config_from_dict({"timeout_seconds": 100})
|
||||
assert get_subagents_app_config().timeout_seconds == 100
|
||||
|
||||
load_subagents_config_from_dict({"timeout_seconds": 200})
|
||||
assert get_subagents_app_config().timeout_seconds == 200
|
||||
|
||||
def test_singleton_returns_same_instance_between_calls(self):
|
||||
load_subagents_config_from_dict({"timeout_seconds": 777})
|
||||
assert get_subagents_app_config() is get_subagents_app_config()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# registry.get_subagent_config – timeout override applied
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistryGetSubagentConfig:
|
||||
def teardown_method(self):
|
||||
_reset_subagents_config()
|
||||
|
||||
def test_returns_none_for_unknown_agent(self):
|
||||
from src.subagents.registry import get_subagent_config
|
||||
|
||||
assert get_subagent_config("nonexistent") is None
|
||||
|
||||
def test_returns_config_for_builtin_agents(self):
|
||||
from src.subagents.registry import get_subagent_config
|
||||
|
||||
assert get_subagent_config("general-purpose") is not None
|
||||
assert get_subagent_config("bash") is not None
|
||||
|
||||
def test_default_timeout_preserved_when_no_config(self):
|
||||
from src.subagents.registry import get_subagent_config
|
||||
|
||||
_reset_subagents_config(timeout_seconds=900)
|
||||
config = get_subagent_config("general-purpose")
|
||||
assert config.timeout_seconds == 900
|
||||
|
||||
def test_global_timeout_override_applied(self):
|
||||
from src.subagents.registry import get_subagent_config
|
||||
|
||||
_reset_subagents_config(timeout_seconds=1800)
|
||||
config = get_subagent_config("general-purpose")
|
||||
assert config.timeout_seconds == 1800
|
||||
|
||||
def test_per_agent_timeout_override_applied(self):
|
||||
from src.subagents.registry import get_subagent_config
|
||||
|
||||
load_subagents_config_from_dict(
|
||||
{
|
||||
"timeout_seconds": 900,
|
||||
"agents": {"bash": {"timeout_seconds": 120}},
|
||||
}
|
||||
)
|
||||
bash_config = get_subagent_config("bash")
|
||||
assert bash_config.timeout_seconds == 120
|
||||
|
||||
def test_per_agent_override_does_not_affect_other_agents(self):
|
||||
from src.subagents.registry import get_subagent_config
|
||||
|
||||
load_subagents_config_from_dict(
|
||||
{
|
||||
"timeout_seconds": 900,
|
||||
"agents": {"bash": {"timeout_seconds": 120}},
|
||||
}
|
||||
)
|
||||
gp_config = get_subagent_config("general-purpose")
|
||||
assert gp_config.timeout_seconds == 900
|
||||
|
||||
def test_builtin_config_object_is_not_mutated(self):
|
||||
"""Registry must return a new object, leaving the builtin default intact."""
|
||||
from src.subagents.builtins import BUILTIN_SUBAGENTS
|
||||
from src.subagents.registry import get_subagent_config
|
||||
|
||||
original_timeout = BUILTIN_SUBAGENTS["bash"].timeout_seconds
|
||||
load_subagents_config_from_dict({"timeout_seconds": 42})
|
||||
|
||||
returned = get_subagent_config("bash")
|
||||
assert returned.timeout_seconds == 42
|
||||
assert BUILTIN_SUBAGENTS["bash"].timeout_seconds == original_timeout
|
||||
|
||||
def test_config_preserves_other_fields(self):
|
||||
"""Applying timeout override must not change other SubagentConfig fields."""
|
||||
from src.subagents.builtins import BUILTIN_SUBAGENTS
|
||||
from src.subagents.registry import get_subagent_config
|
||||
|
||||
_reset_subagents_config(timeout_seconds=300)
|
||||
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.model == original.model
|
||||
assert overridden.tools == original.tools
|
||||
assert overridden.disallowed_tools == original.disallowed_tools
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# registry.list_subagents – all agents get overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistryListSubagents:
|
||||
def teardown_method(self):
|
||||
_reset_subagents_config()
|
||||
|
||||
def test_lists_both_builtin_agents(self):
|
||||
from src.subagents.registry import list_subagents
|
||||
|
||||
names = {cfg.name for cfg in list_subagents()}
|
||||
assert "general-purpose" in names
|
||||
assert "bash" in names
|
||||
|
||||
def test_all_returned_configs_get_global_override(self):
|
||||
from src.subagents.registry import list_subagents
|
||||
|
||||
_reset_subagents_config(timeout_seconds=123)
|
||||
for cfg in list_subagents():
|
||||
assert cfg.timeout_seconds == 123, f"{cfg.name} has wrong timeout"
|
||||
|
||||
def test_per_agent_overrides_reflected_in_list(self):
|
||||
from src.subagents.registry import list_subagents
|
||||
|
||||
load_subagents_config_from_dict(
|
||||
{
|
||||
"timeout_seconds": 900,
|
||||
"agents": {
|
||||
"general-purpose": {"timeout_seconds": 1800},
|
||||
"bash": {"timeout_seconds": 60},
|
||||
},
|
||||
}
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Polling timeout calculation (logic extracted from task_tool)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPollingTimeoutCalculation:
|
||||
"""Verify the formula (timeout_seconds + 60) // 5 is correct for various inputs."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"timeout_seconds, expected_max_polls",
|
||||
[
|
||||
(900, 192), # default 15 min → (900+60)//5 = 192
|
||||
(300, 72), # 5 min → (300+60)//5 = 72
|
||||
(1800, 372), # 30 min → (1800+60)//5 = 372
|
||||
(60, 24), # 1 min → (60+60)//5 = 24
|
||||
(1, 12), # minimum → (1+60)//5 = 12
|
||||
],
|
||||
)
|
||||
def test_polling_timeout_formula(self, timeout_seconds: int, expected_max_polls: int):
|
||||
dummy_config = SubagentConfig(
|
||||
name="test",
|
||||
description="test",
|
||||
system_prompt="test",
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
max_poll_count = (dummy_config.timeout_seconds + 60) // 5
|
||||
assert max_poll_count == expected_max_polls
|
||||
|
||||
def test_polling_timeout_exceeds_execution_timeout(self):
|
||||
"""Safety-net polling window must always be longer than the execution timeout."""
|
||||
for timeout_seconds in [60, 300, 900, 1800]:
|
||||
dummy_config = SubagentConfig(
|
||||
name="test",
|
||||
description="test",
|
||||
system_prompt="test",
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
max_poll_count = (dummy_config.timeout_seconds + 60) // 5
|
||||
polling_window_seconds = max_poll_count * 5
|
||||
assert polling_window_seconds > timeout_seconds
|
||||
@@ -192,6 +192,23 @@ sandbox:
|
||||
# use: src.community.aio_sandbox:AioSandboxProvider
|
||||
# provisioner_url: http://provisioner:8002
|
||||
|
||||
# ============================================================================
|
||||
# Subagents Configuration
|
||||
# ============================================================================
|
||||
# Configure timeouts for subagent execution
|
||||
# Subagents are background workers delegated tasks by the lead agent
|
||||
|
||||
# subagents:
|
||||
# # Default timeout in seconds for all subagents (default: 900 = 15 minutes)
|
||||
# timeout_seconds: 900
|
||||
#
|
||||
# # Optional per-agent timeout overrides
|
||||
# agents:
|
||||
# general-purpose:
|
||||
# timeout_seconds: 1800 # 30 minutes for complex multi-step tasks
|
||||
# bash:
|
||||
# timeout_seconds: 300 # 5 minutes for quick command execution
|
||||
|
||||
# ============================================================================
|
||||
# Skills Configuration
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user