Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<number>
}) {
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"]
Expand Down Expand Up @@ -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 =
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/test/session/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
() => {
Expand Down
Loading