190 wasted calls and no guard said a word


You ask the agent to spin up 10 background subagents and summarise when they are done. Instead of ending the turn and waiting for the notification, it keeps the turn alive with one no-op after another — execute_code calls that print something and change nothing. 84 minutes later you abort by hand. Reading the log afterwards, there is not a single “you appear to be repeating yourself” notice. The guard was silent the whole way.

That is not a model being dumb. It is a loop detector that could not see this particular kind of spinning. Issue #124072 quantified the whole incident, and PR #124724, merged 2026-09-27, fixed the key part of it.

The incident ledger: 84 minutes, 190 calls

The numbers in the issue come from the session’s own records (the loop window, 22:31:05–23:20:12):

Metric Value
Main-model API calls 190
Input tokens (incl. cached) 31,365,131 (of which 31,272,448 were cache reads)
Output tokens 25,896
Sum of per-call latency 1,869.8 s — 63% of that 49-minute window
Auxiliary approval-model calls 193 (roughly one per wasted iteration)

The whole session (22:09–23:33) is even clearer on the main row: 267 calls, 40,377,600 cache-read tokens, 73,059 output tokens. In other words, 71% of the main-model calls were spent inside the wait window, and the guard never fired once.

Why loop detection stayed quiet

Hermes’ repeated-call guard keys on a triple: (tool name, arguments, result hash). Identical calls have to line up as a streak before the “Nth identical call” warning (or hard stop) can happen.

The problem was the result hash itself:

  • execute_code results carry two per-call bookkeeping fields that change every single time: duration_seconds (wall-clock) and kernel.execution_count (a monotonic kernel cell counter).
  • Byte-identical replays therefore hashed differently every time, and the identical-call streak stayed at 1.
  • The second half of the problem is the sharper one: after_call() returns early for mutating tools, and execute_code is one — so for this tool only that one guard path can ever fire. Together: zero notices. The code comment records “a model re-ran one empty execute_code call 147 times unflagged”; the PR body records zero notices across 186 calls in the incident.

The fix: drop exactly those two fields, for execute_code only

The patch is deliberately narrow:

def _without_execute_code_metadata(parsed):
    if not isinstance(parsed, dict):
        return parsed
    cleaned = {k: v for k, v in parsed.items() if k != "duration_seconds"}
    kernel = cleaned.get("kernel")
    if isinstance(kernel, dict):
        cleaned["kernel"] = {k: v for k, v in kernel.items() if k != "execution_count"}
    return cleaned

The comment explains the scope: for any other tool those same key names can be real output, so touching them would corrupt “identical” comparisons; non-JSON results hash exactly as before. With the change, replay detection works again — a notice appears from the 3rd identical call onward (the exact thresholds are configurable, below).

Worth being precise: issue #124072 lists four defects. This PR fixes one of them — volatile fields blinding the replay detectors. The other three, including the mechanics of waiting on background subagents, are out of scope here.

The knobs you already have

These behaviours are configurable, and the defaults differ between interactive and unattended runs:

# ~/.hermes/config.yaml
tool_loop_guardrails:
  warnings_enabled: true
  hard_stop_enabled: false                  # interactive sessions warn by default
  non_interactive_hard_stop_enabled: true   # gateway / cron stop hard by default
  warn_after:
    exact_failure: 2
    same_tool_failure: 3
    idempotent_no_progress: 2
  hard_stop_after:
    exact_failure: 5
    same_tool_failure: 8
    idempotent_no_progress: 5
  loop_caps:
    max_web_searches: 50    # web_search calls per turn
    max_subagents: 50       # subagents spawned per turn

The reasoning is easy to follow: nobody can press /stop on an unattended run, so those platforms hard-stop by default, while interactive sessions get a warning first so the model can rescue itself. loop_caps are per-turn ceilings that reset every turn and apply regardless of the thresholds above — dozens of searches or subagents in a single turn was already pathological.

To inspect the effective values, hermes config get tool_loop_guardrails works; the subcommands are show|edit|get|set|unset|path|env-path|check|migrate (there is no reload).

What this means if you run long tasks

  • Do not wait by spinning. Let the turn end and come back on the background notification. It is dramatically cheaper than keeping a turn alive with empty probes — this one burned 31M cache-read tokens.
  • The loop costs more than one model. Those 193 auxiliary approval-model calls line up almost one-for-one with the wasted iterations; you pay twice.
  • Hard stops are not a safety net for everything. Even working correctly, this guard only catches identical calls. A model that rephrases each empty call escapes it, and the per-turn ceilings are all that remain. For the adjacent machinery, see live subagent control and what happens when the turn cap is lifted.
  • The full meaning of these keys is in our hermes config reference.

Release status

PR #124724 merged on 2026-09-27, after the v0.21.5 release tag (v2026.9.24), so it is on main only: hermes update will pick it up.

The takeaway

A timestamp that changes on every call is normally just noise. It only becomes expensive when it is used to decide whether this call was the same as the last one — at which point the guard goes blind across the whole tool. The fix does not add another guard; it makes the hash look only at the part of the result that actually represents what happened. A few dozen lines, in exchange for stopping most of those 190 calls much earlier.