From 4a51beb794e73b3960e77ffd417c5af95fee176e 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 | 45 ++++++++++++++++++++++++++++++-- tests/test_runner_rate_limit.py | 2 +- tests/test_runner_root_prompt.py | 2 +- 8 files changed, 83 insertions(+), 5 deletions(-) diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index fe911907c..66a85d949 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -108,6 +108,10 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th Maximum size (in MB) of a local directory target that Strix will copy into the sandbox file-by-file. Larger targets exit early with a suggestion to use `--mount` instead. Set to `0` to disable the check. + + 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 78d52273b..f56c42a6d 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -94,6 +94,8 @@ class RuntimeSettings(BaseSettings): max_local_copy_mb: int = Field(default=1024, alias="STRIX_MAX_LOCAL_COPY_MB") # 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 7c2517779..9c154fcc7 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -52,6 +52,8 @@ _INPUT_REJECTION_CODES = frozenset({400, 404, 422}) _MAX_COMPACTIONS_PER_CYCLE = 2 +_UNLIMITED_CHILD_AGENTS = 0 +_CHILD_AGENT_LIMIT_ERROR = "child agent limit reached" def _run_config_model(run_config: RunConfig) -> str | None: @@ -230,11 +232,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( @@ -278,6 +299,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 fdb432d0c..609b6e529 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -318,6 +318,7 @@ async def spawn_child_agent(**kwargs: Any) -> dict[str, Any]: 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 7e14662ae..da0392300 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -10,7 +10,7 @@ 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: @@ -34,6 +34,7 @@ "STRIX_IMAGE", "STRIX_RUNTIME_BACKEND", "STRIX_MAX_LOCAL_COPY_MB", + "STRIX_MAX_CHILD_AGENTS", # TelemetrySettings "STRIX_TELEMETRY", ] @@ -130,6 +131,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 985541c01..587acc5e5 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -5,14 +5,18 @@ import asyncio import contextlib import json -from typing import Any +from typing import Any, cast import pytest from agents.memory import SQLiteSession from agents.tool_context import ToolContext from strix.core.agents import AgentCoordinator -from strix.core.execution import _notify_parent_on_terminal, _notify_root_on_budget_reserve +from strix.core.execution import ( + _notify_parent_on_terminal, + _notify_root_on_budget_reserve, + spawn_child_agent, +) from strix.tools.finish.tool import finish_scan @@ -56,6 +60,43 @@ async def _record(target_agent_id: str, message: dict[str, Any]) -> bool: 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 482c4730b..2be0d3900 100644 --- a/tests/test_runner_rate_limit.py +++ b/tests/test_runner_rate_limit.py @@ -41,7 +41,7 @@ async def test_persistent_rate_limit_stops_gracefully( timeout=300, prompt_cache=True, ), - 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 56d7caa6e..30419ebfd 100644 --- a/tests/test_runner_root_prompt.py +++ b/tests/test_runner_root_prompt.py @@ -49,7 +49,7 @@ def _patch_engine_scaffold( timeout=300, prompt_cache=True, ), - 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 a6bfcdce10ccfa6d010c72d83fbae894e7f81e4b 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 93aa49752..dce7aef21 100644 --- a/strix/core/agents.py +++ b/strix/core/agents.py @@ -157,6 +157,34 @@ async def register( 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 9c154fcc7..3061ec998 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -238,8 +238,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, @@ -256,15 +264,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, @@ -299,10 +299,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 587acc5e5..92c04d030 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -97,6 +97,55 @@ async def _unexpected_start(**_kwargs: Any) -> None: 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()