Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Compaction>

export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
export type Mcp = z.infer<typeof Mcp>

Expand Down Expand Up @@ -868,6 +892,7 @@ export namespace Config {
.record(
z.string(),
ModelsDev.Model.partial().extend({
compaction: Compaction.optional(),
variants: z
.record(
z.string(),
Expand Down Expand Up @@ -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(),
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down
17 changes: 15 additions & 2 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
139 changes: 139 additions & 0 deletions packages/opencode/test/session/compaction-threshold.test.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
})
})
Loading