diff --git a/.changeset/keep-reasoning-output-budget.md b/.changeset/keep-reasoning-output-budget.md new file mode 100644 index 00000000000..dc0df20cf97 --- /dev/null +++ b/.changeset/keep-reasoning-output-budget.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent encrypted reasoning state from incorrectly reducing the output token budget for long-running sessions. diff --git a/packages/opencode/src/kilocode/session/llm.ts b/packages/opencode/src/kilocode/session/llm.ts index c6627a28b9f..12fcd00fb1c 100644 --- a/packages/opencode/src/kilocode/session/llm.ts +++ b/packages/opencode/src/kilocode/session/llm.ts @@ -44,10 +44,11 @@ export namespace KiloLLM { * Like opencode, the provider is the source of truth: when the last finished * turn reported usage, `reported` carries that provider-tokenized context size * (input + output + cache), which already accounts for image/vision input the - * client cannot see. The client-side media-normalized estimate (encoded bytes - * excluded) is used as a floor so newly added text or tool schemas still cap - * output, and as the sole basis on the first turn before any usage is reported. - * The larger of the two is used so the cap never under-counts. + * client cannot see. The client-side normalized estimate (encoded media and + * opaque reasoning-state bytes excluded) is used as a floor so newly added + * text or tool schemas still cap output, and as the sole basis on the first + * turn before any usage is reported. The larger of the two is used so the cap + * never under-counts. * * Many small models (e.g. qwen 7B, 32K context) ship with a default * max_output of 32K, leaving no room for input once tools are included. diff --git a/packages/opencode/src/kilocode/session/overflow.ts b/packages/opencode/src/kilocode/session/overflow.ts index bf299c00f7d..ea842033482 100644 --- a/packages/opencode/src/kilocode/session/overflow.ts +++ b/packages/opencode/src/kilocode/session/overflow.ts @@ -8,6 +8,8 @@ import type { ModelMessage } from "ai" const FACTOR = 1.3 const MEDIA = "[encoded media]" const MEDIA_TOKENS = Token.estimate(MEDIA) +const OPAQUE = "[opaque reasoning state]" +const OPAQUE_TOKENS = Token.estimate(OPAQUE) type Payload = { messages: ModelMessage[] @@ -46,6 +48,13 @@ export namespace KiloSessionOverflow { export function measure(input: Payload) { let extra = 0 const normalized = JSON.stringify(input.messages, function (this: unknown, key, value: unknown) { + // Providers replay encrypted reasoning state as an opaque continuation value. + // Its encoded byte length is not a token count and can be several times larger + // than the context the provider reports for the same request. + if (key === "reasoningEncryptedContent" && typeof value === "string") { + extra += Math.max(0, Token.estimate(value) - OPAQUE_TOKENS) + return OPAQUE + } if (!["data", "url", "image"].includes(key)) return value if (!this || typeof this !== "object" || !("type" in this)) return value if (!["file", "image", "media"].includes(String(this.type))) return value diff --git a/packages/opencode/test/kilocode/session-overflow.test.ts b/packages/opencode/test/kilocode/session-overflow.test.ts index 7f5c67afa16..ee1cc2492d3 100644 --- a/packages/opencode/test/kilocode/session-overflow.test.ts +++ b/packages/opencode/test/kilocode/session-overflow.test.ts @@ -163,6 +163,53 @@ describe("Kilo request estimation", () => { expect(KiloLLM.capOutputTokens({ model: mdl, messages, tools: {}, configured: 32_000, usage })).toBe(32_000) }) + test.each(["providerMetadata", "providerOptions"] as const)( + "does not reduce output for encrypted reasoning in %s", + (field) => { + const mdl = model({ context: 1_050_000, output: 128_000 }) + const reasoning = { + type: "reasoning" as const, + text: "Checked the previous tool results.", + [field]: { + openai: { + itemId: "rs_1", + reasoningEncryptedContent: "x".repeat(3_200_000), + }, + }, + } + const messages = [ + { role: "assistant", content: [reasoning] }, + { role: "user", content: "Continue." }, + ] satisfies ModelMessage[] + const usage = KiloSessionOverflow.measure({ messages, tools: {} }) + + expect(usage.raw).toBeGreaterThan(1_000_000) + expect(usage.normalized).toBeLessThan(1_000) + expect( + KiloLLM.capOutputTokens({ + model: mdl, + messages, + tools: {}, + configured: 32_000, + usage, + reported: 624_205, + }), + ).toBe(32_000) + }, + ) + + test("still counts visible reasoning text", () => { + const mdl = model({ context: 200_000, output: 32_000 }) + const messages = [ + { + role: "assistant", + content: [{ type: "reasoning", text: "x".repeat(600_000) }], + }, + ] satisfies ModelMessage[] + + expect(KiloLLM.capOutputTokens({ model: mdl, messages, tools: {}, configured: 32_000 })).toBeLessThan(32_000) + }) + test("still reduces output for oversized text", () => { const mdl = model({ context: 200_000, output: 32_000 }) const messages = [{ role: "user" as const, content: "x".repeat(600_000) }]