Skip to content

[FEATURE]: v2: doom_loop remains in the permission schema but nothing asserts it — repeated tool-call loops run indefinitely #59339

Open

Description

@metal-huang
  • 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:

Proposal

Add four complementary detectors behind the existing doom_loop permission action:

  1. 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.
  2. Signal A: per-signature (tool + canonical input) byte-identical output streak. Warn at 3; challenge the next emission after streak 6.
  3. 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.
  4. 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:

Timeline of the loop: 197 of 200 calls in an ~87-minute window are repeats

The same 199 calls replayed under the v1 baseline and the three implemented signals


Offline copy of the report (zip)

Activity

  1. opencode-agent commented on Oct 26, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed write-up and the replay report, @metal-huang.

    To restate the request: v2 still accepts the doom_loop permission 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 checked dev and it matches what you describe: doom_loop exists 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:

  2. metal-huang commented on Oct 26, 2026

    @metal-huang
    ContributorAuthor

    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.

  3. metal-huang commented on Nov 4, 2026

    @metal-huang
    ContributorAuthor

    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"] in opencode.jsonc (opencode installs missing npm plugins automatically at startup). Details and verification are in the linked report and in the package README.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions