diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7969e3079574..60c3a302061e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -505,6 +505,30 @@ export namespace Config { ref: "McpRemoteConfig", }) + export const Compaction = z + .object({ + auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"), + prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), + threshold: z + .number() + .min(0) + .max(1) + .optional() + .describe("Percentage of context window at which to trigger compaction (default: 0.75)"), + strategy: z.enum(["summarize", "truncate", "archive"]).optional().describe("Compaction strategy"), + preserveRecentMessages: z + .number() + .int() + .nonnegative() + .optional() + .describe("Number of recent messages to always preserve"), + preserveSystemPrompt: z.boolean().optional().describe("Always preserve the system prompt"), + }) + .meta({ + ref: "CompactionConfig", + }) + export type Compaction = z.infer + export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote]) export type Mcp = z.infer @@ -868,6 +892,7 @@ export namespace Config { .record( z.string(), ModelsDev.Model.partial().extend({ + compaction: Compaction.optional(), variants: z .record( z.string(), @@ -1070,12 +1095,7 @@ export namespace Config { url: z.string().optional().describe("Enterprise URL"), }) .optional(), - compaction: z - .object({ - auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"), - prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), - }) - .optional(), + compaction: Compaction.optional(), experimental: z .object({ disable_paste_summary: z.boolean().optional(), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e79cb1708947..81cbd12e92c4 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -573,6 +573,7 @@ export namespace Provider { headers: z.record(z.string(), z.string()), release_date: z.string(), variants: z.record(z.string(), z.record(z.string(), z.any())).optional(), + compaction: Config.Compaction.optional(), }) .meta({ ref: "Model", @@ -925,6 +926,12 @@ export namespace Provider { model.variants = mapValues(ProviderTransform.variants(model), (v) => v) + const configCompaction = configProvider?.models?.[modelID]?.compaction + if (configCompaction) { + // @ts-expect-error + model.compaction = mergeDeep(model.compaction ?? {}, configCompaction) + } + // Filter out disabled variants from config const configVariants = configProvider?.models?.[modelID]?.variants if (configVariants && model.variants) { diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index fb3825302918..3f77ceb609cc 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -29,12 +29,25 @@ export namespace SessionCompaction { export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { const config = await Config.get() - if (config.compaction?.auto === false) return false + + // Check auto compaction setting - model override takes precedence + const auto = input.model.compaction?.auto ?? config.compaction?.auto + if (auto === false) return false + const context = input.model.limit.context if (context === 0) return false + const count = input.tokens.input + input.tokens.cache.read + input.tokens.output const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX - const usable = input.model.limit.input || context - output + + // Check threshold setting - model override takes precedence + const threshold = input.model.compaction?.threshold ?? config.compaction?.threshold + + let usable = input.model.limit.input || context - output + if (threshold !== undefined) { + usable = context * threshold + } + return count > usable } diff --git a/packages/opencode/test/session/compaction-threshold.test.ts b/packages/opencode/test/session/compaction-threshold.test.ts new file mode 100644 index 000000000000..c8868020d15b --- /dev/null +++ b/packages/opencode/test/session/compaction-threshold.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { SessionCompaction } from "../../src/session/compaction" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" +import type { Provider } from "../../src/provider/provider" + +Log.init({ print: false }) + +function createModel(opts: { + context: number + output: number + input?: number + compaction?: { threshold?: number } +}): Provider.Model { + return { + id: "test-model", + providerID: "test", + name: "Test", + limit: { + context: opts.context, + input: opts.input, + output: opts.output, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + capabilities: { + toolcall: true, + attachment: false, + reasoning: false, + temperature: true, + input: { text: true, image: false, audio: false, video: false }, + output: { text: true, image: false, audio: false, video: false }, + }, + api: { npm: "@ai-sdk/anthropic" }, + options: {}, + compaction: opts.compaction, + variants: {}, + headers: {}, + release_date: "2024-01-01", + } as Provider.Model +} + +describe("session.compaction.isOverflow with threshold", () => { + test("uses configured threshold when set in model", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Threshold 0.5 (50,000 tokens) + const model = createModel({ + context: 100_000, + output: 10_000, + compaction: { threshold: 0.5 } + }) + + // 49,000 tokens - Should NOT overflow + expect(await SessionCompaction.isOverflow({ + tokens: { input: 49_000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + model + })).toBe(false) + + // 51,000 tokens - Should overflow + expect(await SessionCompaction.isOverflow({ + tokens: { input: 51_000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + model + })).toBe(true) + }, + }) + }) + + test("uses global config threshold", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + compaction: { threshold: 0.3 }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Context 100,000. Threshold 0.3 => 30,000. + const model = createModel({ context: 100_000, output: 10_000 }) + + // 29,000 tokens - No overflow + expect(await SessionCompaction.isOverflow({ + tokens: { input: 29_000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + model + })).toBe(false) + + // 31,000 tokens - Overflow + expect(await SessionCompaction.isOverflow({ + tokens: { input: 31_000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + model + })).toBe(true) + }, + }) + }) + + test("model override takes precedence over global config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + compaction: { threshold: 0.3 }, // Global 30% + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Model override 80% (80,000) + const model = createModel({ + context: 100_000, + output: 10_000, + compaction: { threshold: 0.8 } + }) + + // 50,000 tokens (would overflow global 30% but not model 80%) + expect(await SessionCompaction.isOverflow({ + tokens: { input: 50_000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + model + })).toBe(false) + + // 81,000 tokens - Overflow + expect(await SessionCompaction.isOverflow({ + tokens: { input: 81_000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + model + })).toBe(true) + }, + }) + }) +})