From 077a72d56062d04e2c1a09300f2cbe648a626ccd Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Mon, 27 Jul 2026 19:31:23 +0000 Subject: [PATCH 1/2] feat(runtime): cap spawned child agents --- docs/advanced/configuration.mdx | 4 ++++ strix/config/settings.py | 2 ++ strix/core/execution.py | 25 +++++++++++++++++++++ strix/core/runner.py | 1 + tests/test_config_loader.py | 7 +++++- tests/test_execution.py | 38 ++++++++++++++++++++++++++++++++ tests/test_runner_rate_limit.py | 2 +- tests/test_runner_root_prompt.py | 2 +- 8 files changed, 78 insertions(+), 3 deletions(-) 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) From 799ef24842433de1c0225e7bf112ebcf5eb4ca40 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Mon, 27 Jul 2026 19:45:51 +0000 Subject: [PATCH 2/2] fix(runtime): make child agent limit reservation atomic --- strix/core/agents.py | 28 +++++++++++++++++++++++ strix/core/execution.py | 24 +++++++++----------- tests/test_execution.py | 49 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/strix/core/agents.py b/strix/core/agents.py index c96204df..d72236e4 100644 --- a/strix/core/agents.py +++ b/strix/core/agents.py @@ -168,6 +168,34 @@ class AgentCoordinator: logger.info("agent.register %s (%s) parent=%s", agent_id, name, parent_id or "-") await self._maybe_snapshot() + async def register_child_if_capacity( + self, + agent_id: str, + name: str, + parent_id: str, + *, + max_child_agents: int, + task: str | None = None, + skills: list[str] | None = None, + ) -> tuple[bool, int]: + async with self._lock: + child_count = sum(parent is not None for parent in self.parent_of.values()) + if max_child_agents > 0 and child_count >= max_child_agents: + return False, child_count + self.statuses[agent_id] = "running" + self.parent_of[agent_id] = parent_id + self.names[agent_id] = name + self.pending_counts.setdefault(agent_id, 0) + self.metadata[agent_id] = { + "task": task or "", + "skills": list(skills or []), + } + self.runtimes.setdefault(agent_id, AgentRuntime()) + child_count += 1 + logger.info("agent.register %s (%s) parent=%s", agent_id, name, parent_id) + await self._maybe_snapshot() + return True, child_count + async def attach_runtime( self, agent_id: str, diff --git a/strix/core/execution.py b/strix/core/execution.py index 6b7c3462..b8ce1cfa 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -310,8 +310,16 @@ async def spawn_child_agent( 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: + child_id = uuid.uuid4().hex[:8] + registered, child_count = await coordinator.register_child_if_capacity( + child_id, + name, + parent_id, + max_child_agents=max_child_agents, + task=task, + skills=skills, + ) + if not registered: logger.info( "refusing to spawn child agent %r: limit %d already reached", name, @@ -328,15 +336,7 @@ async def spawn_child_agent( "current_child_agents": child_count, } - child_id = uuid.uuid4().hex[:8] child_agent = factory(name=name, skills=skills) - await coordinator.register( - child_id, - name, - parent_id, - task=task, - skills=skills, - ) await _start_child_runner( parent_ctx=parent_ctx, @@ -371,10 +371,6 @@ 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/tests/test_execution.py b/tests/test_execution.py index 4d3b5d25..94dccc1f 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -168,6 +168,55 @@ async def test_spawn_child_agent_respects_child_limit( assert set(coordinator.parent_of) == {"root", "child-1"} +@pytest.mark.asyncio +async def test_concurrent_spawn_child_agent_reserves_limit_atomically( + monkeypatch: pytest.MonkeyPatch, tmp_path: Any +) -> None: + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + + async def _noop_start(**_kwargs: Any) -> None: + return None + + monkeypatch.setattr("strix.core.execution._start_child_runner", _noop_start) + + def _factory(**_kwargs: Any) -> object: + return object() + + async def _spawn(name: str) -> dict[str, Any]: + return await spawn_child_agent( + coordinator=coordinator, + factory=_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=name, + task="do more recon", + skills=[], + parent_history=[], + max_child_agents=1, + ) + + async with coordinator._lock: + tasks = [ + asyncio.create_task(_spawn("extra-a")), + asyncio.create_task(_spawn("extra-b")), + ] + await asyncio.sleep(0) + + results = await asyncio.gather(*tasks) + + assert [result["success"] for result in results].count(True) == 1 + assert _child_count(coordinator) == 1 + + +def _child_count(coordinator: AgentCoordinator) -> int: + return sum(parent_id is not None for parent_id in coordinator.parent_of.values()) + + @pytest.mark.asyncio async def test_concurrent_reserve_claims_yield_single_root() -> None: coordinator = AgentCoordinator()