Skip to content

feat(core): interrupt the loop where the model is repeating itself - #269

Open
oratis wants to merge 1 commit into
mainfrom
feat/repeat-tool-guard
Open

feat(core): interrupt the loop where the model is repeating itself#269
oratis wants to merge 1 commit into
mainfrom
feat/repeat-tool-guard

Conversation

@oratis

@oratis oratis commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Second implementation PR from docs/DSH_ADOPTION_PLAN.md §1.2. Independent of #268 — no shared files beyond agent.ts, which touches different regions.

The failure this is for

The most expensive thing an agent does is not erroring. It is the loop that neither errors nor terminates: same tool, same arguments, same answer, again — quietly spending the budget until a turn cap or a human intervenes. Nothing in DeepCode noticed this.

The guard counts consecutive calls with identical canonicalized arguments and, at 3 / 5 / 8, injects an escalating reminder — first a brief nudge, then one naming the tool, the run length, and the arguments being resent.

It has no veto

Not in the tool list. Cannot block a call, cannot rewrite arguments, cannot fail one. A legitimately repeated call is delayed by exactly nothing.

That is the whole argument for tolerating false positives. Polling a file until it changes is a legitimate identical repeat, and the cost of being wrong about it is one paragraph of context. The plan doc records this trade explicitly rather than pretending the heuristic is sound.

Three decisions worth reviewing

Excluded tools are transparent to the chain, not resets. Grep X → TodoWrite → Grep X still counts as two consecutive Grep X. If exclusion reset the counter, interleaving one bookkeeping call would launder any loop — which is the failure mode, not an edge case. Default exclusions are TodoWrite and AskUserQuestion (repetition there means "still waiting", not "stuck").

Denied calls count. Detection sits before the gate. A model hammering a call the gate keeps refusing is exactly the loop worth interrupting, and it would otherwise be invisible.

The reminder is its own message. It is appended after the tool-results message rather than as a text block beside them. The provider maps a user message's text and its tool_result blocks to separate wire messages, so a text block there would emit a user turn between the assistant's tool_calls and their tool replies — a sequence the API rejects. There is a test asserting no message ever carries both kinds, because this is the sort of thing that breaks silently at runtime and never in a unit test.

Bad configuration fails loud

An empty threshold list, a non-integer, or a threshold below 2 throws at construction rather than silently falling back to defaults. Someone who wrote a bad list wants to know, not to get default behaviour and wonder why their setting did nothing.

The argument-preview cap bounds the reminder, never the comparison — two payloads differing only past the cap are still different calls. Tested, since getting this backwards would make the guard fire on distinct writes.

Verification

pnpm typecheck, lint, format:check clean. Full suite green — core 1074 passed / 28 skipped, cli 242, desktop 104, server 49, protocol 34, lsp 13, vscode 12, scripts 42.

19 new tests: 14 unit (chain semantics, canonicalization, wildcards, escalation, threshold validation) and 5 through the real agent loop — including one driving three identical calls under permissions: { deny: [...] } to prove refused calls still count, and the message-sequence check above.

Off by repeatGuard: false; thresholds and exclusions are configurable.

The most expensive failure an agent has is not an error — it is the loop that
neither errors nor terminates. The model calls the same tool with the same
arguments, reads the same answer, and calls it again, quietly spending the
budget until a turn cap or a human notices.

The guard watches each run's chain of tool calls, counts consecutive calls with
identical canonicalized arguments, and at 3, 5, and 8 injects an escalating
reminder: first a brief nudge, then one naming the tool, the run length, and the
arguments it keeps sending.

It has no veto. It is not in the tool list, cannot block a call, and cannot
rewrite arguments. That is what makes false positives affordable — polling a
file for a change is a legitimate identical repeat, and it costs one paragraph.

Two behaviours are deliberate rather than incidental:

Excluded tools are transparent to the chain, not resets. `Grep X → TodoWrite →
Grep X` is still two consecutive `Grep X`, so bookkeeping interleaved into a
loop cannot launder it.

Denied calls count. Detection sits before the gate, because a model hammering a
call the gate keeps refusing is precisely the loop worth interrupting.

The reminder is appended as its own user message after the tool results, not as
a text block beside them: the provider maps a user message's text and its
tool_result blocks to separate wire messages, and a `user` turn between an
assistant's tool_calls and their `tool` replies is a sequence the API rejects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant