From f6cb97672e3b9fc978dc42bd71a357b4ad22c9b3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 26 Sep 2026 00:06:15 -0500 Subject: [PATCH 1/7] feat(core): fit output limits to the context window --- packages/core/src/plugin/provider/openai.ts | 8 +++ packages/core/src/session/compaction.ts | 25 ++++++-- packages/core/src/session/model-request.ts | 28 ++++++++- packages/core/src/session/runner/llm.ts | 1 + .../core/test/plugin/provider-openai.test.ts | 25 ++++++++ packages/core/test/session-compaction.test.ts | 4 +- .../test/session-model-request-hooks.test.ts | 58 +++++++++++++++++++ .../core/test/session-model-request.test.ts | 33 ++++++++++- packages/core/test/session-runner.test.ts | 15 ++++- 9 files changed, 187 insertions(+), 10 deletions(-) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 255cbb2cfcc4..5c7bdc38aff0 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -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")), diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index f695a9953758..ae02876a8281 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -265,7 +265,8 @@ export const layer = Layer.effect( const prompt = buildPrompt(previous !== undefined, previous?.summary.includes(LEGACY_HEADING) ?? false) const headings = SUMMARY_TEMPLATE.split("\n").filter((line) => line.startsWith("##")) const filled = (text: string) => text.split("\n").some((line) => headings.includes(line.trim())) - const prepared = yield* prepare(context, split.older) + const overhead = Token.estimate(prompt) + Token.estimate(NUDGE) + const prepared = yield* prepare(context, split.older, undefined, overhead) // Hooks saw the request without the summary prompt, so it is appended here. A reply that ignores the // template gets one reminder before it counts as a failure. @@ -288,7 +289,6 @@ export const layer = Layer.effect( }) }) - const overhead = Token.estimate(prompt) + Token.estimate(NUDGE) return yield* deliver(trigger, prepared, split.recent, budget - overhead, send) }) @@ -551,12 +551,15 @@ export const layer = Layer.effect( } /** The conversation as the runner would send it, after request hooks. */ + /** `overhead` counts text sent outside the history, such as the summary prompt, toward the prompt size. */ const prepare = ( context: SessionContext.Loaded, messages: ReadonlyArray, webSocket?: "session", + overhead = 0, ) => { const base = transcript(context, messages) + const prompt = estimatePrompt({ ...context, messages }) return requests.compaction({ session: context.session, agent: context.agent.id, @@ -565,6 +568,7 @@ export const layer = Layer.effect( system: base.system, messages: base.messages, webSocket, + inputTokens: { measured: prompt.measured, estimated: prompt.estimated + overhead }, }) } @@ -843,6 +847,12 @@ export const recentUserMessages = ( } export const estimateContext = (context: SessionContext.Loaded) => { + const prompt = estimatePrompt(context) + 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 = (context: SessionContext.Loaded) => { const anchorIndex = context.messages.findLastIndex((message) => hasMeasuredPrompt(message, context.model.ref)) const anchor = context.messages[anchorIndex] const base = transcript(context, context.messages.slice(Math.max(0, anchorIndex))) @@ -856,11 +866,16 @@ export const estimateContext = (context: SessionContext.Loaded) => { const unmeasured = sent.filter((message) => message.role !== "assistant" || message.id !== anchor?.id) if (anchor?.type !== "assistant" || !anchor.tokens) - return estimateRequest({ system: base.system, tools: context.tools.definitions, messages: unmeasured }) + return { + measured: 0, + estimated: estimateRequest({ system: base.system, tools: context.tools.definitions, messages: unmeasured }), + } const tokens = anchor.tokens - const measured = tokens.input + tokens.cache.read + tokens.cache.write + tokens.output + tokens.reasoning - return measured + unmeasured.reduce((sum, message) => sum + estimateMessage(message), 0) + return { + measured: tokens.input + tokens.cache.read + tokens.cache.write + tokens.output + tokens.reasoning, + estimated: unmeasured.reduce((sum, message) => sum + estimateMessage(message), 0), + } } /** The largest request the model takes while leaving room for its reply. */ diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index d2277ce8ac7c..c04603dc2201 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -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> = { 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 diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 2b654657413d..3ffe4ed6e900 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -240,6 +240,7 @@ 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(loaded), }) const outcome = yield* steps.attempt({ isLocationClosed: lifecycle.isClosed, diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 2f6725f3cf2f..7ca86ee7d0ec 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -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 diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 04c51e75e34c..982fce68310b 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -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" @@ -452,7 +452,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") diff --git a/packages/core/test/session-model-request-hooks.test.ts b/packages/core/test/session-model-request-hooks.test.ts index 3b7b1d45f45b..9e18804bd027 100644 --- a/packages/core/test/session-model-request-hooks.test.ts +++ b/packages/core/test/session-model-request-hooks.test.ts @@ -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) => 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 = [] + 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)), + ) +}) diff --git a/packages/core/test/session-model-request.test.ts b/packages/core/test/session-model-request.test.ts index fb3570ef5ded..977a6d206563 100644 --- a/packages/core/test/session-model-request.test.ts +++ b/packages/core/test/session-model-request.test.ts @@ -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( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 8f445872f9db..70a778024e40 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -126,6 +126,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( @@ -3205,7 +3206,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", @@ -3410,6 +3411,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( From 1b91a0e86c71138f6e67888902dd08d795a77014 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 26 Sep 2026 20:13:08 -0500 Subject: [PATCH 2/7] fix(core): reserve room for the reply and the summary before compacting Live runs against Anthropic, MiniMax, Kimi, DeepSeek, and GLM showed the summary request's output limit collapsing to the 1k floor at the threshold: it was sized from the request as prepared, which overshoots the budget the request is later shrunk to. A summary longer than that failed the compaction. Compaction now keeps a reserve of max(10% of the window, 16k) free, so the last reply before compaction and the summary itself always have that much room. The summary's output limit is a flat 16k. The output limit for normal requests fits the model's limit to the room the prompt leaves, padding only the estimated part by 5%; the 256k cap and the 4k slack are gone. --- packages/core/src/session/compaction.ts | 31 ++++++++++----- packages/core/src/session/model-request.ts | 31 ++++++++++----- packages/core/test/session-compaction.test.ts | 11 +++--- .../test/session-model-request-hooks.test.ts | 11 +++--- .../core/test/session-model-request.test.ts | 27 ++++++------- packages/core/test/session-runner.test.ts | 39 ++++++++++++++++++- 6 files changed, 107 insertions(+), 43 deletions(-) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index ae02876a8281..f386e625f31c 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -265,8 +265,7 @@ export const layer = Layer.effect( const prompt = buildPrompt(previous !== undefined, previous?.summary.includes(LEGACY_HEADING) ?? false) const headings = SUMMARY_TEMPLATE.split("\n").filter((line) => line.startsWith("##")) const filled = (text: string) => text.split("\n").some((line) => headings.includes(line.trim())) - const overhead = Token.estimate(prompt) + Token.estimate(NUDGE) - const prepared = yield* prepare(context, split.older, undefined, overhead) + const prepared = yield* prepare(context, split.older, budget) // Hooks saw the request without the summary prompt, so it is appended here. A reply that ignores the // template gets one reminder before it counts as a failure. @@ -289,6 +288,7 @@ export const layer = Layer.effect( }) }) + const overhead = Token.estimate(prompt) + Token.estimate(NUDGE) return yield* deliver(trigger, prepared, split.recent, budget - overhead, send) }) @@ -313,7 +313,7 @@ export const layer = Layer.effect( if (!context.messages.some(messageToText)) return yield* Effect.fail(NOTHING_TO_COMPACT) const unsupported = (message: string) => Effect.fail({ error: { type: "provider.unsupported-operation", message } }) - const prepared = yield* prepare(context, context.messages, "session") + const prepared = yield* prepare(context, context.messages, budget, "session") // History is selected before request hooks, so a hook that reroutes the request cannot be honored here. const provenance = SessionProviderContext.provenance(context.model) @@ -550,16 +550,21 @@ export const layer = Layer.effect( ) } - /** The conversation as the runner would send it, after request hooks. */ - /** `overhead` counts text sent outside the history, such as the summary prompt, toward the prompt size. */ + /** + * The conversation as the runner would send it, after request hooks. + * + * The output limit leaves room for `budget`, the most `deliver` sends. The request prepared here can be larger + * when the conversation overshot the threshold, and is only shrunk to fit after hooks have seen it, so sizing the + * output to it would leave next to no room. A prompt the estimate undersells is rejected and shrunk like any + * other. + */ const prepare = ( context: SessionContext.Loaded, messages: ReadonlyArray, + budget: number, webSocket?: "session", - overhead = 0, ) => { const base = transcript(context, messages) - const prompt = estimatePrompt({ ...context, messages }) return requests.compaction({ session: context.session, agent: context.agent.id, @@ -568,7 +573,7 @@ export const layer = Layer.effect( system: base.system, messages: base.messages, webSocket, - inputTokens: { measured: prompt.measured, estimated: prompt.estimated + overhead }, + inputTokens: { measured: budget, estimated: 0 }, }) } @@ -878,13 +883,19 @@ export const estimatePrompt = (context: SessionContext.Loaded) => { } } -/** The largest request the model takes while leaving room for its reply. */ +/** + * The largest request the model takes while leaving room for its reply: 10% of the window, or the summary's output + * limit when that is more, so the last reply before compaction and the summary itself always have that much. A window + * too small to give up the summary's limit keeps 10%. + */ const calculateCeiling = (limit: SessionContext.Loaded["model"]["limit"], buffer: number | undefined) => { // Unknown limits are reported as 0. An unknown input limit falls back to the context window; with no window at // all, only a provider rejection can limit the request. const window = limit.input || limit.context if (window <= 0) return Number.POSITIVE_INFINITY - return buffer === undefined ? Math.floor(window * 0.9) : window - buffer + if (buffer !== undefined) return window - buffer + const reserve = SessionModelRequest.SUMMARY_OUTPUT_TOKENS + return window - Math.max(Math.floor(window * 0.1), window >= 2 * reserve ? reserve : 0) } /** diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index c04603dc2201..2c68dbefdb9c 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -44,13 +44,19 @@ 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> = { primary: 256_000, compaction: 32_000 } +/** + * The output limit of a compaction summary. Compaction keeps this much of the window free (see `SessionCompaction`), + * so the summary request always has room for it. + */ +export const SUMMARY_OUTPUT_TOKENS = 16_000 +// Titles and generate have no default output limit and keep the provider default, because their reasoning is hard to +// budget. +const OUTPUT_LIMITED: ReadonlySet = new Set(["primary", "compaction"]) // 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 ESTIMATE_ERROR = 0.05 +// Never ask for less; only reachable with automatic compaction off, since it keeps the window from filling this far. const OUTPUT_TOKEN_MIN = 1_024 /** Tool errors, plus the user declining a permission or dismissing a question. */ @@ -81,9 +87,14 @@ export interface Input { 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) +/** The default output limit: the catalog limit, fitted to the room the prompt leaves in the context window. */ +export const outputLimit = ( + limit: Model.Info["limit"], + kind: "primary" | "compaction", + inputTokens?: Input["inputTokens"], +) => { + const model = limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK + const requested = kind === "compaction" ? Math.min(model, SUMMARY_OUTPUT_TOKENS) : model 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)) @@ -237,14 +248,16 @@ export const layer = Layer.effect( 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: cap === undefined ? {} : { maxTokens: outputLimit(model.limit, cap, input.inputTokens) }, + options: + kind === "primary" || kind === "compaction" + ? { maxTokens: outputLimit(model.limit, kind, input.inputTokens) } + : {}, }, Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])), ) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 982fce68310b..e6ecbaaab782 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -212,14 +212,15 @@ it.effect("auto compaction estimates current content against the buffered prompt expect(yield* due(native(244_800))).toBe(true) expect(yield* due(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false) + // The summary's 16k output limit is more than 10% of a 100k window, so it sets the ceiling. const contextLimited = { context: 100_000, output: 10_000 } - expect(yield* due(input(89_999, contextLimited))).toBe(false) - expect(yield* due(input(90_000, contextLimited))).toBe(true) + expect(yield* due(input(83_999, contextLimited))).toBe(false) + expect(yield* due(input(84_000, contextLimited))).toBe(true) // The reply limit does not lower the ceiling. const outputLimited = { context: 100_000, output: 30_000 } - expect(yield* due(input(89_999, outputLimited))).toBe(false) - expect(yield* due(input(90_000, outputLimited))).toBe(true) + expect(yield* due(input(83_999, outputLimited))).toBe(false) + expect(yield* due(input(84_000, outputLimited))).toBe(true) const assistant = input(89_000, contextLimited).messages[0] const tool = SessionMessage.AssistantTool.make({ @@ -452,7 +453,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () => "x-opencode-session": sessionID, "x-opencode-client": "opencode", }) - expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 32_000 })) + expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 16_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") diff --git a/packages/core/test/session-model-request-hooks.test.ts b/packages/core/test/session-model-request-hooks.test.ts index 9e18804bd027..062cd6f7346b 100644 --- a/packages/core/test/session-model-request-hooks.test.ts +++ b/packages/core/test/session-model-request-hooks.test.ts @@ -158,7 +158,7 @@ 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", () => + it.effect("defaults the output limit per request kind", () => Effect.gen(function* () { const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer)) const large = { @@ -170,11 +170,12 @@ describe("SessionModelRequest output limit", () => { }), } const maxTokens = (prepared: SessionModelRequest.Prepared) => prepared.request.generation?.maxTokens - expect(maxTokens(yield* requests.primary(large))).toBe(256_000) - expect(maxTokens(yield* requests.compaction(large))).toBe(32_000) + expect(maxTokens(yield* requests.primary(large))).toBe(384_000) + expect(maxTokens(yield* requests.compaction(large))).toBe(16_000) + // 200k window − 170k measured − 8k estimated with 5% padding 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) + expect(maxTokens(yield* requests.primary({ ...input, inputTokens }))).toBe(21_600) + expect(maxTokens(yield* requests.compaction({ ...input, inputTokens }))).toBe(16_000) }).pipe(Effect.provideService(SessionModelTransport.Service, transport)), ) diff --git a/packages/core/test/session-model-request.test.ts b/packages/core/test/session-model-request.test.ts index 977a6d206563..31098a7121a5 100644 --- a/packages/core/test/session-model-request.test.ts +++ b/packages/core/test/session-model-request.test.ts @@ -5,33 +5,34 @@ import { boundImages, outputLimit, unsupportedParts } from "@opencode/core/sessi 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("requests the catalog output limit, and 16k for a summary", () => { + expect(outputLimit({ context: 1_000_000, output: 128_000 }, "primary")).toBe(128_000) + expect(outputLimit({ context: 1_048_576, output: 1_048_576 }, "primary")).toBe(1_048_576) + expect(outputLimit({ context: 200_000, output: 64_000 }, "compaction")).toBe(16_000) + expect(outputLimit({ context: 200_000, output: 8_000 }, "compaction")).toBe(8_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) + expect(outputLimit({ context: 200_000, output: 0 }, "primary")).toBe(32_000) + expect(outputLimit({ context: 200_000, output: 0 }, "compaction")).toBe(16_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) + expect(outputLimit(limit, "primary", { measured: 50_000, estimated: 0 })).toBe(128_000) + expect(outputLimit(limit, "primary", { measured: 900_000, estimated: 0 })).toBe(100_000) + // Estimated text counts 5% extra, so 40k estimated takes 42k of the room. + expect(outputLimit(limit, "primary", { measured: 900_000, estimated: 40_000 })).toBe(58_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) + expect(outputLimit({ context: 200_000, output: 64_000 }, "primary", prompt)).toBe(1_024) + expect(outputLimit({ context: 200_000, output: 512 }, "primary", 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) + expect(outputLimit({ context: 0, output: 32_000 }, "primary", { measured: 500_000, estimated: 0 })).toBe(32_000) }) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 70a778024e40..cd3c08deb6f2 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -127,6 +127,7 @@ const unknownContextModel = testModel("unknown-context", { context: 0, output: 3 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 }) +const smallWindowModel = testModel("small-window", { context: 64_000, output: 16_000 }) test("calculates step cost using the matching context tier", () => { expect( @@ -3420,7 +3421,43 @@ describe("SessionRunnerLLM", () => { 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) + expect(s.requests[1]?.generation?.maxTokens).toBeGreaterThan(100_000 - 50_000 - 200) + }) + + scenario("gives the summary its full output limit when the conversation overshot the threshold", function* (s) { + // The conversation overshot the threshold, so the prepared summary request exceeds the budget and `deliver` + // shrinks it before sending. The output limit must follow the budget, not the oversized prepared request. + yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-budget-first", 185_000)) + yield* s.runPrompt("Earlier question") + s.requests.length = 0 + yield* s.llm.push( + TestLLM.text("## Objective\n- Preserve the task", "text-budget-summary"), + TestLLM.text("Continued", "text-budget-final"), + ) + yield* s.runPrompt("Recent request ".repeat(400)) + + expect(s.requests).toHaveLength(2) + expect(userTexts(s.requests[0]).at(-1)).toContain("## Objective") + expect(s.requests[0]?.generation?.maxTokens).toBe(16_000) + expect(s.requests[1]?.generation?.maxTokens).toBe(32_000) + }) + + scenario("keeps the summary its room on a small window", function* (s) { + // 90% of 64k would leave 6.4k for the summary, so the 16k reserve sets the ceiling at 48k instead. + s.currentModel = smallWindowModel + yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-small-first", 59_000)) + yield* s.runPrompt("Earlier question") + s.requests.length = 0 + yield* s.llm.push( + TestLLM.text("## Objective\n- Preserve the task", "text-small-summary"), + TestLLM.text("Continued", "text-small-final"), + ) + yield* s.runPrompt("Recent request ".repeat(400)) + + expect(s.requests).toHaveLength(2) + expect(userTexts(s.requests[0]).at(-1)).toContain("## Objective") + expect(s.requests[0]?.generation?.maxTokens).toBe(16_000) + expect(s.requests[1]?.generation?.maxTokens).toBe(16_000) }) scenario("publishes the original overflow when recovery summarization fails", function* (s) { From a3242c6da60b53ef1c5ca41c19e266d3e14a61b1 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 26 Sep 2026 20:36:46 -0500 Subject: [PATCH 3/7] fix(core): let the summary use the whole compaction reserve --- packages/core/src/session/compaction.ts | 11 +++++---- packages/core/src/session/model-request.ts | 22 ++++-------------- packages/core/test/session-compaction.test.ts | 2 +- .../test/session-model-request-hooks.test.ts | 6 ++--- .../core/test/session-model-request.test.ts | 23 ++++++++----------- packages/core/test/session-runner.test.ts | 3 ++- 6 files changed, 27 insertions(+), 40 deletions(-) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index f386e625f31c..5de1ef8dc1b4 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -90,6 +90,8 @@ type Streamed = { const NOTHING_TO_COMPACT: Failure = { error: { type: "compaction.unavailable", message: "Nothing to compact yet" } } /** After each "too long" rejection, the next attempt aims at this share of the first rejected request's size. */ const SHRINK_STEPS = [0.7, 0.5, 0.35] +// The least of the window kept free for the last reply before compaction and for the summary itself. +const RESERVE_MIN = 16_000 /** A common window size, assumed for the compaction request when the model's window is unknown. */ const UNKNOWN_WINDOW = 200_000 const TOOL_OUTPUT_MAX_CHARS = 1_250 @@ -884,9 +886,9 @@ export const estimatePrompt = (context: SessionContext.Loaded) => { } /** - * The largest request the model takes while leaving room for its reply: 10% of the window, or the summary's output - * limit when that is more, so the last reply before compaction and the summary itself always have that much. A window - * too small to give up the summary's limit keeps 10%. + * The largest request the model takes while leaving room for its reply: 10% of the window, or `RESERVE_MIN` when that + * is more. The summary request is capped at the same size, so its output limit is whatever the reserve leaves. A window + * too small to give up `RESERVE_MIN` keeps 10%. */ const calculateCeiling = (limit: SessionContext.Loaded["model"]["limit"], buffer: number | undefined) => { // Unknown limits are reported as 0. An unknown input limit falls back to the context window; with no window at @@ -894,8 +896,7 @@ const calculateCeiling = (limit: SessionContext.Loaded["model"]["limit"], buffer const window = limit.input || limit.context if (window <= 0) return Number.POSITIVE_INFINITY if (buffer !== undefined) return window - buffer - const reserve = SessionModelRequest.SUMMARY_OUTPUT_TOKENS - return window - Math.max(Math.floor(window * 0.1), window >= 2 * reserve ? reserve : 0) + return window - Math.max(Math.floor(window * 0.1), window >= 2 * RESERVE_MIN ? RESERVE_MIN : 0) } /** diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 2c68dbefdb9c..0de04ecdb695 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -44,14 +44,6 @@ 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)) -/** - * The output limit of a compaction summary. Compaction keeps this much of the window free (see `SessionCompaction`), - * so the summary request always has room for it. - */ -export const SUMMARY_OUTPUT_TOKENS = 16_000 -// Titles and generate have no default output limit and keep the provider default, because their reasoning is hard to -// budget. -const OUTPUT_LIMITED: ReadonlySet = new Set(["primary", "compaction"]) // 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. @@ -88,13 +80,8 @@ export interface Input { } /** The default output limit: the catalog limit, fitted to the room the prompt leaves in the context window. */ -export const outputLimit = ( - limit: Model.Info["limit"], - kind: "primary" | "compaction", - inputTokens?: Input["inputTokens"], -) => { - const model = limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK - const requested = kind === "compaction" ? Math.min(model, SUMMARY_OUTPUT_TOKENS) : model +export const outputLimit = (limit: Model.Info["limit"], inputTokens?: Input["inputTokens"]) => { + const requested = limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK 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)) @@ -247,7 +234,8 @@ 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. + // Hooks see the default output limit and may change or remove it. Titles and generate keep the provider default, + // because their reasoning is hard to budget. const shaped = yield* shape( { sessionID: session.id, @@ -256,7 +244,7 @@ export const layer = Layer.effect( messages: input.messages, options: kind === "primary" || kind === "compaction" - ? { maxTokens: outputLimit(model.limit, kind, input.inputTokens) } + ? { maxTokens: outputLimit(model.limit, input.inputTokens) } : {}, }, Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])), diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index e6ecbaaab782..0f554f453f4a 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -453,7 +453,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () => "x-opencode-session": sessionID, "x-opencode-client": "opencode", }) - expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 16_000 })) + expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 20_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") diff --git a/packages/core/test/session-model-request-hooks.test.ts b/packages/core/test/session-model-request-hooks.test.ts index 062cd6f7346b..39d8b6d570ab 100644 --- a/packages/core/test/session-model-request-hooks.test.ts +++ b/packages/core/test/session-model-request-hooks.test.ts @@ -158,7 +158,7 @@ describe("SessionModelRequest HTTP hooks", () => { describe("SessionModelRequest output limit", () => { const input = { session, agent: Agent.ID.make("build"), model, system: [], messages: [] } - it.effect("defaults the output limit per request kind", () => + it.effect("defaults the output limit on primary and compaction requests", () => Effect.gen(function* () { const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer)) const large = { @@ -171,11 +171,11 @@ describe("SessionModelRequest output limit", () => { } const maxTokens = (prepared: SessionModelRequest.Prepared) => prepared.request.generation?.maxTokens expect(maxTokens(yield* requests.primary(large))).toBe(384_000) - expect(maxTokens(yield* requests.compaction(large))).toBe(16_000) + expect(maxTokens(yield* requests.compaction(large))).toBe(384_000) // 200k window − 170k measured − 8k estimated with 5% padding const inputTokens = { measured: 170_000, estimated: 8_000 } expect(maxTokens(yield* requests.primary({ ...input, inputTokens }))).toBe(21_600) - expect(maxTokens(yield* requests.compaction({ ...input, inputTokens }))).toBe(16_000) + expect(maxTokens(yield* requests.compaction({ ...input, inputTokens }))).toBe(21_600) }).pipe(Effect.provideService(SessionModelTransport.Service, transport)), ) diff --git a/packages/core/test/session-model-request.test.ts b/packages/core/test/session-model-request.test.ts index 31098a7121a5..5541a7701209 100644 --- a/packages/core/test/session-model-request.test.ts +++ b/packages/core/test/session-model-request.test.ts @@ -5,34 +5,31 @@ import { boundImages, outputLimit, unsupportedParts } from "@opencode/core/sessi const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] }) describe("SessionModelRequest.outputLimit", () => { - test("requests the catalog output limit, and 16k for a summary", () => { - expect(outputLimit({ context: 1_000_000, output: 128_000 }, "primary")).toBe(128_000) - expect(outputLimit({ context: 1_048_576, output: 1_048_576 }, "primary")).toBe(1_048_576) - expect(outputLimit({ context: 200_000, output: 64_000 }, "compaction")).toBe(16_000) - expect(outputLimit({ context: 200_000, output: 8_000 }, "compaction")).toBe(8_000) + test("requests the catalog output limit", () => { + expect(outputLimit({ context: 1_000_000, output: 128_000 })).toBe(128_000) + expect(outputLimit({ context: 1_048_576, output: 1_048_576 })).toBe(1_048_576) }) test("falls back to 32k when the catalog has no output limit", () => { - expect(outputLimit({ context: 200_000, output: 0 }, "primary")).toBe(32_000) - expect(outputLimit({ context: 200_000, output: 0 }, "compaction")).toBe(16_000) + expect(outputLimit({ context: 200_000, output: 0 })).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, "primary", { measured: 50_000, estimated: 0 })).toBe(128_000) - expect(outputLimit(limit, "primary", { measured: 900_000, estimated: 0 })).toBe(100_000) + expect(outputLimit(limit, { measured: 50_000, estimated: 0 })).toBe(128_000) + expect(outputLimit(limit, { measured: 900_000, estimated: 0 })).toBe(100_000) // Estimated text counts 5% extra, so 40k estimated takes 42k of the room. - expect(outputLimit(limit, "primary", { measured: 900_000, estimated: 40_000 })).toBe(58_000) + expect(outputLimit(limit, { measured: 900_000, estimated: 40_000 })).toBe(58_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 }, "primary", prompt)).toBe(1_024) - expect(outputLimit({ context: 200_000, output: 512 }, "primary", prompt)).toBe(512) + expect(outputLimit({ context: 200_000, output: 64_000 }, prompt)).toBe(1_024) + expect(outputLimit({ context: 200_000, output: 512 }, prompt)).toBe(512) }) test("ignores the prompt size when the context window is unknown", () => { - expect(outputLimit({ context: 0, output: 32_000 }, "primary", { measured: 500_000, estimated: 0 })).toBe(32_000) + expect(outputLimit({ context: 0, output: 32_000 }, { measured: 500_000, estimated: 0 })).toBe(32_000) }) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index cd3c08deb6f2..4f1555d17a28 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -3438,7 +3438,8 @@ describe("SessionRunnerLLM", () => { expect(s.requests).toHaveLength(2) expect(userTexts(s.requests[0]).at(-1)).toContain("## Objective") - expect(s.requests[0]?.generation?.maxTokens).toBe(16_000) + // The summary may use the whole 20k reserve of a 200k window. + expect(s.requests[0]?.generation?.maxTokens).toBe(20_000) expect(s.requests[1]?.generation?.maxTokens).toBe(32_000) }) From e8a70dd5d21973d7f1efffa12f996494cb7c0c84 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 26 Sep 2026 21:21:47 -0500 Subject: [PATCH 4/7] fix(core): cap the summary output limit at 32k --- packages/core/src/session/model-request.ts | 13 ++++++++--- .../test/session-model-request-hooks.test.ts | 2 +- .../core/test/session-model-request.test.ts | 22 ++++++++++--------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 0de04ecdb695..81fd46ce7c0f 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -46,6 +46,8 @@ const IMAGE_REMOVED = const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields)) // Used when the catalog has no output limit for the model. const OUTPUT_TOKEN_FALLBACK = 32_000 +// A summary never needs more, and a request asking for more cannot be shrunk to fit a window the catalog overstates. +const SUMMARY_OUTPUT_MAX = 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.05 // Never ask for less; only reachable with automatic compaction off, since it keeps the window from filling this far. @@ -80,8 +82,13 @@ export interface Input { } /** The default output limit: the catalog limit, fitted to the room the prompt leaves in the context window. */ -export const outputLimit = (limit: Model.Info["limit"], inputTokens?: Input["inputTokens"]) => { - const requested = limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK +export const outputLimit = ( + limit: Model.Info["limit"], + kind: "primary" | "compaction", + inputTokens?: Input["inputTokens"], +) => { + const model = limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK + const requested = kind === "compaction" ? Math.min(model, SUMMARY_OUTPUT_MAX) : model 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)) @@ -244,7 +251,7 @@ export const layer = Layer.effect( messages: input.messages, options: kind === "primary" || kind === "compaction" - ? { maxTokens: outputLimit(model.limit, input.inputTokens) } + ? { maxTokens: outputLimit(model.limit, kind, input.inputTokens) } : {}, }, Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])), diff --git a/packages/core/test/session-model-request-hooks.test.ts b/packages/core/test/session-model-request-hooks.test.ts index 39d8b6d570ab..367947eee866 100644 --- a/packages/core/test/session-model-request-hooks.test.ts +++ b/packages/core/test/session-model-request-hooks.test.ts @@ -171,7 +171,7 @@ describe("SessionModelRequest output limit", () => { } const maxTokens = (prepared: SessionModelRequest.Prepared) => prepared.request.generation?.maxTokens expect(maxTokens(yield* requests.primary(large))).toBe(384_000) - expect(maxTokens(yield* requests.compaction(large))).toBe(384_000) + expect(maxTokens(yield* requests.compaction(large))).toBe(32_000) // 200k window − 170k measured − 8k estimated with 5% padding const inputTokens = { measured: 170_000, estimated: 8_000 } expect(maxTokens(yield* requests.primary({ ...input, inputTokens }))).toBe(21_600) diff --git a/packages/core/test/session-model-request.test.ts b/packages/core/test/session-model-request.test.ts index 5541a7701209..536d3e842a74 100644 --- a/packages/core/test/session-model-request.test.ts +++ b/packages/core/test/session-model-request.test.ts @@ -5,31 +5,33 @@ import { boundImages, outputLimit, unsupportedParts } from "@opencode/core/sessi const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] }) describe("SessionModelRequest.outputLimit", () => { - test("requests the catalog output limit", () => { - expect(outputLimit({ context: 1_000_000, output: 128_000 })).toBe(128_000) - expect(outputLimit({ context: 1_048_576, output: 1_048_576 })).toBe(1_048_576) + test("requests the catalog output limit, at most 32k for a summary", () => { + expect(outputLimit({ context: 1_000_000, output: 128_000 }, "primary")).toBe(128_000) + expect(outputLimit({ context: 1_048_576, output: 1_048_576 }, "primary")).toBe(1_048_576) + expect(outputLimit({ context: 1_000_000, output: 128_000 }, "compaction")).toBe(32_000) + expect(outputLimit({ context: 200_000, output: 8_000 }, "compaction")).toBe(8_000) }) test("falls back to 32k when the catalog has no output limit", () => { - expect(outputLimit({ context: 200_000, output: 0 })).toBe(32_000) + expect(outputLimit({ context: 200_000, output: 0 }, "primary")).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, { measured: 50_000, estimated: 0 })).toBe(128_000) - expect(outputLimit(limit, { measured: 900_000, estimated: 0 })).toBe(100_000) + expect(outputLimit(limit, "primary", { measured: 50_000, estimated: 0 })).toBe(128_000) + expect(outputLimit(limit, "primary", { measured: 900_000, estimated: 0 })).toBe(100_000) // Estimated text counts 5% extra, so 40k estimated takes 42k of the room. - expect(outputLimit(limit, { measured: 900_000, estimated: 40_000 })).toBe(58_000) + expect(outputLimit(limit, "primary", { measured: 900_000, estimated: 40_000 })).toBe(58_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 }, prompt)).toBe(1_024) - expect(outputLimit({ context: 200_000, output: 512 }, prompt)).toBe(512) + expect(outputLimit({ context: 200_000, output: 64_000 }, "primary", prompt)).toBe(1_024) + expect(outputLimit({ context: 200_000, output: 512 }, "primary", prompt)).toBe(512) }) test("ignores the prompt size when the context window is unknown", () => { - expect(outputLimit({ context: 0, output: 32_000 }, { measured: 500_000, estimated: 0 })).toBe(32_000) + expect(outputLimit({ context: 0, output: 32_000 }, "primary", { measured: 500_000, estimated: 0 })).toBe(32_000) }) }) From 259740350cacca8a2683b7bbf1e9f7b551e07ebb Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 26 Sep 2026 22:10:37 -0500 Subject: [PATCH 5/7] fix(core): pad estimated prompt text by 15% --- packages/core/src/session/model-request.ts | 2 +- packages/core/test/session-model-request-hooks.test.ts | 6 +++--- packages/core/test/session-model-request.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 81fd46ce7c0f..6e208b79afd1 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -49,7 +49,7 @@ const OUTPUT_TOKEN_FALLBACK = 32_000 // A summary never needs more, and a request asking for more cannot be shrunk to fit a window the catalog overstates. const SUMMARY_OUTPUT_MAX = 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.05 +const ESTIMATE_ERROR = 0.15 // Never ask for less; only reachable with automatic compaction off, since it keeps the window from filling this far. const OUTPUT_TOKEN_MIN = 1_024 diff --git a/packages/core/test/session-model-request-hooks.test.ts b/packages/core/test/session-model-request-hooks.test.ts index 367947eee866..2d58ca34b415 100644 --- a/packages/core/test/session-model-request-hooks.test.ts +++ b/packages/core/test/session-model-request-hooks.test.ts @@ -172,10 +172,10 @@ describe("SessionModelRequest output limit", () => { const maxTokens = (prepared: SessionModelRequest.Prepared) => prepared.request.generation?.maxTokens expect(maxTokens(yield* requests.primary(large))).toBe(384_000) expect(maxTokens(yield* requests.compaction(large))).toBe(32_000) - // 200k window − 170k measured − 8k estimated with 5% padding + // 200k window − 170k measured − 8k estimated with 15% padding const inputTokens = { measured: 170_000, estimated: 8_000 } - expect(maxTokens(yield* requests.primary({ ...input, inputTokens }))).toBe(21_600) - expect(maxTokens(yield* requests.compaction({ ...input, inputTokens }))).toBe(21_600) + 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)), ) diff --git a/packages/core/test/session-model-request.test.ts b/packages/core/test/session-model-request.test.ts index 536d3e842a74..e23971f53fa9 100644 --- a/packages/core/test/session-model-request.test.ts +++ b/packages/core/test/session-model-request.test.ts @@ -20,8 +20,8 @@ describe("SessionModelRequest.outputLimit", () => { const limit = { context: 1_000_000, output: 128_000 } expect(outputLimit(limit, "primary", { measured: 50_000, estimated: 0 })).toBe(128_000) expect(outputLimit(limit, "primary", { measured: 900_000, estimated: 0 })).toBe(100_000) - // Estimated text counts 5% extra, so 40k estimated takes 42k of the room. - expect(outputLimit(limit, "primary", { measured: 900_000, estimated: 40_000 })).toBe(58_000) + // Estimated text counts 15% extra, so 40k estimated takes 46k of the room. + expect(outputLimit(limit, "primary", { measured: 900_000, estimated: 40_000 })).toBe(54_000) }) test("keeps a minimum limit when the prompt nearly fills the context window", () => { From 4c11baa750ac9dc1fbe73ead61d368193b67995d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 26 Sep 2026 22:28:48 -0500 Subject: [PATCH 6/7] test(core): drop redundant output limit hook tests --- .../test/session-model-request-hooks.test.ts | 59 ------------------- 1 file changed, 59 deletions(-) diff --git a/packages/core/test/session-model-request-hooks.test.ts b/packages/core/test/session-model-request-hooks.test.ts index 2d58ca34b415..3b7b1d45f45b 100644 --- a/packages/core/test/session-model-request-hooks.test.ts +++ b/packages/core/test/session-model-request-hooks.test.ts @@ -154,62 +154,3 @@ describe("SessionModelRequest HTTP hooks", () => { }), ) }) - -describe("SessionModelRequest output limit", () => { - const input = { session, agent: Agent.ID.make("build"), model, system: [], messages: [] } - - it.effect("defaults the output limit on primary and compaction requests", () => - 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) => prepared.request.generation?.maxTokens - expect(maxTokens(yield* requests.primary(large))).toBe(384_000) - expect(maxTokens(yield* requests.compaction(large))).toBe(32_000) - // 200k window − 170k measured − 8k estimated with 15% padding - 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 = [] - 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)), - ) -}) From ed6ffc6dd119eec71879099ce680241edc6471a4 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 26 Sep 2026 22:29:35 -0500 Subject: [PATCH 7/7] test(core): keep outputLimit private --- packages/core/src/session/model-request.ts | 2 +- .../core/test/session-model-request.test.ts | 33 +------------------ 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 6e208b79afd1..21ad82c03348 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -82,7 +82,7 @@ export interface Input { } /** The default output limit: the catalog limit, fitted to the room the prompt leaves in the context window. */ -export const outputLimit = ( +const outputLimit = ( limit: Model.Info["limit"], kind: "primary" | "compaction", inputTokens?: Input["inputTokens"], diff --git a/packages/core/test/session-model-request.test.ts b/packages/core/test/session-model-request.test.ts index e23971f53fa9..fb3570ef5ded 100644 --- a/packages/core/test/session-model-request.test.ts +++ b/packages/core/test/session-model-request.test.ts @@ -1,40 +1,9 @@ import { describe, expect, test } from "bun:test" import { Message, ToolResultPart, Media } from "@opencode/ai" -import { boundImages, outputLimit, unsupportedParts } from "@opencode/core/session/model-request" +import { boundImages, 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, at most 32k for a summary", () => { - expect(outputLimit({ context: 1_000_000, output: 128_000 }, "primary")).toBe(128_000) - expect(outputLimit({ context: 1_048_576, output: 1_048_576 }, "primary")).toBe(1_048_576) - expect(outputLimit({ context: 1_000_000, output: 128_000 }, "compaction")).toBe(32_000) - expect(outputLimit({ context: 200_000, output: 8_000 }, "compaction")).toBe(8_000) - }) - - test("falls back to 32k when the catalog has no output limit", () => { - expect(outputLimit({ context: 200_000, output: 0 }, "primary")).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, "primary", { measured: 50_000, estimated: 0 })).toBe(128_000) - expect(outputLimit(limit, "primary", { measured: 900_000, estimated: 0 })).toBe(100_000) - // Estimated text counts 15% extra, so 40k estimated takes 46k of the room. - expect(outputLimit(limit, "primary", { 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 }, "primary", prompt)).toBe(1_024) - expect(outputLimit({ context: 200_000, output: 512 }, "primary", prompt)).toBe(512) - }) - - test("ignores the prompt size when the context window is unknown", () => { - expect(outputLimit({ context: 0, output: 32_000 }, "primary", { measured: 500_000, estimated: 0 })).toBe(32_000) - }) -}) - describe("SessionModelRequest.unsupportedParts", () => { test("replaces unsupported user media with a visible error", () => { const messages = unsupportedParts(