From da702a85a6a1f8d0cd7503920763bafc38775d7a Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:37:46 +0100 Subject: [PATCH] fix(core): leave headroom when fitting output to the context window (#59934) --- packages/core/src/session/model-request.ts | 19 ++++++++++++++----- packages/core/test/session-runner.test.ts | 5 +++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 72169b6dbd5..739d7c0d16e 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -50,6 +50,10 @@ const OUTPUT_TOKEN_FALLBACK = 32_000 const OUTPUT_TOKEN_MAX = 256_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 +// Share of the window a turn leaves unused when its output limit is fitted to the room left. Providers count a few +// tokens more than we do (chat template, tool schemas), and strict ones reject input plus output past the window. +// Compaction sizes its summary against its own reserve instead. +const WINDOW_HEADROOM = 0.01 // 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 @@ -82,10 +86,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"]) => { +export const outputLimit = (limit: Model.Info["limit"], inputTokens?: Input["inputTokens"], headroom = 0) => { const requested = Math.min(limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK, OUTPUT_TOKEN_MAX) if (inputTokens === undefined || limit.context <= 0) return requested - const room = limit.context - inputTokens.measured - Math.ceil(inputTokens.estimated * (1 + ESTIMATE_ERROR)) + const room = + Math.floor(limit.context * (1 - headroom)) - + inputTokens.measured - + Math.ceil(inputTokens.estimated * (1 + ESTIMATE_ERROR)) return Math.min(requested, Math.max(OUTPUT_TOKEN_MIN, room)) } @@ -255,9 +262,11 @@ export const layer = Layer.effect( system: input.system, messages: input.messages, options: - kind === "primary" || kind === "compaction" - ? { maxTokens: outputLimit(model.limit, input.inputTokens) } - : {}, + kind === "primary" + ? { maxTokens: outputLimit(model.limit, input.inputTokens, WINDOW_HEADROOM) } + : kind === "compaction" + ? { maxTokens: outputLimit(model.limit, input.inputTokens) } + : {}, }, Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 3ccc2518191..62c610faf1e 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -3561,8 +3561,9 @@ describe("SessionRunnerLLM", () => { 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 - 200) + // 1% of the window stays unused, since strict providers reject a request that overshoots it by a few tokens. + expect(s.requests[1]?.generation?.maxTokens).toBeLessThan(99_000 - 50_000) + expect(s.requests[1]?.generation?.maxTokens).toBeGreaterThan(99_000 - 50_000 - 200) }) scenario("caps the output limit a large model advertises", function* (s) {