fix(core): fit model limits and recover compaction overflow

This commit is contained in:
Aiden Cline
2026-09-24 18:59:41 -05:00
parent 5256f30957
commit fbd16ef93e
12 changed files with 560 additions and 142 deletions
@@ -1,5 +1,6 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import { define } from "@opencode/plugin/effect/plugin"
import type { SessionRequest } from "@opencode/plugin/effect/session"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import type { Server } from "node:http"
import { App } from "../../app.js"
@@ -307,6 +308,13 @@ export const OpenAIPlugin = define({
}),
{ providerID: Provider.ID.openai },
)
// The ChatGPT backend rejects a requested output limit, and OpenAI counts one against rate limits.
const omitOutputLimit = (evt: SessionRequest) =>
Effect.sync(() => {
delete evt.options.maxTokens
})
for (const name of ["context", "compaction"] as const)
yield* ctx.session.hook(name, omitOutputLimit, { providerID: Provider.ID.openai })
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
+247 -99
View File
@@ -37,12 +37,17 @@ 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.9
const DEFAULT_KEEP_TOKENS = 15_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const FALLBACK_TOOL_CHARS = 1_000
const FALLBACK_OVERFLOW_RETRIES = 3
const IMAGE_TOKEN_ESTIMATE = 1_500
const PDF_TOKEN_ESTIMATE = 2_000
const usableWindow = (limit: SessionRunnerModel.Resolved["limit"]) =>
Math.min(limit.context > 0 ? limit.context : (limit.input ?? 0), limit.input ?? Number.POSITIVE_INFINITY)
const compactionCeiling = (usable: number, buffer?: number) =>
buffer === undefined ? Math.floor(usable * AUTO_THRESHOLD) : usable - buffer
const SUMMARY_TEMPLATE = `You MUST use this format for your response (you may omit sections that aren't applicable). Do not include the <template> tags in your response.
<template>
## Objective
@@ -89,7 +94,7 @@ const LEGACY_HEADING = "## Additional Context"
export type Settings = {
auto: boolean
buffer: number
buffer?: number
tokens: number
}
@@ -172,6 +177,12 @@ const hasInputUsage = (message: SessionMessage.Info) =>
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0
export const estimateTokens = (input: RequiredInput) => {
const prompt = estimatePrompt(input)
return prompt.measured + prompt.estimated
}
/** The prompt size: `measured` is what the provider reported at the latest response, `estimated` is the text since. */
export const estimatePrompt = (input: RequiredInput) => {
const index = input.messages.findLastIndex(hasInputUsage)
const last = input.messages[index]
// Keep the anchor's local tool results: they are not covered by its provider usage.
@@ -182,14 +193,15 @@ export const estimateTokens = (input: RequiredInput) => {
.filter((message) => message.role !== "assistant" || message.id !== last?.id)
.reduce((sum, message) => sum + message.content.reduce((sum, part) => sum + estimatePart(part), 0), 0)
if (last?.type === "assistant" && last.tokens)
return (
added +
last.tokens.input +
last.tokens.cache.read +
last.tokens.cache.write +
last.tokens.output +
last.tokens.reasoning
)
return {
measured:
last.tokens.input +
last.tokens.cache.read +
last.tokens.cache.write +
last.tokens.output +
last.tokens.reasoning,
estimated: added,
}
const transcript = SessionModelRequest.baseTranscript({
agent: input.context.agent.info,
model: input.resolved,
@@ -197,14 +209,16 @@ export const estimateTokens = (input: RequiredInput) => {
initial: input.context.initial,
messages: [],
})
return (
added +
transcript.system.reduce((sum, part) => sum + Token.estimate(part.text), 0) +
input.context.tools.definitions.reduce(
(sum, tool) => sum + Token.estimate(tool.name + tool.description + JSON.stringify(tool.inputSchema)),
0,
)
)
return {
measured: 0,
estimated:
added +
transcript.system.reduce((sum, part) => sum + Token.estimate(part.text), 0) +
input.context.tools.definitions.reduce(
(sum, tool) => sum + Token.estimate(tool.name + tool.description + JSON.stringify(tool.inputSchema)),
0,
),
}
}
const estimateMedia = (mime: string) => {
@@ -255,10 +269,10 @@ export const retainUsers = (
return users.slice(start)
}
export const truncateToolOutput = (value: string) => {
if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
export const truncateToolOutput = (value: string, maxChars = TOOL_OUTPUT_MAX_CHARS) => {
if (value.length <= maxChars) return value
let end = 0
for (let count = 0; count < TOOL_OUTPUT_MAX_CHARS && end < value.length; count++) {
for (let count = 0; count < maxChars && end < value.length; count++) {
const code = value.charCodeAt(end)
end +=
code >= 0xd800 && code <= 0xdbff && value.charCodeAt(end + 1) >= 0xdc00 && value.charCodeAt(end + 1) <= 0xdfff
@@ -319,6 +333,60 @@ const serializeRecentMessage = (message: SessionMessage.Info) => {
return ""
}
/** Flatten provider-bound history into text so a reduced summary request carries no tool pairs or reasoning signatures. */
const serializeFallback = (messages: readonly Message[]) =>
messages.flatMap((message) => {
const parts = message.content.flatMap((part) => {
if (part.type === "text" || part.type === "reasoning") return part.text ? [part.text] : []
if (part.type === "media")
return [`[Attached ${part.media.mediaType}${part.filename ? `: ${part.filename}` : ""}; content omitted]`]
if (part.type === "tool-call") return [`[Tool call ${part.name}(${JSON.stringify(part.input)})]`]
if (part.type === "tool-result") {
const value =
part.result.type === "content"
? part.result.value
.map((item) =>
item.type === "text" ? item.text : `[Attached ${item.mime}${item.name ? `: ${item.name}` : ""}]`,
)
.join("\n")
: typeof part.result.value === "string"
? part.result.value
: JSON.stringify(part.result.value)
return [`[Tool result ${part.name}]: ${truncateToolOutput(value ?? "", FALLBACK_TOOL_CHARS)}`]
}
if (part.type === "compaction" && part.text) return [part.text]
return []
})
return parts.length ? [{ role: message.role, text: `[${message.role}]: ${parts.join("\n")}` }] : []
})
/** Retain a prior checkpoint and the newest complete exchanges that fit the summary input budget. */
const fitFallback = (entries: ReturnType<typeof serializeFallback>, budget: number) => {
const previous = entries[0]?.text.includes("<conversation-checkpoint>") ? entries[0] : undefined
const rest = previous ? entries.slice(1) : entries
const groups = rest.reduce<Array<string>>((groups, entry) => {
if (entry.role === "user" || groups.length === 0) groups.push(entry.text)
else groups[groups.length - 1] += `\n\n${entry.text}`
return groups
}, [])
const header = previous ? `${previous.text}\n\n` : ""
const allowance = budget - Token.estimate(header)
if (allowance <= 0) return
let start = groups.length
let total = 0
while (start > 0) {
const size = Token.estimate(groups[start - 1])
if (total + size > allowance) break
total += size
start--
}
if (groups.length && start === groups.length) return
return {
text: `${header}${start ? `[${start} older exchanges omitted from this summary input]\n\n` : ""}${groups.slice(start).join("\n\n")}`,
omitted: start,
}
}
const splitHistory = (messages: readonly SessionMessage.Info[], keepTokens: number) => {
const tailStart = findTailStart(messages, keepTokens)
if (tailStart === undefined) return
@@ -401,7 +469,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
@@ -470,6 +538,7 @@ export const layer = Layer.effect(
input: ExecuteInput,
messages: readonly SessionMessage.Info[],
webSocket?: "session",
summaryPrompt?: string,
) => {
const context = input.context
const transcript = SessionModelRequest.baseTranscript({
@@ -479,6 +548,7 @@ export const layer = Layer.effect(
initial: context.initial,
messages,
})
const prompt = estimatePrompt({ messages, resolved: context.model, context })
return input.prepare({
session: context.session,
agent: context.agent.id,
@@ -490,6 +560,11 @@ export const layer = Layer.effect(
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
],
webSocket,
// The instruction update and summary prompt are sent outside the history, so count them too.
inputTokens: {
measured: prompt.measured,
estimated: prompt.estimated + Token.estimate((input.instructionUpdate ?? "") + (summaryPrompt ?? "")),
},
})
}
/** The durable transcript since the last local summary, re-expanding every native window. */
@@ -623,87 +698,163 @@ export const layer = Layer.effect(
)
// Checkpoints from the previous template ran far longer than this one asks for; its catch-all heading identifies them.
const legacy = previous?.summary.includes(LEGACY_HEADING) ?? false
const prepared = yield* compactionRequest(input, history.messages)
const prompt = buildPrompt(previous !== undefined, legacy)
const prepared = yield* compactionRequest(input, history.messages, undefined, prompt)
if (prepared.event.result) return yield* supplied(input, prepared.event.result, history.recent)
// Hooks see the transcript alone; the summary prompt is appended after they run.
const first = LLMRequest.update(prepared.request, {
messages: [...prepared.request.messages, Message.user(buildPrompt(previous !== undefined, legacy))],
messages: [...prepared.request.messages, Message.user(prompt)],
})
const usable = usableWindow(context.model.limit)
const fixed =
prepared.request.system.reduce((sum, part) => sum + Token.estimate(part.text), 0) +
context.tools.definitions.reduce(
(sum, tool) => sum + Token.estimate(tool.name + tool.description + JSON.stringify(tool.inputSchema)),
0,
) +
Token.estimate(prompt)
let entries: ReturnType<typeof serializeFallback> | undefined
let budget = usable ? compactionCeiling(usable, state.get().buffer) - fixed : undefined
let fallback =
usable > 0 &&
(budget ?? 0) > 0 &&
estimateTokens({ messages: history.messages, resolved: context.model, context }) +
Token.estimate(prompt) >
usable
let retries = 0
let last: string | undefined
let omitted = 0
// Both requests share the retry allowance; rejected output never enters the reminder request.
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: context.agent.id,
model: context.model.ref,
hook: prepared.retry,
})
for (const request of [
first,
LLMRequest.update(first, {
messages: [
...first.messages,
Message.user(
"The previous response did not fill in the required summary template. Do not call tools. Return the summary as text using the exact section headings from the template.",
),
],
}),
]) {
yield* Stream.suspend(() => {
chunks.length = 0
providerState = undefined
failure = undefined
return llm.stream(request, prepared.options)
}).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
failure = {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message,
}
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return bus.publish(SessionEvent.Compaction.Delta, {
sessionID: context.session.id,
text: event.text,
})
}
if (LLMEvent.is.stepFinish(event)) {
providerState =
event.providerMetadata?.[context.model.model.route.providerMetadataKey ?? context.model.model.provider]
const step = SessionUsage.record(event.usage, context.model.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
if (LLMEvent.is.finish(event)) {
if (event.reason.normalized === "length")
failure = { type: "compaction.failed", message: "Compaction summary reached the output token limit" }
if (event.reason.normalized === "content-filter")
failure = {
type: "provider.content-filter",
message: "Compaction summary was blocked by the provider",
}
if (event.reason.normalized === "unknown")
return Effect.fail(
new AIError({
reason: new InvalidProviderOutputError({
message: "The provider response ended with an unknown finish reason.",
classification: "incomplete-stream",
}),
}),
)
if (event.reason.normalized === "error")
return Effect.fail(
new AIError({ reason: new UnknownProviderError({ message: "Compaction generation failed" }) }),
)
}
return Effect.void
let overflow = false
while (true) {
if (fallback) {
entries ??= serializeFallback(prepared.request.messages)
budget ??= Token.estimate(entries.map((entry) => entry.text).join("\n\n"))
}
const fitted = fallback && entries && budget !== undefined ? fitFallback(entries, budget) : undefined
if (fallback && (!fitted || fitted.text === last)) {
failure = {
type: "compaction.failed",
message: "The summary input cannot be reduced further without losing the latest exchange or checkpoint",
}
break
}
if (fitted) {
last = fitted.text
omitted = fitted.omitted
retries++
}
const selected = fitted
? yield* input.prepare({
session: context.session,
agent: context.agent.id,
model: context.model,
tools: context.tools,
system: SessionModelRequest.baseTranscript({
agent: context.agent.info,
model: context.model,
tools: context.tools,
initial: context.initial,
messages: [],
}).system,
messages: [Message.user(fitted.text)],
inputTokens: { measured: 0, estimated: fixed + Token.estimate(fitted.text) },
})
: prepared
if (selected.event.result) {
yield* recordUsage
return yield* supplied(input, selected.event.result, history.recent)
}
const request = fallback
? LLMRequest.update(selected.request, {
messages: [...selected.request.messages, Message.user(prompt)],
})
: first
for (const attempt of [
request,
LLMRequest.update(request, {
messages: [
...request.messages,
Message.user(
"The previous response did not fill in the required summary template. Do not call tools. Return the summary as text using the exact section headings from the template.",
),
],
}),
transient,
Effect.catchTag("AI.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
]) {
yield* Stream.suspend(() => {
chunks.length = 0
providerState = undefined
failure = undefined
overflow = false
return llm.stream(attempt, selected.options)
}).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) {
overflow = event.classification === "context-overflow"
failure = {
type: overflow ? "provider.invalid-request" : "provider.error",
message: event.message,
}
}
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return bus.publish(SessionEvent.Compaction.Delta, {
sessionID: context.session.id,
text: event.text,
})
}
if (LLMEvent.is.stepFinish(event)) {
providerState =
event.providerMetadata?.[
context.model.model.route.providerMetadataKey ?? context.model.model.provider
]
const step = SessionUsage.record(event.usage, context.model.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
if (LLMEvent.is.finish(event)) {
if (event.reason.normalized === "length")
failure = { type: "compaction.failed", message: "Compaction summary reached the output token limit" }
if (event.reason.normalized === "content-filter")
failure = {
type: "provider.content-filter",
message: "Compaction summary was blocked by the provider",
}
if (event.reason.normalized === "unknown")
return Effect.fail(
new AIError({
reason: new InvalidProviderOutputError({
message: "The provider response ended with an unknown finish reason.",
classification: "incomplete-stream",
}),
}),
)
if (event.reason.normalized === "error")
return Effect.fail(
new AIError({ reason: new UnknownProviderError({ message: "Compaction generation failed" }) }),
)
}
return Effect.void
}),
),
Effect.onInterrupt(() => recordUsage.pipe(Effect.andThen(interrupted(input)))),
)
if (failure || hasSummarySection(chunks.join(""))) break
transient,
Effect.catchTag("AI.Error", (error) =>
Effect.sync(() => {
overflow = isContextOverflowFailure(error)
failure = toSessionError(error)
}),
),
Effect.onInterrupt(() => recordUsage.pipe(Effect.andThen(interrupted(input)))),
)
if (failure || hasSummarySection(chunks.join(""))) break
}
if (!overflow) break
if (retries >= FALLBACK_OVERFLOW_RETRIES) break
fallback = true
if (retries && budget !== undefined) budget = Math.floor(budget / 2)
}
yield* recordUsage
const summary = chunks.join("")
@@ -727,7 +878,9 @@ export const layer = Layer.effect(
reason: input.reason,
model: context.model.ref,
providerState,
text: summary,
text: omitted
? `${summary}\n\n[${omitted} older exchanges were omitted from the summary input; original session history is retained.]`
: summary,
recent: history.recent,
...usage,
})
@@ -754,12 +907,7 @@ 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),
)
return estimateTokens(input) >= promptCeiling
return estimateTokens(input) >= compactionCeiling(usableWindow(limit), config.buffer)
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
if (findTailStart(input.messages, state.get().tokens) === undefined)
+27 -1
View File
@@ -44,6 +44,14 @@ const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
// Default output limit caps per request kind. Titles and generate have none and keep the provider default, because
// their reasoning is hard to budget.
const OUTPUT_TOKEN_CAPS: Partial<Record<SessionRequestKind, number>> = { primary: 256_000, compaction: 32_000 }
// Used when the catalog has no output limit for the model.
const OUTPUT_TOKEN_FALLBACK = 32_000
// Prompt text is estimated at about 4 characters per token, which can run low on dense text such as code.
const ESTIMATE_ERROR = 0.15
const OUTPUT_TOKEN_MIN = 1_024
/** Tool errors, plus the user declining a permission or dismissing a question. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
@@ -69,6 +77,16 @@ export interface Input {
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/** Only the durable runner may use a stateful WebSocket. */
readonly webSocket?: "session"
/** Prompt size, measured by the provider or estimated. The default output limit leaves room for it. */
readonly inputTokens?: { readonly measured: number; readonly estimated: number }
}
/** The default output limit: the catalog limit, capped, and fitted to the room the prompt leaves in the context window. */
export const outputLimit = (limit: Model.Info["limit"], cap: number, inputTokens?: Input["inputTokens"]) => {
const requested = Math.min(limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK, cap)
if (inputTokens === undefined || limit.context <= 0) return requested
const room = limit.context - inputTokens.measured - Math.ceil(inputTokens.estimated * (1 + ESTIMATE_ERROR))
return Math.min(requested, Math.max(OUTPUT_TOKEN_MIN, room))
}
export const baseTranscript = (input: {
@@ -218,8 +236,16 @@ export const layer = Layer.effect(
const given = new Map(
tools.definitions.map((t) => [{ description: t.description, input: { ...t.inputSchema } }, t] as const),
)
// Hooks see the default output limit and may change or remove it.
const cap = OUTPUT_TOKEN_CAPS[kind]
const shaped = yield* shape(
{ sessionID: session.id, model: model.ref, system: input.system, messages: input.messages, options: {} },
{
sessionID: session.id,
model: model.ref,
system: input.system,
messages: input.messages,
options: cap === undefined ? {} : { maxTokens: outputLimit(model.limit, cap, input.inputTokens) },
},
Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])),
)
// Match by identity first, then by key. Entries matching neither were invented by a
+5
View File
@@ -244,6 +244,11 @@ const layer = Layer.effect(
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
inputTokens: SessionCompaction.estimatePrompt({
messages: loaded.messages,
resolved: loaded.model,
context: loaded,
}),
})
const outcome = yield* steps.attempt({
isLocationClosed: lifecycle.isClosed,
+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)
@@ -216,6 +216,31 @@ describe("OpenAIPlugin", () => {
}),
)
it.effect("omits the default output limit from OpenAI steps and compaction", () =>
Effect.gen(function* () {
yield* addPlugin()
const hooks = yield* PluginHooks.Service
const maxTokens = (providerID: Provider.ID) =>
Effect.gen(function* () {
const draft = {
sessionID: Session.ID.make("ses_test"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
system: [],
messages: [],
options: { maxTokens: 128_000 },
}
const events = [
yield* hooks.trigger("session", "context", { ...draft, agent: Agent.ID.make("build"), tools: {} }),
yield* hooks.trigger("session", "compaction", { ...draft, agent: Agent.ID.make("build"), tools: {} }),
]
return events.map((event) => event.options.maxTokens)
})
expect(yield* maxTokens(Provider.ID.openai)).toEqual([undefined, undefined])
expect(yield* maxTokens(Provider.ID.azure)).toEqual([128_000, 128_000])
}),
)
it.effect("selects WebSocket only from explicit policy", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
+21 -13
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode/ai"
import { GenerationOptions, LLMClient, LLMEvent, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
@@ -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 90% by default and a configured buffer instead", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const session = Session.Info.make({
@@ -205,23 +205,22 @@ 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(244_799, inputLimited))).toBe(false)
expect(compaction.required(input(244_800, 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(244_800))).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(89_999, contextLimited))).toBe(false)
expect(compaction.required(input(90_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)
const smallWindow = { context: 32_000, output: 32_000 }
expect(compaction.required(input(28_799, smallWindow))).toBe(false)
expect(compaction.required(input(28_800, smallWindow))).toBe(true)
const assistant = input(79_000, contextLimited).messages[0]
const tool = SessionMessage.AssistantTool.make({
@@ -233,7 +232,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(89_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 +286,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: 5_000 }))
expect(compaction.required(input(94_999, contextLimited))).toBe(false)
expect(compaction.required(input(95_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)
}),
)
@@ -407,7 +415,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
"x-opencode-session": sessionID,
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toBeUndefined()
expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 32_000 }))
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
expect(JSON.stringify(requests[0]?.messages)).toContain("User shell pwd completed: /project")
@@ -154,3 +154,61 @@ describe("SessionModelRequest HTTP hooks", () => {
}),
)
})
describe("SessionModelRequest output limit", () => {
const input = { session, agent: Agent.ID.make("build"), model, system: [], messages: [] }
it.effect("caps the default output limit per request kind", () =>
Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
const large = {
...input,
model: SessionRunnerModel.resolved(OpenAIChat.route.model({ id: "large-output", provider: "test" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 1_000_000, output: 384_000 },
}),
}
const maxTokens = (prepared: SessionModelRequest.Prepared<unknown>) => prepared.request.generation?.maxTokens
expect(maxTokens(yield* requests.primary(large))).toBe(256_000)
expect(maxTokens(yield* requests.compaction(large))).toBe(32_000)
const inputTokens = { measured: 170_000, estimated: 8_000 }
expect(maxTokens(yield* requests.primary({ ...input, inputTokens }))).toBe(20_800)
expect(maxTokens(yield* requests.compaction({ ...input, inputTokens }))).toBe(20_800)
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
// Provider plugins that remove the default limit only hook `context` and `compaction`. If titles or generate get a
// default, also hook `title` and `generate` in: the OpenAI plugin (`omitOutputLimit`), whose ChatGPT backend
// rejects any requested limit.
it.effect("sends no output limit for titles and generate by default", () =>
Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
expect((yield* requests.title(input)).request.generation?.maxTokens).toBeUndefined()
expect((yield* requests.generate(input)).request.generation?.maxTokens).toBeUndefined()
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
it.effect("lets hooks change or remove the default output limit", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: Array<number | undefined> = []
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
seen.push(event.options.maxTokens)
delete event.options.maxTokens
}),
)
yield* hooks.register("session", "title", (event) =>
Effect.sync(() => {
event.options.maxTokens = 100
}),
)
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
expect((yield* requests.primary(input)).request.generation).toBeUndefined()
expect((yield* requests.title(input)).request.generation?.maxTokens).toBe(100)
expect(seen).toEqual([32_000])
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
})
@@ -1,9 +1,40 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolResultPart, Media } from "@opencode/ai"
import { boundImages, unsupportedParts } from "@opencode/core/session/model-request"
import { boundImages, outputLimit, unsupportedParts } from "@opencode/core/session/model-request"
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
describe("SessionModelRequest.outputLimit", () => {
test("requests the catalog output limit up to the cap", () => {
expect(outputLimit({ context: 1_000_000, output: 128_000 }, 256_000)).toBe(128_000)
expect(outputLimit({ context: 200_000, output: 64_000 }, 256_000)).toBe(64_000)
expect(outputLimit({ context: 1_048_576, output: 1_048_576 }, 256_000)).toBe(256_000)
expect(outputLimit({ context: 200_000, output: 64_000 }, 32_000)).toBe(32_000)
})
test("falls back to 32k when the catalog has no output limit", () => {
expect(outputLimit({ context: 200_000, output: 0 }, 256_000)).toBe(32_000)
})
test("fits the limit to the room the prompt leaves in the context window", () => {
const limit = { context: 1_000_000, output: 128_000 }
expect(outputLimit(limit, 256_000, { measured: 50_000, estimated: 0 })).toBe(128_000)
expect(outputLimit(limit, 256_000, { measured: 900_000, estimated: 0 })).toBe(100_000)
// Estimated text counts 15% extra, so 40k estimated takes 46k of the room.
expect(outputLimit(limit, 256_000, { measured: 900_000, estimated: 40_000 })).toBe(54_000)
})
test("keeps a minimum limit when the prompt nearly fills the context window", () => {
const prompt = { measured: 199_000, estimated: 0 }
expect(outputLimit({ context: 200_000, output: 64_000 }, 256_000, prompt)).toBe(1_024)
expect(outputLimit({ context: 200_000, output: 512 }, 256_000, prompt)).toBe(512)
})
test("ignores the prompt size when the context window is unknown", () => {
expect(outputLimit({ context: 0, output: 32_000 }, 256_000, { measured: 500_000, estimated: 0 })).toBe(32_000)
})
})
describe("SessionModelRequest.unsupportedParts", () => {
test("replaces unsupported user media with a visible error", () => {
const messages = unsupportedParts(
@@ -393,13 +393,15 @@ it.live("only known automatic native overflow falls back locally and failed reco
fixture.state.overflow = true
fixture.state.localFailure = true
expect(yield* fixture.automatic).toMatchObject({ status: "failed" })
expect(fixture.state.calls).toBe(5)
expect(fixture.state.calls).toBe(6)
expect(yield* fixture.checkpoint).toEqual(installed)
expect(JSON.stringify(fixture.bodies[4])).toContain("Original durable request")
expect(JSON.stringify(fixture.bodies[4])).not.toContain("encrypted_1")
expect(JSON.stringify(fixture.bodies[5])).toContain("Original durable request")
expect(JSON.stringify(fixture.bodies[5])).not.toContain("encrypted_1")
fixture.state.localFailure = false
expect(yield* fixture.automatic).toEqual({ status: "completed", recoveredOverflow: true })
expect(fixture.state.calls).toBe(7)
expect(fixture.state.calls).toBe(8)
expect((yield* fixture.load).messages).toContainEqual(
expect.objectContaining({ type: "compaction", summary: "## Objective\n- Recovered locally" }),
)
+114 -15
View File
@@ -127,6 +127,7 @@ const fullOutputModel = testModel("full-output", { context: 262_144, output: 262
const unknownContextModel = testModel("unknown-context", { context: 0, output: 32_000 })
const undersizedContextModel = testModel("undersized-context", { context: 1, output: 1_000 })
const recoveryModel = testModel("recovery", { context: 200_000, output: 1_000 })
const fittedOutputModel = testModel("fitted-output", { context: 100_000, output: 64_000 })
test("calculates step cost using the matching context tier", () => {
expect(
@@ -2749,22 +2750,16 @@ describe("SessionRunnerLLM", () => {
})
}
for (const response of ["length", "content-filter", "context overflow"] as const) {
for (const response of ["length", "content-filter"] as const) {
scenario(`rejects compaction ${response} without retrying or committing its draft`, function* (s) {
yield* s.llm.push(TestLLM.text("Earlier answer", "history"))
yield* s.runPrompt("Earlier question")
s.requests.length = 0
yield* s.llm.push(
response === "context overflow"
? Stream.fail(
new AIError({
reason: new InvalidRequestError({ message: "Too long", classification: "context-overflow" }),
}),
)
: TestLLM.complete(
{ reason: { normalized: response } },
LLMEvent.textDelta({ id: "truncated", text: "## Objective\n- Incomplete summary" }),
),
TestLLM.complete(
{ reason: { normalized: response } },
LLMEvent.textDelta({ id: "truncated", text: "## Objective\n- Incomplete summary" }),
),
)
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
@@ -2778,6 +2773,98 @@ describe("SessionRunnerLLM", () => {
})
}
scenario("stops after three smaller compaction inputs overflow", function* (s) {
yield* s.llm.push(...Array.from({ length: 8 }, (_, index) => TestLLM.text(`Answer ${index}`, `answer-${index}`)))
yield* Effect.forEach(Array.from({ length: 8 }, (_, index) => index), (index) =>
s.runPrompt(`Request ${index}: ${"context ".repeat(30)}`),
)
s.currentModel = unknownContextModel
s.requests.length = 0
const overflow = () =>
Stream.fail(
new AIError({ reason: new InvalidRequestError({ message: "Too long", classification: "context-overflow" }) }),
)
yield* s.llm.push(overflow(), overflow(), overflow(), overflow())
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
expect(s.requests).toHaveLength(4)
expect(userTexts(s.requests[2])[0].length).toBeLessThan(userTexts(s.requests[1])[0].length)
expect(userTexts(s.requests[3])[0].length).toBeLessThan(userTexts(s.requests[2])[0].length)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({ status: "failed" })
yield* s.llm.push(TestLLM.text("Continued", "continued"))
yield* s.runPrompt("Continue")
expect(userTexts(s.requests[4])).toContain("Request 0: " + "context ".repeat(30))
})
scenario("serializes history and omits media after a summary input overflow", function* (s) {
const image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
yield* s.session.prompt({
sessionID,
text: "Earlier question",
files: [{ uri: `data:image/png;base64,${image}` }],
resume: false,
})
yield* s.llm.push(
TestLLM.stop(
LLMEvent.toolCall({ id: "hosted", name: "web_search", input: { query: "earlier" }, providerExecuted: true }),
LLMEvent.toolResult({
id: "hosted",
name: "web_search",
result: { type: "text", value: "x".repeat(5_000) },
providerExecuted: true,
}),
LLMEvent.textStart({ id: "history" }),
LLMEvent.textDelta({ id: "history", text: "Earlier answer" }),
LLMEvent.textEnd({ id: "history" }),
),
)
yield* s.resume
s.requests.length = 0
yield* s.llm.push(
[LLMEvent.providerError({ message: "Too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recovered", "summary"),
)
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
expect(s.requests).toHaveLength(2)
expect(s.requests[0]?.messages.some((message) => message.content.some((part) => part.type === "media"))).toBeTrue()
expect(s.requests[1]?.messages.every((message) => message.role === "user")).toBeTrue()
expect(userTexts(s.requests[1])[0]).toContain("[Attached image/png; content omitted]")
expect(userTexts(s.requests[1])[0]).toContain("[Tool result web_search]:")
expect(userTexts(s.requests[1])[0]).toContain("[truncated]")
expect(userTexts(s.requests[1])[0]).not.toContain("x".repeat(2_000))
expect(s.requests[1]?.system).toEqual(s.requests[0]?.system)
expect(s.requests[1]?.tools).toEqual(s.requests[0]?.tools)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({ status: "completed" })
})
scenario("fits serialized compaction history by omitting oldest exchanges", function* (s) {
const service = yield* SessionCompaction.Service
yield* service.transform((editor) => editor.configure({ buffer: 3_000 }))
s.currentModel = testModel("large-history", { context: 1_000_000, output: 32_000 })
yield* s.llm.push(...Array.from({ length: 4 }, (_, index) => TestLLM.text(`Answer ${index}`, `answer-${index}`)))
yield* Effect.forEach(Array.from({ length: 4 }, (_, index) => index), (index) =>
s.runPrompt(`Request ${index}: ${"x".repeat(8_000)}`),
)
s.currentModel = testModel("smaller-history", { context: 7_000, output: 1_000 })
s.requests.length = 0
yield* s.llm.push(TestLLM.text("## Objective\n- Recovered", "summary"))
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
expect(s.requests).toHaveLength(1)
expect(userTexts(s.requests[0])[0]).toContain("older exchanges omitted")
expect(userTexts(s.requests[0])[0]).not.toContain("Request 0:")
expect(userTexts(s.requests[0])[0]).not.toContain("Request 1:")
expect(userTexts(s.requests[0])[0]).toContain("Request 2:")
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
status: "completed",
summary: expect.stringContaining("older exchanges were omitted from the summary input"),
})
})
scenario("records cancelled manual compaction without surfacing an internal failure", function* (s) {
yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-interrupt-history"))
yield* s.runPrompt("Earlier question")
@@ -2883,7 +2970,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", 39_000))
yield* s.runPrompt("First real request")
const checkpoint = (encrypted: string) =>
CompactionCheckpointResponse.make({
@@ -2903,7 +2990,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", 39_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 +3016,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", 39_000))
yield* s.runPrompt("Original durable request")
yield* s.llm.push(
CompactionCheckpointResponse.make({
@@ -2985,7 +3072,7 @@ describe("SessionRunnerLLM", () => {
expect(yield* Effect.exit(s.resume)).toMatchObject({ _tag: "Failure" })
expect(s.requests).toHaveLength(1)
expect(s.requests[0]?.generation).toBeUndefined()
expect(s.requests[0]?.generation?.maxTokens).toBe(50)
expect(yield* s.context).toContainEqual(
expect.objectContaining({
type: "compaction",
@@ -3190,6 +3277,18 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("fits the output limit to the prompt size", function* (s) {
s.currentModel = fittedOutputModel
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-fitted-first", 50_000))
yield* s.runPrompt("Earlier question")
yield* s.llm.push(TestLLM.text("Continued", "text-fitted-final"))
yield* s.runPrompt("Continue")
expect(s.requests[0]?.generation?.maxTokens).toBe(64_000)
expect(s.requests[1]?.generation?.maxTokens).toBeLessThan(100_000 - 50_000)
expect(s.requests[1]?.generation?.maxTokens).toBeGreaterThan(100_000 - 50_000 - 100)
})
scenario("publishes the original overflow when recovery summarization fails", function* (s) {
yield* setupOverflowRecovery(s)
yield* s.llm.push(
+18 -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 90% 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(90% 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 115,200 tokens:
```text
128,000 - 20,000 = 108,000
128,000 × 90% = 115,200
```
The estimate follows these rules:
@@ -97,11 +100,16 @@ 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.
Compaction normally sends the existing conversation for summarization. If it
cannot fit the selected model, OpenCode retries within the same input threshold
with a shorter text version:
old tool results and media are reduced first, then older exchanges are omitted
if needed. The original messages remain in session history.
OpenCode also recognizes provider errors classified as context overflow. If no
assistant output or other retry evidence was produced, it can compact and retry
that model step once:
@@ -132,11 +140,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 90% 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 +169,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 90% 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. |