feat(core): interrupt the loop where the model is repeating itself - #269
Open
oratis wants to merge 1 commit into
Open
feat(core): interrupt the loop where the model is repeating itself#269oratis wants to merge 1 commit into
oratis wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second implementation PR from
docs/DSH_ADOPTION_PLAN.md§1.2. Independent of #268 — no shared files beyondagent.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 Xstill counts as two consecutiveGrep 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 areTodoWriteandAskUserQuestion(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_resultblocks to separate wire messages, so a text block there would emit auserturn between the assistant'stool_callsand theirtoolreplies — 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:checkclean. 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.