Skip to content

keep.tokens is not honoured: the compaction split walk-back is unbounded and skips synthetic messages, so agent-driven sessions carry 234K past a 15K setting #43250

Open

Description

@Zaczero

Description

The docs describe keep.tokens (default 15000) as "Tokens from newest context to retain alongside
summary". In agent-driven sessions the actual figure is routinely 5–15× that.

select() accumulates from the tail until it exceeds keep.tokens, then walks backwards to the
nearest user message:

const select = (
messages: readonly SessionMessage.Info[],
tokens: number,
): { readonly head: string; readonly recent: string } | undefined => {
const conversation = messages
.filter((message) => message.type !== "compaction" && message.type !== "system")
.flatMap((message) => {
const text = serialize(message)
return text ? [{ message, text }] : []
})
if (conversation.length === 0) return undefined
let total = 0
let split = conversation.length
for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index].text)
if (split < conversation.length && next > tokens) break
total = next
split = index
}
while (split > 0 && conversation[split].message.type !== "user") split--
if (split === 0) {
const latestUser = conversation.findLastIndex((item) => item.message.type === "user")
if (latestUser > 0) split = latestUser
}
return {
head: conversation
.slice(0, split)
.map((item) => item.text)
.join("\n\n"),
recent: conversation
.slice(split)
.map((item) => item.text)
.join("\n\n"),
}
}

while (split > 0 && conversation[split].message.type !== "user") split--

keep.tokens only decides where that walk starts. The walk itself has no bound, so everything back
to the last user turn ends up in recent and is carried forward verbatim. Interactive sessions have a
user turn every few messages; agent-driven ones — subagents, long tool loops, autonomous runs — do
not, and the walk crosses hundreds of messages.

Synthetic messages are already user messages everywhere else. to-llm-message.ts lowers them to
role: "user" on the wire:

case "synthetic":
return [Message.make({ id: message.id, role: "user", content: message.text })]

but select() runs against the pre-lowering SessionMessage.Info, where the type is "synthetic",
so the walk-back steps straight over every one. Two views of the same message disagree about what it
is, and that disagreement is what makes the walk long. shell messages have the same mismatch — also
lowered to role: "user", also skipped.

Measured impact

169 completed compactions from a local opencode.db, bucketed by tokens carried forward in recent
(chars/4):

carried forward compactions
<15K (the configured target) 46
15–40K 56
40–80K 39
80–150K 17
>150K 11

Worst case: 234,632 tokens carried forward, with 243 assistant messages between the last user
message and the compaction. That session holds 2,985 assistant messages against 45 user messages
(66:1) and 495 synthetics — roughly 11 usable anchors per user turn, none of them used.

The excess is re-read on every request until the next compaction. Over the seven sessions with ≥5
compactions, tokens carried above the 15K target × requests in the window comes to ~0.71 Gtok of
avoidable re-read:

session windows avg carried reqs/window excess re-read
A 18 91,024 160 0.20 Gtok
B 20 84,446 136 0.15 Gtok
C 19 64,832 161 0.15 Gtok
D 26 45,848 140 0.12 Gtok

It compounds: planContent feeds the previous recent back into the next compaction prompt, so an
overshoot inflates the next compaction's cold input too.

Expected behaviour

keep.tokens is a hard upper bound on the estimated serialized recent context:

Token.estimate(recent) <= keep.tokens

If the newest serialized message or logical unit alone exceeds the budget, retaining no recent tail is valid. The implementation must never walk backwards without a bound simply to find a role anchor.

Suggested fix

The fix should be expressed in terms of the retained-budget invariant:

  1. Select the newest serialized messages or logical units without exceeding keep.tokens.
  2. If role-aware boundaries are preserved, classify every message that lowers to a user-role provider message consistently, including synthetic and shell.
  3. Do not use an unbounded walk-back. If no valid boundary fits, split at the budget or retain no recent tail.

Plugins

No response

OpenCode version

0.0.0-beta-17498 (source verified at 39be159)

Steps to reproduce

  1. Leave compaction.keep.tokens at its default of 15000.
  2. Run an agent session that executes many tool calls without intervening user messages — a subagent
    fan-out or an autonomous loop.
  3. Let auto-compaction fire.
  4. Inspect the resulting compaction message's recent field. It contains everything since the last
    user turn, which can be an order of magnitude over keep.tokens.

Screenshot and/or share link

No response

Operating System

No response

Terminal

No response

Activity

  1. Danielkgr commented on Sep 26, 2026

    @Danielkgr

    Still reproducing on the current v2 release line. Measured case from 2.0.15; the pattern survives the 26 September v2 refactor.

    Measured case. OpenCode 2.0.15, packages/core/src/session/compaction.ts, compaction.keep.tokens: 16384. An automatic compaction reduced context from approximately 107k to 82k tokens, but the checkpoint recent field retained 229,085 characters, roughly 57k tokens at characters / 4. The findTailStart logic is byte-identical at tag v2.0.18 (source-verified only; no runtime reproduction on that version).

    Case pattern. The history handed to findTailStart is the transcript since the previous checkpoint. The budget walk picked a tail, then the unbounded while loop rewound its start to a user message. The previous compaction had fired mid assistant/tool sequence, so no user message existed after the previous checkpoint. With no anchor, the rewind reached the transcript start and the fallback retained everything since the previous checkpoint. Trigger: two automatic compactions during one long assistant/tool sequence with no user message in between.

    Reproduction status. Replay of this exact checkpoint through the real request pipeline in an isolated standalone instance confirmed the oversized tail survives into the following model request. Replay verification only; no from-scratch reproduction of a new oversized checkpoint.

    Workaround. A custom local plugin trims the checkpoint recent text in a context hook. It is deployed locally as mitigation. None of the core fix below is implemented, upstream or locally.

    Proposed fix. Following this issue's budget invariant: keep the budget-selected boundary as the fallback. Rewind to a user message only when the resulting tail stays within keep.tokens; otherwise start at an assistant-step boundary. A v2 assistant message carries its own tool calls and results, so retaining whole messages keeps each step intact. Define an explicit policy for an individually oversized step instead of absorbing older steps. Note that the current recentStart on v2 (a64bc361) still retains everything when no user message exists since the previous checkpoint.

    Regression coverage. User message within budget, distant user message, no user message since the previous checkpoint, consecutive automatic compactions, and an individually oversized assistant step.

  2. tpischke commented on Sep 27, 2026

    @tpischke

    Still reproducing on 2.0.18, with two further consequences for agent-driven sessions that I don't think are covered above.

    Setup: OpenCode 2.0.18, compaction: { auto: true, keep: { tokens: 16000 }, buffer: 20000 }, a local model with a 232k limit.context. An orchestrator session dispatches subagents (child sessions) for multi-hour implementation work and re-uses an implementer for its fix rounds. The numbers below come from the session DB (session_message, type compaction) and the server's request log.

    1. keep.tokens is ignored: recent = the whole current turn (as reported)

    A child session got a follow-up user message at 17:25. It then ran 12 assistant turns and 70 tool calls, with no further user message, until auto-compaction at 17:53:

    • the walk-back in findTailStart (v2.0.18 L348-350) went all the way to the 17:25 user message;
    • recent = 367,935 chars (~100k tokens) against keep.tokens: 16000;
    • the continuation prompt was 110,092 tokens, down only from 192,954. With a 232k limit, the session therefore has ~90k of room before it compacts again. Over 10 h, one re-used child session compacted 7 times.

    2. The summary describes the previous task and marks the active one as done

    Because everything since the last user message goes to recent, the summarizer only sees what came before the current task. Here that was the child's earlier, finished assignment. The resulting checkpoint reads:

    ### Active — (none) — both commits are committed; nothing uncommitted remains.
    ## Next Move — Nothing outstanding on the fix itself.

    The work actually in progress is present only in the verbatim recent. The model resumes with a checkpoint that says the work is finished, followed by 100k tokens that say otherwise. We had two orchestrator turns on Sep 26 that reported results the agent never received, and this is the pattern I would suspect for them.

    3. A fresh child session's first compaction keeps nothing

    When the only user message is the dispatch at conversation index 0, the walk-back stops at start = 0, latestUser is 0, and with no previous summary findTailStart returns messages.length (L350-361). Everything is summarized and nothing is retained verbatim, including the task currently in progress. Observed: the same child's first compaction (10:18) has recent: "".

    So for a subagent, the retained tail is either the entire current turn or nothing. keep.tokens never takes part.

    4. The subagent's role does not survive

    The dispatch message opens with You are a subagent spawned by another session. The summarizer is instructed to "Leave out instructions and setup the assistant was given rather than told by the user" (L366), and neither checkpoint of that session mentions it. The checkpoint instead says that questions were "posed to the operator" and that it should "act on it if the operator answers", although a child session has no operator to answer. The follow-up message had to state separately that a child session must never call the question tool, because an unanswered call aborts the dispatch.

    Suggestions

    • Bound the walk-back: if the nearest user boundary is more than N × keep.tokens back, split at an assistant-message boundary instead (keeping each tool call with its result). Or count synthetic messages as boundaries, as the issue proposes.
    • For the index-0 case, keep the newest assistant messages up to keep.tokens instead of retaining nothing.
    • For child sessions, carry the dispatch message's opening, or a "you are a subagent of " line, into the checkpoint verbatim.
  3. 1sfb commented on Sep 28, 2026

    @1sfb

    Hitting this on 2.0.18 too, with a local model (qwen3.8-27b via Ollama, n_ctx 262144, keep.tokens 15000, buffer 65536). For us it isn't just extra tokens. It killed a session on the 26th and nearly killed another today.

    The setup is one prompt followed by an hour or more of autonomous work, so the walk-back always goes all the way to that first message.

    Today's run, from session_message:

    step 3745        183,344 in + 16,457 out     -> auto compaction
    compaction 3760  recent = 776,649 chars (walked back 33 steps)
    step 3763        248,032 in + 12,074 out     = 260,106 / 262,144
    compaction 3780  248,735 in + 9,297 out      = 258,032 / 262,144
    step 3783        33,995 in
    

    So the first compaction actually made the context bigger (~200k -> 248k), and we got through with about 2k tokens to spare. The second compaction only worked because there was no user message after the first checkpoint, so it hit the fallback.

    On the 26th it went the other way. The checkpoint kept 919k chars of recent, and the next request was 382,090 tokens (exceed_context_size_error, n_ctx 262144). The overflow compaction then failed with "Nothing to compact yet", because the only thing after the checkpoint is its own recent text, and that doesn't count as compactable. Dead session.

    The rewind is still there on v2 after the refactor:

    const userBoundary = entries.findLastIndex((entry, index) => index <= dropped && entry.message.type === "user")

    Looking at the new overflow path, I don't think it would have saved the 26th either. The old recent plus everything after it is one "exchange" (no user message in between), so there's nothing to drop. I haven't tested that though.

    Side note: about 90% of that 776k was [Assistant reasoning] text, which never reached the model in the live requests (Ollama drops reasoning_content from earlier assistant messages). Written out as plain text it counts in full, which is why compaction grew the context instead of shrinking it. Opened #51818 for that.

  4. runsonmypc commented on Oct 4, 2026

    @runsonmypc

    @neriousy I see this is assigned to you. I reproduced the retention overflow on current v2 with an offline runner test: a 15k keep budget retained 76,099 characters and prevented continuation after switching to a smaller model. I'm preparing a focused draft PR that advances to a complete user exchange within the allowance, or summarizes everything if none fits. Tests cover zero retention, separator rounding, assistant-only history after a checkpoint, and continuation. This addresses the retained-text ceiling; total summary/reply budgeting and existing oversized checkpoints remain separate. Please flag any overlapping work already underway.

  5. nkoynov commented on Oct 7, 2026

    @nkoynov

    Data point from 2.0.24 in an agent-driven session (default compaction: keep.tokens 15000, ceiling 900K on a 1M model), plus a test of #53233 and a second loss it doesn't cover.

    1. The walk-back keeps the whole autonomous turn. After the last user message (09:08) the agent worked alone until auto compaction at 12:39. recent was 2,685,658 characters (~670K tokens: 1 user message, 460 assistant texts, 1,577 tool calls), so the summary covered only 06:49–09:08. A standing instruction from the 09:08 message never reached a summary.

    2. The next compaction dropped that tail unsummarized. At 18:32 an overflow compaction ran flattenAndDropOldest. Its first exchange was the previous recent plus everything up to the next user-role message (12:40–15:28, another 1.52M characters of assistant/tool records), and it was dropped as one block. The 09:08 instruction has been missing from every summary since.

    #53233 on v2.0.24 applies cleanly, and the compaction (19) and runner (223) tests pass. VM A/B with the ceiling lowered to 70K (one short exchange, then a standing rule plus 164 file reads in one turn), two runs per build:

    2.0.24 2.0.24 + #53233
    first summary covers turn 1 only turns 1 and 2, rule included
    recent after the first compaction ~125K chars empty
    prompt before → after ~69K → ~93K ~70K → ~36.5K
    compactions in the session 3 2

    What #53233 doesn't cover (offline runner scenarios, same result with and without it): on the overflow path, an instruction followed by a few hundred assistant steps before the next user message is still dropped as one exchange ([1 older exchange omitted]). Starting new groups at assistant-message boundaries, and summarizing the oldest groups (or at least naming what was dropped) instead of omitting them, would close that.

  6. superbiche commented on Oct 13, 2026

    @superbiche

    Another data point from 2.0.24, plus a different take on the fix than #53233 (my #54649, closed as its duplicate).

    Worker sessions here get one message and then run ~50 tool turns. Over 9 consecutive compactions on two of them, 7 kept 1–3× keep.tokens. The worst kept 434k characters of recent and summarized only 61k. The next compaction, 16 minutes later, had no user message since the checkpoint, so the last branch of recentStart kept everything again and the summarizer re-read the whole previous recent (449k-character input).

    On the fix: #53233 keeps only whole exchanges that fit, so in a single-prompt run the tail is always empty and the agent resumes with only the summary. For an autonomous worker the most useful verbatim context is its last few tool calls and results, which is exactly what goes. #54649 keeps the user message as the start when it is within another keep of the cut; otherwise the kept part starts at the cut, measured in characters with separators so it stays within keep. A tool call never gets split from its result, since both live in one assistant message. What #54649 does not cover, and #53233 does: a newest entry larger than keep alone is still kept whole.

    Branch: https://github.com/superbiche/opencode/tree/compaction-recent-bound. I'm happy to fold the "summarize an oversized newest entry" part of #53233 into it, or to bring the cut-point tail to #53233, whichever the maintainers prefer.

    Disclosure: this comment, the measurements and the fix were drafted with AI assistance; I reviewed them and the decisions are mine.


    @superbiche · user · drafted with Claude Opus 5.5, reviewed before posting; the voice and decisions are mine.

  7. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I reproduced this on the latest dev (ced73dc0f). I couldn't install the exact beta build you reported, so I tested against dev only.

    The script below builds a session with one user turn, then 60 assistant tool steps of about 2K tokens each, with a synthetic message after every fifth step. It then runs a compaction with the default keep of 15,000 tokens. The result:

    • keepID points at the user message, so 73 of the 74 messages are kept as-is. Sent to the model, that is about 122K tokens against the 15K setting.
    • None of the 12 synthetic messages was used as the cut point, even though they are sent to the model as user messages.

    This matches your reading of recentStart() in packages/core/src/session/compaction.ts. keep only sets where the search for a user message starts, and nothing limits how far back it goes. I've passed this to the maintainers.

    Reproduction script (issue-43250.test.ts)
    // Reproduction for anomalyco/opencode#43250. Copy to packages/core/test/issue-43250.test.ts in a dev checkout,
    // run `bun install`, then `cd packages/core && bun test test/issue-43250.test.ts`.
    import { expect, test } from "bun:test"
    import {
      GenerationOptions,
      LLMClient,
      LLMEvent,
      LanguageModel,
      Message,
      ToolDefinition,
      type LLMRequest,
    } from "@opencode/ai/effect"
    import { OpenAIChat } from "@opencode/ai/protocols"
    import { Database } from "@opencode/core/database/database"
    import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
    import { llmClient } from "@opencode/core/effect/app-node-platform"
    import { LayerNode } from "@opencode/util/effect/layer-node"
    import { Bus } from "@opencode/core/bus"
    import { EventTable } from "@opencode/core/event/sql"
    import { SessionCompaction } from "@opencode/core/session/compaction"
    import type { SessionContext } from "@opencode/core/session/context"
    import { MessageDecodeError } from "@opencode/core/session/error"
    import { SessionEvent } from "@opencode/core/session/event"
    import { SessionHistory } from "@opencode/core/session/history"
    import { SessionMessage } from "@opencode/core/session/message"
    import { SessionModelRequest } from "@opencode/core/session/model-request"
    import { PluginHooks } from "@opencode/core/plugin/hooks"
    import { Token } from "@opencode/core/util/token"
    import { SessionProjector } from "@opencode/core/session/projector"
    import { SessionRunnerModel } from "@opencode/core/session/runner/model"
    import { toLLMMessages } from "@opencode/core/session/runner/to-llm-message"
    import { SessionMessageTable, SessionTable } from "@opencode/core/session/sql"
    import { SessionStore } from "@opencode/core/session/store"
    import { Session } from "@opencode/core/session"
    import { Location } from "@opencode/core/location"
    import { Project } from "@opencode/core/project"
    import { ProjectTable } from "@opencode/core/project/sql"
    import { App } from "@opencode/core/app"
    import { Agent } from "@opencode/core/agent"
    import { Model } from "@opencode/core/model"
    import { Provider } from "@opencode/core/provider"
    import { AbsolutePath } from "@opencode/core/schema"
    import { Money } from "@opencode/schema/money"
    import { Skill } from "@opencode/schema/skill"
    import { Shell } from "@opencode/schema/shell"
    import { DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
    import { asc, eq, inArray, sql } from "drizzle-orm"
    import { testEffect } from "./lib/effect"
    
    let requests: LLMRequest[] = []
    /** Runs before a request's response streams; tests hold it back to keep a background summary in flight. */
    let beforeResponse: Effect.Effect<void> = Effect.void
    let reject: ((request: LLMRequest) => boolean) | undefined
    const model = LanguageModel.make({
      id: "summary-model",
      provider: "test",
      route: OpenAIChat.route,
    })
    const cost = [
      {
        input: Money.USDPerMillionTokens.make(1),
        output: Money.USDPerMillionTokens.make(2),
        cache: {
          read: Money.USDPerMillionTokens.make(0.1),
          write: Money.USDPerMillionTokens.make(0.5),
        },
      },
    ]
    const client = Layer.mock(LLMClient.Service)({
      stream: (request: LLMRequest) => {
        requests.push(request)
        if (reject?.(request)) {
          return Stream.make(LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }))
        }
        return Stream.fromEffect(beforeResponse).pipe(
          Stream.drain,
          Stream.concat(
            Stream.make(
              LLMEvent.textDelta({ id: "summary", text: "## Objective\n- manual summary" }),
              LLMEvent.stepFinish({
                index: 0,
                reason: { normalized: "stop" },
                usage: {
                  inputTokens: 15,
                  outputTokens: 6,
                  nonCachedInputTokens: 10,
                  cacheReadInputTokens: 3,
                  cacheWriteInputTokens: 2,
                  reasoningTokens: 2,
                },
              }),
              LLMEvent.finish({
                reason: { normalized: "stop" },
              }),
            ),
          ),
        )
      },
      generate: () => Effect.die("unused"),
    })
    const resolved = SessionRunnerModel.resolved(model, {
      capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
      cost,
      limit: { context: 200_000, output: 32_000 },
    })
    const it = testEffect(
      AppNodeBuilder.build(
        LayerNode.group([
          Database.node,
          Bus.node,
          SessionProjector.node,
          SessionStore.node,
          SessionCompaction.node,
          SessionModelRequest.node,
          PluginHooks.node,
        ]),
        [
          Bus.node.replace(Bus.configured({ persist: true })),
          llmClient.replace(client),
          Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
        ],
      ),
    )
    
    const insertSession = (id: Session.ID, overrides?: Partial<typeof SessionTable.$inferInsert>) =>
      Effect.gen(function* () {
        const db = (yield* Database.Service).db
        yield* db
          .insert(ProjectTable)
          .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
          .onConflictDoNothing()
          .run()
          .pipe(Effect.orDie)
        yield* db
          .insert(SessionTable)
          .values({
            id,
            project_id: Project.ID.global,
            slug: id,
            directory: "/project",
            title: id,
            version: "test",
            ...overrides,
          })
          .run()
          .pipe(Effect.orDie)
        const store = yield* SessionStore.Service
        return yield* store
          .get(id)
          .pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die(`session missing: ${id}`))))
      })
    
    const loaded = (session: Session.Info, messages: readonly SessionMessage.Info[]) => ({
      session,
      messages,
      model: resolved,
      agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
      initial: "Session instructions",
      tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
    })
    
    /** Opens the compaction's message as the runner does when it delivers `/compact`, then compacts. */
    const compactManually = (
      session: Session.Info,
      messages: readonly SessionMessage.Info[],
      inputID = SessionMessage.ID.create(),
    ) =>
      Effect.gen(function* () {
        const bus = yield* Bus.Service
        const compaction = yield* SessionCompaction.Service
        yield* bus.publish(SessionEvent.Compaction.Started, {
          sessionID: session.id,
          reason: "manual",
          inputID,
        })
        return yield* compaction.compact({ reason: "manual", context: loaded(session, messages), inputID })
      })
    
    /** Stores messages as the session's history so far, ahead of anything published next. */
    const storeMessages = (sessionID: Session.ID, messages: readonly SessionMessage.Info[]) =>
      Effect.gen(function* () {
        const db = (yield* Database.Service).db
        yield* db
          .insert(SessionMessageTable)
          .values(
            messages.map((message, index) => {
              const { id, type, ...data } = Schema.encodeSync(SessionMessage.Info)(message)
              return { id: SessionMessage.ID.make(id), session_id: sessionID, type, seq: index + 1, time_created: 0, data }
            }),
          )
          .run()
          .pipe(Effect.orDie)
        yield* Bus.reserveSequence(db, sessionID, messages.length)
      })
    
    
    // Repro for anomalyco/opencode#43250: keep.tokens (default 15000) is not an upper bound on the retained tail.
    it.effect("issue 43250: retained tail stays within keep tokens in a long agent loop", () =>
      Effect.gen(function* () {
        requests = []
        const session = yield* insertSession(Session.ID.make("ses_issue_43250"))
        const user = SessionMessage.User.make({
          id: SessionMessage.ID.create(),
          type: "user",
          text: "Run the autonomous refactor",
          time: { created: DateTime.makeUnsafe(0) },
        })
        const assistant = (i: number) =>
          Schema.decodeUnknownSync(SessionMessage.Assistant)({
            id: SessionMessage.ID.create(),
            type: "assistant",
            agent: Agent.defaultID,
            model: { id: "summary-model", providerID: "test" },
            content: [
              {
                type: "tool",
                id: `call_${i}`,
                name: "read",
                state: { status: "completed", input: {}, content: [{ type: "text", text: `file ${i} ` + "y".repeat(8_000) }] },
                time: { created: 0 },
              },
            ],
            time: { created: 0, completed: 0 },
          })
        const synthetic = (i: number) =>
          Schema.decodeUnknownSync(SessionMessage.Info)({
            id: SessionMessage.ID.create(),
            type: "synthetic",
            text: `Synthetic continuation ${i}`,
            time: { created: 0 },
          })
        // One user turn, then 60 assistant tool steps (~2k tokens each) with a synthetic message every 5 steps.
        const loop = Array.from({ length: 60 }, (_, i) => (i % 5 === 4 ? [assistant(i), synthetic(i)] : [assistant(i)])).flat()
        const messages = [SessionMessage.User.make({ id: SessionMessage.ID.create(), type: "user", text: "Earlier work", time: { created: DateTime.makeUnsafe(0) } }), user, ...loop]
        yield* storeMessages(session.id, messages)
        expect(yield* compactManually(session, messages)).toEqual({ status: "completed" })
        const store = yield* SessionStore.Service
        const stored = (yield* store.context(session.id))[0]
        const keepID = stored?.type === "compaction" && stored.status === "completed" ? stored.metadata?.keepID : undefined
        const recent = stored?.type === "compaction" && stored.status === "completed" ? (stored.metadata?.recent ?? "") : ""
        const keptFrom = messages.findIndex((m) => m.id === keepID)
        console.log(
          `keepID index=${keptFrom} type=${messages[keptFrom]?.type}; kept ${messages.length - keptFrom} of ${messages.length} messages; ` +
            `recent estimate=${Token.estimate(recent)} tokens (keep=15000)`,
        )
        const verbatim = Token.estimate(
          toLLMMessages(messages.slice(keptFrom) as SessionMessage.Info[], { id: "summary-model", providerID: "test" } as any).map((m) => JSON.stringify(m.content)).join("\n"),
        )
        console.log(`messages from keepID on, as sent to the model after compaction: ~${verbatim} tokens`)
        expect(Token.estimate(recent)).toBeLessThanOrEqual(15_000)
      }),
    )

    Run it: bun issue-43250.test.ts

  8. woonaval commented on Nov 3, 2026

    @woonaval

    Same root cause, still present on v2.2.5 (2026-10-30), and reproducible offline.

    Independent data point from a single OpenCode server (1M-context model, compaction: { auto: true, keep: { tokens: 12000 }, buffer: 700000 }):

    • Across 86 completed compactions in opencode.db, the first post-compaction request (tokens.input + cache.read + cache.write) is typically 30–50k tokens (median 35.9k), minimum 13.2k (empty tail = fixed prompt ~11k + summary ~2k), maximum 106.3k.
    • Worst case: pre-context ~299k → compaction → first turn 91,680 new + 11,662 cached = 103,342. metadata.keepID pointed at a user message 80.5 min earlier (122 messages kept verbatim).
    • The history handed to the walk is the transcript since the previous checkpoint (matches the reading above), so the unbounded rewind is bounded only by that window; when the window contains no user message, the fallback keeps the entire window.
    • Offline reproduction: a reimplementation of the tail selection — budget walk accumulating round(len/4) per scored message, then rewind to the last type: "user" message, with the no-anchor fallback — driven by keep.tokens: 12000 alone, reproduces the recorded metadata.keepID for 38 / 38 compactions since 2026-11-01. Confirms keep sets only where the search starts, not the retained size.
    • Cost: the first turn after every compaction is a prompt-cache miss, so the oversized checkpoint is billed as fresh input on each compaction.

    PR #53233 fixes it. I built the branch (runsonmypc/opencode@8456cc2, base dev = v2.2.5), ran its tests (44/44 pass), and put it on the same server. Two consecutive auto-compactions of the same session, same model:

    build first post-compaction request retained tail
    v2.2.5 33,318 (23,206 new + 10,112 cached) metadata.keepID = a user message ~2.5 min earlier
    #53233 15,370 (all new input) no metadata.keepID — the oversized latest exchange is summarized

    The pre-compaction context was ~299k in both cases, so the only variable is the checkpoint; the oversized checkpoint also disappears from the record. Thanks — this is the fix.

  9. Trasen commented on Nov 9, 2026

    @Trasen

    Data point from v2.1.3, proxy-served Qwen (OpenAI-compatible), 262144 window

    Most reports here are Ollama/local — mine is a 262144-context Qwen served through an OpenAI-compatible proxy, default compaction (keep.tokens: 15000, default buffer). Setup: one driving prompt, then a multi-hour autonomous loop (no user messages until the end).

    From opencode.db (session_message) for the dead session:

    1,076 assistant messages vs 13 user messages; 6 successful auto-compactions
    Final checkpoint's keepID sits 249 messages (~1.5 MB of raw data) before the end, against the 15k setting
    Timeline:

    step N-1    222,272 tokens total prompt (delta input 4,912 + cache read 217,360)
    compaction  completed (auto)
    step N      provider rejects: "prompt contains at least 262145 input tokens"
    recovery    compaction.unavailable: "Nothing to compact yet"
    [user removes their question, resumes]
    retry       compaction.failed: "cannot be reduced further without losing
                the latest exchange or checkpoint"
    final state idle_outcome: failed, unrecoverable
    
    

    Two observations that match the thread but weren't stated together:

    The trigger was skipped by one big step. The last measured step sat ~550 tokens under the default-buffer trigger (~222,822); the next step added ~40k (tool output + reasoning), jumping straight past the hard limit. Step-boundary triggering means one large step can leapfrog the trigger entirely.
    Once the oversized tail is established, user-side mitigation is impossible. With the keep window swallowing the whole conversation, the compaction floor (fixed overhead + summary + retained tail + latest exchange) exceeds the window, so removing/reverting user content can't help — the shrink ladder bottoms out immediately. This is what makes the failure feel like "compaction stopped working" rather than "context grew too big".
    Happy to share more measurements if useful.

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