Skip to content

feat(opencode): restore doom loop detection as internal plugin for v2 - #61328

Open
metal-huang wants to merge 7 commits into
anomalyco:devfrom
metal-huang:doom-loop-detection
Open

metal-huang wants to merge 7 commits into
anomalyco:devfrom
metal-huang:doom-loop-detection

Conversation

@metal-huang

@metal-huang metal-huang commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Issue for this PR

Closes #59339

Type of change

  • New feature

What does this PR do?

This is offered for design review; I can adapt the implementation to whichever direction the team prefers.

v2 currently has no doom-loop detection: the doom_loop action is still accepted by the v1 config schema and rendered by the TUI, but no code produces it. The v1 detector also missed rotations, because it required three consecutive identical calls inside one assistant message.

This PR restores detection as a zero-dependency detector module plus a thin internal plugin, with no runner changes:

  1. Same-message guard — v1's one working dimension, carried over unchanged: the same call emitted 3 times inside one assistant message asks once.
  2. Signal A — per-signature identical-output streak (warn 3, block 6). Counting is per signature, so rotations and interleaved calls never reset it. This is the part that catches the shape v1 missed.
  3. Signal B — line-level information novelty (warn 3/5, surgical ask at 8 only when that signature's A streak is ≥3, circuit breaker at 12). Single-line outputs are exempt.
  4. Signal C — per-tool failure-equivalence streak (warn 4, block 6). Volatile error tokens are normalized; successes of other tools do not reset it.

Every challenge goes through the existing doom_loop permission ask, so intentional polling keeps an escape hatch. A user message resets only B and the breaker; A/C counters deliberately survive, so an interruption does not hand the loop a fresh counter. Memory is bounded with LRU caps and a 10k line store; state is rebuilt from durable history after a restart.

Relation to #53032: different shape — a pure module plus a thin plugin rather than runner changes. Its consecutive-only predicate does not catch rotations: running it over the 199-call incident sequence gives zero firings.

Scope: tool-call loops only. Text/reasoning loops (#15271, #46030, #46492) and harness/compaction loops (#15533, #52697) are out of scope and linked as related in the issue. AST normalization of bash inputs is deliberately deferred to keep the core zero-dependency; it can return as an optional layer if maintainers want parse capability exposed to plugins.

How did you verify your code works?

Visual

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" width="240" height="72" role="img" aria-label="Repeated tool calls are counted per signature and stopped by a doom_loop permission ask">
  <g fill="currentColor">
    <!-- 通用工具调用签名 A/B/C 轮换出现,表示周期循环 -->
    <g font-family="ui-monospace,monospace" font-size="8.5">
      <text x="12" y="22" opacity="0.9">tool A(args)
        <animate attributeName="opacity" values="0.25;1;0.25" dur="2.4s" begin="0s" repeatCount="indefinite"/>
      </text>
      <text x="12" y="38" opacity="0.9">tool B(args)
        <animate attributeName="opacity" values="0.25;1;0.25" dur="2.4s" begin="0.8s" repeatCount="indefinite"/>
      </text>
      <text x="12" y="54" opacity="0.9">tool C(args)
        <animate attributeName="opacity" values="0.25;1;0.25" dur="2.4s" begin="1.6s" repeatCount="indefinite"/>
      </text>
    </g>
    <!-- 按签名各自计数,满 3 触发 -->
    <g>
      <circle cx="112" cy="34" r="4" opacity="0.9">
        <animate attributeName="opacity" values="0.2;1;0.2" dur="2.4s" begin="0s" repeatCount="indefinite"/>
      </circle>
      <circle cx="126" cy="34" r="4" opacity="0.9">
        <animate attributeName="opacity" values="0.2;1;0.2" dur="2.4s" begin="0.8s" repeatCount="indefinite"/>
      </circle>
      <circle cx="140" cy="34" r="4" opacity="0.9">
        <animate attributeName="opacity" values="0.2;1;0.2" dur="2.4s" begin="1.6s" repeatCount="indefinite"/>
      </circle>
    </g>
    <!-- 循环箭头:周期到 3 后停止 -->
    <path d="M96 62 Q120 72 144 62" fill="none" stroke="currentColor" stroke-width="1.4" opacity="0.5">
      <animate attributeName="opacity" values="0.15;0.6;0.15" dur="2.4s" repeatCount="indefinite"/>
    </path>
    <path d="M140 58 L146 62 L139 65 Z" opacity="0.5">
      <animate attributeName="opacity" values="0.15;0.6;0.15" dur="2.4s" repeatCount="indefinite"/>
    </path>
    <!-- doom_loop 权限询问:循环在此被打断 -->
    <g>
      <rect x="166" y="16" width="62" height="40" rx="7" fill="none" stroke="currentColor" stroke-width="1.6" opacity="0.95">
        <animate attributeName="opacity" values="0.25;0.95;0.25" dur="2.4s" begin="1.6s" repeatCount="indefinite"/>
      </rect>
      <text x="174" y="33" font-family="ui-monospace,monospace" font-size="8.5" opacity="0.95">doom_loop
        <animate attributeName="opacity" values="0.3;1;0.3" dur="2.4s" begin="1.6s" repeatCount="indefinite"/>
      </text>
      <text x="174" y="46" font-family="ui-monospace,monospace" font-size="8.5" opacity="0.95">ask user
        <animate attributeName="opacity" values="0.3;1;0.3" dur="2.4s" begin="1.6s" repeatCount="indefinite"/>
      </text>
    </g>
    <path d="M154 36 L162 36" stroke="currentColor" stroke-width="1.6" opacity="0.9">
      <animate attributeName="opacity" values="0.2;1;0.2" dur="2.4s" begin="1.6s" repeatCount="indefinite"/>
    </path>
    <path d="M158 32 L164 36 L158 40" fill="none" stroke="currentColor" stroke-width="1.6" opacity="0.9">
      <animate attributeName="opacity" values="0.2;1;0.2" dur="2.4s" begin="1.6s" repeatCount="indefinite"/>
    </path>
  </g>
</svg>

Screenshots / recordings

Not a UI change. The report above includes stills of the incident timeline and of the same 199 calls replayed under the v1 baseline and the three new signals.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Fixes #61198

Related: #45442, #59567

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detector works end to end. I ran OpenCode from source with the simulated model making the same read call once per step and doom_loop: ask. On the base, all reads ran with no prompt. With this PR, the 7th call asked for doom_loop. The new tests (65) pass and tsc -b for packages/core is clean.

Public API: no new or changed config keys, events, endpoints or plugin API. The behaviour of existing setups does change: the existing doom_loop permission is now actually requested. Users without an allow rule will see prompts, and unattended sessions get a denied tool call instead.

Concerns, most important first:

  1. The circuit breaker skips the permission system. Every other signal goes through doom_loop, but the breaker at 12 low-novelty outputs interrupts the session even when doom_loop is allowed. With *: allow, my run of identical reads stopped after 14 calls. The TUI showed nothing about why, and the model's final text was lost. Until the user sends a message, every tool call then fails. This contradicts the description ("every challenge goes through the existing doom_loop permission ask"). Please route the breaker through doom_loop too, or at least respect allow, and show the user why the session stopped.
  2. A and C streaks never decay. They survive user messages and execution boundaries, and other calls in between don't reset them. Only a changed output (A) or a success of the same tool (C) resets them. In a long session where the user asks a few times to re-check an unchanged file, or git status stays clean, the 7th check (6 identical outputs earlier) prompts, even though there is no loop. Signals that are meant to catch runaway loops should probably be scoped to the current run or turn, or decay over time.
  3. Design overlap with #53032 and #60443. Both also start raising doom_loop, in the runner. Maintainers will need to pick one direction. Merging more than one would ask twice for the same loop.
  4. Brittle coupling. The plugin depends on host details by matching strings: tool names (execute, shell, question), the Invalid arguments for tool message prefix, and the runner's error type names in fold.ts. If any of these change upstream, detection quietly misses or misfires.
  5. Tests and test seam. 1,780 lines of tests for about 700 lines of code is a lot to maintain, and much of it pins the exact thresholds and wording. Please cut them down to the core cases: each signal firing, the reset or escape paths, and the history rebuild. The module-level setMaxTrackedSessions seam changes state shared by all plugin instances; injecting the cap would avoid that.

The TUI also shows these prompts as "Continue after repeated failures", which doesn't fit signals A and B (identical successful outputs).

Fixes #61198
Related: #45442, #59567

messageID: event.messageID,
})
if (verdict.kind === "pass") return
if (verdict.kind === "circuit") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hard stop never consults the doom_loop rule, so it fires even with doom_loop: allow. I reproduced it with *: allow: identical reads stopped after 14 calls, the final assistant text never arrived, and the TUI said nothing about why. After that, every tool call fails until the user sends a message. Please route this through permission.assert like the other signals (or at least respect allow), and show the user why the session stopped.


// User message: the circuit breaker's defined escape hatch. B resets, the breaker re-arms; A/C
// survive across user messages (a user interjection must not hand the loop a fresh counter).
export function userMessage(current: SessionState) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because A and C survive user messages and other calls in between never reset them, a streak builds up over the whole session. Re-checking an unchanged file or git status across several separate user requests eventually triggers a doom_loop ask with no loop going on. Consider resetting or decaying A and C per run or turn. A real loop rebuilds its streak within a few calls anyway.

if (current.bStreak >= B_BLOCK && entry !== undefined && entry.streak >= A_WARN) {
return { kind: "assert", signal: "b", message: copy.blockB }
}
const failure = current.failures.get(call.tool)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C is keyed by tool name only, and only a success of the same tool resets it. Six different shell commands failing with the same normalized message (e.g. a generic non-zero exit or the same missing binary) would block the next shell call, even when its input is unrelated. Keying on signature, or on tool plus a normalized input, would avoid that.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only new commit merges dev. The doom-loop plugin, detector, fold and tests are unchanged, so the three earlier points (circuit breaker bypassing doom_loop, A/C streaks never decaying, C keyed by tool name only) are still open. On the merged head, the 65 doom-loop tests pass, tsc -b for packages/core is clean, and the doom_loop prompt still appears on the 7th identical read with the simulated model. The public API is unchanged by this push. Nothing new to raise.

@metal-huang

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. All five points are addressed in 98bdd36a4f:

  1. Circuit through doom_loop: the breaker now goes through the permission ask like every other signal. Allow resets B and the run continues; a denial makes the stop sticky and attaches the reason. The sticky path no longer fires silently.
  2. Per-turn counters: A and C now reset on a new user message, so a periodic legitimate re-check cannot accumulate into a block over days. A real loop re-accumulates within seconds after an interrupt, so detection is preserved (added tests for both directions).
  3. Overlap: acknowledged — fix(core): stop repeated tool call loops and stalled streams #53032 and fix(opencode): enforce loop permissions across tool calls and read ranges #60443 need one direction from maintainers, not from me.
  4. Coupling: the validation prefix is centralized in VALIDATION_PREFIX, with a documented fallback (an unmatched message still counts as a real failure).
  5. Test seam: the cap is now factory-injected via makePlugin({ maxTrackedSessions }); the global setMaxTrackedSessions seam is removed.

66 tests pass and packages/core typecheck is clean, including a new test for allow-at-circuit.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The circuit breaker now goes through doom_loop, and a user message resets A and C. I re-ran the circuit scenario with the simulated model (16 identical reads under *: allow): the run now completes, where before it was silently interrupted after 14 calls. The doom-loop tests pass (66) and tsc -b for packages/core is clean. C is still keyed by tool name only (open thread). The public API is unchanged by this push. Two small points on the new denial path are inline.

source: { type: "tool", messageID: event.messageID, id: event.id },
}),
)
if (Exit.isSuccess(outcome)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every failure is treated as a hard stop here, including a CorrectedError, where the user rejects with feedback for the model. That feedback is put in the Tool.Error, but the session is interrupted and the breaker made sticky right away, so the model never acts on it. Treating a correction like the other signals do (return the feedback, don't interrupt) would keep that escape hatch.

outcome.cause.toString() renders as Cause([Fail(Permission.BlockedError: The user rejected ...)]), so internal Effect text ends up in the tool error the model sees. Use the error's message instead, as the other signals do via mapError (e.g. Cause.squash or matching the failure).

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thread 3 is addressed: failure streaks are now counted per call (tool plus input), both live and in the history rebuild, so unrelated calls of the same tool no longer add up. Thread 4 is addressed: when the user rejects the circuit-breaker prompt with feedback, the model gets that feedback and the session isn't stopped or locked, and the message uses the error text instead of the internal Cause([...]) string. Threads 1 and 2 were addressed in the previous push and are unchanged. The doom-loop tests pass (67) and tsc -b for packages/core is clean. A re-run of the 16-read circuit scenario with *: allow completes normally. The public API is unchanged by this push. Nothing new to raise.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This push only merges dev. The doom-loop plugin, detector, fold and tests are unchanged. On the merged head, the 67 doom-loop tests pass, tsc -b for packages/core is clean, and the doom_loop prompt still appears on the 7th identical read with the simulated model. The earlier points were addressed in previous pushes. The public API is unchanged by this push. Nothing new to raise.

@metal-huang

metal-huang commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

One more distribution path, independent of the upstream decision: the same implementation is now published as a standalone npm plugin, opencode-doom-loop-detector, so users can install it today. It uses only the public plugin API, has 45 tests of its own, and needs no runner changes.

https://www.npmjs.com/package/opencode-doom-loop-detector

Install: opencode plugin add opencode-doom-loop-detector, or "plugins": ["opencode-doom-loop-detector"] in opencode.jsonc (opencode installs missing npm plugins automatically at startup).

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This push only merges dev. The doom-loop plugin, detector, fold and tests are unchanged. On the merged head, the 67 doom-loop tests pass and tsc -b for packages/core is clean. The earlier points were addressed in previous pushes. The public API is unchanged by this push. Nothing new to raise.

This branch has not been deployed

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

Projects

None yet

1 participant