Skip to content

Plan agent loops forever after auto-compaction (opencode run never exits) #62628

Description

@vbrevik

Description

With the plan agent, once auto-compaction triggers, every compaction is followed by the "You are in Plan mode…" synthetic reminder, and the model answers again. That triggers another compaction, and so on. opencode run --agent plan never exits.

In 150 s, sessions had 2, 3 and 4 compaction/reminder/answer cycles after the model's first ok. The same prompt with a large context limit (no compaction) ends normally, and build with the small limit ends normally. Long plan sessions that reach compaction are likely affected in the TUI too, though I only tested run.

Plugins

none (minimal config)

OpenCode version

2.2.6

Steps to reproduce

  1. Custom provider model with "limit": { "context": 32768, "output": 2048 } (small enough that the system prompt triggers auto-compaction).
  2. opencode run --agent plan "reply with exactly: ok": hangs (3 of 3); the session shows repeated compaction → synthetic (plan reminder) → assistant entries.
  3. Same with "context": 262144: exits normally (3 of 3).

Operating System

macOS 27.0.1

Terminal

n/a (opencode run)

Activity

  1. opencodejr commented on Nov 7, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report! I reproduced this on 2.2.6, and it still happens on the latest dev (c33eaa170).

    Setup: a local OpenAI-compatible mock model (no real provider), with the model's context limit set small enough that the system prompt alone triggers auto-compaction.

    • opencode run --agent plan "reply with exactly: ok" never exits. In 40 seconds the mock got about 3,000 requests, alternating compaction and answer, until timeout killed it.
    • --agent build with the same small limit exits after one compaction and one answer.
    • --agent plan with a large context limit exits normally.

    The script below reproduces it with a mock model, so no credentials are needed. The likely cause is that the Plan-mode reminder is added back after every compaction, which seems to start another turn that compacts again. That has not been confirmed yet.

    Related: #52697 and #48827 (earlier auto-compaction loops, not specific to the plan agent) and #58395 (when the Plan reminder is saved).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #62628: `opencode run --agent plan` never exits once auto-compaction triggers.
    # Uses a local OpenAI-compatible mock model (no credentials). Requires bun.
    # OC="opencode" by default; set OC to another command (e.g. a source checkout) to test it.
    # For a source checkout set OC to its CLI command and OC_SERVE=1.
    # CONTEXT (default 3000) is the model's context limit; small enough that the first request auto-compacts.
    set -u
    OC=${OC:-opencode}
    AGENT=${AGENT:-plan}
    CONTEXT=${CONTEXT:-3000}
    PORT=${PORT:-4567}
    WORK=$(mktemp -d)
    mkdir -p "$WORK/proj" "$WORK/home"
    cat > "$WORK/mock.ts" <<'EOF'
    let n = 0
    const SUMMARY = "## Objective\n- Reply with exactly: ok\n\n## Requirements\n- (none)\n\n## Decisions\n- (none)\n\n## Work State\n### Completed\n- (none)\n\n### Active\n- Reply ok\n\n### Blocked\n- (none)\n\n## Next Move\n1. Reply ok\n\n## Relevant Files\n- (none)\n\n## Important Context\n- (none)\n"
    Bun.serve({
      port: Number(process.env.PORT),
      async fetch(req) {
        if (!new URL(req.url).pathname.endsWith("/chat/completions")) return new Response("{}", { status: 404 })
        const body: any = await req.json()
        n++
        const size = JSON.stringify(body.messages).length
        const last = body.messages.at(-1)
        const lastText = typeof last.content === "string" ? last.content : (last.content ?? []).map((p: any) => p.text ?? "").join("")
        const title = /first message of the conversation/.test(lastText)
        const compaction = /^(You MUST summarize|Update the existing checkpoint|The previous response did not fill in the required summary template)/.test(lastText)
        const kind = title ? "title" : compaction ? "COMPACTION" : "answer"
        console.log(`request #${n} ${kind}`)
        const text = compaction ? SUMMARY : title ? "Reply ok" : "ok"
        const enc = new TextEncoder()
        const base = { id: "c" + n, object: "chat.completion.chunk", created: 0, model: "m" }
        const chunk = (o: any) => enc.encode(`data: ${JSON.stringify({ ...base, ...o })}\n\n`)
        return new Response(new ReadableStream({ start(c) {
          c.enqueue(chunk({ choices: [{ index: 0, delta: { role: "assistant", content: text } }] }))
          c.enqueue(chunk({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }))
          c.enqueue(chunk({ choices: [], usage: { prompt_tokens: Math.ceil(size / 4), completion_tokens: 2, total_tokens: Math.ceil(size / 4) + 2 } }))
          c.enqueue(enc.encode("data: [DONE]\n\n")); c.close()
        } }), { headers: { "content-type": "text/event-stream" } })
      },
    })
    EOF
    PORT=$PORT bun "$WORK/mock.ts" > "$WORK/mock.log" 2>&1 &
    MOCK=$!
    trap 'kill $MOCK 2>/dev/null' EXIT
    sleep 1
    cat > "$WORK/proj/opencode.json" <<EOF
    {
      "\$schema": "https://opencode.ai/config.json",
      "model": "mock/m",
      "provider": { "mock": {
        "npm": "@ai-sdk/openai-compatible",
        "options": { "baseURL": "http://127.0.0.1:$PORT/v1", "apiKey": "x" },
        "models": { "m": { "name": "m", "limit": { "context": $CONTEXT, "output": 2048 } } }
      } }
    }
    EOF
    cd "$WORK/proj" && git init -q
    echo "== $($OC --version) agent=$AGENT context=$CONTEXT"
    if [ -n "${OC_SERVE:-}" ]; then
      # Source checkouts: start the server separately (the --standalone child lacks bun's preload/conditions).
      OPENCODE_PASSWORD=repro HOME="$WORK/home" $OC serve --port $((PORT+1)) > "$WORK/serve.log" 2>&1 &
      SERVE=$!; trap 'kill $MOCK $SERVE 2>/dev/null' EXIT; sleep 8
      CONN="--server http://127.0.0.1:$((PORT+1))"
    else
      CONN="--standalone"
    fi
    OPENCODE_PASSWORD=repro HOME="$WORK/home" timeout 40 $OC run $CONN --agent "$AGENT" "reply with exactly: ok" > "$WORK/run.out" 2>&1
    code=$?; [ -n "${SHOW_OUT:-}" ] && tail -c 2000 "$WORK/run.out"
    [ $code = 124 ] && echo "RESULT: opencode run did NOT exit within 40s (killed)" || echo "RESULT: opencode run exited with code $code"
    echo "== model requests ($(grep -c '^request' "$WORK/mock.log") total), first 15:"
    head -15 "$WORK/mock.log"

    Run it: bash repro.sh

  2. tomwangcn commented on Nov 7, 2026

    @tomwangcn

    I've picked this up and opened #62633.

    Root cause, confirmed locally with the mock-model repro from @opencodejr[bot]'s comment (latest dev, ~2,000 requests in 40s before the fix): the Plan plugin's context hook persists the re-injected reminder via ctx.session.synthetic, which admits a pending steer item; the next idle boundary promotes it into a fresh turn answering the reminder, and with auto-compaction stripping the reminder every turn the cycle never ends.

    The fix records the reminder straight onto the durable log instead (same path as instruction-file injection), so it stays in history and in the current request without spawning answer turns. Repro now exits after 3 requests; plan plugin tests and typecheck pass.

  3. vijaym2k6 commented on Nov 8, 2026

    @vijaym2k6

    Thanks for the quick fix in #62633. A cheap backstop for this whole class: cap compactions per turn (say 3) and end the turn with an error after that. A turn that compacts more than a few times is not making progress.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions