Repository navigation
Description
- I have verified this feature I'm about to request has not been suggested before.
Summary
This is a discussion issue, not a bug report with a minimal repro. I have an implementation-ready fix, but I would like to align on the approach before opening a PR.
The v1 doom_loop detector covered one narrow shape: the same tool call emitted 3+ times within one assistant message (it lived in packages/opencode/src/session/processor.ts, last shipped in v1.18.30). In v2 the action is still accepted by the permission schema (packages/core/src/v1/config/permission.ts, the actionOnly set) and still has settings-UI labels, but no code path produces it — the only remaining references are that schema acceptance, the labels, and a TUI display branch (packages/tui/src/util/permission.ts). The v2 docs already note the gap explicitly: doom_loop is "not a current V2 Core permission action" (services/www/src/docs/content/permissions.mdx). A fresh report on v2.1.2 (#57804) shows repeated reads with no doom_loop prompt; #51965 was closed as v1-only with a request to file a v2 issue.
Evidence
From an anonymized real session of mine on v1, with its detector live: about 200 calls rotated status/diff commands for ~87 minutes. The user interrupted mid-loop; the model acknowledged the repetition in its own reasoning, then continued for another 138 calls. The loop ended only after the user switched models.
Model/provider labels are anonymized as Model A/B in the report. That is a presentation choice to keep the discussion about harness enforcement, not about ranking or blaming a model; community reports show the same failure family across multiple providers.
The rotation, verbatim — three commands, ~60 cycles with no other calls interleaved:
$ git status --short
$ git diff --stat
$ git diff packages/tui/src/component/dialog-status.tsx
$ git status --short
$ git diff --stat
$ git diff packages/tui/src/component/dialog-status.tsx
... 58 more cycles
Cycle after cycle, all three outputs stayed byte-identical. Yet three identical calls never landed inside one assistant message — the only shape v1 checked.
Community reports match the same failure family:
- subagent: infinite loop of identical tool calls for ~50min with no loop protection, uncontrollable token burn #45442: 364 identical greps in a subagent, ~142M cached-read tokens.
- Doom-loop detection misses periodic tool-call cycles #47759: periodic A/B/A/B calls escape per-call repetition checks.
- agent: repetitive tool call loop blocks task completion #56825: repeated one query dozens of times until the user stopped it.
- Model Beam Free stuck in a loop thinking and reading #57804: on v2.1.2, the same read repeated until the user interrupted; no
doom_loopprompt appeared. A full session export is attached to that issue. - Bug: New MiMo 2.6 Flash is unusable due to it ending up in infinite loops #50678: 261 identical emissions in one assistant message — the shape v1 did catch.
Proposal
Add four complementary detectors behind the existing doom_loop permission action:
- Same-message guard: the exact same call emitted 3 times in one assistant message asks once. This is v1's one working dimension, carried over unchanged.
- Signal A: per-signature (
tool + canonical input) byte-identical output streak. Warn at 3; challenge the next emission after streak 6. - Signal B: global line-level information novelty. Warn at 3/5; challenge at 8 only when the same signature's A streak is at least 3; circuit-break the drain at 12.
- Signal C: per-tool failure-equivalence streak. Volatile tokens in errors are normalized; interleaved successes of other tools do not reset it. Warn at 4; challenge the next emission after streak 6.
Design note: AST normalization is deliberately deferred. A prototype using the repo's existing tree-sitter-bash dependency grouped nine raw command strings into six canonical signatures on this session, including quote and echo separator variants. I did not attach it to Signal A because the detector core is intentionally zero-dependency, and the v2 plugin API does not expose shell parsing. Rewording usually changes output too, so Signal B backstops multi-line variant loops; one-line variant probes such as #57584 remain out of scope. If maintainers want parse capability exposed to plugins, AST normalization can be a follow-up optional layer.
All challenge tiers use the existing permission flow, so intentional polling has an escape hatch. A user message resets only B and the circuit breaker; A/C counters intentionally survive, so a short user interruption does not hand the loop a fresh counter. After a restart, A/C counters and B's line store are rebuilt from durable session history; in-memory state is bounded with LRU limits.
Scope notes
This proposal is deliberately limited to tool-call loops. It does not claim to solve text-only loops (#57501), harness/compaction loops, or degenerate one-line variant probes such as #57584.
The failure-streak shape was first described by @paolothomas72 in #50073; credit for that observation belongs there. There is also an open related PR, #53032. This proposal differs in shape: a pure detector module plus a thin internal plugin using the plugin permission API, with no runner changes.
Implementation status
I have an implementation-ready branch with the detector, plugin wiring, and 65 passing tests. If the design direction looks right, I will open the PR.
I also put together a small interactive report that walks through the incident, the community evidence, and a call-by-call replay of this exact loop under each algorithm — much easier to follow than this wall of text: https://metal-huang.github.io/opencode/doom-loop-report/. Feedback very welcome, here or there. Two stills from it:


Activity
opencode-agent commentedon Oct 26, 2026
Thanks for the detailed write-up and the replay report, @metal-huang.
To restate the request: v2 still accepts the
doom_looppermission action, but nothing in v2 produces it. You're proposing a set of loop detectors behind that action, built as a detector module plus a thin plugin on the plugin permission API. The detectors are the v1 same-message guard, an identical-output streak per call, line-level novelty with a circuit breaker, and a normalized failure streak. They would use the normal permission flow, so intentional polling can still be allowed. I checkeddevand it matches what you describe:doom_loopexists only in the schema, the TUI display and the settings labels, and the v2 docs say it is not currently a V2 Core action.Maintainers will look at the approach. In the meantime, these are the closest related items:
doom_looppermission, so it would be good to agree on one direction before either is merged.metal-huang commentedon Oct 26, 2026
I read #53032's diff. I also ran its exact predicate on the incident data from my report. Two points need a decision before we align on one direction.
1. Coverage: consecutive calls or per-signature counts. #53032 fires only when the last three tool items are identical: same tool and same
JSON.stringify(input). v1 used the same predicate inside one assistant message. #53032 widens the window to the last user message. This helps bursts that span messages. But the rule still requires three identical calls in a row. My incident is a periodic rotation: status → diff --stat → diff file. No three calls in a row ever match. I ran its predicate on the 199-call sequence. It fired zero times. #47759's A,B,A,B pattern escapes for the same reason. Both designs catch bursts like #45442 and #50678. My signal A uses one counter per signature. Interleaved calls do not reset it. That is what catches rotations.2. Reset on user messages. #53032 stops counting at the last user message. A user interrupt resets detection. My A/C counters survive user messages on purpose. A short interruption does not give the loop a fresh counter. In my incident the model made 138 more calls after the user interrupted. So this case is not theoretical.
Its two-strike stop and the 90-second stall watchdog are good ideas. They are independent of the detector choice. If we keep one direction, I ask for per-signature counting as the core rule. The rest is packaging. I can adapt my branch to the shape you prefer.
metal-huang commentedon Nov 4, 2026
For anyone landing here: the detector is also available as an npm plugin, so you can use it without waiting for upstream —
opencode plugin add opencode-doom-loop-detector, or"plugins": ["opencode-doom-loop-detector"]inopencode.jsonc(opencode installs missing npm plugins automatically at startup). Details and verification are in the linked report and in the package README.