Skip to content

subagent: infinite loop of identical tool calls for ~50min with no loop protection, uncontrollable token burn #45442

Open

Description

@wujiachen0727

Subagent infinite loop: repeated identical tool calls for ~50 min, no loop protection, uncontrollable token burn

Summary

A background general subagent entered an infinite loop: it issued 364 identical grep tool calls (same pattern, same path, same directory) over ~50 minutes with a steady ~5s cadence, burning ~184k input tokens + ~40k output tokens + ~142M cache-read tokens before I manually discovered and interrupted it. OpenCode's agent loop has no repeated-action detection, no step limit, no heartbeat timeout, and the parent session has no way to notice or abort a stuck subagent — it was reported as running the whole time.

Environment

  • opencode version: 0.0.0-beta-18286 (opencode --version reports 1.18.18)
  • OS: Darwin 25.5.0 (macOS 26.5.1, arm64)
  • Terminal: Unavailable: terminal environment variables are not set (running via OpenCode Beta desktop app)
  • Shell: /bin/zsh
  • Install/channel: beta (OpenCode Beta.app, CLI path .../cli/0.0.0-beta-18286/opencode-cli serve --service)
  • Active plugins: None found in config
  • Model: a deepseek-v4-flash variant (via a private/custom OpenAI-compatible provider; details redacted for privacy)

Reproduction

  1. Start a parent session (TUI/desktop).
  2. Spawn a background subagent (general agent) with a large task — in my case: "review a 47KB / 21-file merge request diff", where the subagent cross-verifies claims against source code.
  3. Let it run. The subagent loads a large context (cache-read peaked at ~142M tokens total over the loop) and enters a degenerate mode where it repeatedly decides to execute the same grep call: pattern ErrsSceneConfigNotFound|ErrsSceneConfigFailed, path .../PlanServer, every ~5 seconds.
  4. Observe: 364 consecutive messages with identical tool input, zero progress, session stays running in GET /api/session/active.

Expected Behavior

  • OpenCode should detect repeated identical tool calls (or a step/iteration cap, or a per-session max duration) and either stop the loop, surface a warning to the parent session, or expose a configurable limit.
  • The parent session / API should expose a way to notice a stuck subagent (e.g., heartbeat, last-activity timestamp in session data) and abort it without requiring the user to inspect the raw session database.
  • Background subagents that burn tokens with no progress should not be reported indefinitely as running.

Actual Behavior

  • The subagent ran 364 identical grep calls at a ~5.0s cadence (sampled intervals: 5,5,5,5,5,6,4,6,5,5) for ~50 minutes (10:04:37 → 10:54:26 local).
  • Token burn during the loop: ~184,196 input + ~39,518 output + ~142,413,184 cache-read tokens (from session DB).
  • No built-in detection, limit, warning, or auto-interrupt. GET /api/session/active reported {"ses_...": {"type": "running"}} throughout.
  • I had to discover the loop by querying the SQLite DB (~/.local/share/opencode/opencode.db, table session_message) for repeated tool calls, then manually call POST /api/session/{id}/interrupt using the authenticated HTTP API (credentials from the local service config).

Additional Context

  • Root cause of the repetition is model behavior (a long-context degenerate repetition), but the framework provides zero protection against it — that is the bug.
  • I could not find any documented step limit / max-turns / loop-detection / heartbeat configuration in the config schema; only reasoningEffort variants are present in the provider config.
  • Interrupt endpoint exists (POST /api/session/{sessionID}/interrupt) and works, but it is purely manual/out-of-band; nothing in the framework triggers it.
  • Frequency: observed once so far; reproducible trigger appears to be a long-running background subagent with repeated verification tool calls.
  • Workaround used: manual SQLite inspection + manual HTTP interrupt via opencode2 api post /api/session/{id}/interrupt (or raw curl with Basic auth).

Suggested directions (for maintainers):

  • Add a loop guard: N consecutive identical tool calls (same name + same input hash) → warn or abort (configurable).
  • Add a step/iteration cap per agent turn loop (e.g., maxSteps) with a clear terminal state instead of running.
  • Surface last-activity/heartbeat per session in the API so parents/clients can detect stalls.
  • Allow cancelling background subagents from the parent session UI/API.

Activity

  1. github-actions commented on Aug 27, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    These issues share the same root cause: the agent loop has no repeated-action detection, no step cap, and no heartbeat/stall detection — which is exactly what you're describing. Your report adds valuable new detail around the subagent-specific angle (parent session unable to observe or abort a stuck background subagent) and the token accounting from the session DB, which is complementary to the above.

  2. agentgoblin commented on Aug 27, 2026

    @agentgoblin

    Root cause of the repetition is model behavior (a long-context degenerate repetition), but the framework provides zero protection against it — that is the bug.

    From https://opencode.ai/docs/en/permissions/
    doom_loop — triggered when the same tool call repeats 3 times with identical input

    What is the status of permission doom_loop in your configuration?

  3. agentgoblin commented on Aug 27, 2026

    @agentgoblin

    I asked the neural network to execute the bash ls command five times. After the third time, the doom_loop worked.

    OpenCode 1.18.16

  4. kevin-elser commented on Sep 8, 2026

    @kevin-elser

    Root cause of the repetition is model behavior (a long-context degenerate repetition), but the framework provides zero protection against it — that is the bug.

    From https://opencode.ai/docs/en/permissions/ doom_loop — triggered when the same tool call repeats 3 times with identical input

    What is the status of permission doom_loop in your configuration?

    I can confirm this bypasses doom_loop when it happens entirely. My doom_loop is enabled and logs indicate the times it has fired off in the past. But today I had a single subagent do over 500 read calls until context was full and only heal on auto-compaction.

  5. agentgoblin commented on Sep 10, 2026

    @agentgoblin

    I can confirm this bypasses doom_loop when it happens entirely. My doom_loop is enabled

    “enabled” means the default value is “ask” or is “deny” set? There’s a subtlety here: in fully automatic operation (if you run opencode --auto), “ask” turns into “allow”.

    Furthermore, as far as I know, subagents or specific agents can use their own permission scheme, i.e., when calling a subagent, it may turn out that doom_loop is disabled. Perhaps the problem isn’t the lack of a loop protection, but the fact that subagents don’t use it. It’s interesting to check; perhaps I’ll try tomorrow.

  6. kevin-elser commented on Sep 10, 2026

    @kevin-elser

    I can confirm this bypasses doom_loop when it happens entirely. My doom_loop is enabled

    “enabled” means the default value is “ask” or is “deny” set? There’s a subtlety here: in fully automatic operation (if you run opencode --auto), “ask” turns into “allow”.

    Furthermore, as far as I know, subagents or specific agents can use their own permission scheme, i.e., when calling a subagent, it may turn out that doom_loop is disabled. Perhaps the problem isn’t the lack of a loop protection, but the fact that subagents don’t use it. It’s interesting to check; perhaps I’ll try tomorrow.

    It was set to "ask", but I tried "deny" as well and it failed. I fixed it with a custom plugin, but obviously that's just to fix my case here. It could also be an interaction with openchamber (which I'm well aware wouldn't be on opencode at that point) but I was unable to verify that since the bypassing happens basically randomly. I can confirm I have only ever seen this with subagents, and it doesn't matter if they're background or not. The entire sequence itself is very odd in that the times I saw it happen were essentially success loops. Subagent would read a file, get what it was looking for, and do the exact same read on the exact same lines over and over again until it auto-compacted and fixed itself. It consumed tokens the entire time, so the data was being consumed.

    Custom plugin I wrote essentially just does this:
    Track repeated tool results per child, grouped by the same resource/call family rather than just identical arguments. That lets it catch things like different shell commands repeatedly reading the same file and returning the same content.
    If a child gets an already-seen result, append a recovery warning telling it to change approach. It gets 5 recovery attempts.
    If it makes genuine progress, reset the incident.
    After recovery 5/5, an exact repeat is blocked before execution and the child session is aborted.
    If it changes the arguments enough to avoid that pre-execution check but still gets the same already-seen result from the same resource, abort it immediately afterward.
    Abort the child through session.abort() rather than throwing from the hook, so the plugin itself doesn’t break the session/plugin chain.
    Store a diagnostic when killing the child, then use experimental.chat.messages.transform to replace the generic parent-facing "Task cancelled" error with a useful report explaining which tool/resource looped and why the child was terminated.

    That entirely fixed it for me. The bug has happened twice since then, both times with glm-5.3-flash (which itself might just be misaligned on tool use and that's the root cause, idk), but plugin stepped in, parent resumed subagent thread, and it went back to normal work.

  7. agentgoblin commented on Sep 11, 2026

    @agentgoblin

    I tried to break the doom_loop. I set it to deny and tried to start the task directly or automatically. Everywhere I asked to run the command ls -la five times in a row.

    Interactive mode: doom_loop works;
    Starting from `--auto': doom_loop works;

    Then I created a subagent that runs the same command five times in a row.

    Interactive mode: doom_loop does not always work;
    Starting from `--auto': doom_loop works;

    I looked at what was going on. The fact is that the subagent inserts some thoughts between launches (this is all sorts of nonsense like "nothing has changed since last time, but I have to launch again blah blah blah"). And such a scenario leads to a circumvention of the rule - the system does not consider an identical command repeated five times to be something bad, because the agent was doing something in between. The interesting thing is that it's not clear what to do about it. In some scenarios, the agent really needs to execute the same command, drawing conclusions at intermediate stages. For example, monitoring the progress of any processes.

    I suppose the main agent could also break the current implementation of doom_loop in the way described. It's just that in my case, he honestly ran the team in a row, without trying to draw intermediate conclusions.

    If I understood what you wrote correctly, is this exactly what you caught?

  8. kevin-elser commented on Sep 11, 2026

    @kevin-elser

    I tried to break the doom_loop. I set it to deny and tried to start the task directly or automatically. Everywhere I asked to run the command ls -la five times in a row.

    Interactive mode: doom_loop works; Starting from `--auto': doom_loop works;

    Then I created a subagent that runs the same command five times in a row.

    Interactive mode: doom_loop does not always work; Starting from `--auto': doom_loop works;

    I looked at what was going on. The fact is that the subagent inserts some thoughts between launches (this is all sorts of nonsense like "nothing has changed since last time, but I have to launch again blah blah blah"). And such a scenario leads to a circumvention of the rule - the system does not consider an identical command repeated five times to be something bad, because the agent was doing something in between. The interesting thing is that it's not clear what to do about it. In some scenarios, the agent really needs to execute the same command, drawing conclusions at intermediate stages. For example, monitoring the progress of any processes.

    I suppose the main agent could also break the current implementation of doom_loop in the way described. It's just that in my case, he honestly ran the team in a row, without trying to draw intermediate conclusions.

    If I understood what you wrote correctly, is this exactly what you caught?

    At least in my case, there was no thinking in the session between tool reads, but I do understand why in theory doom_loop would avoid blocking repeated successful calls. After all, a read with no errors is not something on its own to flag. I've been unable to actually force it to do this, it just happens at random. I would expect if doing intermediate thinking on a file though, for example, that it'd read lines, think, then read a different set of lines, rather than repeatedly read the same, but perhaps it's just the model families I use that don't typically do that.

    The misaligned behavior I think gets around the base doom_loop because on its face it is doing successful calls. Just not doing anything but them, on loop, forever (until compaction).

    One thing that's pretty important to note though is that in the last day I've begun to suspect this is an issue on the serving end of Ollama cloud causing the misaligned behavior, and the behavior itself just presents in a way the standard doom_loop wasn't made to detect. There's several open issues on the Ollama repo around this (which I didn't originally think had anything to do with their cloud hosting). So I sent them a direct email asking about potential causes that might create this issue and if any are at play (KV cache quant for example hurts GLM particularly bad relative to other models). Either way, it could be good to have opencode doom_loop detect outrageous repeated successes, like if a successful call repeats 10 times in a row with the exact same input/output, we can reasonably assume that the model has fucked up somewhere.

    Just from my notes on what I found indicating the actual misalignment is on Ollama's end, they have all of these issues around this:
    18193 glm-5.3:cloud endless reasoning in OpenCode/ZCode while official Z.AI works normally
    17617 Cloud model becomes trapped in 193 identical tool calls after reasoning-tag corruption
    16389 Cloud model fails to correctly process tool-result messages
    16066 / 15647 Cloud-side tool-call argument truncation/handling problems

  9. a88web3 commented on Oct 28, 2026

    @a88web3

    Confirming this bug class on the 2.x line, in a plain top-level session (not a subagent), and adding a detail about the loop shape that matters for any fix.

    Environment

    • OpenCode 2.0.25 (Homebrew, macOS arm64), default build agent, no custom plugins loaded.
    • Provider: self-hosted OpenAI-compatible endpoint serving a local 27B instruct model. limit.context = 49152, limit.output = 8192, compaction = { auto: true, buffer: 10240, keep.tokens: 8000 }, tool_output.max_bytes = 8192. No agent step limit configured.

    Trace shape

    A construction session degenerated into a cycle that repeated 55 times over ~2h07m until it was interrupted by hand. Each cycle:

    1. one automatic compaction that completed successfully,
    2. one assistant turn whose persisted payload was byte-identical every cycle — same length, same tool-call fingerprint, same one-line text preamble — containing exactly one read of the same project source file with byte-identical arguments (no offset/limit variation),
    3. and no edit, no write, and no test command at any point in the whole run.

    So per cycle the model produced no new information, compaction kept the task alive, and the next turn re-planned the same first read. Nothing in the run terminates it.

    On the --auto question: correcting the record

    @agentgoblin tested this and found doom_loop does work under --auto, both for the main agent and for a subagent. I have nothing to contradict that and did not reproduce an --auto bypass either, so that theory should probably be dropped.

    What I can corroborate is the other mechanism they found — that interleaved reasoning between identical calls defeats detection. In my trace the identical read calls were never adjacent: each was preceded by a short text preamble inside the same assistant turn, so the flattened content is text, tool, text, tool, …. Any detector that looks for three consecutive identical tool parts will never fire on this, and it happened in a top-level session with no subagent involved. That is worth designing against explicitly, since "model narrates one line, then re-does the same read" appears to be the dominant shape in the wild — it is what #43673, #43800 and this issue all describe.

    Why 2.x has no protection at all today

    Separate from how doom_loop is configured, on the 2.x line there is no runner-side detection behind the permission:

    • in 2.0.25 the only doom_loop occurrence in the codebase is packages/core/src/v1/config/permission.ts, i.e. the legacy v1 config schema;
    • in v2.2.0 (2026-10-27) packages/core/src/session/runner/step.ts still contains no repeated-call comparison, packages/schema/src/agent.ts has no doom_loop entry in the default agent permissions, and no equivalent module exists.

    So the permission documented under /docs/permissions has nothing firing it on 2.x, which is why this ran unbounded for two hours. #53032 adds exactly that missing runner-level detection; I reviewed it and left a comment there about the adjacency requirement and about adding a progress signal, since this trace is a case its current detection would miss.

    What did stop it

    Nothing automatic — a manual interrupt. There was no session error, no step-limit failure and no tool error; the run simply kept going.

    Happy to share the anonymized trace shape (cycle count, per-cycle tool fingerprint, preamble-before-tool ordering, compaction cadence) and the two end-to-end cases I used to validate a fix — one that must be stopped and one that must not be — if that helps reviewing #53032. To be explicit about provenance: those two cases come from an isolated single-model harness, not from production, because the guard I built was deployed after this incident and has never fired in production.

  10. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I reproduced this on v2.2.0 and on the latest dev (a882cf9).

    The script below makes a simulated model send the same grep call (same pattern, same path) 8 times in a row, each after a short line of text. All 8 calls ran and the turn finished normally, with no doom_loop prompt, warning or error. The docs say doom_loop should fire on the third identical call. As far as I can tell, the v2 session runner never checks for repeated calls. The recording and screenshot below show the repeated calls.

    I tested in a top-level session rather than a background subagent; subagents go through the same session runner. Until this is fixed, setting steps on an agent puts a hard cap on how many steps it can take.

    Open PR #53032 adds repeated-call detection and links this issue. Related earlier reports of the same symptom on v1: #43673, #43800, #43603.

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#45442: the simulated model issues the same `grep`
    // call (same pattern, same path) over and over. Per the docs, `doom_loop` should
    // fire on the 3rd identical call; on v2 nothing stops or warns, and the run keeps going.
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const REPEATS = 8
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        project: {
          git: true,
          files: { "src/errors.ts": "export const ErrsSceneConfigNotFound = 1\nexport const ErrsSceneConfigFailed = 2\n" },
        },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          let calls = 0
          yield* llm.serve((_request, index) => {
            if (index < REPEATS) {
              calls++
              return Stream.make(
                Llm.text("Let me verify the error constants again."),
                Llm.toolCall({
                  index: 0,
                  id: `call_grep_${index}`,
                  name: "grep",
                  input: { pattern: "ErrsSceneConfigNotFound|ErrsSceneConfigFailed", path: "src" },
                }),
                Llm.finish("tool-calls"),
              )
            }
            return Stream.make(Llm.text(`LOOP FINISHED after ${calls} identical grep calls, no doom_loop guard fired.`), Llm.finish())
          })
          yield* ui.submit("Review the error constants")
          yield* ui.waitFor("LOOP FINISHED", { timeout: 90_000 })
          yield* ui.screenshot("after-loop")
          yield* Effect.log(`identical grep calls executed: ${calls}`)
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions