Skip to content

compaction: auto-compaction loops when provider reports input tokens above the model's context limit #50474

Open

Description

@thomasvdv

Summary

Auto-compaction can enter a runaway loop with the ollama-cloud provider. When the provider reports an implausible usage.input value (larger than the model's own context limit), OpenCode's compaction preflight treats it as real, compacts, and the next step re-reports the same inflated value — so it compacts again, indefinitely. In one session this fired 4 auto-compactions in 75 seconds ($23.41 in auto-compaction alone per the session's ledger, out of a $48.39 session total).

Environment

  • opencode version: 2.0.12
  • OS: Darwin 25.6.0 (macOS, arm64)
  • Terminal: xterm-256color (TERM_PROGRAM=ghostty)
  • Shell: /bin/zsh
  • Install/channel: latest (installed at ~/.opencode/bin/opencode)
  • Active plugins: None found in config (~/.config/opencode/opencode.jsonc has keys $schema, mcp, providers, skills — no plugins entry, no .opencode/plugin/ directory)
  • Relevant config: compaction is unset, so defaults apply (auto: true, buffer: 20000, keep.tokens: 15000)

Reproduction

Not yet reduced to a minimal repro — the trigger appears to require the specific provider and a session that has accumulated a large image payload. What was observed:

  1. Use a session on provider ollama-cloud, model deepseek-v4.1-flash (declared limit.context: 1048576, limit.output: 384000), variant max.
  2. Build up history including many full-size (1920x1080) screenshots read via the read tool, until the provider rejects a request with HTTP 413 Request Entity Too Large (this provider rejects a request once accumulated image data exceeds roughly 4 MB of base64).
  3. Run a manual compaction to recover: POST /api/session/{sessionID}/compact.
  4. Send any further prompt (here: the single word "continue").
  5. Observe: the next step reports an enormous usage.input, triggers an automatic compaction, and immediately repeats.

Observed sequence (session ses_f3977a8caffeCoXYUH4TxHDGxG, all times local):

seq message reason reported input tokens reported output tokens cost
1194 compaction manual 124,792,980 1,300,075 $19.4990
1211 assistant — 4,055,630 58,650 $0.6436
1226 compaction auto 25,391,744 2,041,207 $5.0335
1229 assistant — 3,406,996 30,625 $0.5295
1248 compaction auto 33,091,776 2,600,337 $6.5240
1251 assistant — 4,221,155 49,767 $0.6631
1268 compaction auto 33,401,627 2,802,525 $6.6918
1271 assistant — 19,573,066 982,098 $3.5252
1292 compaction auto 27,403,988 1,749,382 $5.1603

The four auto compactions at seq 1226/1248/1268/1292 occurred within 75 seconds (21:35:20, 21:35:40, 21:36:04, 21:36:35).

Expected Behavior

The compaction preflight should not schedule a checkpoint based on a usage report that is internally inconsistent with the model's own limits. A reported usage.input that is 24-32x the model's declared limit.context cannot be a real prompt size. Once a checkpoint is installed, the session should resume normally rather than immediately re-compacting.

Additionally, compaction.auto: true is documented to give one provider-overflow recovery attempt with a second overflow returned as an error — not an unbounded loop.

Actual Behavior

The loop repeats until the session is interrupted. Each cycle installs a fresh checkpoint, but the following model step again reports an inflated usage.input, which again exceeds the preflight ceiling, which schedules another auto-compaction.

The preflight ceiling with default settings and this model would be:

min(input limit - buffer, context limit - max(output reserve, buffer))

Even granting the full 1,048,576-token context as the input limit, the ceiling cannot exceed 1,048,576. A reported input of 25,391,744 exceeds it by ~24x, so every step re-triggers.

Why the reported usage cannot be genuine: the model's declared context limit is 1,048,576 tokens, and the entire stored session (all user + assistant messages, including every inline base64 screenshot) is ~5.1 MB — on the order of 1.3M tokens at the provider's own observed ratio of ~4 characters/token. There is no way for a request built from this session to contain 25-33M prompt tokens. The active context after the final compaction was 3 messages totaling 0.02 MB. The strongest form of this check, which does not depend on any estimate: a single model step reported usage.input = 25,391,744, which is 24x the model's context window and therefore physically impossible.

The derived cost is therefore unreliable too: because session_v2.cost is computed from these reported token counts, the $48.39 attributed to this session (89% of it from compaction) inherits the same problem. For balance: the provider's own usage endpoint reports a monthly figure of 0.187 for this account, which does not reconcile with OpenCode's $85.66 across 158 sessions. I could not resolve that discrepancy — a controlled request of ~100,000 prompt tokens incremented the provider's request_count (2795 to 2796) but left its cost figure unchanged, so the two measure different things or one is stale. I am not claiming the user was billed $48.39; I am claiming the token counts OpenCode acts on are provably impossible, and any cost derived from them should not be trusted or used to drive scheduling decisions.

Scope check: scanning every assistant step in this database, the only steps whose reported usage.input exceeds their own model's declared context limit are in this session (4 steps, worst 18.7x the limit). Other sessions on the same provider and model do not show it, so this is not a general per-provider accounting failure — it correlates with the screenshot-heavy history in this specific session. Note the reported values are also inconsistent with each other: compaction steps report 25-33M input tokens while the intervening plain assistant steps report ~3-4M, and the session's final active context was 0.02 MB.

Additional Context

  • The session involved reading many 1920x1080 PNG screenshots through the read tool, which the provider accepts inline as data:image/png;base64,... file parts. Screenshot data is the likely contributor to whatever produces the anomalous usage figure.
  • Manual compaction on the same session initially appeared to work (active context reduced to 2 messages / 0.006 MB) and a subsequent trivial prompt ("Reply with only the word READY") returned correctly with usage.input: 61198. The loop only began on the following prompt.
  • Workaround applied by the user: stopping the session. The natural mitigation is compaction: { auto: false } in config, which prevents the automatic path entirely.
  • The provider is entered via auth.json as provider id ollama-cloud, base URL https://ollama.com/v1, OpenAI-compatible (@opencode/ai/providers/openai-compatible). Ollama Cloud is not the standard Ollama local provider.
  • Related prior symptom in the same session, possibly a separate issue: this provider returns HTTP 413 Request Entity Too Large (ref: ffee2105-58a8-49fa-beff-d946d3f11b20) once accumulated image payload in the request exceeds roughly 4 MB of base64 data, and the session becomes permanently unusable because every retry re-sends the same history. OpenCode surfaced this as provider.invalid-request / AI.Error.InvalidRequest. Worth noting that overflow-classified errors are supposed to trigger one compaction-and-retry recovery; if this 413 were classified as context overflow rather than invalid-request, the session may have self-recovered instead of hard-failing.

Relevant log lines (from ~/.local/share/opencode/log/opencode.log), for the original 413 failure:

timestamp=2026-09-22T01:04:40.704Z level=ERROR run=c6164560 message="Failed to drain Session" cause="AI.Error: Request Entity Too Large (ref: ffee2105-58a8-49fa-beff-d946d3f11b20) ..." role=server sessionID=ses_f3977a8caffeCoXYUH4TxHDGxG

No secrets are included in this report; session and message ids are included for traceability.

Activity

  1. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I can reproduce this on 2.0.12, and it still happens on the latest dev (fa1260d).

    To reproduce it, I pointed an OpenAI-compatible provider at a mock server, using your model's limits (limit.context: 1048576, limit.output: 384000) and the default compaction settings. Every response from the mock reports prompt_tokens: 25391744. During a normal agent turn of 5 read tool steps, OpenCode ran an automatic compaction before every step after the first, so one turn produced 5 compactions. When the mock kept calling tools, the cycle never stopped: about 5,400 compaction and step requests in 90 seconds. The script below reproduces it.

    OpenCode trusts the reported input count even when it is far larger than the model's context window. Each step after a checkpoint therefore looks over the limit again, and the compaction that just ran never lowers that number.

    Until this is addressed, compaction: { auto: false } avoids the loop, as you noted. The 413 being reported as invalid-request instead of a context overflow is a separate problem and isn't covered here.

    Reproduction script (reproduce.sh)
    #!/usr/bin/env bash
    # Repro for #50474: a provider that reports usage.prompt_tokens above the model's context window
    # makes OpenCode auto-compact before every step of an agent turn.
    # Usage: ./reproduce.sh            (uses `opencode` on PATH)
    #        OC="bun --conditions=browser --preload=/workspace/repo/packages/tui/node_modules/@opentui/solid/scripts/preload.js /workspace/repo/packages/cli/src/index.ts" ./reproduce.sh
    set -u
    DIR=$(cd "$(dirname "$0")" && pwd)
    OC=${OC:-opencode}
    WORK=$(mktemp -d)
    mkdir -p "$WORK/proj/.opencode" "$WORK/home"
    echo "hello readme" > "$WORK/proj/README.md"
    cat > "$WORK/proj/.opencode/opencode.jsonc" <<'JSON'
    {
      "model": "ollama-cloud/deepseek-v4.1-flash",
      "providers": {
        "ollama-cloud": {
          "name": "Ollama Cloud (mock)",
          "package": "@opencode/ai/providers/openai-compatible",
          "settings": { "apiKey": "x", "baseURL": "http://127.0.0.1:4599/v1" },
          "models": {
            "deepseek-v4.1-flash": {
              "capabilities": { "tools": true, "input": ["text"], "output": ["text"] },
              "limit": { "context": 1048576, "output": 384000 }
            }
          }
        }
      }
    }
    JSON
    (cd "$WORK/proj" && git init -q)
    cat > "$WORK/mock.ts" <<'MOCK'
    // OpenAI-compatible mock that always reports prompt_tokens far above the model's 1,048,576 context window.
    const INFLATED = Number(process.env.INFLATED ?? 25_391_744)
    let n = 0
    let summaries = 0
    let toolCalls = 0
    Bun.serve({
      port: Number(process.env.PORT ?? 4599),
      async fetch(req) {
        const url = new URL(req.url)
        const body: any = await req.json().catch(() => ({}))
        n++
        const last = JSON.stringify(body.messages?.at(-1) ?? "").slice(0, 120)
        const full = JSON.stringify(body.messages?.at(-1) ?? ""); const isSummary = full.includes("summarize the conversation") || full.includes("summary template") || full.includes("updated Markdown sections")
        if (isSummary) summaries++
        console.log(`#${n} summaries=${summaries} ${new Date().toISOString()} ${url.pathname} msgs=${body.messages?.length} tools=${body.tools?.length ?? 0} summary=${isSummary} last=${last}`)
        const text = isSummary
          ? "## Objective\nTest.\n\n## Requirements\nNone.\n\n## Decisions\nNone.\n\n## Work State\n\n### Completed\nNothing.\n\n### Active\nNothing.\n\n### Blocked\nNothing.\n\n## Next Move\nContinue.\n\n## Relevant Files\nNone.\n\n## Important Context\nNone."
          : "Done."
        const enc = new TextEncoder()
        const chunk = (o: any) => enc.encode(`data: ${JSON.stringify(o)}\n\n`)
        const base = { id: "c" + n, object: "chat.completion.chunk", created: 0, model: body.model }
        const stream = new ReadableStream({
          start(c) {
            // Agentic step: keep calling a tool for the first TOOL_STEPS steps of a turn (tool messages since the last user message).
            // A well-behaved agent turn: TOOL_STEPS tool calls in total, then a final text answer.
            const wantTool = !isSummary && body.tools?.length && toolCalls < Number(process.env.TOOL_STEPS ?? 5)
            if (wantTool) toolCalls++
            if (wantTool) {
              c.enqueue(chunk({ ...base, choices: [{ index: 0, delta: { role: "assistant", tool_calls: [{ index: 0, id: "call_" + n, type: "function", function: { name: "read", arguments: JSON.stringify({ path: "README.md" }) } }] }, finish_reason: null }] }))
              c.enqueue(chunk({ ...base, choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }))
            } else {
              c.enqueue(chunk({ ...base, choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] }))
              c.enqueue(chunk({ ...base, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }))
            }
            c.enqueue(chunk({ ...base, choices: [], usage: { prompt_tokens: INFLATED, completion_tokens: 50, total_tokens: INFLATED + 50 } }))
            c.enqueue(enc.encode("data: [DONE]\n\n"))
            c.close()
          },
        })
        return new Response(stream, { headers: { "content-type": "text/event-stream" } })
      },
    })
    console.log("mock up")
    MOCK
    PORT=4599 TOOL_STEPS=5 bun "$WORK/mock.ts" > "$WORK/mock.log" 2>&1 &
    MOCK=$!
    sleep 1
    export HOME="$WORK/home" XDG_DATA_HOME="$WORK/home/data" XDG_CONFIG_HOME="$WORK/home/cfg"
    cd "$WORK/proj"
    echo "== turn 1 (5 tool steps, then a text answer)"
    timeout 60 $OC run --standalone --auto "read the readme a few times" 2>&1 | tail -3
    kill $MOCK
    echo "== requests seen by the mock provider:"
    cut -c1-150 "$WORK/mock.log"
    echo "== compaction requests: $(grep -c 'summary=true' "$WORK/mock.log")"

    Run it: bash reproduce.sh

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