diff --git a/.github/workflows/backend-unit-tests.yml b/.github/workflows/backend-unit-tests.yml index b41ef85..f0aa56d 100644 --- a/.github/workflows/backend-unit-tests.yml +++ b/.github/workflows/backend-unit-tests.yml @@ -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 diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 8bf376f..ae93d0a 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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_.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_.py -v +``` + ### Running the Full Application From the **project root** directory: diff --git a/backend/Makefile b/backend/Makefile index 768b9a3..32dc4bf 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -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 . diff --git a/backend/src/agents/lead_agent/agent.py b/backend/src/agents/lead_agent/agent.py index 6853940..5877a71 100644 --- a/backend/src/agents/lead_agent/agent.py +++ b/backend/src/agents/lead_agent/agent.py @@ -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), diff --git a/backend/src/community/aio_sandbox/remote_backend.py b/backend/src/community/aio_sandbox/remote_backend.py index fc405db..fbec7af 100644 --- a/backend/src/community/aio_sandbox/remote_backend.py +++ b/backend/src/community/aio_sandbox/remote_backend.py @@ -18,7 +18,6 @@ Architecture: from __future__ import annotations import logging -import os import requests diff --git a/backend/src/config/app_config.py b/backend/src/config/app_config.py index d3886ea..f438315 100644 --- a/backend/src/config/app_config.py +++ b/backend/src/config/app_config.py @@ -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() diff --git a/backend/src/config/subagents_config.py b/backend/src/config/subagents_config.py new file mode 100644 index 0000000..2611fe8 --- /dev/null +++ b/backend/src/config/subagents_config.py @@ -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") diff --git a/backend/src/config/tracing_config.py b/backend/src/config/tracing_config.py index d279db4..138f5cc 100644 --- a/backend/src/config/tracing_config.py +++ b/backend/src/config/tracing_config.py @@ -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 - diff --git a/backend/src/models/factory.py b/backend/src/models/factory.py index da3af3e..f705e92 100644 --- a/backend/src/models/factory.py +++ b/backend/src/models/factory.py @@ -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 diff --git a/backend/src/subagents/executor.py b/backend/src/subagents/executor.py index e517864..4e1d0e8 100644 --- a/backend/src/subagents/executor.py +++ b/backend/src/subagents/executor.py @@ -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 diff --git a/backend/src/subagents/registry.py b/backend/src/subagents/registry.py index 6e881ba..e2a6b11 100644 --- a/backend/src/subagents/registry.py +++ b/backend/src/subagents/registry.py @@ -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]: diff --git a/backend/src/tools/builtins/task_tool.py b/backend/src/tools/builtins/task_tool.py index 1400c87..2cf725e 100644 --- a/backend/src/tools/builtins/task_tool.py +++ b/backend/src/tools/builtins/task_tool.py @@ -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}" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..f460ead --- /dev/null +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_docker_sandbox_mode_detection.py b/backend/tests/test_docker_sandbox_mode_detection.py index 16bc9fe..7921e80 100644 --- a/backend/tests/test_docker_sandbox_mode_detection.py +++ b/backend/tests/test_docker_sandbox_mode_detection.py @@ -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" diff --git a/backend/tests/test_provisioner_kubeconfig.py b/backend/tests/test_provisioner_kubeconfig.py index 4c932a5..ebc944e 100644 --- a/backend/tests/test_provisioner_kubeconfig.py +++ b/backend/tests/test_provisioner_kubeconfig.py @@ -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" diff --git a/backend/tests/test_subagent_timeout_config.py b/backend/tests/test_subagent_timeout_config.py new file mode 100644 index 0000000..0e6b655 --- /dev/null +++ b/backend/tests/test_subagent_timeout_config.py @@ -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 diff --git a/config.example.yaml b/config.example.yaml index 7fa4ff4..da907f2 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 # ============================================================================