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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user