From a64bc3616e9fa036e28ebf714a2b8a00138b3a78 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:00:11 -0500 Subject: [PATCH] refactor(core): cleanup session compaction (#51447) --- AGENTS.md | 2 +- packages/core/src/config/plugin/compaction.ts | 2 +- packages/core/src/plugin/compaction.ts | 29 - packages/core/src/plugin/internal.ts | 2 - packages/core/src/session/compaction.ts | 1454 +++++++++-------- packages/core/src/session/runner/llm.ts | 74 +- .../core/src/session/runner/to-llm-message.ts | 19 +- packages/core/test/config/compaction.test.ts | 28 +- packages/core/test/session-compaction.test.ts | 317 ++-- .../test/session-native-compaction.test.ts | 101 +- .../core/test/session-runner-message.test.ts | 30 + packages/core/test/session-runner.test.ts | 288 +++- services/www/src/docs/content/compaction.mdx | 5 +- 13 files changed, 1318 insertions(+), 1033 deletions(-) delete mode 100644 packages/core/src/plugin/compaction.ts diff --git a/AGENTS.md b/AGENTS.md index 257175fc05c..38926ad794f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,7 +184,7 @@ const table = sqliteTable("session", { - Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. - Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop. - Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee. -- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence. +- Keep provider-specific native compaction mechanisms in `@opencode/ai` behind `LLMClient.compact`. `SessionCompaction` chooses a summary or native compaction from the model's `compaction` setting and owns route provenance, request shrinking, the retry policy, interruption, usage accounting, and checkpoint persistence. - Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once. - One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle. - Keep event replay ownership separate from clustered Session execution ownership. diff --git a/packages/core/src/config/plugin/compaction.ts b/packages/core/src/config/plugin/compaction.ts index 6788ca7d320..7080d88836c 100644 --- a/packages/core/src/config/plugin/compaction.ts +++ b/packages/core/src/config/plugin/compaction.ts @@ -18,7 +18,7 @@ export const Plugin = define({ editor.configure({ ...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }), ...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }), - ...(entry.info.compaction.keep?.tokens === undefined ? {} : { tokens: entry.info.compaction.keep.tokens }), + ...(entry.info.compaction.keep?.tokens === undefined ? {} : { keep: entry.info.compaction.keep.tokens }), }) } }) diff --git a/packages/core/src/plugin/compaction.ts b/packages/core/src/plugin/compaction.ts deleted file mode 100644 index 8b80afb7c48..00000000000 --- a/packages/core/src/plugin/compaction.ts +++ /dev/null @@ -1,29 +0,0 @@ -export * as NativeCompactionPlugin from "./compaction.js" - -import { LLMClient, Message } from "@opencode/ai" -import { define } from "@opencode/plugin/effect/plugin" -import { Effect } from "effect" -import { SessionCompaction } from "../session/compaction.js" -import type { PluginInternal } from "./internal.js" - -export const Plugin = define({ - id: "opencode.compaction.native", - effect: Effect.fn("NativeCompactionPlugin")(function* () { - const llm = yield* LLMClient.Service - const compaction = yield* SessionCompaction.Service - yield* compaction.transform((editor) => { - editor.native((input) => { - const request = input.request - if (LLMClient.canCompact(request, { mechanism: "trigger" })) - return Effect.gen(function* () { - const retained = yield* input.retained - const result = yield* llm.compact(request, { ...input.options, mechanism: "trigger" }) - return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage } - }) - if (LLMClient.canCompact(request)) - return llm.compact(request, { mechanism: "endpoint", http: input.options.http }) - return undefined - }) - }) - }), -} satisfies PluginInternal.InternalPlugin) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 9417691be34..36fa55c94c0 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -88,7 +88,6 @@ import { WriteTool } from "../tool/plugin/write.js" import { AgentPlugin } from "./agent.js" import BrowserPlugin from "@opencode/plugin-browser" import { CommandPlugin } from "./command.js" -import { NativeCompactionPlugin } from "./compaction.js" import { IdentityPlugin } from "./identity.js" import { PlanPlugin } from "./plan.js" import { ModelsDevPlugin } from "./models-dev.js" @@ -225,7 +224,6 @@ const pre = [ SkillPlugin.Plugin, VcsHgPlugin.Plugin, ModelsDevPlugin, - NativeCompactionPlugin.Plugin, ...ProviderPlugins, ...WebSearchPlugins, PatchTool.Plugin, diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index fc9d16799a0..f695a995375 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -2,47 +2,100 @@ export * as SessionCompaction from "./compaction.js" import { AIError, + type ContentPart, InvalidProviderOutputError, - UnknownProviderError, + InvalidRequestError, isContextOverflowFailure, LLMClient, LLMEvent, LLMRequest, Message, - type ContentPart, + type ToolEntry, + UnknownProviderError, type Usage, } from "@opencode/ai" import type { StreamOptions } from "@opencode/ai/route" import type { SessionCompactionResult } from "@opencode/plugin/effect/session" -import { SessionError } from "@opencode/schema/session-error" -import { Context, Effect, Layer, Stream } from "effect" +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 { Bus } from "../bus.js" import { Database } from "../database/database.js" -import { makeLocationNode } from "@opencode/util/effect/app-node" import { llmClient } from "../effect/app-node-platform.js" -import { SessionEvent } from "./event.js" +import { State } from "../state.js" +import { Token } from "../util/token.js" import type { SessionContext } from "./context.js" +import { SessionEvent } from "./event.js" import { SessionHistory } from "./history.js" import type { SessionMessage } from "./message.js" import { SessionModelRequest } from "./model-request.js" import { SessionProviderContext } from "./provider-context.js" -import type { SessionRunnerModel } from "./runner/model.js" import { SessionRunnerRetry } from "./runner/retry.js" -import { SessionSchema } from "./schema.js" -import { toSessionError } from "./to-session-error.js" -import { Token } from "../util/token.js" -import { SessionUsage } from "./usage.js" -import { State } from "../state.js" import { toLLMMessages } from "./runner/to-llm-message.js" -import type { AgentNotFoundError } from "./error.js" -import type { Instructions } from "../instructions/index.js" +import { toSessionError } from "./to-session-error.js" +import { SessionUsage } from "./usage.js" -const DEFAULT_BUFFER = 20_000 -const DEFAULT_KEEP_TOKENS = 15_000 -const OUTPUT_TOKEN_MAX = 32_000 -const TOOL_OUTPUT_MAX_CHARS = 2_000 +export type Settings = { + auto: boolean + /** Tokens kept free below the model's limits before compacting. Unset keeps 10% free. */ + buffer?: number + /** Tokens of recent conversation kept verbatim beside the summary. */ + keep: number +} + +export type Editor = { + configure: (settings: Partial) => void +} + +export type Trigger = + /** `overflow`: the provider just rejected this context as too long. */ + | { readonly reason: "auto" | "overflow"; readonly context: SessionContext.Loaded } + /** `inputID` is the `/compact` inbox item, whose message shows the outcome. */ + | { readonly reason: "manual"; readonly context: SessionContext.Loaded; readonly inputID: SessionMessage.ID } + +export type Outcome = + /** Only `auto` skips: the context fits, or automatic compaction is off. */ + | { readonly status: "skipped" } + | { readonly status: "completed" } + | { readonly status: "failed"; readonly error: SessionError.Error } + +export interface Interface extends State.Transformable { + readonly compact: (trigger: Trigger) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/SessionCompaction") {} + +/** A summary fills `text` and `recent`; a native compaction fills `providerContext`. */ +type Result = { + readonly text: string + // todo: this should become a msg reference, we shouldnt be stringifying the recent history (confuses some models...) + readonly recent: string + // todo: consolidate these 2 + readonly providerState?: SessionMessage.ProviderState + readonly providerContext?: SessionProviderContext.Info + readonly metadata?: Record +} + +type Failure = { + readonly error: SessionError.Error +} + +type Prepared = Effect.Success> + +type Streamed = { + readonly text: string + readonly providerState?: SessionMessage.ProviderState +} + +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] +/** A common window size, assumed for the compaction request when the model's window is unknown. */ +const UNKNOWN_WINDOW = 200_000 +const TOOL_OUTPUT_MAX_CHARS = 1_250 const IMAGE_TOKEN_ESTIMATE = 1_500 const PDF_TOKEN_ESTIMATE = 2_000 + const SUMMARY_TEMPLATE = `You MUST use this format for your response (you may omit sections that aren't applicable). Do not include the