feat(cost): opt-in agent fan-out caps, context trim, --reasoning-effort

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.
This commit is contained in:
Hazemwaddah 2026-09-01 13:50:09 +03:00
parent 1df67c52e2
commit ec0131d43f
9 changed files with 292 additions and 2 deletions

View file

@ -36,7 +36,26 @@ Configure Strix using environment variables or a config file.
</ParamField>
<ParamField path="STRIX_REASONING_EFFORT" default="high" type="string">
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.
</ParamField>
### 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.**
<ParamField path="STRIX_MAX_AGENTS" default="0" type="integer">
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.
</ParamField>
<ParamField path="STRIX_MAX_AGENT_DEPTH" default="0" type="integer">
Maximum spawn depth. The root is depth 1; a child of the root is depth 2. `0` = unlimited.
</ParamField>
<ParamField path="STRIX_INHERIT_CONTEXT_MAX_TOKENS" default="0" type="integer">
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.
</ParamField>
<ParamField path="STRIX_MEMORY_COMPRESSOR_TIMEOUT" default="30" type="integer">

View file

@ -48,6 +48,13 @@ strix (--target <target> | --target-list <path>) [options]
Scan depth: `quick`, `standard`, or `deep`.
</ParamField>
<ParamField path="--reasoning-effort" type="string">
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`).
</ParamField>
<ParamField path="--scope-mode" type="string" default="auto">
Code scope mode: `auto` (enable PR diff-scope in CI/headless runs), `diff` (force changed-files scope), or `full` (disable diff-scope).
</ParamField>

View file

@ -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)

View file

@ -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]]:

View file

@ -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),

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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")