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:
DanielWalnut
2026-02-25 08:39:29 +08:00
committed by GitHub
parent 310d54e443
commit faa422072c
17 changed files with 554 additions and 40 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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 .

View File

@@ -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),

View File

@@ -18,7 +18,6 @@ Architecture:
from __future__ import annotations
import logging
import os
import requests

View File

@@ -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()

View 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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]:

View File

@@ -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
View 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

View File

@@ -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"

View File

@@ -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"

View 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

View File

@@ -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
# ============================================================================