feat: runtime stall guards — identical-call loop breaker and continue-intent recovery (agent.stall_guards)

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.
This commit is contained in:
Teknium
2026-08-19 15:44:46 -07:00
parent 803397ecc3
commit 449471c334
8 changed files with 416 additions and 1 deletions
+5
View File
@@ -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
+31
View File
@@ -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.
+25 -1
View File
@@ -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)
+88
View File
@@ -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. ``<vendor>_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,
+9
View File
@@ -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
+20
View File
@@ -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)
+229
View File
@@ -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!"
)
+9
View File
@@ -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