diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index c0bbf1cb451b..5b3526d2c636 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2349,6 +2349,7 @@ export type ConfigEntry = experimental?: { portable_shell_scanner?: boolean todo?: boolean + precompute_compaction?: boolean subagent_depth?: number location_idle_timeout?: string | false policies?: Array<{ diff --git a/packages/core/src/config/normalize.ts b/packages/core/src/config/normalize.ts index 4ef02b96e5e3..ab5204471870 100644 --- a/packages/core/src/config/normalize.ts +++ b/packages/core/src/config/normalize.ts @@ -476,6 +476,15 @@ function normalizeExperimental( ) if (value !== undefined) result.todo = value } + if (own(experimental, "precompute_compaction")) { + const value = decodeEncoded( + ConfigExperimental.Info.fields.precompute_compaction, + experimental.precompute_compaction, + ["experimental", "precompute_compaction"], + diagnostics, + ) + if (value !== undefined) result.precompute_compaction = value + } if (own(experimental, "subagent_depth")) { const value = decodeEncoded( ConfigExperimental.Info.fields.subagent_depth, diff --git a/packages/core/src/config/plugin/compaction.ts b/packages/core/src/config/plugin/compaction.ts index 7080d88836c5..c183aafd624d 100644 --- a/packages/core/src/config/plugin/compaction.ts +++ b/packages/core/src/config/plugin/compaction.ts @@ -13,6 +13,7 @@ export const Plugin = define({ const compaction = yield* SessionCompaction.Service const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, compaction.reload()) yield* compaction.transform((editor) => { + editor.configure({ precompute: Config.latest(loaded.entries, "experimental")?.precompute_compaction === true }) for (const entry of loaded.entries) { if (entry.type !== "document" || !entry.info.compaction) continue editor.configure({ diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 7b305d8d661f..a6b7f5d05cbb 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -18,7 +18,7 @@ import type { StreamOptions } from "@opencode/ai/route" import type { SessionCompactionResult } from "@opencode/plugin/effect/session" import type { SessionError } from "@opencode/schema/session-error" import { makeLocationNode } from "@opencode/util/effect/app-node" -import { Context, Effect, Layer, Result, Stream } from "effect" +import { Context, Effect, Exit, Fiber, Layer, Result, Scope, Stream } from "effect" import { Agent } from "../agent.js" import { Bus } from "../bus.js" import { Database } from "../database/database.js" @@ -44,6 +44,8 @@ export type Settings = { buffer?: number /** Tokens of recent conversation kept verbatim beside the summary. */ keep: number + /** Summarize in the background as the context nears the threshold, so automatic compaction can swap it in. */ + precompute: boolean } export type Editor = { @@ -64,6 +66,11 @@ export type Outcome = export interface Interface extends State.Transformable { readonly compact: (trigger: Trigger) => Effect.Effect + /** + * Starts a background summary when the context nears the threshold. Never waits for it. Loads `context` only when + * background summaries are on. + */ + readonly precompute: (context: Effect.Effect) => Effect.Effect } export class Service extends Context.Service()("@opencode/SessionCompaction") {} @@ -88,6 +95,26 @@ type Streamed = { readonly text: string } +/** A summary run off the critical path: it covers only `messages`, streams no deltas, and bills into `usage`. */ +type Background = { + readonly messages: ReadonlyArray + readonly usage: (usage: SessionUsage.Recorded) => void +} + +/** A background summary of everything before `keepID`, held until automatic compaction is due. */ +type Precomputed = { + readonly firstID: SessionMessage.ID | undefined + readonly keepID: SessionMessage.ID + readonly model: SessionContext.Loaded["model"]["ref"] + readonly initial: string + /** The longest tail since `keepID` that compaction still accepts beside this summary. */ + readonly tailLimit: number + /** Background summaries that failed since the last compaction, this one included once it has. */ + readonly failures: number + readonly usage: SessionUsage.Recorded[] + readonly fiber: Fiber.Fiber +} + const NOTHING_TO_COMPACT: Failure = { error: { type: "compaction.unavailable", message: "Nothing to compact yet" } } /** After each "too long" rejection, the next attempt aims at this share of the first rejected request's size. */ const SHRINK_STEPS = [0.7, 0.5, 0.35] @@ -105,6 +132,18 @@ const UNKNOWN_WINDOW = 200_000 const SUMMARY_OUTPUT_FLOOR = 32_000 const IMAGE_TOKEN_ESTIMATE = 1_500 const PDF_TOKEN_ESTIMATE = 2_000 +/** Share of the compaction ceiling at which a background summary starts. */ +const PRECOMPUTE_RATIO = 0.85 +/** Measured prompt tokens below which a background summary is not worth its cost. */ +const PRECOMPUTE_MIN_TOKENS = 64_000 +/** + * The most text a swapped-in summary keeps verbatim beside it: twice `keep`, at least 30k, at most a quarter of the + * budget. A longer tail would leave the context near the threshold, so compaction summarizes from scratch instead. + */ +const PRECOMPUTED_TAIL_MIN = 30_000 +const PRECOMPUTED_TAIL_SHARE = 0.25 +/** Failed background summaries after which no new one starts until the next compaction. */ +const PRECOMPUTE_MAX_FAILURES = 2 const SUMMARY_TEMPLATE = `You MUST use this format for your response (you may omit sections that aren't applicable). Do not include the