From 449471c33424f529863f982a31ef70fcd97f8e44 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:44:46 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20runtime=20stall=20guards=20=E2=80=94=20?= =?UTF-8?q?identical-call=20loop=20breaker=20and=20continue-intent=20recov?= =?UTF-8?q?ery=20(agent.stall=5Fguards)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composio eval traces showed Hermes wasting turns re-issuing identical tool calls (same tool, same args, same result — 3x/4x in one run) and ending turns by announcing an action it never took. Two conservative, config-gated guards (agent.stall_guards, default true): - Identical-call loop breaker: ToolCallGuardrailController.observe_identical_call tracks the consecutive streak of (tool, canonical args, result-hash); on the 3rd identical call a compact one-line notice is appended to that tool RESULT at construction time (cache-safe — tool results are append-only). Never blocks the call. Pollers (process, *_get_result, *_poll) are exempt via STALL_GUARD_REPEATABLE_TOOLS. Streak resets on any different call, changed result, or new turn. Observed on the raw result before the tool-loop warning suffix so its changing count can't defeat matching. - Said-continue-but-stopped recovery: trailing_continue_intent() detects a short reply ENDING on an announced next action ('Let me now…', 'I will now…', 'Next, I…'); the conversation loop feeds it into the EXISTING intent-ack continuation path (same interim-assistant + user-nudge mechanism, same codex_ack_continuations cap of 2), preserving message alternation — no parallel recovery machinery. Config: agent.stall_guards in DEFAULT_CONFIG; docs in configuration.md; unit tests for streak/allowlist/reset/gate and detector pos/neg cases. --- agent/agent_init.py | 5 + agent/agent_runtime_helpers.py | 31 +++ agent/conversation_loop.py | 26 ++- agent/tool_guardrails.py | 88 +++++++++ hermes_cli/config_defaults.py | 9 + run_agent.py | 20 ++ tests/agent/test_stall_guards.py | 229 +++++++++++++++++++++++ website/docs/user-guide/configuration.md | 9 + 8 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 tests/agent/test_stall_guards.py diff --git a/agent/agent_init.py b/agent/agent_init.py index 25cef45e0b..4e097d6dfc 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1957,6 +1957,11 @@ def init_agent( # conversation loop's intent-ack block. agent._intent_ack_continuation = _agent_section.get("intent_ack_continuation", "auto") + # Runtime anti-stall guards (identical-call loop-breaker notice on tool + # results + continue-intent extension of the empty-response recovery). + # Single boolean gate, default True. Notice-only — never blocks a call. + agent._stall_guards = bool(_agent_section.get("stall_guards", True)) + # Universal task-completion guidance toggle. Default True. Surfaced # as a separate flag from tool_use_enforcement because the guidance # applies to ALL models, not just the model families enforcement diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index dbbb0d11e9..983c95b5d6 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -3863,6 +3863,37 @@ def looks_like_codex_intermediate_ack( return user_targets_workspace or assistant_targets_workspace +# Conservative "trailing continue-intent" detector for the said-continue-but- +# stopped stall guard (agent.stall_guards). Matches only when the message TAIL +# announces an immediate next action ("Let me now…", "I will now…", +# "Next, I…"), which is the observed stall shape: the model narrates the next +# step and then ends the turn with no tool call. Kept deliberately narrow so +# ordinary answers that merely contain "I will" mid-sentence never trip it. +_TRAILING_CONTINUE_INTENT_RE = re.compile( + r"(?:\blet me now\b|\bi(?:['\u2019])?ll now\b|\bi will now\b" + r"|\bnow i(?:['\u2019]ll| will)\b|\bnext[,:] i\b)" + r"[^.!?\n]{0,100}[.:\u2026]?\s*$", + re.IGNORECASE, +) + +# Content longer than this is a substantive reply, not a dangling ack. +_TRAILING_CONTINUE_INTENT_MAX_CHARS = 400 + + +def trailing_continue_intent(text: str) -> bool: + """Whether ``text`` is a short reply ENDING on an announced next action. + + Used by the stall-guard extension of the intent-ack continuation path in + ``agent.conversation_loop``: when a turn is about to end with this shape + (no tool calls, short content, trailing intent), the loop re-prompts via + the existing bounded continuation mechanism instead of stopping. + """ + t = (text or "").strip() + if not t or len(t) > _TRAILING_CONTINUE_INTENT_MAX_CHARS: + return False + return bool(_TRAILING_CONTINUE_INTENT_RE.search(t[-160:])) + + def intent_ack_continuation_mode(agent) -> str: """Classify the resolved intent-ack continuation mode for this turn. diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index e15a7d3c90..e0250124ad 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -7931,10 +7931,28 @@ def run_conversation( from agent.agent_runtime_helpers import ( intent_ack_continuation_mode, + trailing_continue_intent, ) _ack_mode = intent_ack_continuation_mode(agent) - if ( + # Said-continue-but-stopped guard (agent.stall_guards): the + # model ended the turn with no tool calls but its short reply + # TAILS with an announced next action ("Let me now…", + # "I will now…"). Unlike the intent-ack detector below, this + # fires mid-task too (after tool results), which is exactly + # where eval traces show the stall. It reuses the SAME bounded + # continuation path and counter (max 2 per turn), so the + # alternation-safe interim-assistant + user-nudge mechanism — + # not a new parallel one — carries the recovery. + _stall_continue_intent = ( + bool(getattr(agent, "_stall_guards", True)) + and agent.valid_tool_names + and codex_ack_continuations < 2 + and trailing_continue_intent( + agent._strip_think_blocks(final_response or "") + ) + ) + if _stall_continue_intent or ( _ack_mode != "off" and agent.valid_tool_names and codex_ack_continuations < 2 @@ -7945,6 +7963,12 @@ def run_conversation( require_workspace=(_ack_mode == "codex_only"), ) ): + if _stall_continue_intent: + logger.info( + "Stall guard: turn ending on trailing continue-" + "intent with no tool calls — re-prompting to act " + "(%d/2)", codex_ack_continuations + 1, + ) codex_ack_continuations += 1 interim_msg = agent._build_assistant_message(assistant_message, "incomplete") append_message(messages, interim_msg) diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py index 6e6a9cd8f5..b319046335 100644 --- a/agent/tool_guardrails.py +++ b/agent/tool_guardrails.py @@ -59,6 +59,39 @@ MUTATING_TOOL_NAMES = frozenset( } ) +# Tools that are legitimately re-invoked with identical arguments and may +# legitimately return an unchanged result while waiting on external progress — +# background-process management and job pollers. The identical-call loop +# notice (agent.stall_guards) never fires for these, so polling patterns like +# ``process(action="poll")`` or repeatedly checking a generation job stay +# unannotated. +STALL_GUARD_REPEATABLE_TOOLS = frozenset( + { + "process", + "bfl_flux3_get_result", + } +) + +# Poller naming conventions (e.g. ``_get_result``) used by generated / +# MCP tool surfaces. Matched as suffixes so vendor-prefixed pollers are exempt +# without enumerating every vendor. +_STALL_GUARD_REPEATABLE_SUFFIXES = ( + "_get_result", + "_poll", +) + +# The notice fires on the Nth consecutive identical call (same tool, same +# canonical args, same result). 3 tolerates one legitimate double-check while +# catching the observed re-issue loops (3x/4x identical calls in eval traces). +STALL_GUARD_IDENTICAL_CALL_THRESHOLD = 3 + + +def is_stall_guard_repeatable(tool_name: str) -> bool: + """Whether a tool is exempt from the identical-call loop notice.""" + if tool_name in STALL_GUARD_REPEATABLE_TOOLS: + return True + return tool_name.endswith(_STALL_GUARD_REPEATABLE_SUFFIXES) + @dataclass(frozen=True) class ToolCallGuardrailConfig: @@ -282,6 +315,14 @@ class ToolCallGuardrailController: self._same_tool_failure_counts: dict[str, int] = {} self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {} self._halt_decision: ToolGuardrailDecision | None = None + # Identical-call loop-breaker state (agent.stall_guards): tracks the + # CONSECUTIVE streak of identical (tool, canonical args) calls whose + # results were also identical. Any different call — or a different + # result — resets the streak, so legitimate re-reads after edits and + # varied polling are never flagged. Per-turn, like everything else here. + self._identical_streak_sig: ToolCallSignature | None = None + self._identical_streak_result_hash: str = "" + self._identical_streak_count: int = 0 # 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. @@ -444,6 +485,53 @@ class ToolCallGuardrailController: return False return tool_name in self.config.idempotent_tools + def observe_identical_call( + self, + tool_name: str, + args: Mapping[str, Any] | None, + result: str | None, + ) -> str | None: + """Track consecutive identical calls; return a loop-breaker notice or None. + + Fires the compact notice when the SAME tool is called with identical + canonical arguments AND returns an identical result for the + ``STALL_GUARD_IDENTICAL_CALL_THRESHOLD``-th (and every subsequent) + consecutive time within the turn. Purely observational — never blocks + the call. Allowlisted pollers (``is_stall_guard_repeatable``) are + exempt, and any intervening different call or changed result resets + the streak. Callers append the returned notice to the tool RESULT at + construction time, which is cache-safe: tool results are append-only + and never mutate already-sent context. + """ + if is_stall_guard_repeatable(tool_name): + # Don't let a poller streak carry over into the next tool either. + self._identical_streak_sig = None + self._identical_streak_count = 0 + return None + + signature = ToolCallSignature.from_call(tool_name, _coerce_args(args)) + result_hash = _result_hash(result) + if ( + self._identical_streak_sig == signature + and self._identical_streak_result_hash == result_hash + ): + self._identical_streak_count += 1 + else: + self._identical_streak_sig = signature + self._identical_streak_result_hash = result_hash + self._identical_streak_count = 1 + + count = self._identical_streak_count + if count < STALL_GUARD_IDENTICAL_CALL_THRESHOLD: + return None + ordinal = f"{count}{'th' if 11 <= count % 100 <= 13 else {1: 'st', 2: 'nd', 3: 'rd'}.get(count % 10, 'th')}" + return ( + f"[hermes note: this is the {ordinal} consecutive identical call to " + f"{tool_name} with identical arguments returning the same result. " + "Do not repeat it — change arguments, use a different tool, or " + "proceed with what you have.]" + ) + def _check_loop_cap( self, tool_name: str, diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 6c287725b8..290ca4d694 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -171,6 +171,15 @@ DEFAULT_CONFIG = { # api_modes — fixes the Gemini/Claude "stops after stating intent" case), # false (never), or a list of model-name substrings to match. "intent_ack_continuation": "auto", + # Runtime anti-stall guards. When True (default), two conservative + # guards run: (1) an identical-call loop breaker that appends a short + # notice to the tool result when the same tool is called 3+ consecutive + # times with identical arguments AND identical results (never blocks; + # pollers like `process` are exempt), and (2) a continue-intent + # extension of the empty-response recovery that re-prompts once when + # the model ends its turn saying it will continue but takes no action. + # Set False to disable both. + "stall_guards": True, # Universal "finish the job" guidance — short prompt block applied to # all models that targets two cross-family failure modes: (1) stopping # after a stub instead of finishing the artifact, (2) fabricating diff --git a/run_agent.py b/run_agent.py index e8161e5f21..007726c463 100644 --- a/run_agent.py +++ b/run_agent.py @@ -8226,12 +8226,32 @@ class AIAgent: function_result, failed=failed, ) + # Identical-call loop breaker (agent.stall_guards): notice-only, no + # blocking. Observed on the RAW result (before the loop-warning suffix + # below, whose embedded count changes per call and would defeat + # result-identity matching). Appended here — at result construction, + # before the tool message is built — so it is cache-safe (tool results + # are append-only; nothing already sent to the provider is mutated). + stall_notice = None + if self._stall_guards_enabled(): + try: + stall_notice = self._tool_guardrails.observe_identical_call( + tool_name, function_args, function_result, + ) + except Exception as exc: + logger.debug("stall-guard identical-call observation failed: %s", exc) if decision.action in {"warn", "halt"}: function_result = append_toolguard_guidance(function_result, decision) if decision.should_halt: self._set_tool_guardrail_halt(decision) + if stall_notice: + function_result = (function_result or "") + "\n\n" + stall_notice return function_result + def _stall_guards_enabled(self) -> bool: + """Config gate for the runtime anti-stall guards (agent.stall_guards).""" + return bool(getattr(self, "_stall_guards", True)) + def _guardrail_block_result(self, decision: ToolGuardrailDecision) -> str: self._set_tool_guardrail_halt(decision) return toolguard_synthetic_result(decision) diff --git a/tests/agent/test_stall_guards.py b/tests/agent/test_stall_guards.py new file mode 100644 index 0000000000..33267362c0 --- /dev/null +++ b/tests/agent/test_stall_guards.py @@ -0,0 +1,229 @@ +"""Runtime anti-stall guards (agent.stall_guards). + +Two guards, both notice/re-prompt-only: + +1. Identical-call loop breaker — ``ToolCallGuardrailController.observe_identical_call`` + appends a compact notice to the tool RESULT on the 3rd consecutive call + with identical (tool, canonical args) AND an identical result. It never + blocks execution, exempts legitimately-repeatable pollers, and resets on + any change of args, tool, or result — and per turn. + +2. Said-continue-but-stopped detector — ``trailing_continue_intent`` flags a + short reply that ENDS on an announced next action, feeding the existing + bounded intent-ack continuation path (no new recovery machinery). + +These assert behavior contracts, not message snapshots. +""" + +from agent.agent_runtime_helpers import trailing_continue_intent +from agent.tool_guardrails import ( + STALL_GUARD_IDENTICAL_CALL_THRESHOLD, + STALL_GUARD_REPEATABLE_TOOLS, + ToolCallGuardrailController, + is_stall_guard_repeatable, +) + + +def _observe_n(controller, n, tool="web_search", args=None, result="same result"): + notices = [] + for _ in range(n): + notices.append( + controller.observe_identical_call(tool, args or {"query": "x"}, result) + ) + return notices + + +# ── identical-call loop breaker ──────────────────────────────────────────── + + +def test_fires_on_third_consecutive_identical_call_and_result(): + c = ToolCallGuardrailController() + notices = _observe_n(c, 3) + assert notices[0] is None + assert notices[1] is None + assert notices[2] is not None + assert "hermes note" in notices[2] + assert "3rd" in notices[2] + assert "web_search" in notices[2] + + +def test_keeps_firing_past_threshold(): + c = ToolCallGuardrailController() + notices = _observe_n(c, 4) + assert notices[3] is not None + assert "4th" in notices[3] + + +def test_does_not_fire_when_arguments_differ(): + c = ToolCallGuardrailController() + for i in range(5): + notice = c.observe_identical_call( + "web_search", {"query": f"q{i}"}, "same result" + ) + assert notice is None + + +def test_does_not_fire_when_results_differ(): + c = ToolCallGuardrailController() + for i in range(5): + notice = c.observe_identical_call( + "terminal", {"command": "poll-status"}, f"output {i}" + ) + assert notice is None + + +def test_streak_resets_when_a_different_call_intervenes(): + c = ToolCallGuardrailController() + assert _observe_n(c, 2)[-1] is None + # Different tool breaks the consecutive streak. + assert c.observe_identical_call("read_file", {"path": "/a"}, "data") is None + # Two more of the original are a fresh streak of 2 — still no notice. + assert all(n is None for n in _observe_n(c, 2)) + + +def test_arg_canonicalization_ignores_key_order(): + c = ToolCallGuardrailController() + r = "same" + assert c.observe_identical_call("t", {"a": 1, "b": 2}, r) is None + assert c.observe_identical_call("t", {"b": 2, "a": 1}, r) is None + assert c.observe_identical_call("t", {"a": 1, "b": 2}, r) is not None + + +def test_allowlisted_pollers_never_fire(): + c = ToolCallGuardrailController() + for tool in ("process", "bfl_flux3_get_result", "vendor_get_result", "job_poll"): + for _ in range(STALL_GUARD_IDENTICAL_CALL_THRESHOLD + 2): + assert c.observe_identical_call(tool, {"id": "j1"}, "Generating") is None + + +def test_allowlist_membership_contract(): + # The module constant drives the exemption; suffix conventions extend it. + for tool in STALL_GUARD_REPEATABLE_TOOLS: + assert is_stall_guard_repeatable(tool) + assert is_stall_guard_repeatable("acme_get_result") + assert not is_stall_guard_repeatable("web_search") + assert not is_stall_guard_repeatable("terminal") + + +def test_resets_per_turn(): + c = ToolCallGuardrailController() + assert _observe_n(c, 2)[-1] is None + c.reset_for_turn() + # Streak restarted: two more identical calls still under threshold. + assert all(n is None for n in _observe_n(c, 2)) + # Third after the reset fires. + assert _observe_n(c, 1)[-1] is not None + + +def test_never_blocks_execution(): + # The guard is observational only: before_call still allows the call even + # after the notice fires repeatedly. + c = ToolCallGuardrailController() + _observe_n(c, 6) + decision = c.before_call("web_search", {"query": "x"}) + assert decision.allows_execution + + +# ── result-append integration (AIAgent._append_guardrail_observation) ───── + + +def _fake_agent(stall_guards=True): + from types import SimpleNamespace + + from run_agent import AIAgent + + agent = SimpleNamespace( + _tool_guardrails=ToolCallGuardrailController(), + _stall_guards=stall_guards, + _tool_guardrail_halt_decision=None, + ) + agent._stall_guards_enabled = lambda: AIAgent._stall_guards_enabled(agent) + agent._set_tool_guardrail_halt = ( + lambda decision: AIAgent._set_tool_guardrail_halt(agent, decision) + ) + agent._append = ( + lambda name, args, result: AIAgent._append_guardrail_observation( + agent, name, args, result, failed=False + ) + ) + return agent + + +def test_notice_appended_to_third_identical_result(): + agent = _fake_agent() + args = {"query": "hermes"} + r1 = agent._append("web_search", args, "results") + r2 = agent._append("web_search", args, "results") + r3 = agent._append("web_search", args, "results") + assert "hermes note" not in r1 + assert "hermes note" not in r2 + assert "hermes note" in r3 + assert r3.startswith("results") # notice appended, result preserved + + +def test_config_gate_disables_notice(): + agent = _fake_agent(stall_guards=False) + args = {"query": "hermes"} + for _ in range(4): + result = agent._append("web_search", args, "results") + assert "hermes note" not in result + + +def test_notice_streak_keys_on_raw_result_not_annotated_result(): + # The idempotent-no-progress warning suffix (whose count changes per call) + # must not defeat result-identity matching: web_search is not in the + # idempotent set by default, but read_file is — its results gain a + # changing "[Tool loop warning: ...count=N...]" suffix from after_call, + # and the streak must still be recognized from the raw result. + agent = _fake_agent() + args = {"path": "/tmp/x"} + outs = [agent._append("read_file", args, "contents") for _ in range(3)] + assert "hermes note" in outs[2] + + +# ── said-continue-but-stopped detector ───────────────────────────────────── + + +def test_detects_trailing_let_me_now(): + assert trailing_continue_intent("Found the config file. Let me now update it.") + + +def test_detects_trailing_i_will_now(): + assert trailing_continue_intent("The tests pass. I will now push the branch.") + assert trailing_continue_intent("Good. I'll now run the linter") + + +def test_detects_trailing_next_i(): + assert trailing_continue_intent("Done with step one. Next, I check the logs") + assert trailing_continue_intent("Step one complete. Next: I run the tests") + + +def test_ignores_intent_followed_by_more_content(): + # Intent phrase mid-message with substantive content after it — the model + # already continued; nothing dangling. + assert not trailing_continue_intent( + "I will now explain the tradeoffs. First, caching: the design keeps " + "the prefix stable. Second, alternation: roles must strictly alternate." + ) + + +def test_ignores_long_substantive_replies(): + long_reply = ("Here is the full analysis. " * 30) + "Let me now summarize." + assert not trailing_continue_intent(long_reply) + + +def test_ignores_plain_final_answers(): + assert not trailing_continue_intent("The answer is 42.") + assert not trailing_continue_intent("All tests pass and the branch is pushed.") + assert not trailing_continue_intent("") + assert not trailing_continue_intent(None) + + +def test_ignores_conversational_future_offers(): + # "I will" without the immediate-action shape must not trip the guard. + assert not trailing_continue_intent( + "I can help with that tomorrow if you'd like." + ) + assert not trailing_continue_intent( + "If you want, I will happily review the PR once CI is green. Just say so!" + ) diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 4417f12df4..e937e2d59d 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1743,6 +1743,15 @@ A single `delegate_task` batch counts each task toward `max_subagents` (a batch This mirrors Claude Code's per-session WebSearch and subagent caps (v2.1.212), which also default to 200 and reset on `/clear`. +### Runtime anti-stall guards + +Complementing the failure-based guardrails above, `agent.stall_guards` (default `true`) enables two conservative runtime guards against wasted turns. First, an **identical-call loop breaker**: when the same tool is called 3+ consecutive times with identical arguments *and* returns an identical result, a short one-line notice is appended to that tool result telling the model not to repeat the call — it never blocks the call, and legitimately-repeatable pollers (`process`, `*_get_result`, `*_poll`) are exempt. Second, a **continue-intent recovery**: when the model ends a turn with no tool calls but its short reply trails off announcing an action ("Let me now update the file…"), Hermes re-prompts it to act via the same bounded continuation mechanism used for intent-ack recovery (max 2 re-prompts per turn). Both are cache-safe (notices are added at result construction, never retroactively) and can be disabled together: + +```yaml +agent: + stall_guards: false +``` + ## TTS Configuration ```yaml