Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/advanced/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</ParamField>

<ParamField path="STRIX_MAX_CHILD_AGENTS" default="0" type="integer">
Maximum number of child agents a scan may spawn. Set to `0` for no limit.
</ParamField>

## Sandbox Configuration

<ParamField path="STRIX_SANDBOX_EXECUTION_TIMEOUT" default="120" type="integer">
Expand Down
2 changes: 2 additions & 0 deletions strix/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
28 changes: 28 additions & 0 deletions strix/core/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 23 additions & 2 deletions strix/core/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -230,20 +232,39 @@ 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_id = uuid.uuid4().hex[:8]
child_agent = factory(name=name, skills=skills)
await coordinator.register(
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,
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_agent = factory(name=name, skills=skills)

await _start_child_runner(
parent_ctx=parent_ctx,
Expand Down
1 change: 1 addition & 0 deletions strix/core/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
7 changes: 6 additions & 1 deletion tests/test_config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -34,6 +34,7 @@
"STRIX_IMAGE",
"STRIX_RUNTIME_BACKEND",
"STRIX_MAX_LOCAL_COPY_MB",
"STRIX_MAX_CHILD_AGENTS",
# TelemetrySettings
"STRIX_TELEMETRY",
]
Expand Down Expand Up @@ -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
# --------------------------------------------------------------------------- #
Expand Down
94 changes: 92 additions & 2 deletions tests/test_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -56,6 +60,92 @@ 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_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()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_runner_rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_runner_root_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down