diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx
index f1542b75..129a6f63 100644
--- a/docs/advanced/configuration.mdx
+++ b/docs/advanced/configuration.mdx
@@ -122,6 +122,10 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
Runtime backend for the sandbox environment.
+
+ Maximum number of child agents a scan may spawn. Set to `0` for no limit.
+
+
## Sandbox Configuration
diff --git a/strix/config/settings.py b/strix/config/settings.py
index 42a2c97e..2123df67 100644
--- a/strix/config/settings.py
+++ b/strix/config/settings.py
@@ -112,6 +112,8 @@ class RuntimeSettings(BaseSettings):
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
# Max screenshot/image tool outputs kept live per agent context (0 = none).
max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES")
+ # Max spawned child agents per scan (0 = unlimited).
+ max_child_agents: int = Field(default=0, ge=0, alias="STRIX_MAX_CHILD_AGENTS")
class TelemetrySettings(BaseSettings):
diff --git a/strix/core/execution.py b/strix/core/execution.py
index bd99e7c3..6b7c3462 100644
--- a/strix/core/execution.py
+++ b/strix/core/execution.py
@@ -54,6 +54,8 @@ StreamEventSink = Callable[[str, Any], None]
_INPUT_REJECTION_CODES = frozenset({400, 404, 422})
_MAX_COMPACTIONS_PER_CYCLE = 2
+_UNLIMITED_CHILD_AGENTS = 0
+_CHILD_AGENT_LIMIT_ERROR = "child agent limit reached"
class ProviderRefusalError(AgentsException):
@@ -302,11 +304,30 @@ async def spawn_child_agent(
parent_history: list[Any],
event_sink: StreamEventSink | None = None,
hooks: RunHooks[dict[str, Any]] | None = None,
+ max_child_agents: int = _UNLIMITED_CHILD_AGENTS,
) -> dict[str, Any]:
parent_id = parent_ctx.get("agent_id")
if not isinstance(parent_id, str):
raise TypeError("Parent agent_id missing from context")
+ child_count = _child_agent_count(coordinator)
+ if max_child_agents > _UNLIMITED_CHILD_AGENTS and child_count >= max_child_agents:
+ logger.info(
+ "refusing to spawn child agent %r: limit %d already reached",
+ name,
+ max_child_agents,
+ )
+ return {
+ "success": False,
+ "error": _CHILD_AGENT_LIMIT_ERROR,
+ "message": (
+ f"Cannot spawn '{name}': configured child agent limit "
+ f"({max_child_agents}) is already reached."
+ ),
+ "limit": max_child_agents,
+ "current_child_agents": child_count,
+ }
+
child_id = uuid.uuid4().hex[:8]
child_agent = factory(name=name, skills=skills)
await coordinator.register(
@@ -350,6 +371,10 @@ async def spawn_child_agent(
}
+def _child_agent_count(coordinator: AgentCoordinator) -> int:
+ return sum(parent_id is not None for parent_id in coordinator.parent_of.values())
+
+
async def respawn_subagents(
*,
coordinator: AgentCoordinator,
diff --git a/strix/core/runner.py b/strix/core/runner.py
index b4afdfaf..b6825cea 100644
--- a/strix/core/runner.py
+++ b/strix/core/runner.py
@@ -338,6 +338,7 @@ async def run_strix_scan(
interactive=interactive,
event_sink=event_sink,
hooks=hooks,
+ max_child_agents=settings.runtime.max_child_agents,
**kwargs,
)
diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py
index e83ab119..3c878640 100644
--- a/tests/test_config_loader.py
+++ b/tests/test_config_loader.py
@@ -10,7 +10,7 @@ from pydantic import AliasChoices, Field, ValidationError
from pydantic.fields import FieldInfo
from strix.config import loader
-from strix.config.settings import ContextSettings
+from strix.config.settings import ContextSettings, RuntimeSettings
if TYPE_CHECKING:
@@ -33,6 +33,7 @@ _LLM_ENV_KEYS = [
# RuntimeSettings
"STRIX_IMAGE",
"STRIX_RUNTIME_BACKEND",
+ "STRIX_MAX_CHILD_AGENTS",
# TelemetrySettings
"STRIX_TELEMETRY",
]
@@ -129,6 +130,10 @@ def test_tool_output_max_bytes_accepts_floor() -> None:
assert ContextSettings(STRIX_TOOL_OUTPUT_MAX_BYTES=1024).tool_output_max_bytes == 1024
+def test_max_child_agents_env_alias() -> None:
+ assert RuntimeSettings(STRIX_MAX_CHILD_AGENTS=7).max_child_agents == 7
+
+
# --------------------------------------------------------------------------- #
# _aliases_for
# --------------------------------------------------------------------------- #
diff --git a/tests/test_execution.py b/tests/test_execution.py
index d389bde3..4d3b5d25 100644
--- a/tests/test_execution.py
+++ b/tests/test_execution.py
@@ -20,6 +20,7 @@ from strix.core.agents import AgentCoordinator
from strix.core.execution import (
_notify_root_on_budget_reserve,
notify_parent_on_terminal,
+ spawn_child_agent,
)
from strix.core.sessions import seed_initial_input
from strix.tools.agents_graph.tools import agent_finish, stop_agent
@@ -130,6 +131,43 @@ async def test_reserve_stop_notifies_root_once(monkeypatch: pytest.MonkeyPatch)
assert "finish_scan" in str(message["content"])
+@pytest.mark.asyncio
+async def test_spawn_child_agent_respects_child_limit(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Any
+) -> None:
+ coordinator = AgentCoordinator()
+ await coordinator.register("root", "strix", parent_id=None)
+ await coordinator.register("child-1", "recon", parent_id="root")
+
+ def _unexpected_factory(**_kwargs: Any) -> object:
+ raise AssertionError("child factory should not be called at the child limit")
+
+ async def _unexpected_start(**_kwargs: Any) -> None:
+ raise AssertionError("child runner should not start at the child limit")
+
+ monkeypatch.setattr("strix.core.execution._start_child_runner", _unexpected_start)
+
+ result = await spawn_child_agent(
+ coordinator=coordinator,
+ factory=_unexpected_factory,
+ agents_db_path=tmp_path / "agents.db",
+ sessions_to_close=[],
+ run_config=cast("Any", object()),
+ max_turns=1,
+ interactive=False,
+ parent_ctx={"agent_id": "root"},
+ name="extra",
+ task="do more recon",
+ skills=[],
+ parent_history=[],
+ max_child_agents=1,
+ )
+
+ assert result["success"] is False
+ assert "child agent limit" in result["error"]
+ assert set(coordinator.parent_of) == {"root", "child-1"}
+
+
@pytest.mark.asyncio
async def test_concurrent_reserve_claims_yield_single_root() -> None:
coordinator = AgentCoordinator()
diff --git a/tests/test_runner_rate_limit.py b/tests/test_runner_rate_limit.py
index 3110ae2c..c2370c3b 100644
--- a/tests/test_runner_rate_limit.py
+++ b/tests/test_runner_rate_limit.py
@@ -43,7 +43,7 @@ async def test_persistent_rate_limit_stops_gracefully(
prompt_cache=True,
extra_headers=None,
),
- runtime=types.SimpleNamespace(max_context_images=3),
+ runtime=types.SimpleNamespace(max_context_images=3, max_child_agents=0),
)
monkeypatch.setattr(runner, "load_settings", lambda: settings)
monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _settings: None)
diff --git a/tests/test_runner_root_prompt.py b/tests/test_runner_root_prompt.py
index 2c346203..1c434043 100644
--- a/tests/test_runner_root_prompt.py
+++ b/tests/test_runner_root_prompt.py
@@ -51,7 +51,7 @@ def _patch_engine_scaffold(
prompt_cache=True,
extra_headers=None,
),
- runtime=types.SimpleNamespace(max_context_images=3),
+ runtime=types.SimpleNamespace(max_context_images=3, max_child_agents=0),
)
monkeypatch.setattr(runner, "load_settings", lambda: settings)
monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _settings: None)