From 497de467900b63603a580c90e63a3629f236f69e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:08:52 -0700 Subject: [PATCH 1/2] Inspired by Claude Code: session-wide runaway-loop caps for web_search and delegate_task Add per-session lifetime caps on web_search calls and subagent spawns (defaults 200/200, matching Claude Code v2.1.212). Unlike the existing per-turn tool-loop guardrails, these count over the whole session and reset only when a fresh agent is built (/new, /clear). Hitting a cap blocks the offending call and halts the turn cleanly. - agent/tool_guardrails.py: SessionCapConfig + session counters on the controller (in __init__, not reset_for_turn, so they persist across turns). before_call() enforces caps first, independent of hard_stop_enabled. delegate_task batches count each task. - hermes_cli/config.py: tool_loop_guardrails.session_caps defaults. - docs + tests (unit + E2E validated against a real AIAgent). --- agent/tool_guardrails.py | 150 +++++++++++++++++++++++ hermes_cli/config.py | 11 ++ tests/agent/test_tool_guardrails.py | 94 ++++++++++++++ website/docs/user-guide/configuration.md | 11 ++ 4 files changed, 266 insertions(+) diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py index f08f1b604786..bb77eec17d53 100644 --- a/agent/tool_guardrails.py +++ b/agent/tool_guardrails.py @@ -79,6 +79,7 @@ class ToolCallGuardrailConfig: no_progress_block_after: int = 5 idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES) mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES) + session_caps: "SessionCapConfig" = field(default_factory=lambda: SessionCapConfig()) @classmethod def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfig": @@ -121,6 +122,51 @@ def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfi hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")), defaults.no_progress_block_after, ), + session_caps=SessionCapConfig.from_mapping(data.get("session_caps")), + ) + + +# Default session-wide caps, matching Claude Code's v2.1.212 runaway-loop +# limits (CLAUDE_CODE_MAX_WEB_SEARCHES_PER_SESSION / +# CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION, both default 200, reset on /clear). +_DEFAULT_MAX_WEB_SEARCHES_PER_SESSION = 200 +_DEFAULT_MAX_SUBAGENTS_PER_SESSION = 200 + + +@dataclass(frozen=True) +class SessionCapConfig: + """Session-wide lifetime caps on runaway-prone tool calls. + + Inspired by Claude Code v2.1.212 (Week 29, July 2026), which added a + per-session cap on WebSearch calls and subagent spawns to stop runaway + search / delegation loops. These caps count over the whole session (not + per turn like the loop-detection guardrails above) and reset only when a + fresh agent is created — i.e. on ``/new`` or ``/clear`` — because the + counters live on the ``AIAgent`` instance, not in ``reset_for_turn``. + + Semantics differ from the per-turn loop guardrails: session caps are a + hard safety ceiling and fire regardless of ``hard_stop_enabled``. A value + of ``0`` disables the cap (unlimited). Defaults match Claude Code (200); + a legitimate session almost never issues 200 web searches or spawns 200 + subagents, so hitting the cap is a strong runaway-loop signal. + """ + + max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_SESSION + max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_SESSION + + @classmethod + def from_mapping(cls, data: Mapping[str, Any] | None) -> "SessionCapConfig": + """Build config from the ``tool_loop_guardrails.session_caps`` section.""" + if not isinstance(data, Mapping): + return cls() + defaults = cls() + return cls( + max_web_searches=_non_negative_int( + data.get("max_web_searches"), defaults.max_web_searches + ), + max_subagents=_non_negative_int( + data.get("max_subagents"), defaults.max_subagents + ), ) @@ -226,6 +272,12 @@ class ToolCallGuardrailController: def __init__(self, config: ToolCallGuardrailConfig | None = None): self.config = config or ToolCallGuardrailConfig() + # Session-wide counters persist for the life of this controller (i.e. + # the life of the AIAgent instance). They are deliberately NOT cleared + # in reset_for_turn — a runaway loop that spans many turns must still be + # caught. They reset only when a fresh agent is built (/new, /clear). + self._session_web_search_count = 0 + self._session_subagent_count = 0 self.reset_for_turn() def reset_for_turn(self) -> None: @@ -240,6 +292,16 @@ def halt_decision(self) -> ToolGuardrailDecision | None: def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision: signature = ToolCallSignature.from_call(tool_name, _coerce_args(args)) + + # ── Session-wide runaway-loop caps ────────────────────────────── + # These are hard safety ceilings that apply regardless of + # hard_stop_enabled (which only governs the per-turn loop detector). + # We block BEFORE the call runs once the count is already at the cap, + # then increment for an allowed call so the (cap+1)-th is refused. + cap_block = self._check_session_cap(tool_name, _coerce_args(args), signature) + if cap_block is not None: + return cap_block + if not self.config.hard_stop_enabled: return ToolGuardrailDecision(tool_name=tool_name, signature=signature) @@ -379,6 +441,68 @@ def _is_idempotent(self, tool_name: str) -> bool: return False return tool_name in self.config.idempotent_tools + def _check_session_cap( + self, + tool_name: str, + args: Mapping[str, Any], + signature: ToolCallSignature, + ) -> ToolGuardrailDecision | None: + """Enforce and advance the session-wide runaway-loop counters. + + Returns a ``block`` decision when the cap is already reached, otherwise + increments the relevant counter for the allowed call and returns + ``None``. A cap of 0 disables that limit entirely. + """ + caps = self.config.session_caps + + if tool_name == "web_search": + cap = caps.max_web_searches + if cap and self._session_web_search_count >= cap: + decision = ToolGuardrailDecision( + action="block", + code="session_web_search_cap", + message=( + f"Blocked web_search: this session has already made {cap} " + "web searches, the per-session limit. This looks like a " + "runaway search loop. Work with the results you already " + "have, or start a fresh session (/new) to reset the budget." + ), + tool_name=tool_name, + count=self._session_web_search_count, + signature=signature, + ) + self._halt_decision = decision + return decision + self._session_web_search_count += 1 + return None + + if tool_name == "delegate_task": + cap = caps.max_subagents + if not cap: + return None + spawn_count = _subagent_spawn_count(args) + if self._session_subagent_count >= cap: + decision = ToolGuardrailDecision( + action="block", + code="session_subagent_cap", + message=( + f"Blocked delegate_task: this session has already spawned " + f"{self._session_subagent_count} subagents (limit {cap}). " + "This looks like a runaway delegation loop. Finish the work " + "with the results you have, or start a fresh session (/new) " + "to reset the budget." + ), + tool_name=tool_name, + count=self._session_subagent_count, + signature=signature, + ) + self._halt_decision = decision + return decision + self._session_subagent_count += spawn_count + return None + + return None + def toolguard_synthetic_result(decision: ToolGuardrailDecision) -> str: """Build a synthetic role=tool content string for a blocked tool call.""" @@ -471,6 +595,32 @@ def _positive_int(value: Any, default: int) -> int: return parsed if parsed >= 1 else default +def _non_negative_int(value: Any, default: int) -> int: + """Parse a session-cap value. 0 is a valid (disable) value; negatives and + junk fall back to the default.""" + if value is None: + return default + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= 0 else default + + +def _subagent_spawn_count(args: Mapping[str, Any]) -> int: + """How many subagents a single delegate_task call spawns. + + delegate_task runs in one of two modes: a batch (``tasks`` is a non-empty + list, one child per item) or a single task (``goal``). Count the batch size + when present, otherwise 1, so the session subagent cap reflects real spawns + rather than delegate_task invocations. + """ + tasks = args.get("tasks") if isinstance(args, Mapping) else None + if isinstance(tasks, list) and tasks: + return len(tasks) + return 1 + + def _sha256(value: str) -> str: # surrogatepass: tool results scraped from the web can carry unpaired # UTF-16 surrogates (e.g. half of a mathematical-bold pair); a strict diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 6a5f39028ff9..ff32e30c1b6c 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1421,6 +1421,17 @@ def _ensure_hermes_home_managed(home: Path): "same_tool_failure": 8, "idempotent_no_progress": 5, }, + # Session-wide runaway-loop caps (inspired by Claude Code v2.1.212, + # Week 29, July 2026). Unlike the per-turn warn/hard-stop thresholds + # above, these count over the WHOLE session and reset only when a fresh + # session starts (/new or /clear). They are always-on hard ceilings — a + # legitimate session almost never issues 200 web searches or spawns 200 + # subagents, so hitting one is a strong runaway-loop signal. Set either + # to 0 to disable that cap (unlimited). + "session_caps": { + "max_web_searches": 200, # max web_search calls per session (0 = unlimited) + "max_subagents": 200, # max subagents spawned per session (0 = unlimited) + }, }, "compression": { diff --git a/tests/agent/test_tool_guardrails.py b/tests/agent/test_tool_guardrails.py index 35b4e67f37a4..b9a4c2a4a6c7 100644 --- a/tests/agent/test_tool_guardrails.py +++ b/tests/agent/test_tool_guardrails.py @@ -277,3 +277,97 @@ def test_after_call_survives_lone_surrogates_in_result_and_args(): controller.after_call("web_search", {"query": dirty}, '{"error":"\ud835 boom"}', failed=True) controller.after_call("web_search", {"query": dirty}, '{"error":"\ud835 boom"}', failed=True) assert controller.before_call("web_search", {"query": dirty}).action == "block" + + +# ── Session-wide runaway-loop caps (Claude Code v2.1.212, Week 29) ────────── + +from agent.tool_guardrails import SessionCapConfig # noqa: E402 + + +def test_session_cap_defaults_match_claude_code(): + caps = ToolCallGuardrailConfig().session_caps + assert caps.max_web_searches == 200 + assert caps.max_subagents == 200 + + +def test_session_cap_config_parses_nested_section(): + cfg = ToolCallGuardrailConfig.from_mapping( + {"session_caps": {"max_web_searches": 3, "max_subagents": 0}} + ) + assert cfg.session_caps.max_web_searches == 3 + assert cfg.session_caps.max_subagents == 0 + + +def test_session_cap_zero_disables_and_junk_falls_back(): + # 0 is a legitimate "unlimited" value; negatives / junk fall back to default. + assert SessionCapConfig.from_mapping({"max_web_searches": 0}).max_web_searches == 0 + assert SessionCapConfig.from_mapping({"max_web_searches": -5}).max_web_searches == 200 + assert SessionCapConfig.from_mapping({"max_subagents": "nope"}).max_subagents == 200 + + +def test_web_search_cap_blocks_after_limit_regardless_of_hard_stop(): + # Session caps fire even with hard_stop_enabled=False (the per-turn loop + # detector's flag). Each distinct query avoids the loop detector so we know + # the block came from the session cap, not exact-failure repetition. + controller = ToolCallGuardrailController( + ToolCallGuardrailConfig( + hard_stop_enabled=False, + session_caps=SessionCapConfig(max_web_searches=3), + ) + ) + for i in range(3): + assert controller.before_call("web_search", {"query": f"q{i}"}).action == "allow" + decision = controller.before_call("web_search", {"query": "q4"}) + assert decision.action == "block" + assert decision.code == "session_web_search_cap" + assert decision.should_halt is True + + +def test_web_search_cap_persists_across_turn_resets(): + controller = ToolCallGuardrailController( + ToolCallGuardrailConfig(session_caps=SessionCapConfig(max_web_searches=2)) + ) + assert controller.before_call("web_search", {"query": "a"}).action == "allow" + controller.reset_for_turn() # a per-turn reset must NOT clear the session count + assert controller.before_call("web_search", {"query": "b"}).action == "allow" + controller.reset_for_turn() + assert controller.before_call("web_search", {"query": "c"}).action == "block" + + +def test_subagent_cap_counts_batch_task_spawns(): + # A single delegate_task batch of N tasks spends N of the subagent budget. + controller = ToolCallGuardrailController( + ToolCallGuardrailConfig(session_caps=SessionCapConfig(max_subagents=5)) + ) + # First call spawns 3 (batch) → count 3, allowed. + assert controller.before_call( + "delegate_task", {"tasks": [{"goal": "a"}, {"goal": "b"}, {"goal": "c"}]} + ).action == "allow" + # Second call spawns 1 (goal) → count 4, allowed. + assert controller.before_call("delegate_task", {"goal": "d"}).action == "allow" + # Count is 4 (< 5) so this is allowed and bumps to 5. + assert controller.before_call("delegate_task", {"goal": "e"}).action == "allow" + # Now count is 5 (>= 5) so the next call is blocked. + decision = controller.before_call("delegate_task", {"goal": "f"}) + assert decision.action == "block" + assert decision.code == "session_subagent_cap" + + +def test_session_caps_disabled_when_zero(): + controller = ToolCallGuardrailController( + ToolCallGuardrailConfig( + session_caps=SessionCapConfig(max_web_searches=0, max_subagents=0) + ) + ) + for i in range(50): + assert controller.before_call("web_search", {"query": f"q{i}"}).action == "allow" + assert controller.before_call("delegate_task", {"goal": f"g{i}"}).action == "allow" + + +def test_other_tools_never_touched_by_session_caps(): + controller = ToolCallGuardrailController( + ToolCallGuardrailConfig(session_caps=SessionCapConfig(max_web_searches=1)) + ) + # read_file / terminal / etc. are unaffected regardless of the web cap. + for _ in range(10): + assert controller.before_call("read_file", {"path": "/tmp/x"}).action == "allow" diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index f7b015bba17d..58264cf01741 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1455,10 +1455,21 @@ tool_loop_guardrails: exact_failure: 5 same_tool_failure: 8 idempotent_no_progress: 5 + session_caps: + max_web_searches: 200 # max web_search calls per session (0 = unlimited) + max_subagents: 200 # max subagents spawned per session (0 = unlimited) ``` `hard_stop_enabled` defaults to `false` because interactive sessions have a human in the loop. In unattended deployments (gateway, cron, kanban workers) set it to `true` so repeated failures are blocked rather than only warned. See also [Docker / unattended deployments](docker.md). +### Session-wide runaway-loop caps + +Separate from the per-turn thresholds above, `session_caps` sets hard ceilings on the total number of `web_search` calls and subagent spawns over the **whole session** (not per turn). These are always on — a legitimate session almost never issues 200 web searches or spawns 200 subagents, so hitting a cap is a strong runaway-loop signal. When a cap is reached, the offending tool call is blocked with an explanatory message and the turn stops cleanly instead of burning the rest of the budget. The counters reset only when a fresh session starts (`/new` or `/clear`). Set either value to `0` to disable that cap entirely. + +A single `delegate_task` batch counts each task toward `max_subagents` (a batch of 3 spends 3), so the cap tracks real subagents spawned rather than `delegate_task` invocations. + +This mirrors Claude Code's per-session WebSearch and subagent caps (v2.1.212), which also default to 200 and reset on `/clear`. + ## TTS Configuration ```yaml From d2f5bd5bf316ce632a8d1d8ed8de2a9bc27fa5e4 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:57:58 -0700 Subject: [PATCH 2/2] refactor(guardrails): make runaway-loop caps per-turn, not session-total Per Teknium: the caps should bound a single agent loop, not accumulate over the whole session. Rename SessionCapConfig -> LoopCapConfig and the config section session_caps -> loop_caps; move the counters into reset_for_turn (invoked per turn via turn_context) so each turn starts with a fresh budget; retune defaults 200 -> 50 (a single turn issuing 50 web searches / spawning 50 subagents is already pathological). Block codes session_*_cap -> loop_*_cap and messages updated to drop the /new-resets-the-budget guidance (irrelevant now that it resets per turn). Tests flipped: the old persists-across-turn-resets assertion becomes resets-each-turn. --- agent/tool_guardrails.py | 113 ++++++++++++----------- hermes_cli/config.py | 21 +++-- tests/agent/test_tool_guardrails.py | 74 +++++++++------ website/docs/user-guide/configuration.md | 10 +- 4 files changed, 119 insertions(+), 99 deletions(-) diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py index bb77eec17d53..444ce3739596 100644 --- a/agent/tool_guardrails.py +++ b/agent/tool_guardrails.py @@ -79,7 +79,7 @@ class ToolCallGuardrailConfig: no_progress_block_after: int = 5 idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES) mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES) - session_caps: "SessionCapConfig" = field(default_factory=lambda: SessionCapConfig()) + loop_caps: "LoopCapConfig" = field(default_factory=lambda: LoopCapConfig()) @classmethod def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfig": @@ -122,41 +122,44 @@ def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfi hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")), defaults.no_progress_block_after, ), - session_caps=SessionCapConfig.from_mapping(data.get("session_caps")), + loop_caps=LoopCapConfig.from_mapping(data.get("loop_caps")), ) # Default session-wide caps, matching Claude Code's v2.1.212 runaway-loop -# limits (CLAUDE_CODE_MAX_WEB_SEARCHES_PER_SESSION / -# CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION, both default 200, reset on /clear). -_DEFAULT_MAX_WEB_SEARCHES_PER_SESSION = 200 -_DEFAULT_MAX_SUBAGENTS_PER_SESSION = 200 +# Per-turn (per-agent-loop) caps on runaway-prone tool calls. Counts reset at +# the start of every agent loop (reset_for_turn), so the limit is "within a +# single turn" rather than cumulative over the whole session. A single loop +# issuing dozens of web searches or spawning dozens of subagents is already +# pathological, so the defaults are deliberately low. +_DEFAULT_MAX_WEB_SEARCHES_PER_TURN = 50 +_DEFAULT_MAX_SUBAGENTS_PER_TURN = 50 @dataclass(frozen=True) -class SessionCapConfig: - """Session-wide lifetime caps on runaway-prone tool calls. - - Inspired by Claude Code v2.1.212 (Week 29, July 2026), which added a - per-session cap on WebSearch calls and subagent spawns to stop runaway - search / delegation loops. These caps count over the whole session (not - per turn like the loop-detection guardrails above) and reset only when a - fresh agent is created — i.e. on ``/new`` or ``/clear`` — because the - counters live on the ``AIAgent`` instance, not in ``reset_for_turn``. - - Semantics differ from the per-turn loop guardrails: session caps are a - hard safety ceiling and fire regardless of ``hard_stop_enabled``. A value - of ``0`` disables the cap (unlimited). Defaults match Claude Code (200); - a legitimate session almost never issues 200 web searches or spawns 200 - subagents, so hitting the cap is a strong runaway-loop signal. +class LoopCapConfig: + """Per-turn caps on runaway-prone tool calls. + + Inspired by Claude Code v2.1.212 (Week 29, July 2026), which added caps on + WebSearch calls and subagent spawns to stop runaway search / delegation + loops. Here the caps count *within a single agent loop* (one turn): the + counters reset in ``reset_for_turn`` at the start of every + ``run_conversation``, so a legitimate multi-turn session is never starved, + but a single turn that spirals into an unbounded search / delegation loop + is stopped. + + Semantics differ from the per-turn loop *detector* above (which keys on + repeated identical/failing calls): these caps are a hard ceiling on the + total count of a tool within the turn and fire regardless of + ``hard_stop_enabled``. A value of ``0`` disables the cap (unlimited). """ - max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_SESSION - max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_SESSION + max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_TURN + max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_TURN @classmethod - def from_mapping(cls, data: Mapping[str, Any] | None) -> "SessionCapConfig": - """Build config from the ``tool_loop_guardrails.session_caps`` section.""" + def from_mapping(cls, data: Mapping[str, Any] | None) -> "LoopCapConfig": + """Build config from the ``tool_loop_guardrails.loop_caps`` section.""" if not isinstance(data, Mapping): return cls() defaults = cls() @@ -272,12 +275,6 @@ class ToolCallGuardrailController: def __init__(self, config: ToolCallGuardrailConfig | None = None): self.config = config or ToolCallGuardrailConfig() - # Session-wide counters persist for the life of this controller (i.e. - # the life of the AIAgent instance). They are deliberately NOT cleared - # in reset_for_turn — a runaway loop that spans many turns must still be - # caught. They reset only when a fresh agent is built (/new, /clear). - self._session_web_search_count = 0 - self._session_subagent_count = 0 self.reset_for_turn() def reset_for_turn(self) -> None: @@ -285,6 +282,11 @@ def reset_for_turn(self) -> None: self._same_tool_failure_counts: dict[str, int] = {} self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {} self._halt_decision: ToolGuardrailDecision | None = None + # Per-turn runaway-loop cap counters. Reset every turn (this method + # runs at the start of each run_conversation), so the caps bound a + # single agent loop rather than accumulating across the session. + self._turn_web_search_count = 0 + self._turn_subagent_count = 0 @property def halt_decision(self) -> ToolGuardrailDecision | None: @@ -293,12 +295,13 @@ def halt_decision(self) -> ToolGuardrailDecision | None: def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision: signature = ToolCallSignature.from_call(tool_name, _coerce_args(args)) - # ── Session-wide runaway-loop caps ────────────────────────────── - # These are hard safety ceilings that apply regardless of - # hard_stop_enabled (which only governs the per-turn loop detector). + # ── Per-turn runaway-loop caps ────────────────────────────────── + # These are hard ceilings on how many times a runaway-prone tool may + # be called within a single agent loop (turn). They apply regardless + # of hard_stop_enabled (which only governs the per-turn loop detector). # We block BEFORE the call runs once the count is already at the cap, # then increment for an allowed call so the (cap+1)-th is refused. - cap_block = self._check_session_cap(tool_name, _coerce_args(args), signature) + cap_block = self._check_loop_cap(tool_name, _coerce_args(args), signature) if cap_block is not None: return cap_block @@ -441,39 +444,40 @@ def _is_idempotent(self, tool_name: str) -> bool: return False return tool_name in self.config.idempotent_tools - def _check_session_cap( + def _check_loop_cap( self, tool_name: str, args: Mapping[str, Any], signature: ToolCallSignature, ) -> ToolGuardrailDecision | None: - """Enforce and advance the session-wide runaway-loop counters. + """Enforce and advance the per-turn runaway-loop counters. Returns a ``block`` decision when the cap is already reached, otherwise increments the relevant counter for the allowed call and returns - ``None``. A cap of 0 disables that limit entirely. + ``None``. A cap of 0 disables that limit entirely. Counters reset each + turn via ``reset_for_turn``. """ - caps = self.config.session_caps + caps = self.config.loop_caps if tool_name == "web_search": cap = caps.max_web_searches - if cap and self._session_web_search_count >= cap: + if cap and self._turn_web_search_count >= cap: decision = ToolGuardrailDecision( action="block", - code="session_web_search_cap", + code="loop_web_search_cap", message=( - f"Blocked web_search: this session has already made {cap} " - "web searches, the per-session limit. This looks like a " + f"Blocked web_search: this turn has already made {cap} " + "web searches, the per-turn limit. This looks like a " "runaway search loop. Work with the results you already " - "have, or start a fresh session (/new) to reset the budget." + "have and give the user your answer." ), tool_name=tool_name, - count=self._session_web_search_count, + count=self._turn_web_search_count, signature=signature, ) self._halt_decision = decision return decision - self._session_web_search_count += 1 + self._turn_web_search_count += 1 return None if tool_name == "delegate_task": @@ -481,24 +485,23 @@ def _check_session_cap( if not cap: return None spawn_count = _subagent_spawn_count(args) - if self._session_subagent_count >= cap: + if self._turn_subagent_count >= cap: decision = ToolGuardrailDecision( action="block", - code="session_subagent_cap", + code="loop_subagent_cap", message=( - f"Blocked delegate_task: this session has already spawned " - f"{self._session_subagent_count} subagents (limit {cap}). " - "This looks like a runaway delegation loop. Finish the work " - "with the results you have, or start a fresh session (/new) " - "to reset the budget." + f"Blocked delegate_task: this turn has already spawned " + f"{self._turn_subagent_count} subagents (limit {cap}). " + "This looks like a runaway delegation loop. Finish the " + "work with the results you have and answer the user." ), tool_name=tool_name, - count=self._session_subagent_count, + count=self._turn_subagent_count, signature=signature, ) self._halt_decision = decision return decision - self._session_subagent_count += spawn_count + self._turn_subagent_count += spawn_count return None return None diff --git a/hermes_cli/config.py b/hermes_cli/config.py index ff32e30c1b6c..3e8b6e5f07dc 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1421,16 +1421,17 @@ def _ensure_hermes_home_managed(home: Path): "same_tool_failure": 8, "idempotent_no_progress": 5, }, - # Session-wide runaway-loop caps (inspired by Claude Code v2.1.212, - # Week 29, July 2026). Unlike the per-turn warn/hard-stop thresholds - # above, these count over the WHOLE session and reset only when a fresh - # session starts (/new or /clear). They are always-on hard ceilings — a - # legitimate session almost never issues 200 web searches or spawns 200 - # subagents, so hitting one is a strong runaway-loop signal. Set either - # to 0 to disable that cap (unlimited). - "session_caps": { - "max_web_searches": 200, # max web_search calls per session (0 = unlimited) - "max_subagents": 200, # max subagents spawned per session (0 = unlimited) + # Per-turn runaway-loop caps (inspired by Claude Code v2.1.212, + # Week 29, July 2026). Hard ceilings on how many times a runaway-prone + # tool may be called within a SINGLE agent loop (turn); the counters + # reset at the start of every turn, so a legitimate multi-turn session + # is never starved. They are always-on and fire regardless of the + # warn/hard-stop thresholds above. A single turn issuing dozens of web + # searches or spawning dozens of subagents is already pathological, so + # the defaults are low. Set either to 0 to disable that cap (unlimited). + "loop_caps": { + "max_web_searches": 50, # max web_search calls per turn (0 = unlimited) + "max_subagents": 50, # max subagents spawned per turn (0 = unlimited) }, }, diff --git a/tests/agent/test_tool_guardrails.py b/tests/agent/test_tool_guardrails.py index b9a4c2a4a6c7..ecf81e973199 100644 --- a/tests/agent/test_tool_guardrails.py +++ b/tests/agent/test_tool_guardrails.py @@ -279,65 +279,71 @@ def test_after_call_survives_lone_surrogates_in_result_and_args(): assert controller.before_call("web_search", {"query": dirty}).action == "block" -# ── Session-wide runaway-loop caps (Claude Code v2.1.212, Week 29) ────────── +# ── Per-turn runaway-loop caps (Claude Code v2.1.212, Week 29) ────────────── -from agent.tool_guardrails import SessionCapConfig # noqa: E402 +from agent.tool_guardrails import LoopCapConfig # noqa: E402 -def test_session_cap_defaults_match_claude_code(): - caps = ToolCallGuardrailConfig().session_caps - assert caps.max_web_searches == 200 - assert caps.max_subagents == 200 +def test_loop_cap_defaults(): + caps = ToolCallGuardrailConfig().loop_caps + assert caps.max_web_searches == 50 + assert caps.max_subagents == 50 -def test_session_cap_config_parses_nested_section(): +def test_loop_cap_config_parses_nested_section(): cfg = ToolCallGuardrailConfig.from_mapping( - {"session_caps": {"max_web_searches": 3, "max_subagents": 0}} + {"loop_caps": {"max_web_searches": 3, "max_subagents": 0}} ) - assert cfg.session_caps.max_web_searches == 3 - assert cfg.session_caps.max_subagents == 0 + assert cfg.loop_caps.max_web_searches == 3 + assert cfg.loop_caps.max_subagents == 0 -def test_session_cap_zero_disables_and_junk_falls_back(): +def test_loop_cap_zero_disables_and_junk_falls_back(): # 0 is a legitimate "unlimited" value; negatives / junk fall back to default. - assert SessionCapConfig.from_mapping({"max_web_searches": 0}).max_web_searches == 0 - assert SessionCapConfig.from_mapping({"max_web_searches": -5}).max_web_searches == 200 - assert SessionCapConfig.from_mapping({"max_subagents": "nope"}).max_subagents == 200 + assert LoopCapConfig.from_mapping({"max_web_searches": 0}).max_web_searches == 0 + assert LoopCapConfig.from_mapping({"max_web_searches": -5}).max_web_searches == 50 + assert LoopCapConfig.from_mapping({"max_subagents": "nope"}).max_subagents == 50 def test_web_search_cap_blocks_after_limit_regardless_of_hard_stop(): - # Session caps fire even with hard_stop_enabled=False (the per-turn loop + # Loop caps fire even with hard_stop_enabled=False (the per-turn loop # detector's flag). Each distinct query avoids the loop detector so we know - # the block came from the session cap, not exact-failure repetition. + # the block came from the loop cap, not exact-failure repetition. controller = ToolCallGuardrailController( ToolCallGuardrailConfig( hard_stop_enabled=False, - session_caps=SessionCapConfig(max_web_searches=3), + loop_caps=LoopCapConfig(max_web_searches=3), ) ) for i in range(3): assert controller.before_call("web_search", {"query": f"q{i}"}).action == "allow" decision = controller.before_call("web_search", {"query": "q4"}) assert decision.action == "block" - assert decision.code == "session_web_search_cap" + assert decision.code == "loop_web_search_cap" assert decision.should_halt is True -def test_web_search_cap_persists_across_turn_resets(): +def test_web_search_cap_resets_each_turn(): + # The cap bounds a single turn: reset_for_turn clears the counter so a + # legitimate multi-turn session is never starved. controller = ToolCallGuardrailController( - ToolCallGuardrailConfig(session_caps=SessionCapConfig(max_web_searches=2)) + ToolCallGuardrailConfig(loop_caps=LoopCapConfig(max_web_searches=2)) ) + # Turn 1: two searches allowed, the third would block within the turn. assert controller.before_call("web_search", {"query": "a"}).action == "allow" - controller.reset_for_turn() # a per-turn reset must NOT clear the session count assert controller.before_call("web_search", {"query": "b"}).action == "allow" - controller.reset_for_turn() assert controller.before_call("web_search", {"query": "c"}).action == "block" + # New turn: the counter resets, so the budget is fresh again. + controller.reset_for_turn() + assert controller.before_call("web_search", {"query": "d"}).action == "allow" + assert controller.before_call("web_search", {"query": "e"}).action == "allow" + assert controller.before_call("web_search", {"query": "f"}).action == "block" def test_subagent_cap_counts_batch_task_spawns(): # A single delegate_task batch of N tasks spends N of the subagent budget. controller = ToolCallGuardrailController( - ToolCallGuardrailConfig(session_caps=SessionCapConfig(max_subagents=5)) + ToolCallGuardrailConfig(loop_caps=LoopCapConfig(max_subagents=5)) ) # First call spawns 3 (batch) → count 3, allowed. assert controller.before_call( @@ -350,23 +356,33 @@ def test_subagent_cap_counts_batch_task_spawns(): # Now count is 5 (>= 5) so the next call is blocked. decision = controller.before_call("delegate_task", {"goal": "f"}) assert decision.action == "block" - assert decision.code == "session_subagent_cap" + assert decision.code == "loop_subagent_cap" + + +def test_subagent_cap_resets_each_turn(): + controller = ToolCallGuardrailController( + ToolCallGuardrailConfig(loop_caps=LoopCapConfig(max_subagents=1)) + ) + assert controller.before_call("delegate_task", {"goal": "a"}).action == "allow" + assert controller.before_call("delegate_task", {"goal": "b"}).action == "block" + controller.reset_for_turn() + assert controller.before_call("delegate_task", {"goal": "c"}).action == "allow" -def test_session_caps_disabled_when_zero(): +def test_loop_caps_disabled_when_zero(): controller = ToolCallGuardrailController( ToolCallGuardrailConfig( - session_caps=SessionCapConfig(max_web_searches=0, max_subagents=0) + loop_caps=LoopCapConfig(max_web_searches=0, max_subagents=0) ) ) - for i in range(50): + for i in range(60): assert controller.before_call("web_search", {"query": f"q{i}"}).action == "allow" assert controller.before_call("delegate_task", {"goal": f"g{i}"}).action == "allow" -def test_other_tools_never_touched_by_session_caps(): +def test_other_tools_never_touched_by_loop_caps(): controller = ToolCallGuardrailController( - ToolCallGuardrailConfig(session_caps=SessionCapConfig(max_web_searches=1)) + ToolCallGuardrailConfig(loop_caps=LoopCapConfig(max_web_searches=1)) ) # read_file / terminal / etc. are unaffected regardless of the web cap. for _ in range(10): diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 58264cf01741..4a773b30d53a 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1455,16 +1455,16 @@ tool_loop_guardrails: exact_failure: 5 same_tool_failure: 8 idempotent_no_progress: 5 - session_caps: - max_web_searches: 200 # max web_search calls per session (0 = unlimited) - max_subagents: 200 # max subagents spawned per session (0 = unlimited) + loop_caps: + max_web_searches: 50 # max web_search calls per turn (0 = unlimited) + max_subagents: 50 # max subagents spawned per turn (0 = unlimited) ``` `hard_stop_enabled` defaults to `false` because interactive sessions have a human in the loop. In unattended deployments (gateway, cron, kanban workers) set it to `true` so repeated failures are blocked rather than only warned. See also [Docker / unattended deployments](docker.md). -### Session-wide runaway-loop caps +### Per-turn runaway-loop caps -Separate from the per-turn thresholds above, `session_caps` sets hard ceilings on the total number of `web_search` calls and subagent spawns over the **whole session** (not per turn). These are always on — a legitimate session almost never issues 200 web searches or spawns 200 subagents, so hitting a cap is a strong runaway-loop signal. When a cap is reached, the offending tool call is blocked with an explanatory message and the turn stops cleanly instead of burning the rest of the budget. The counters reset only when a fresh session starts (`/new` or `/clear`). Set either value to `0` to disable that cap entirely. +Separate from the failure-based thresholds above, `loop_caps` sets hard ceilings on how many `web_search` calls and subagent spawns a single agent loop (turn) may make. The counters reset at the start of every turn, so a legitimate multi-turn session is never starved — but a single turn that spirals into an unbounded search or delegation loop is stopped. These are always on and fire regardless of `hard_stop_enabled`. A single turn issuing dozens of web searches or spawning dozens of subagents is already pathological, so the defaults are low. When a cap is reached, the offending tool call is blocked with an explanatory message and the turn stops cleanly instead of burning the rest of the budget. Set either value to `0` to disable that cap entirely. A single `delegate_task` batch counts each task toward `max_subagents` (a batch of 3 spends 3), so the cap tracks real subagents spawned rather than `delegate_task` invocations.