Compare commits

...
Author SHA1 Message Date
Aiden Cline a9da993e28 fix(core): trigger compaction at 85% of the input window 2026-09-24 17:36:58 -05:00
5 changed files with 45 additions and 33 deletions
+6 -9
View File
@@ -37,9 +37,8 @@ import { toLLMMessages } from "./runner/to-llm-message.js"
import type { AgentNotFoundError } from "./error.js"
import type { Instructions } from "../instructions/index.js"
const DEFAULT_BUFFER = 20_000
const AUTO_THRESHOLD = 0.85
const DEFAULT_KEEP_TOKENS = 15_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const IMAGE_TOKEN_ESTIMATE = 1_500
const PDF_TOKEN_ESTIMATE = 2_000
@@ -89,7 +88,7 @@ const LEGACY_HEADING = "## Additional Context"
export type Settings = {
auto: boolean
buffer: number
buffer?: number
tokens: number
}
@@ -401,7 +400,7 @@ export const layer = Layer.effect(
const state = State.create<Settings & { readonly native: NativeStrategy[] }, Editor>({
name: "session-compaction",
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS, native: [] }),
initial: () => ({ auto: true, tokens: DEFAULT_KEEP_TOKENS, native: [] }),
editor: (editor) => ({
configure: (settings) => {
if (settings.auto !== undefined) editor.auto = settings.auto
@@ -754,11 +753,9 @@ export const layer = Layer.effect(
const limit = input.resolved.limit
const context = limit.context
if (context <= 0) return false
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX)
const promptCeiling = Math.min(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
const usable = Math.min(context, limit.input ?? context)
const promptCeiling =
config.buffer === undefined ? Math.floor(usable * AUTO_THRESHOLD) : usable - config.buffer
return estimateTokens(input) >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
+1 -1
View File
@@ -172,5 +172,5 @@ const input = (tokens: number) => {
},
}
}
const bufferedInput = input(85_000)
const bufferedInput = input(82_000)
const nearInput = input(95_000)
+23 -10
View File
@@ -153,7 +153,7 @@ test("compaction prompts prohibit task execution", () => {
expect(SessionCompaction.buildPrompt(update)).toContain("Do not continue the task or call tools")
})
it.effect("auto compaction estimates current content against the buffered prompt ceiling", () =>
it.effect("auto compaction uses 85% by default and a configured buffer instead", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const session = Session.Info.make({
@@ -205,23 +205,27 @@ it.effect("auto compaction estimates current content against the buffered prompt
}
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
expect(compaction.required(input(231_199, inputLimited))).toBe(false)
expect(compaction.required(input(231_200, inputLimited))).toBe(true)
const native = (tokens: number, limit: { context: number; input?: number; output: number } = inputLimited) => {
const selected = input(tokens, limit)
return { ...selected, resolved: { ...selected.resolved, compaction: { type: "native" as const } } }
}
expect(compaction.required(native(251_999))).toBe(false)
expect(compaction.required(native(252_000))).toBe(true)
expect(compaction.required(native(231_199))).toBe(false)
expect(compaction.required(native(231_200))).toBe(true)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
expect(compaction.required(input(80_000, contextLimited))).toBe(true)
expect(compaction.required(input(84_999, contextLimited))).toBe(false)
expect(compaction.required(input(85_000, contextLimited))).toBe(true)
const outputLimited = { context: 100_000, output: 30_000 }
expect(compaction.required(input(69_999, outputLimited))).toBe(false)
expect(compaction.required(input(70_000, outputLimited))).toBe(true)
expect(compaction.required(input(84_999, outputLimited))).toBe(false)
expect(compaction.required(input(85_000, outputLimited))).toBe(true)
const smallWindow = { context: 32_000, output: 32_000 }
expect(compaction.required(input(27_199, smallWindow))).toBe(false)
expect(compaction.required(input(27_200, smallWindow))).toBe(true)
const assistant = input(79_000, contextLimited).messages[0]
const tool = SessionMessage.AssistantTool.make({
@@ -233,7 +237,9 @@ it.effect("auto compaction estimates current content against the buffered prompt
})
const grown = { ...input(79_000, contextLimited), messages: [{ ...assistant, content: [tool] }] }
expect(SessionCompaction.estimateTokens(grown)).toBe(80_000)
expect(compaction.required(grown)).toBe(true)
expect(compaction.required(grown)).toBe(false)
const near = input(84_000, contextLimited)
expect(compaction.required({ ...near, messages: [{ ...near.messages[0], content: [tool] }] })).toBe(true)
const interrupted = { ...assistant, id: SessionMessage.ID.create(), tokens: undefined }
expect(SessionCompaction.estimateTokens({ ...grown, messages: [...grown.messages, interrupted] })).toBe(80_001)
@@ -285,6 +291,13 @@ it.effect("auto compaction estimates current content against the buffered prompt
time: { created: 0, completed: 0 },
})
expect(compaction.required({ ...grown, messages: [checkpoint] })).toBe(false)
yield* compaction.transform((editor) => editor.configure({ buffer: 10_000 }))
expect(compaction.required(input(89_999, contextLimited))).toBe(false)
expect(compaction.required(input(90_000, contextLimited))).toBe(true)
yield* compaction.transform((editor) => editor.configure({ buffer: 0 }))
expect(compaction.required(input(99_999, contextLimited))).toBe(false)
expect(compaction.required(input(100_000, contextLimited))).toBe(true)
}),
)
+3 -3
View File
@@ -2883,7 +2883,7 @@ describe("SessionRunnerLLM", () => {
agent.steps = 2
}),
)
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 36_000))
yield* s.runPrompt("First real request")
const checkpoint = (encrypted: string) =>
CompactionCheckpointResponse.make({
@@ -2903,7 +2903,7 @@ describe("SessionRunnerLLM", () => {
const installed = (yield* s.messages).filter((message) => message.type === "compaction")
expect(installed).toMatchObject([{ status: "completed", reason: "auto", providerContext: { version: 1 } }])
// New input without a post-checkpoint usage anchor must not retrigger compaction.
yield* s.llm.push(TestLLM.textWithUsage("Measured", "measured", 10_000))
yield* s.llm.push(TestLLM.textWithUsage("Measured", "measured", 36_000))
yield* s.runPrompt("Third real request")
expect(s.requests).toHaveLength(5)
yield* s.llm.push(checkpoint("second"), TestLLM.textWithUsage("Continued", "continued", 10_000))
@@ -2929,7 +2929,7 @@ describe("SessionRunnerLLM", () => {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
modelLimits.set("native", { context: 42_000, output: 32_000 })
s.compaction = { type: "native" }
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 36_000))
yield* s.runPrompt("Original durable request")
yield* s.llm.push(
CompactionCheckpointResponse.make({
+12 -10
View File
@@ -78,17 +78,20 @@ Reusing that ID for another record returns a conflict.
## Automatic
Before each model call, OpenCode estimates the final size of the system prompt,
messages, and advertised tools. It starts compaction at this ceiling:
messages, and advertised tools. By default it starts compaction at 85% of the
smaller context or input limit. A configured `buffer` replaces that percentage
with an absolute number of tokens to reserve:
```text
estimated tokens >= min(input limit - buffer, context limit - max(output reserve, buffer))
usable window = min(context limit, input limit if present)
compact when estimated tokens >= (buffer configured ? usable window - buffer : floor(85% of usable window))
```
For example, with a 128,000-token input limit and the default 20,000-token
buffer, the input-limit side of the ceiling is 108,000 tokens.
For example, with a 200,000-token context and a 128,000-token input limit,
the default threshold is 108,800 tokens:
```text
128,000 - 20,000 = 108,000
128,000 × 85% = 108,800
```
The estimate follows these rules:
@@ -97,8 +100,7 @@ The estimate follows these rules:
newer content are then added.
- Without provider usage, OpenCode estimates text, media, instructions, and
tools locally.
- The reserved model output is capped at 32,000 tokens.
- A model without an input limit is constrained by its context limit instead.
- Without a separate input limit, the context limit sets the threshold.
- A successful checkpoint rebuilds the same pending model step. It does not
promote the input again or spend another agent step.
@@ -132,11 +134,11 @@ Add `compaction` to any [OpenCode configuration file](/config):
| --- | ---: | --- |
| `auto` | `true` | Enables preflight checks and one provider-overflow recovery attempt. It does not control manual compaction. |
| `keep.tokens` | `15000` | Approximate recent serialized context retained beside a local summary, or real user input retained for a provider checkpoint. |
| `buffer` | `20000` | Safety margin below an explicit input limit. Without one, it is the minimum context reserve; a larger model output allowance wins. |
| `buffer` | unset | Replaces the 85% threshold with `usable window - buffer`. Set it to reserve a fixed number of tokens. |
`keep.tokens` and `buffer` accept non-negative integers. Larger
`keep.tokens` preserves more recent detail but leaves less room for new work;
larger `buffer` starts automatic compaction earlier.
larger `buffer` starts automatic compaction earlier when configured.
## Providers
@@ -161,7 +163,7 @@ A model setting overrides the provider setting.
| Topic | Provider behavior |
| --- | --- |
| Threshold | OpenCode uses the model's usable input ceiling configured by its context, input, and output limits plus the global compaction buffer. |
| Threshold | OpenCode starts at 85% of the smaller context or input limit, unless `buffer` replaces it with a fixed reserve. |
| Scheduling | Checkpoints run at normal safe step boundaries. `compaction.auto: false` disables all new automatic work. |
| Usage | After a native checkpoint is installed, automatic checks wait for fresh model usage because encrypted checkpoint bytes cannot provide a meaningful token count. |
| OpenAI | Responses routes use a streamed compaction trigger when supported; endpoint-only routes use the standalone compaction endpoint. Deployment and model support vary. |