diff --git a/packages/core/src/config/plugin/compaction.ts b/packages/core/src/config/plugin/compaction.ts new file mode 100644 index 0000000000..c265e138fa --- /dev/null +++ b/packages/core/src/config/plugin/compaction.ts @@ -0,0 +1,37 @@ +export * as ConfigCompactionPlugin from "./compaction.js" + +import { define } from "@opencode-ai/plugin/effect/plugin" +import { Effect, Stream } from "effect" +import { Config } from "../../config.js" +import { SessionCompaction } from "../../session/compaction.js" + +export const Plugin = define({ + id: "opencode.config.compaction", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const compaction = yield* SessionCompaction.Service + const loaded = { entries: yield* config.entries() } + const reload = config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(compaction.reload()), + ) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => reload), + Effect.forkScoped({ startImmediately: true }), + ) + loaded.entries = yield* config.entries() + yield* compaction.transform((draft) => { + for (const entry of loaded.entries) { + if (entry.type !== "document" || !entry.info.compaction) continue + draft.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 }), + }) + } + }) + }), +}) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 3fe3cd0c82..d3a74387bb 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -13,6 +13,7 @@ import { Config } from "../config.js" import { Credential } from "../credential.js" import { ConfigAgentPlugin } from "../config/plugin/agent.js" import { ConfigCommandPlugin } from "../config/plugin/command.js" +import { ConfigCompactionPlugin } from "../config/plugin/compaction.js" import { ConfigFormatterPlugin } from "../config/plugin/formatter.js" import { ConfigImagePlugin } from "../config/plugin/image.js" import { ConfigInstructionPlugin } from "../config/plugin/instruction.js" @@ -47,6 +48,7 @@ import { Permission } from "../permission.js" import { Reference } from "../reference.js" import { WebSearch } from "../websearch.js" import { Ripgrep } from "../ripgrep.js" +import { SessionCompaction } from "../session/compaction.js" import { SessionInstructions } from "../session/instructions.js" import { Shell } from "../shell.js" import { ShellSelect } from "../shell/select.js" @@ -116,6 +118,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const reference = yield* Reference.Service const websearch = yield* WebSearch.Service const ripgrep = yield* Ripgrep.Service + const compaction = yield* SessionCompaction.Service const instructions = yield* SessionInstructions.Service const shell = yield* Shell.Service const shellSelect = yield* ShellSelect.Service @@ -158,6 +161,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(Reference.Service, reference), Context.make(WebSearch.Service, websearch), Context.make(Ripgrep.Service, ripgrep), + Context.make(SessionCompaction.Service, compaction), Context.make(SessionInstructions.Service, instructions), Context.make(Shell.Service, shell), Context.make(ShellSelect.Service, shellSelect), @@ -207,6 +211,7 @@ export const requirements = LayerNode.group([ Reference.node, WebSearch.node, Ripgrep.node, + SessionCompaction.node, SessionInstructions.node, Shell.node, ShellSelect.node, @@ -253,6 +258,7 @@ const post = [ ConfigReferencePlugin.Plugin, ConfigAgentPlugin.Plugin, ConfigCommandPlugin.Plugin, + ConfigCompactionPlugin.Plugin, ConfigFormatterPlugin.Plugin, ConfigImagePlugin.Plugin, ConfigShellPlugin.Plugin, diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index ed3fb859bd..e1d8641804 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -3,9 +3,7 @@ export * as SessionCompaction from "./compaction.js" import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai" import type { StreamOptions } from "@opencode-ai/ai/route" import { SessionError } from "@opencode-ai/schema/session-error" -import { Document, type Entry } from "@opencode-ai/schema/config" import { Context, Effect, Layer, Stream } from "effect" -import { Config } from "../config.js" import { Bus } from "../bus.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { llmClient } from "../effect/app-node-platform.js" @@ -24,6 +22,7 @@ import type { Info, Ref } from "../model.js" import { SessionUsage } from "./usage.js" import { PluginHooks } from "../plugin/hooks.js" import { Agent } from "../agent.js" +import { State } from "../state.js" const DEFAULT_BUFFER = 20_000 const DEFAULT_KEEP_TOKENS = 15_000 @@ -61,10 +60,14 @@ Rules: - Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known. - Do not mention the summary process or that context was compacted.` -type Settings = { - readonly auto: boolean - readonly buffer: number - readonly tokens: number +export type Settings = { + auto: boolean + buffer: number + tokens: number +} + +export type Draft = { + configure: (settings: Partial) => void } type Dependencies = { @@ -74,7 +77,6 @@ type Dependencies = { readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream } readonly models: SessionRunnerModel.Interface - readonly config: Settings readonly hooks: PluginHooks.Interface } @@ -111,7 +113,7 @@ export type Outcome = | Pick | Pick -export interface Interface { +export interface Interface extends State.Transformable { readonly required: (input: RequiredInput) => boolean readonly compact: (input: AutoInput) => Effect.Effect readonly compactManual: (input: ManualInput) => Effect.Effect @@ -165,17 +167,6 @@ const serialize = (message: SessionMessage.Info) => { return "" } -const settings = (documents: readonly Entry[]) => { - const configured = documents - .filter((entry): entry is Document => entry.type === "document") - .flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : [])) - return { - auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true, - buffer: configured.findLast((value) => value.buffer !== undefined)?.buffer ?? DEFAULT_BUFFER, - tokens: configured.findLast((value) => value.keep?.tokens !== undefined)?.keep?.tokens ?? DEFAULT_KEEP_TOKENS, - } -} - const select = ( messages: readonly SessionMessage.Info[], tokens: number, @@ -240,7 +231,17 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) = } const make = (dependencies: Dependencies) => { - const config = dependencies.config + const state = State.create({ + name: "session-compaction", + initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }), + draft: (draft) => ({ + configure: (settings) => { + if (settings.auto !== undefined) draft.auto = settings.auto + if (settings.buffer !== undefined) draft.buffer = settings.buffer + if (settings.tokens !== undefined) draft.tokens = settings.tokens + }, + }), + }) const failed = Effect.fnUntraced(function* (input: { readonly sessionID: SessionSchema.ID readonly reason: SessionMessage.Compaction["reason"] @@ -350,7 +351,7 @@ const make = (dependencies: Dependencies) => { return { status: "completed" as const } }) const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) { - const content = planContent(input.messages, config.tokens) + const content = planContent(input.messages, state.get().tokens) if (content) return yield* execute({ session: input.session, @@ -368,6 +369,7 @@ const make = (dependencies: Dependencies) => { }) }) const required = (input: RequiredInput) => { + const config = state.get() if (!config.auto) return false const context = input.model.route.defaults.limits?.context if (context === undefined || context <= 0) return false @@ -388,7 +390,7 @@ const make = (dependencies: Dependencies) => { return used >= promptCeiling } const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) { - const content = planContent(input.messages, config.tokens) + const content = planContent(input.messages, state.get().tokens) if (!content) return yield* failed({ sessionID: input.session.id, @@ -419,6 +421,8 @@ const make = (dependencies: Dependencies) => { }) }) return Service.of({ + transform: state.transform, + reload: state.reload, required, compact, compactManual, @@ -430,16 +434,15 @@ export const layer = Layer.effect( Effect.gen(function* () { const bus = yield* Bus.Service const llm = yield* LLMClient.Service - const config = yield* Config.Service const models = yield* SessionRunnerModel.Service const app = yield* App.Metadata const hooks = yield* PluginHooks.Service - return make({ bus, llm, models, config: settings(yield* config.entries()), app, hooks }) + return make({ bus, llm, models, app, hooks }) }), ) export const node = makeLocationNode({ service: Service, layer, - deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node, PluginHooks.node], + deps: [Bus.node, llmClient, SessionRunnerModel.node, App.node, PluginHooks.node], }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index e6ad849469..cb157faf2e 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -34,6 +34,7 @@ import { toSessionError } from "../to-session-error.js" import { SessionRunnerRetry } from "./retry.js" import { SessionUsage } from "../usage.js" import { ToolOutput } from "../../tool-output.js" +import { PluginSupervisor } from "../../plugin/supervisor.js" /** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */ type CallOutcome = Data.TaggedEnum<{ @@ -114,6 +115,7 @@ const layer = Layer.effect( const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service + const plugins = yield* PluginSupervisor.Service const title = yield* SessionTitle.Service const toolOutput = yield* ToolOutput.Service // Title generation starts once input is visible and must not delay model execution. @@ -135,6 +137,7 @@ const layer = Layer.effect( const promotable = input.promotable ?? "input" if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable))) return { type: "complete" as const } + yield* plugins.flush yield* settleStaleToolCalls(input.sessionID) while (true) { if (yield* runPendingCompaction(input.sessionID, promotable)) { @@ -646,6 +649,7 @@ export const node = makeLocationNode({ SessionModelTransport.node, SessionStore.node, SessionCompaction.node, + PluginSupervisor.node, SessionTitle.node, Snapshot.node, ToolOutput.node, diff --git a/packages/core/test/config/compaction.test.ts b/packages/core/test/config/compaction.test.ts new file mode 100644 index 0000000000..0010c6871c --- /dev/null +++ b/packages/core/test/config/compaction.test.ts @@ -0,0 +1,168 @@ +import { describe, expect } from "bun:test" +import { LanguageModel, LLMClient, LLMEvent } from "@opencode-ai/ai" +import { OpenAIChat } from "@opencode-ai/ai/protocols" +import { Bus } from "@opencode-ai/core/bus" +import { Config } from "@opencode-ai/core/config" +import { ConfigCompactionPlugin } from "@opencode-ai/core/config/plugin/compaction" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { llmClient } from "@opencode-ai/core/effect/app-node-platform" +import { SessionCompaction } from "@opencode-ai/core/session/compaction" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { Session } from "@opencode-ai/core/session" +import { Agent } from "@opencode-ai/core/agent" +import { Location } from "@opencode-ai/core/location" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { ConfigCompaction } from "@opencode-ai/schema/config/compaction" +import { Document, Event, Info } from "@opencode-ai/schema/config" +import { Money } from "@opencode-ai/schema/money" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { DateTime, Effect, Fiber, Layer, Option, Schema, Stream } from "effect" +import { testEffect } from "../lib/effect" +import { host } from "../plugin/host" + +const model = LanguageModel.make({ + id: "test-model", + provider: "test-provider", + route: OpenAIChat.route.with({ limits: { context: 100_000, output: 1_000 } }), +}) +const config = Config.testLayer() +const it = testEffect( + Layer.merge( + config, + AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, Config.node, Bus.node]), [ + [ + llmClient, + Layer.mock(LLMClient.Service)({ + stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })), + }), + ], + [ + SessionRunnerModel.node, + Layer.mock(SessionRunnerModel.Service)({ + resolve: () => + Effect.succeed( + SessionRunnerModel.resolved(model, { + capabilities: { tools: true, input: ["text"], output: ["text"] }, + cost: [], + }), + ), + }), + ], + [Config.node, config], + ]), + ), +) +describe("ConfigCompactionPlugin.Plugin", () => { + it.live("merges settings and reloads changed config", () => + Effect.gen(function* () { + const compaction = yield* SessionCompaction.Service + const config = yield* Config.Test + const bus = yield* Bus.Service + yield* config.setEntries([ + new Document({ + type: "document", + info: new Info({ compaction: new ConfigCompaction.Info({ auto: false, buffer: 20_000 }) }), + }), + new Document({ + type: "document", + info: new Info({ + compaction: new ConfigCompaction.Info({ + buffer: 10_000, + keep: new ConfigCompaction.Keep({ tokens: 0 }), + }), + }), + }), + ]) + yield* ConfigCompactionPlugin.Plugin.effect(host({ event: { subscribe: () => bus.subscribe(Event.Updated) } })) + + expect(compaction.required(nearInput)).toBe(false) + const started = yield* bus + .subscribe(SessionEvent.Compaction.Started) + .pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true })) + expect( + yield* compaction.compactManual({ + session, + messages: [ + { + id: SessionMessage.ID.create(), + type: "user", + text: "Older context", + time: { created: DateTime.makeUnsafe(0) }, + }, + { + id: SessionMessage.ID.create(), + type: "user", + text: "Recent context", + time: { created: DateTime.makeUnsafe(1) }, + }, + ], + inputID: SessionMessage.ID.make("msg_compaction_manual"), + }), + ).toEqual({ status: "completed" }) + expect(Option.getOrThrow(yield* Fiber.join(started)).data.recent).toContain("Recent context") + + yield* config.setEntries([ + new Document({ + type: "document", + info: new Info({ compaction: new ConfigCompaction.Info({ auto: true, buffer: 20_000 }) }), + }), + new Document({ + type: "document", + info: new Info({ compaction: new ConfigCompaction.Info({ buffer: 10_000 }) }), + }), + ]) + yield* bus.publish(Event.Updated, {}) + yield* Effect.gen(function* () { + for (let attempt = 0; attempt < 200; attempt++) { + if (compaction.required(nearInput)) return + yield* Effect.sleep("10 millis") + } + yield* Effect.die(new Error("Timed out waiting for compaction config reload")) + }) + expect(compaction.required(bufferedInput)).toBe(false) + + yield* config.setEntries([ + new Document({ + type: "document", + info: new Info({ compaction: new ConfigCompaction.Info({ auto: true, buffer: 20_000 }) }), + }), + ]) + yield* bus.publish(Event.Updated, {}) + for (let attempt = 0; attempt < 200; attempt++) { + if (compaction.required(bufferedInput)) return + yield* Effect.sleep("10 millis") + } + yield* Effect.die(new Error("Timed out waiting for compaction config reload")) + }), + ) +}) + +const session = Session.Info.make({ + id: Session.ID.make("ses_compaction_config"), + projectID: Project.ID.global, + cost: Money.USD.zero, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }), +}) +const input = (tokens: number) => ({ + session, + model, + cost: [], + messages: [ + Schema.decodeUnknownSync(SessionMessage.Assistant)({ + id: SessionMessage.ID.make("msg_compaction_config"), + type: "assistant", + agent: Agent.defaultID, + model: { id: "test-model", providerID: "test-provider" }, + content: [], + tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, completed: 0 }, + }), + ], +}) +const bufferedInput = input(85_000) +const nearInput = input(95_000) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 5fb39460fb..08033ba8aa 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -1,7 +1,6 @@ import { expect, test } from "bun:test" import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" -import { Config } from "@opencode-ai/core/config" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { llmClient } from "@opencode-ai/core/effect/app-node-platform" @@ -67,7 +66,6 @@ const client = Layer.mock(LLMClient.Service)({ }, generate: () => Effect.die("unused"), }) -const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed( @@ -83,7 +81,6 @@ const it = testEffect( [ [Bus.node, Bus.configured({ persist: true })], [llmClient, client], - [Config.node, config], [SessionRunnerModel.node, models], ], ),