From ec0131d43fe2e01547def3e2bb76d251a41422a6 Mon Sep 17 00:00:00 2001 From: Hazemwaddah <47379135+Hazemwaddah@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:50:09 +0300 Subject: [PATCH] feat(cost): opt-in agent fan-out caps, context trim, --reasoning-effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autonomous scans can spawn unbounded agents, each re-paying the full system prompt on every turn and (by default) inheriting a full copy of the parent's history — a large token-cost driver on a single target. This adds knobs to bound it, all OFF by default so out-of-the-box behavior is unchanged. New (opt-in via env, default 0 = disabled): - STRIX_MAX_AGENTS — cap total agents in the graph; create_agent refuses past the cap with a model-facing message to reuse/wait/self-serve. - STRIX_MAX_AGENT_DEPTH — cap spawn depth (root = 1). - STRIX_INHERIT_CONTEXT_MAX_TOKENS — trim inherited parent history to the most-recent tail within a token budget. Also: - New --reasoning-effort CLI flag (overrides STRIX_REASONING_EFFORT per run); pure addition, no default change. Coordinator gains agent_count()/depth_of() helpers. Tests cover the caps and the history trim. Docs updated. No default behavior changes. --- docs/advanced/configuration.mdx | 21 +++++++- docs/usage/cli.mdx | 7 +++ strix/config/settings.py | 28 +++++++++++ strix/core/agents.py | 21 ++++++++ strix/core/inputs.py | 37 ++++++++++++++ strix/interface/cli_args.py | 18 +++++++ strix/tools/agents_graph/tools.py | 41 +++++++++++++++- tests/test_agent_fanout_limits.py | 81 +++++++++++++++++++++++++++++++ tests/test_inputs.py | 40 +++++++++++++++ 9 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 tests/test_agent_fanout_limits.py diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index f1542b75..199122f0 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -36,7 +36,26 @@ Configure Strix using environment variables or a config file. - Control thinking effort for reasoning models. Valid values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Defaults to `medium` for quick scan mode. + Control thinking effort for reasoning models. Valid values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Higher = more thinking tokens = higher cost and (usually) deeper analysis. The `--reasoning-effort` CLI flag overrides this per run. + + +### Cost / fan-out limits + +Every spawned agent re-pays the full system prompt on each of its turns and (by +default) inherits a full copy of its parent's history, so unbounded agent fan-out +is a large token-cost driver on a single target. These knobs let operators bound +it. **All default to `0` (disabled) — out-of-the-box behavior is unchanged.** + + + Maximum total agents in the graph (root included). When reached, `create_agent` refuses to spawn and tells the agent to reuse an existing agent, wait for running ones, or do the work itself. `0` = unlimited. + + + + Maximum spawn depth. The root is depth 1; a child of the root is depth 2. `0` = unlimited. + + + + Token cap on the parent history copied into a child spawned with `inherit_context=true`. The most-recent tail is kept; older turns are dropped with a marker. `0` = copy the full parent history. diff --git a/docs/usage/cli.mdx b/docs/usage/cli.mdx index 699fb1cb..1f6571a0 100644 --- a/docs/usage/cli.mdx +++ b/docs/usage/cli.mdx @@ -48,6 +48,13 @@ strix (--target | --target-list ) [options] Scan depth: `quick`, `standard`, or `deep`. + + Model reasoning effort for this run: `none`, `minimal`, `low`, `medium`, + `high`, `xhigh`, or `max`. Higher = more thinking tokens = higher cost and + (usually) deeper analysis. Overrides `STRIX_REASONING_EFFORT` and the config + file for this run. Defaults to the configured value (`high`). + + Code scope mode: `auto` (enable PR diff-scope in CI/headless runs), `diff` (force changed-files scope), or `full` (disable diff-scope). diff --git a/strix/config/settings.py b/strix/config/settings.py index 42a2c97e..cc7ebb97 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -88,6 +88,9 @@ class ContextSettings(BaseSettings): model_config = _BASE_CONFIG auto_compact: bool = Field(default=True, alias="STRIX_CONTEXT_AUTO_COMPACT") + # A larger buffer makes compaction fire sooner (smaller live window), trading + # a few extra summary calls for a smaller per-turn context. Raise it to lower + # token cost. compact_buffer_tokens: int = Field(default=20_000, gt=0, alias="STRIX_CONTEXT_BUFFER_TOKENS") keep_tokens: int = Field(default=8_000, gt=0, alias="STRIX_CONTEXT_KEEP_TOKENS") fallback_context_tokens: int = Field( @@ -102,6 +105,30 @@ class ContextSettings(BaseSettings): ) +class AgentGraphSettings(BaseSettings): + """Multi-agent fan-out limits — optionally bound how many agents a scan spawns. + + Every spawned agent re-pays the full system prompt on each of its turns and + (by default) inherits a copy of its parent's history, so an unbounded fan-out + is a large driver of token spend on one target. These knobs put a + deterministic ceiling on it when set. All default to ``0`` (disabled), so the + out-of-the-box behavior is unchanged; operators opt in to bound cost. + """ + + model_config = _BASE_CONFIG + + # Max total agents in the graph (root included). 0 = unlimited (default). + max_agents: int = Field(default=0, ge=0, alias="STRIX_MAX_AGENTS") + # Max spawn depth. Root is depth 1; a child of root is depth 2. 0 = unlimited. + max_agent_depth: int = Field(default=0, ge=0, alias="STRIX_MAX_AGENT_DEPTH") + # Token cap on the parent history copied into a child spawned with + # inherit_context=True. The tail (most recent turns) is kept; older turns are + # dropped with a marker. 0 = copy the full parent history (default). + inherit_context_max_tokens: int = Field( + default=0, ge=0, alias="STRIX_INHERIT_CONTEXT_MAX_TOKENS" + ) + + class RuntimeSettings(BaseSettings): model_config = _BASE_CONFIG @@ -149,6 +176,7 @@ class Settings(BaseSettings): llm: LlmSettings = Field(default_factory=LlmSettings) dedupe: DedupeSettings = Field(default_factory=DedupeSettings) + agent_graph: AgentGraphSettings = Field(default_factory=AgentGraphSettings) runtime: RuntimeSettings = Field(default_factory=RuntimeSettings) context: ContextSettings = Field(default_factory=ContextSettings) telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings) diff --git a/strix/core/agents.py b/strix/core/agents.py index edd7863b..529971bc 100644 --- a/strix/core/agents.py +++ b/strix/core/agents.py @@ -430,6 +430,27 @@ class AgentCoordinator: if aid != agent_id and status in {"running", "waiting"} ] + async def agent_count(self) -> int: + """Total agents in the graph (root included), across every status.""" + async with self._lock: + return len(self.parent_of) + + async def depth_of(self, agent_id: str) -> int: + """1-based spawn depth of ``agent_id`` (root = 1). + + Walks parent links defensively: an unknown id or a cycle stops the walk + rather than looping forever. + """ + async with self._lock: + depth = 0 + seen: set[str] = set() + current: str | None = agent_id + while current is not None and current in self.parent_of and current not in seen: + seen.add(current) + depth += 1 + current = self.parent_of.get(current) + return max(depth, 1) + async def graph_snapshot( self, ) -> tuple[dict[str, str | None], dict[str, Status], dict[str, str], dict[str, str]]: diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 3dd0d701..1b5a24f9 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any from agents.model_settings import ModelSettings from openai.types.shared import Reasoning +from strix.config import load_settings from strix.config.models import ( DEFAULT_MODEL_RETRY, OPENROUTER_ATTRIBUTION_HEADERS, @@ -23,6 +24,41 @@ from strix.config.models import ( from strix.core.sessions import scrub_images_from_items +# Rough tokens→chars factor for bounding inherited context without a model-bound +# tokenizer. ~4 chars/token is the usual estimate; kept conservative so the cap +# never lets more through than intended. +_CHARS_PER_TOKEN = 4 +_HISTORY_TRUNCATED_MARKER = { + "role": "user", + "content": "[... older inherited context dropped to bound token cost ...]", +} + + +def _trim_parent_history(parent_history: list[Any]) -> list[Any]: + """Keep the most-recent tail of ``parent_history`` within the configured cap. + + A child inheriting its parent's whole history re-pays for it on every one of + its own turns, so an unbounded copy multiplies token cost across the fan-out. + ``STRIX_INHERIT_CONTEXT_MAX_TOKENS`` bounds it; ``0`` keeps the full history. + """ + max_tokens = load_settings().agent_graph.inherit_context_max_tokens + if max_tokens <= 0 or not parent_history: + return parent_history + + char_budget = max_tokens * _CHARS_PER_TOKEN + kept: list[Any] = [] + used = 0 + for item in reversed(parent_history): + size = len(json.dumps(item, ensure_ascii=False, default=str)) + if used + size > char_budget and kept: + kept.append(_HISTORY_TRUNCATED_MARKER) + break + kept.append(item) + used += size + kept.reverse() + return kept + + if TYPE_CHECKING: from strix.config.settings import ReasoningEffort @@ -351,6 +387,7 @@ def child_initial_input( user messages. """ parts: list[str] = [] + parent_history = _trim_parent_history(parent_history) if parent_history: rendered = json.dumps( scrub_images_from_items(parent_history), diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index dbb1ebdf..cee2cfa7 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -192,6 +192,19 @@ Examples: ), ) + parser.add_argument( + "--reasoning-effort", + dest="reasoning_effort", + type=str, + choices=["none", "minimal", "low", "medium", "high", "xhigh", "max"], + default=None, + help=( + "Model reasoning effort for this run. Higher = more thinking tokens = " + "higher cost and (usually) deeper analysis. Overrides STRIX_REASONING_EFFORT " + "and the config file for this run. Default: the configured value (high)." + ), + ) + parser.add_argument( "--scope-mode", type=str, @@ -306,6 +319,11 @@ Examples: if args.mcp_exclude: os.environ["STRIX_MCP_EXCLUDE"] = ",".join(args.mcp_exclude) + # Settings read STRIX_REASONING_EFFORT from the environment (env wins over the + # config file), so exporting it here makes the flag win for this run. + if args.reasoning_effort: + os.environ["STRIX_REASONING_EFFORT"] = args.reasoning_effort + if args.update: sys.exit(0 if self_update() else 1) diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index ad16fe03..1df5598c 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -12,7 +12,8 @@ from typing import Any, Literal, get_args from agents import RunContextWrapper, function_tool -from strix.core.agents import Status, coordinator_from_context +from strix.config import load_settings +from strix.core.agents import AgentCoordinator, Status, coordinator_from_context from strix.core.execution import notify_parent_on_terminal from strix.core.hooks import LLM_TURN_KEY from strix.skills import validate_requested_skills @@ -24,6 +25,36 @@ _ACTIVE_STATUSES: frozenset[str] = frozenset({"running", "waiting"}) logger = logging.getLogger(__name__) +async def _fan_out_limit_error(coordinator: AgentCoordinator, parent_id: str) -> str | None: + """Return a model-facing error if spawning a child would breach a fan-out cap. + + Bounds token spend: every extra agent re-pays the full system prompt on each + of its turns, so an unbounded graph is the biggest single-target cost driver. + Both caps are configurable (``STRIX_MAX_AGENTS`` / ``STRIX_MAX_AGENT_DEPTH``); + ``0`` disables that check. + """ + graph = load_settings().agent_graph + + if graph.max_agents and await coordinator.agent_count() >= graph.max_agents: + return ( + f"Agent limit reached ({graph.max_agents} agents). Cannot spawn another. " + "Do this work yourself, reuse an existing agent via send_message_to_agent, " + "or wait_for_agents to let running ones finish. The operator can raise " + "STRIX_MAX_AGENTS if a larger fan-out is intended." + ) + + if graph.max_agent_depth: + child_depth = await coordinator.depth_of(parent_id) + 1 + if child_depth > graph.max_agent_depth: + return ( + f"Agent depth limit reached (max {graph.max_agent_depth}). This agent is " + "too deep in the tree to spawn a child. Run the subtask yourself or hand it " + "back to a shallower agent. The operator can raise STRIX_MAX_AGENT_DEPTH." + ) + + return None + + def _ctx(ctx: RunContextWrapper) -> dict[str, Any]: return ctx.context if isinstance(ctx.context, dict) else {} @@ -485,6 +516,14 @@ async def create_agent( default=str, ) + limit_error = await _fan_out_limit_error(coordinator, parent_id) + if limit_error: + return json.dumps( + {"success": False, "error": limit_error, "agent_id": None}, + ensure_ascii=False, + default=str, + ) + skill_list = list(skills or []) skill_error = validate_requested_skills(skill_list) if skill_error: diff --git a/tests/test_agent_fanout_limits.py b/tests/test_agent_fanout_limits.py new file mode 100644 index 00000000..d60bbc0f --- /dev/null +++ b/tests/test_agent_fanout_limits.py @@ -0,0 +1,81 @@ +"""Tests for multi-agent fan-out caps in strix.tools.agents_graph.tools.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from strix.config import loader +from strix.core.agents import AgentCoordinator +from strix.tools.agents_graph.tools import _fan_out_limit_error + + +if TYPE_CHECKING: + import pytest + + +async def _graph(*edges: tuple[str, str | None]) -> AgentCoordinator: + """Build a coordinator from (agent_id, parent_id) edges, root first.""" + coordinator = AgentCoordinator() + for agent_id, parent_id in edges: + await coordinator.register(agent_id, agent_id, parent_id) + return coordinator + + +async def test_max_agents_blocks_when_reached(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_MAX_AGENTS", "2") + monkeypatch.setenv("STRIX_MAX_AGENT_DEPTH", "0") + loader._cached = None + try: + coordinator = await _graph(("root", None), ("child", "root")) + error = await _fan_out_limit_error(coordinator, "root") + finally: + loader._cached = None + + assert error is not None + assert "Agent limit reached" in error + + +async def test_max_agents_allows_below_limit(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_MAX_AGENTS", "4") + monkeypatch.setenv("STRIX_MAX_AGENT_DEPTH", "0") + loader._cached = None + try: + coordinator = await _graph(("root", None), ("child", "root")) + error = await _fan_out_limit_error(coordinator, "root") + finally: + loader._cached = None + + assert error is None + + +async def test_max_depth_blocks_grandchild(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_MAX_AGENTS", "0") + monkeypatch.setenv("STRIX_MAX_AGENT_DEPTH", "2") + loader._cached = None + try: + coordinator = await _graph(("root", None), ("child", "root")) + # Spawning from the child would create a depth-3 grandchild. + child_error = await _fan_out_limit_error(coordinator, "child") + # Spawning from the root creates a depth-2 child — allowed. + root_error = await _fan_out_limit_error(coordinator, "root") + finally: + loader._cached = None + + assert child_error is not None + assert "depth limit reached" in child_error + assert root_error is None + + +async def test_limits_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_MAX_AGENTS", "0") + monkeypatch.setenv("STRIX_MAX_AGENT_DEPTH", "0") + loader._cached = None + try: + coordinator = await _graph( + ("root", None), ("a", "root"), ("b", "a"), ("c", "b"), ("d", "c") + ) + error = await _fan_out_limit_error(coordinator, "d") + finally: + loader._cached = None + + assert error is None diff --git a/tests/test_inputs.py b/tests/test_inputs.py index 5a483edf..22605b60 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -8,6 +8,7 @@ from typing import Any import litellm import pytest +from strix.config import loader from strix.core.inputs import ( build_root_task, build_scan_targets, @@ -62,6 +63,45 @@ def test_child_initial_input_no_consecutive_same_role(parent_history: list[Any]) assert all(prev != nxt for prev, nxt in pairwise(roles)) +def test_child_initial_input_trims_inherited_history(monkeypatch: pytest.MonkeyPatch) -> None: + # Tiny cap so all but the most recent item is dropped. + monkeypatch.setenv("STRIX_INHERIT_CONTEXT_MAX_TOKENS", "1") + loader._cached = None + try: + history = [ + {"role": "assistant", "content": "oldest work item that should be dropped"}, + {"role": "assistant", "content": "newest work item that should be kept"}, + ] + result = child_initial_input(**_child_kwargs(history)) + finally: + loader._cached = None + + content = result[0]["content"] + assert "newest work item that should be kept" in content + assert "oldest work item that should be dropped" not in content + assert "older inherited context dropped" in content + + +def test_child_initial_input_keeps_full_history_when_cap_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("STRIX_INHERIT_CONTEXT_MAX_TOKENS", "0") + loader._cached = None + try: + history = [ + {"role": "assistant", "content": "first item kept"}, + {"role": "assistant", "content": "second item kept"}, + ] + result = child_initial_input(**_child_kwargs(history)) + finally: + loader._cached = None + + content = result[0]["content"] + assert "first item kept" in content + assert "second item kept" in content + assert "older inherited context dropped" not in content + + def _cache_points(model_name: str) -> Any: extra = make_model_settings(None, model_name=model_name).extra_args or {} return extra.get("cache_control_injection_points")