diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 75d6374bfa54..3f1e2a0b9973 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -31,6 +31,9 @@ const TOOL_OUTPUT_MAX_CHARS = 2_000 const PRUNE_PROTECTED_TOOLS = ["skill"] const MIN_PRESERVE_RECENT_TOKENS = 2_000 const MAX_PRESERVE_RECENT_TOKENS = 15_000 +// Prompt template, previous summary, and plugin context that accompany the +// serialized head in the summarizer request. +const SUMMARIZER_PROMPT_RESERVE = 8_000 type Turn = { start: number end: number @@ -162,6 +165,40 @@ function splitTurn(input: { }) } +// The summarizer request only carries the serialized head. When it no longer +// fits the model's usable context, drop the oldest complete turns instead of +// giving up with "session too large to compact". +function shrinkHeadToBudget(input: { + messages: SessionV1.WithParts[] + model: Provider.Model + budget: number + estimate: (input: { messages: SessionV1.WithParts[]; model: Provider.Model }) => Effect.Effect +}) { + return Effect.gen(function* () { + let total = yield* input.estimate({ messages: input.messages, model: input.model }) + if (total <= input.budget) return input.messages + const all = turns(input.messages) + let start = 0 + let i = 0 + while (total > input.budget && i < all.length - 1) { + const next = all[i + 1] + total -= yield* input.estimate({ + messages: input.messages.slice(start, next.start), + model: input.model, + }) + start = next.start + i++ + } + if (start === 0) return input.messages + yield* Effect.logInfo("head exceeds budget; dropping oldest turns", { + dropped: start, + kept: input.messages.length - start, + budget: input.budget, + }) + return input.messages.slice(start) + }) +} + export interface Interface { readonly isOverflow: (input: { tokens: SessionV1.Assistant["tokens"] @@ -375,7 +412,13 @@ const layer = Layer.effect( { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const msgs = structuredClone(selected.head) + const head = yield* shrinkHeadToBudget({ + messages: selected.head, + model, + budget: Math.max(0, usable({ cfg, model, outputTokenMax: flags.outputTokenMax }) - SUMMARIZER_PROMPT_RESERVE), + estimate, + }) + const msgs = structuredClone(head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const conversation = msgs.map(serialize).filter(Boolean).join("\n\n") const nextPrompt = diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c76dd98b8614..03165e5219ef 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1051,6 +1051,43 @@ describe("session.compaction.process", () => { { git: true }, ) + itCompaction.instance( + "drops oldest turns when the head exceeds the model context budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "x".repeat(24_000)) + yield* createUserMessage(session.id, "y".repeat(8_000)) + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + expect(result).toBe("continue") + expect(captured).toContain("yyyy") + expect(captured).not.toContain("xxxx") + }).pipe( + withCompaction({ + llm: stub.llmLayer, + provider: ProviderTest.fake({ model: createModel({ context: 20_000, output: 8_000 }) }), + config: cfg({ preserve_recent_tokens: 100 }), + }), + ) + }, + { git: true }, + ) + itCompaction.instance( "retains a split turn suffix when a later message fits the preserve token budget", () => {