diff --git a/packages/core/src/config/compaction.ts b/packages/core/src/config/compaction.ts index 3c5960c83556..608dedd26614 100644 --- a/packages/core/src/config/compaction.ts +++ b/packages/core/src/config/compaction.ts @@ -12,4 +12,5 @@ export class Info extends Schema.Class("ConfigV2.Compaction")({ prune: Schema.Boolean.pipe(Schema.optional), keep: Keep.pipe(Schema.optional), buffer: NonNegativeInt.pipe(Schema.optional), + pinFirstUserTurn: Schema.Boolean.pipe(Schema.optional), }) {} diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 5229949cb958..26e6dc1d8cac 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -59,6 +59,7 @@ type Settings = { readonly auto: boolean readonly buffer: number readonly tokens: number + readonly pinFirstUserTurn: boolean } type Dependencies = { @@ -125,25 +126,42 @@ const settings = (documents: readonly Config.Entry[]) => { auto: current.auto ?? result.auto, buffer: current.buffer ?? result.buffer, tokens: current.keep?.tokens ?? result.tokens, + pinFirstUserTurn: current.pinFirstUserTurn ?? result.pinFirstUserTurn, }), - { auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }, + { auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS, pinFirstUserTurn: false }, ) } const select = ( entries: readonly Entry[], tokens: number, + pinFirstUserTurn?: boolean, ): { readonly head: string; readonly recent: string } | undefined => { const conversation = entries .filter((entry) => entry.message.type !== "compaction") .map((entry) => serialize(entry.message)) .filter(Boolean) if (conversation.length === 0) return + let firstUserIdx = -1 + if (pinFirstUserTurn) { + let convIdx = 0 + for (const entry of entries) { + if (entry.message.type === "compaction") continue + const text = serialize(entry.message) + if (!text) continue + if (entry.message.type === "user") { + firstUserIdx = convIdx + break + } + convIdx++ + } + } let total = 0 let split = conversation.length let splitPrefix = "" let splitSuffix = "" for (let index = conversation.length - 1; index >= 0; index--) { + if (pinFirstUserTurn && index < firstUserIdx) break const next = total + Token.estimate(conversation[index]) if (next > tokens) { const remaining = Math.max(0, tokens - total) * 4 @@ -178,7 +196,7 @@ export const make = (dependencies: Dependencies) => { const context = input.model.route.defaults.limits?.context if (context === undefined || context <= 0) return false const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0 - const selected = select(input.entries, config.tokens) + const selected = select(input.entries, config.tokens, config.pinFirstUserTurn) const previousSummary = input.entries.find((entry) => entry.message.type === "compaction")?.message if (!selected || (selected.head.length === 0 && previousSummary?.type !== "compaction")) return false const summaryPrompt = buildPrompt({ diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 2e773f71e256..cdf5ecdd4b31 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -161,6 +161,10 @@ export const Info = Schema.Struct({ reserved: Schema.optional(NonNegativeInt).annotate({ description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", }), + pin_first_user_turn: Schema.optional(Schema.Boolean).annotate({ + description: + "Preserve the first user message verbatim during compaction, keeping the prompt prefix stable for cache (default: false)", + }), }), ), experimental: Schema.optional( diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index c7ac963c690e..2a4f9c529e63 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -205,7 +205,11 @@ export const layer = Layer.effect( const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model }) const all = turns(input.messages) if (!all.length) return { head: input.messages, tail_start_id: undefined } - const recent = all.slice(-limit) + let recent = all.slice(-limit) + const pinFirst = input.cfg.compaction?.pin_first_user_turn === true + if (pinFirst && all.length > 0 && !recent.some((t) => t.id === all[0].id)) { + recent = [all[0], ...recent] + } const sizes = yield* Effect.forEach( recent, (turn) =>