From 7712e65d51f6c3f34413d579feb3340f99e183be 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 1/3] 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 98a9d690f..d88eb0dbf 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 699fb1cbc..1f6571a01 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 9309ac39e..85e42a6ec 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
@@ -172,6 +199,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 4d3d65cc6..fff2de879 100644
--- a/strix/core/agents.py
+++ b/strix/core/agents.py
@@ -465,6 +465,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 3dd0d701d..1b5a24f99 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 fc53e15f9..d5da24db7 100644
--- a/strix/interface/cli_args.py
+++ b/strix/interface/cli_args.py
@@ -200,6 +200,19 @@ Strix Cloud:
),
)
+ 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,
@@ -314,6 +327,11 @@ Strix Cloud:
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 da05bb1e7..505234af4 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.report.state import get_global_report_state
@@ -25,6 +26,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 {}
@@ -568,6 +599,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 000000000..d60bbc0f7
--- /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 5a483edf2..22605b607 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")
From 1d05637b686a05cecf6a433216784f6b9bdad8f1 Mon Sep 17 00:00:00 2001
From: Hazemwaddah <47379135+Hazemwaddah@users.noreply.github.com>
Date: Sun, 13 Sep 2026 10:53:35 +0300
Subject: [PATCH 2/3] fix(cost): close three review gaps in the fan-out cost
knobs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three issues raised in review on #1217, all real:
- The STRIX_MAX_AGENTS check read agent_count() and then spawned, so two
parents racing for the last slot both passed before either child
registered and the graph overshot the cap. The coordinator now hands out
slots atomically (try_reserve_agent_slot / release_agent_slot), counting
outstanding reservations alongside live agents under the same lock. The
slot is released once the spawner has registered the child or failed.
Depth stays a plain check — a parent's depth cannot change mid-spawn.
- _trim_parent_history kept the newest item whole when that item alone
exceeded the budget ("and kept"), so the cap bounded nothing on the
child's first request and could overflow the provider context window. An
oversized newest item is now rendered as a truncated text message. No
tool-call pairing can break: nothing else survives that trim.
- --reasoning-effort exported STRIX_REASONING_EFFORT, which persist_current()
then wrote into cli-config.json, turning a documented per-run flag into
the default for every later run. Env vars can now be marked run-scoped and
are skipped when persisting.
Tests cover the concurrent reservation, slot release, the oversized-item
trim, and the persist exemption.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/usage/cli.mdx | 3 +-
strix/config/__init__.py | 3 ++
strix/config/loader.py | 21 ++++++++-
strix/core/agents.py | 25 +++++++++++
strix/core/inputs.py | 31 ++++++++++++-
strix/interface/cli_args.py | 8 +++-
strix/tools/agents_graph/tools.py | 67 ++++++++++++++++------------
tests/test_agent_fanout_limits.py | 73 ++++++++++++++++++++-----------
tests/test_config_loader.py | 41 +++++++++++++++++
tests/test_inputs.py | 35 ++++++++++++++-
10 files changed, 246 insertions(+), 61 deletions(-)
diff --git a/docs/usage/cli.mdx b/docs/usage/cli.mdx
index 1f6571a01..bb06c418e 100644
--- a/docs/usage/cli.mdx
+++ b/docs/usage/cli.mdx
@@ -52,7 +52,8 @@ strix (--target | --target-list ) [options]
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`).
+ file for this run. Defaults to the configured value (`high`). Not written back
+ to the config file, so it never changes what later runs do.
diff --git a/strix/config/__init__.py b/strix/config/__init__.py
index f21fdab6b..10f3bb24f 100644
--- a/strix/config/__init__.py
+++ b/strix/config/__init__.py
@@ -9,11 +9,13 @@ Public surface:
- :func:`load_settings` — memoized resolve (env > JSON file > defaults).
- :func:`apply_config_override` — switch the JSON source to a custom path.
- :func:`persist_current` — write currently-set env vars to the active file.
+- :func:`mark_run_scoped` — keep a per-run env var out of that file.
"""
from strix.config.loader import (
apply_config_override,
load_settings,
+ mark_run_scoped,
persist_current,
)
from strix.config.settings import (
@@ -37,5 +39,6 @@ __all__ = [
"TelemetrySettings",
"apply_config_override",
"load_settings",
+ "mark_run_scoped",
"persist_current",
]
diff --git a/strix/config/loader.py b/strix/config/loader.py
index e6edd5484..c9cf3ee6b 100644
--- a/strix/config/loader.py
+++ b/strix/config/loader.py
@@ -26,6 +26,10 @@ logger = logging.getLogger(__name__)
_DEFAULT_PATH: Path = Path.home() / ".strix" / "cli-config.json"
_override: Path | None = None
_cached: Settings | None = None
+# Env vars set for this process only (e.g. from a per-run CLI flag). They still
+# win over the config file while the run lasts, but persist_current() must not
+# write them back, or a one-off flag would silently become the new default.
+_run_scoped: set[str] = set()
# Model, API key, and API base describe one provider connection. When the shell
# changes any of them, the stored values of the others no longer belong together
@@ -60,6 +64,16 @@ def apply_config_override(path: Path) -> None:
logger.info("config override applied: %s", path)
+def mark_run_scoped(*env_names: str) -> None:
+ """Exempt ``env_names`` from :func:`persist_current`.
+
+ For values that apply to the current run only. Without this, exporting a
+ per-run CLI flag into the environment makes it indistinguishable from a
+ setting the user chose to keep, and it lands in the config file.
+ """
+ _run_scoped.update(name.upper() for name in env_names)
+
+
def persist_current() -> None:
"""Merge currently-set env vars into the active config file (0o600).
@@ -67,6 +81,9 @@ def persist_current() -> None:
run that gets its settings from the file does not erase them. An env
var set to the empty string clears the field from the file. A change to
any linked LLM connection var drops the whole stored connection first.
+
+ Run-scoped vars (see :func:`mark_run_scoped`) are left out entirely: a
+ per-run override must neither be written nor clear what the file holds.
"""
s = load_settings()
target = _override or _DEFAULT_PATH
@@ -80,7 +97,9 @@ def persist_current() -> None:
for finfo in type(sub_model).model_fields.values():
aliases = [alias.upper() for alias in _aliases_for(finfo)]
active = next((alias for alias in aliases if alias in os.environ), None)
- if active is None:
+ # A run-scoped value belongs to this run only, so it neither
+ # overwrites nor clears the field the file already stores.
+ if active is None or active in _run_scoped:
continue
for alias in aliases:
env_block.pop(alias, None)
diff --git a/strix/core/agents.py b/strix/core/agents.py
index fff2de879..2593acb33 100644
--- a/strix/core/agents.py
+++ b/strix/core/agents.py
@@ -63,6 +63,9 @@ class AgentCoordinator:
self.runtimes: dict[str, AgentRuntime] = {}
self._parent_notified: set[str] = set()
self._lock = asyncio.Lock()
+ # Slots claimed by a spawn that has not registered its child yet. Counted
+ # alongside live agents so a concurrent fan-out cannot overshoot the cap.
+ self._reserved_slots = 0
self._snapshot_path: Path | None = None
self.is_shutting_down = False
self._budget_stopped = False
@@ -486,6 +489,28 @@ class AgentCoordinator:
current = self.parent_of.get(current)
return max(depth, 1)
+ async def try_reserve_agent_slot(self, max_agents: int) -> bool:
+ """Atomically claim one slot under ``max_agents``; ``0`` means unlimited.
+
+ Reading :meth:`agent_count` and then spawning is check-then-act: two
+ parents asking for the last slot concurrently both see room before
+ either child registers, and the graph overshoots the cap. Counting
+ outstanding reservations with the live agents under the same lock
+ closes that window. Release the slot with
+ :meth:`release_agent_slot` once the child is registered or the spawn
+ has failed.
+ """
+ async with self._lock:
+ if max_agents and len(self.parent_of) + self._reserved_slots >= max_agents:
+ return False
+ self._reserved_slots += 1
+ return True
+
+ async def release_agent_slot(self) -> None:
+ """Give back a slot claimed by :meth:`try_reserve_agent_slot`."""
+ async with self._lock:
+ self._reserved_slots = max(self._reserved_slots - 1, 0)
+
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 1b5a24f99..9a1ea1566 100644
--- a/strix/core/inputs.py
+++ b/strix/core/inputs.py
@@ -32,6 +32,28 @@ _HISTORY_TRUNCATED_MARKER = {
"role": "user",
"content": "[... older inherited context dropped to bound token cost ...]",
}
+_OVERSIZED_ITEM_NOTE = "[... newest inherited item truncated to bound token cost ...]\n"
+
+
+def _fit_oversized_item(item: Any, char_budget: int) -> dict[str, str]:
+ """Render ``item`` as a text message cut down to ``char_budget``.
+
+ Used only when the single newest item is larger than the whole budget:
+ keeping it whole would blow the cap on the child's very first request and
+ can overflow the provider's context window. Nothing else survives the trim
+ in that case, so rendering it as one text message cannot orphan a tool call
+ from its output.
+ """
+ body = json.dumps(item, ensure_ascii=False, default=str)
+ while body:
+ summary = {"role": "user", "content": f"{_OVERSIZED_ITEM_NOTE}{body}"}
+ overshoot = len(json.dumps(summary, ensure_ascii=False)) - char_budget
+ if overshoot <= 0:
+ return summary
+ body = body[: max(len(body) - overshoot, 0)]
+ # Budget too small to hold even a snippet; the marker alone is the most that
+ # can be said about the dropped item.
+ return dict(_HISTORY_TRUNCATED_MARKER)
def _trim_parent_history(parent_history: list[Any]) -> list[Any]:
@@ -50,8 +72,13 @@ def _trim_parent_history(parent_history: list[Any]) -> 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)
+ if used + size > char_budget:
+ # ``kept`` is empty only on the newest item, i.e. that one item is
+ # over budget all by itself. Truncate it rather than keeping it
+ # whole — otherwise the cap silently fails to bound anything.
+ kept.append(
+ _HISTORY_TRUNCATED_MARKER if kept else _fit_oversized_item(item, char_budget)
+ )
break
kept.append(item)
used += size
diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py
index d5da24db7..e6b16471e 100644
--- a/strix/interface/cli_args.py
+++ b/strix/interface/cli_args.py
@@ -7,7 +7,7 @@ import os
import sys
from pathlib import Path
-from strix.config import apply_config_override
+from strix.config import apply_config_override, mark_run_scoped
from strix.config.settings import DEFAULT_MAX_TURNS
from strix.core.paths import run_dir_for, runtime_state_dir
from strix.interface.scan_setup import attach_workspace_mount, build_targets_info
@@ -328,9 +328,13 @@ Strix Cloud:
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.
+ # config file), so exporting it here makes the flag win for this run. Mark it
+ # run-scoped: persist_current() writes every settings env var it finds into
+ # cli-config.json, which would turn this one-run flag into the new default
+ # for every later scan.
if args.reasoning_effort:
os.environ["STRIX_REASONING_EFFORT"] = args.reasoning_effort
+ mark_run_scoped("STRIX_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 505234af4..914446033 100644
--- a/strix/tools/agents_graph/tools.py
+++ b/strix/tools/agents_graph/tools.py
@@ -26,33 +26,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.
+def _agent_limit_error(max_agents: int) -> str:
+ """Model-facing refusal for a spawn that would breach ``STRIX_MAX_AGENTS``."""
+ return (
+ f"Agent limit reached ({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."
+ )
+
+
+async def _depth_limit_error(coordinator: AgentCoordinator, parent_id: str) -> str | None:
+ """Return a model-facing error if a child of ``parent_id`` would sit too deep.
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.
+ ``STRIX_MAX_AGENT_DEPTH`` caps the tree height; ``0`` disables the check. The
+ parent's own depth is fixed for the life of the agent, so unlike the total
+ agent cap this needs no reservation to stay correct under concurrent spawns.
"""
- graph = load_settings().agent_graph
+ max_depth = load_settings().agent_graph.max_agent_depth
+ if not max_depth:
+ return None
- if graph.max_agents and await coordinator.agent_count() >= graph.max_agents:
+ child_depth = await coordinator.depth_of(parent_id) + 1
+ if child_depth > max_depth:
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."
+ f"Agent depth limit reached (max {max_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."
)
-
- 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
@@ -599,19 +602,24 @@ async def create_agent(
default=str,
)
- limit_error = await _fan_out_limit_error(coordinator, parent_id)
- if limit_error:
+ skill_list = list(skills or [])
+ spawn_error = validate_requested_skills(skill_list) or await _depth_limit_error(
+ coordinator, parent_id
+ )
+ if spawn_error:
return json.dumps(
- {"success": False, "error": limit_error, "agent_id": None},
+ {"success": False, "error": spawn_error, "agent_id": None},
ensure_ascii=False,
default=str,
)
- skill_list = list(skills or [])
- skill_error = validate_requested_skills(skill_list)
- if skill_error:
+ # Claim the slot before spawning, not just before checking: the child only
+ # enters the graph once the spawner registers it, and two parents racing for
+ # the last slot would otherwise both be waved through.
+ max_agents = load_settings().agent_graph.max_agents
+ if not await coordinator.try_reserve_agent_slot(max_agents):
return json.dumps(
- {"success": False, "error": skill_error, "agent_id": None},
+ {"success": False, "error": _agent_limit_error(max_agents), "agent_id": None},
ensure_ascii=False,
default=str,
)
@@ -632,6 +640,11 @@ async def create_agent(
ensure_ascii=False,
default=str,
)
+ finally:
+ # The spawner registers the child before it returns, so by now the slot
+ # is accounted for by the graph itself (and on failure there is nothing
+ # to account for).
+ await coordinator.release_agent_slot()
logger.info(
"create_agent: spawned %s (%s) parent=%s skills=%d task_len=%d",
diff --git a/tests/test_agent_fanout_limits.py b/tests/test_agent_fanout_limits.py
index d60bbc0f7..5d9c15163 100644
--- a/tests/test_agent_fanout_limits.py
+++ b/tests/test_agent_fanout_limits.py
@@ -2,11 +2,12 @@
from __future__ import annotations
+import asyncio
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
+from strix.tools.agents_graph.tools import _depth_limit_error
if TYPE_CHECKING:
@@ -21,31 +22,51 @@ async def _graph(*edges: tuple[str, str | None]) -> AgentCoordinator:
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
+async def test_reserve_blocks_when_limit_reached() -> None:
+ coordinator = await _graph(("root", None), ("child", "root"))
- assert error is not None
- assert "Agent limit reached" in error
+ assert await coordinator.try_reserve_agent_slot(2) is False
-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
+async def test_reserve_allows_below_limit() -> None:
+ coordinator = await _graph(("root", None), ("child", "root"))
- assert error is None
+ assert await coordinator.try_reserve_agent_slot(4) is True
+
+
+async def test_reserve_unlimited_when_zero() -> None:
+ coordinator = await _graph(("root", None), ("a", "root"), ("b", "a"), ("c", "b"))
+
+ assert await coordinator.try_reserve_agent_slot(0) is True
+
+
+async def test_concurrent_reservations_cannot_overshoot_cap() -> None:
+ # Two parents race for the last slot. Counting only registered agents would
+ # wave both through, because neither child registers before the other checks.
+ coordinator = await _graph(("root", None), ("a", "root"))
+
+ granted = await asyncio.gather(*(coordinator.try_reserve_agent_slot(3) for _ in range(2)))
+
+ assert sorted(granted) == [False, True]
+
+
+async def test_released_slot_is_reusable() -> None:
+ coordinator = await _graph(("root", None), ("a", "root"))
+
+ assert await coordinator.try_reserve_agent_slot(3) is True
+ assert await coordinator.try_reserve_agent_slot(3) is False
+ await coordinator.release_agent_slot()
+ assert await coordinator.try_reserve_agent_slot(3) is True
+
+
+async def test_release_does_not_go_negative() -> None:
+ coordinator = await _graph(("root", None))
+
+ await coordinator.release_agent_slot()
+ await coordinator.release_agent_slot()
+
+ # A stray release must not hand out a free slot beyond the cap.
+ assert await coordinator.try_reserve_agent_slot(1) is False
async def test_max_depth_blocks_grandchild(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -55,9 +76,9 @@ async def test_max_depth_blocks_grandchild(monkeypatch: pytest.MonkeyPatch) -> N
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")
+ child_error = await _depth_limit_error(coordinator, "child")
# Spawning from the root creates a depth-2 child — allowed.
- root_error = await _fan_out_limit_error(coordinator, "root")
+ root_error = await _depth_limit_error(coordinator, "root")
finally:
loader._cached = None
@@ -66,7 +87,7 @@ async def test_max_depth_blocks_grandchild(monkeypatch: pytest.MonkeyPatch) -> N
assert root_error is None
-async def test_limits_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> None:
+async def test_depth_limit_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_MAX_AGENTS", "0")
monkeypatch.setenv("STRIX_MAX_AGENT_DEPTH", "0")
loader._cached = None
@@ -74,7 +95,7 @@ async def test_limits_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> Non
coordinator = await _graph(
("root", None), ("a", "root"), ("b", "a"), ("c", "b"), ("d", "c")
)
- error = await _fan_out_limit_error(coordinator, "d")
+ error = await _depth_limit_error(coordinator, "d")
finally:
loader._cached = None
diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py
index d083236a5..a21b47844 100644
--- a/tests/test_config_loader.py
+++ b/tests/test_config_loader.py
@@ -406,6 +406,47 @@ def test_persist_current_replaces_corrupt_file(
assert json.loads(target.read_text(encoding="utf-8")) == {"env": {"STRIX_LLM": "env-model"}}
+def test_persist_current_skips_run_scoped_env(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ # A per-run flag exports its env var so it wins for this run; persisting it
+ # would silently make it the default for every later run.
+ monkeypatch.setenv("STRIX_LLM", "persisted-model")
+ monkeypatch.setenv("STRIX_REASONING_EFFORT", "max")
+ monkeypatch.setattr(loader, "_run_scoped", set())
+ loader.mark_run_scoped("STRIX_REASONING_EFFORT")
+ target = tmp_path / "cli-config.json"
+ loader.apply_config_override(target)
+
+ loader.persist_current()
+
+ assert json.loads(target.read_text(encoding="utf-8")) == {
+ "env": {"STRIX_LLM": "persisted-model"}
+ }
+
+
+def test_run_scoped_env_does_not_clear_stored_value(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ # The per-run override must leave the user's stored preference alone, not
+ # just avoid overwriting it with the run's value.
+ target = tmp_path / "cli-config.json"
+ target.write_text(
+ json.dumps({"env": {"STRIX_REASONING_EFFORT": "low"}}),
+ encoding="utf-8",
+ )
+ loader.apply_config_override(target)
+ monkeypatch.setenv("STRIX_REASONING_EFFORT", "max")
+ monkeypatch.setattr(loader, "_run_scoped", set())
+ loader.mark_run_scoped("STRIX_REASONING_EFFORT")
+
+ loader.persist_current()
+
+ assert json.loads(target.read_text(encoding="utf-8")) == {
+ "env": {"STRIX_REASONING_EFFORT": "low"}
+ }
+
+
def test_persist_current_sets_0600_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_LLM", "persisted-model")
target = tmp_path / "cli-config.json"
diff --git a/tests/test_inputs.py b/tests/test_inputs.py
index 22605b607..3e86084ae 100644
--- a/tests/test_inputs.py
+++ b/tests/test_inputs.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import json
from itertools import pairwise
from typing import Any
@@ -10,6 +11,7 @@ import pytest
from strix.config import loader
from strix.core.inputs import (
+ _trim_parent_history,
build_root_task,
build_scan_targets,
build_scope_context,
@@ -64,8 +66,8 @@ def test_child_initial_input_no_consecutive_same_role(parent_history: list[Any])
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")
+ # Cap (20 tokens ~= 80 chars) fits the newest item but not both.
+ monkeypatch.setenv("STRIX_INHERIT_CONTEXT_MAX_TOKENS", "20")
loader._cached = None
try:
history = [
@@ -82,6 +84,35 @@ def test_child_initial_input_trims_inherited_history(monkeypatch: pytest.MonkeyP
assert "older inherited context dropped" in content
+def test_trim_truncates_oversized_newest_item(monkeypatch: pytest.MonkeyPatch) -> None:
+ # One item, larger than the whole budget: keeping it whole would mean the cap
+ # bounds nothing at all on the child's first request.
+ monkeypatch.setenv("STRIX_INHERIT_CONTEXT_MAX_TOKENS", "40")
+ loader._cached = None
+ try:
+ history = [{"role": "assistant", "content": "x" * 5000}]
+ trimmed = _trim_parent_history(history)
+ finally:
+ loader._cached = None
+
+ assert len(trimmed) == 1
+ assert len(json.dumps(trimmed[0], ensure_ascii=False)) <= 40 * 4
+ assert "truncated to bound token cost" in trimmed[0]["content"]
+
+
+def test_trim_falls_back_to_marker_when_budget_tiny(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("STRIX_INHERIT_CONTEXT_MAX_TOKENS", "1")
+ loader._cached = None
+ try:
+ history = [{"role": "assistant", "content": "x" * 5000}]
+ trimmed = _trim_parent_history(history)
+ finally:
+ loader._cached = None
+
+ assert len(trimmed) == 1
+ assert "x" * 100 not in trimmed[0]["content"]
+
+
def test_child_initial_input_keeps_full_history_when_cap_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
From 0e118a08e8acada5201ae3a869409562ab450c62 Mon Sep 17 00:00:00 2001
From: Hazemwaddah <47379135+Hazemwaddah@users.noreply.github.com>
Date: Sun, 13 Sep 2026 12:13:33 +0300
Subject: [PATCH 3/3] fix(cost): scrub screenshots before measuring inherited
history
_trim_parent_history measured the raw history, but child_initial_input
replaces every input_image block with a short placeholder immediately
afterwards. A single browser screenshot could therefore exhaust the whole
character budget and evict every useful text turn behind it, to make room
for base64 the child never receives.
Scrub first, then trim, so the budget is spent on what is actually sent.
Co-Authored-By: Claude Opus 5 (1M context)
---
strix/core/inputs.py | 7 +++++--
tests/test_inputs.py | 26 ++++++++++++++++++++++++++
2 files changed, 31 insertions(+), 2 deletions(-)
diff --git a/strix/core/inputs.py b/strix/core/inputs.py
index 9a1ea1566..36741a85c 100644
--- a/strix/core/inputs.py
+++ b/strix/core/inputs.py
@@ -414,10 +414,13 @@ def child_initial_input(
user messages.
"""
parts: list[str] = []
- parent_history = _trim_parent_history(parent_history)
+ # Scrub first, then measure: a screenshot's base64 block is replaced by a
+ # short placeholder, so budgeting against the raw block would let one image
+ # evict every useful text turn behind it for size the child never pays.
+ parent_history = _trim_parent_history(scrub_images_from_items(parent_history))
if parent_history:
rendered = json.dumps(
- scrub_images_from_items(parent_history),
+ parent_history,
ensure_ascii=False,
default=str,
)
diff --git a/tests/test_inputs.py b/tests/test_inputs.py
index 3e86084ae..032bd9dca 100644
--- a/tests/test_inputs.py
+++ b/tests/test_inputs.py
@@ -84,6 +84,32 @@ def test_child_initial_input_trims_inherited_history(monkeypatch: pytest.MonkeyP
assert "older inherited context dropped" in content
+def test_screenshot_does_not_evict_text_history(monkeypatch: pytest.MonkeyPatch) -> None:
+ # A screenshot is replaced by a short placeholder before the child ever sees
+ # it, so budgeting against the raw base64 would drop useful text turns to
+ # make room for bytes that are never sent.
+ monkeypatch.setenv("STRIX_INHERIT_CONTEXT_MAX_TOKENS", "200")
+ loader._cached = None
+ try:
+ history = [
+ {"role": "assistant", "content": "earlier finding worth inheriting"},
+ {
+ "role": "user",
+ "content": [
+ {"type": "input_image", "image_url": "data:image/png;base64," + "A" * 20_000}
+ ],
+ },
+ ]
+ result = child_initial_input(**_child_kwargs(history))
+ finally:
+ loader._cached = None
+
+ content = result[0]["content"]
+ assert "earlier finding worth inheriting" in content
+ assert "screenshot omitted from inherited context" in content
+ assert "older inherited context dropped" not in content
+
+
def test_trim_truncates_oversized_newest_item(monkeypatch: pytest.MonkeyPatch) -> None:
# One item, larger than the whole budget: keeping it whole would mean the cap
# bounds nothing at all on the child's first request.