From a13064167df50862e9a4a8622e092ac518110281 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 8 Jun 2026 14:02:06 +0200 Subject: [PATCH 1/3] fix(cli): compact before context threshold overflow --- .changeset/tidy-context-preflight.md | 5 + .../customize/context/context-condensing.md | 4 +- packages/opencode/src/kilocode/session/llm.ts | 26 +-- .../opencode/src/kilocode/session/overflow.ts | 74 +++++++ packages/opencode/src/session/compaction.ts | 15 +- packages/opencode/src/session/llm.ts | 48 ++++- packages/opencode/src/session/processor.ts | 12 +- packages/opencode/src/session/prompt.ts | 3 +- .../test/kilocode/session-overflow.test.ts | 195 +++++++++++++++++- .../session-prompt-compaction-safety.test.ts | 41 ++++ 10 files changed, 389 insertions(+), 34 deletions(-) create mode 100644 .changeset/tidy-context-preflight.md diff --git a/.changeset/tidy-context-preflight.md b/.changeset/tidy-context-preflight.md new file mode 100644 index 000000000000..ce309a6e35b6 --- /dev/null +++ b/.changeset/tidy-context-preflight.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Compact sessions at the configured context percentage before sending an oversized provider request. diff --git a/packages/kilo-docs/pages/customize/context/context-condensing.md b/packages/kilo-docs/pages/customize/context/context-condensing.md index c5b173a3e091..69cfdc4ac6a9 100644 --- a/packages/kilo-docs/pages/customize/context/context-condensing.md +++ b/packages/kilo-docs/pages/customize/context/context-condensing.md @@ -36,7 +36,7 @@ This summary replaces older conversation history while Kilo keeps the most recen ### Automatic trigger -Kilo tracks the total token count for the session: input, output, and cached reads and writes. Compaction runs when token usage reaches `compaction.threshold_percent`, or when the remaining window hits the reserved safety buffer, whichever happens first. +Kilo checks provider-reported usage after each response and estimates the outgoing text, system instructions, and tool definitions before contacting the provider. Compaction runs when either count reaches `compaction.threshold_percent`, or when the remaining window hits the reserved safety buffer, whichever happens first. How the buffer is chosen depends on what the model declares. When the model advertises a separate input limit, the buffer defaults to 20,000 tokens (or the model's maximum output size, whichever is smaller). When the model only declares a single context window, Kilo instead reserves the model's full output cap — up to 32,000 tokens. @@ -136,7 +136,7 @@ This summary replaces older conversation history while Kilo keeps the most recen ### Automatic trigger -Kilo tracks the total token count for the session: input, output, and cached reads and writes. Compaction runs when token usage reaches `compaction.threshold_percent`, or when the remaining window hits the reserved safety buffer, whichever happens first. +Kilo checks provider-reported usage after each response and estimates the outgoing text, system instructions, and tool definitions before contacting the provider. Compaction runs when either count reaches `compaction.threshold_percent`, or when the remaining window hits the reserved safety buffer, whichever happens first. How the buffer is chosen depends on what the model declares. When the model advertises a separate input limit, the buffer defaults to 20,000 tokens (or the model's maximum output size, whichever is smaller). When the model only declares a single context window, Kilo instead reserves the model's full output cap — up to 32,000 tokens. diff --git a/packages/opencode/src/kilocode/session/llm.ts b/packages/opencode/src/kilocode/session/llm.ts index ac7ab6545136..0dd6811d9e85 100644 --- a/packages/opencode/src/kilocode/session/llm.ts +++ b/packages/opencode/src/kilocode/session/llm.ts @@ -3,11 +3,8 @@ import { Effect } from "effect" import * as Stream from "effect/Stream" import type { Provider } from "@/provider/provider" import type { Event } from "@/session/llm" -import { Token } from "@/util/token" +import { KiloSessionOverflow } from "./overflow" -// Token.estimate consistently under-counts by ~15-30% vs. actual provider tokenizers. -// Multiply all estimates by this factor and add a fixed safety margin to compensate. -const ESTIMATE_FACTOR = 1.3 const SAFETY = 2048 const MIN_OUTPUT = 1024 @@ -25,6 +22,10 @@ export namespace KiloLLM { ) } + export function needsEstimate(input: { model: Provider.Model; configured: number | undefined }) { + return input.configured !== undefined && input.configured > 0 && input.model.limit.context > 0 + } + /** * Caps `maxOutputTokens` to fit within the model's context window after * accounting for the actual estimated input tokens (messages + tool schemas). @@ -39,26 +40,15 @@ export namespace KiloLLM { messages: ModelMessage[] tools: Record configured: number | undefined + tokens?: number }): number | undefined { if (input.configured == null) return input.configured if (input.configured <= 0) return undefined const { context } = input.model.limit if (!context) return input.configured - const msgTokens = Math.ceil(Token.estimate(JSON.stringify(input.messages)) * ESTIMATE_FACTOR) - const toolTokens = Math.ceil( - Token.estimate( - JSON.stringify( - Object.entries(input.tools).map(([name, t]) => ({ - name, - description: t.description, - inputSchema: t.inputSchema, - })), - ), - ) * ESTIMATE_FACTOR, - ) - - const available = context - msgTokens - toolTokens - SAFETY + const tokens = input.tokens ?? KiloSessionOverflow.measure({ messages: input.messages, tools: input.tools }).raw + const available = context - tokens - SAFETY // If available is ≤0 the input alone exceeds context — return the original // value so the provider returns a natural overflow error which triggers // compaction (compactionAttempts guard stops the loop eventually). diff --git a/packages/opencode/src/kilocode/session/overflow.ts b/packages/opencode/src/kilocode/session/overflow.ts index 270259176f3c..f30601e78632 100644 --- a/packages/opencode/src/kilocode/session/overflow.ts +++ b/packages/opencode/src/kilocode/session/overflow.ts @@ -1,7 +1,31 @@ import type { Config } from "@/config/config" import type { Provider } from "@/provider/provider" +import { Token } from "@/util/token" +import type { ModelMessage } from "ai" + +// Token.estimate undercounts provider tokenizers, especially for code and JSON payloads. +const FACTOR = 1.3 +const MEDIA = "[encoded media]" +const MEDIA_TOKENS = Token.estimate(MEDIA) + +type Payload = { + messages: ModelMessage[] + tools: Record +} + +function continued(messages: ModelMessage[]) { + const idx = messages.findLastIndex((message) => message.role === "user") + return messages.slice(idx + 1).some((message) => message.role === "tool") +} export namespace KiloSessionOverflow { + export class PreflightError extends Error { + constructor() { + super("Outgoing context reached the automatic compaction threshold") + this.name = "PreflightCompactionError" + } + } + export function limit(input: { cfg: Config.Info; model: Provider.Model; usable: number }) { const percent = input.cfg.compaction?.threshold_percent if (typeof percent !== "number") return input.usable @@ -12,4 +36,54 @@ export namespace KiloSessionOverflow { const cap = Math.floor(context * (percent / 100)) return Math.min(input.usable, cap) } + + export function measure(input: Payload) { + let extra = 0 + const normalized = JSON.stringify(input.messages, function (this: unknown, key, value: unknown) { + 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 + const text = typeof value === "string" ? value : (JSON.stringify(value) ?? "") + extra += Math.max(0, Token.estimate(text) - MEDIA_TOKENS) + return MEDIA + }) + const messages = Token.estimate(normalized) + const raw = messages + extra + const tools = Token.estimate( + JSON.stringify( + Object.entries(input.tools).map(([name, tool]) => ({ + name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + ), + ) + return { + normalized: Math.ceil((messages + tools) * FACTOR), + raw: Math.ceil((raw + tools) * FACTOR), + continuation: continued(input.messages), + } + } + + export function enabled(input: { cfg: Config.Info; model: Provider.Model }) { + return ( + input.cfg.compaction?.auto !== false && + typeof input.cfg.compaction?.threshold_percent === "number" && + input.model.limit.context !== 0 + ) + } + + export function shouldCompact( + input: { + cfg: Config.Info + model: Provider.Model + usable: number + } & (Payload | { tokens: number; continuation: boolean }), + ) { + if (!enabled(input)) return false + const stats = "tokens" in input ? input : measure(input) + if (stats.continuation) return false + const tokens = "tokens" in stats ? stats.tokens : stats.normalized + return tokens >= limit(input) + } } diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index d1f4008a2b7e..b0fd0afdc47d 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -380,8 +380,17 @@ export const layer: Layer.Layer< parts: MessageV2.Part[] } | undefined - if (input.overflow) { - const idx = input.messages.findIndex((m) => m.info.id === input.parentID) + // kilocode_change start - preflight compaction leaves an empty assistant before its marker + const idx = input.messages.findIndex((m) => m.info.id === input.parentID) + const previous = input.messages[idx - 1] + const pending = + input.auto && + previous?.info.role === "assistant" && + !previous.info.finish && + !previous.info.error && + previous.parts.length === 0 + if (input.overflow || pending) { + // kilocode_change end for (let i = idx - 1; i >= 0; i--) { const msg = input.messages[i] if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) { @@ -551,7 +560,7 @@ export const layer: Layer.Layer< for (const part of replay.parts) { if (part.type === "compaction") continue const replayPart = - part.type === "file" && MessageV2.isMedia(part.mime) + input.overflow && part.type === "file" && MessageV2.isMedia(part.mime) // kilocode_change ? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` } : part yield* session.updatePart({ diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index d06813f763f3..29cb3e9bb275 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -18,6 +18,7 @@ import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" import type { Agent } from "@/agent/agent" import type { MessageV2 } from "./message-v2" +import { usable } from "./overflow" // kilocode_change import { Plugin } from "@/plugin" import { SystemPrompt } from "./system" import { Flag } from "@opencode-ai/core/flag/flag" @@ -40,6 +41,7 @@ import { import { Identity } from "@kilocode/kilo-telemetry" import { KiloSession } from "@/kilocode/session" import { KiloLLM } from "@/kilocode/session/llm" +import { KiloSessionOverflow } from "@/kilocode/session/overflow" import { SessionExport } from "@/kilocode/session-export" import { getActiveOrg } from "@/kilocode/session-export/eligibility" // kilocode_change end @@ -71,6 +73,7 @@ export type StreamInput = { tools: Record retries?: number toolChoice?: "auto" | "required" | "none" + preflight?: boolean // kilocode_change - enable proactive threshold compaction for normal session turns } export type StreamRequest = StreamInput & { @@ -243,14 +246,6 @@ const live: Layer.Layer< // kilocode_change end const tools = resolveTools(input) - // kilocode_change start - cap maxOutputTokens to fit within context after estimating real input size - params.maxOutputTokens = KiloLLM.capOutputTokens({ - model: input.model, - messages, - tools, - configured: params.maxOutputTokens, - }) - // kilocode_change end // LiteLLM and some Anthropic proxies require the tools parameter to be present // when message history contains tool calls, even if no tools are being used. @@ -285,6 +280,43 @@ const live: Layer.Layer< } const sortedTools = Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))) + // kilocode_change start - compact at the configured threshold before contacting the provider + const estimated: ModelMessage[] = + isOpenaiOauth || isWorkflow + ? [ + { + role: "system", + content: isOpenaiOauth ? String(options.instructions ?? "") : system.join("\n"), + }, + ...messages, + ] + : messages + const preflight = input.preflight === true && KiloSessionOverflow.enabled({ cfg, model: input.model }) + const cap = KiloLLM.needsEstimate({ model: input.model, configured: params.maxOutputTokens }) + const usage = + cap || preflight ? KiloSessionOverflow.measure({ messages: estimated, tools: sortedTools }) : undefined + params.maxOutputTokens = KiloLLM.capOutputTokens({ + model: input.model, + messages: estimated, + tools: sortedTools, + configured: params.maxOutputTokens, + tokens: usage?.raw, + }) + if ( + preflight && + usage && + KiloSessionOverflow.shouldCompact({ + cfg, + model: input.model, + usable: usable({ cfg, model: input.model }), + tokens: usage.normalized, + continuation: usage.continuation, + }) + ) { + return yield* Effect.fail(new KiloSessionOverflow.PreflightError()) + } + // kilocode_change end + // Wire up toolExecutor for DWS workflow models so that tool calls // from the workflow service are executed via opencode's tool system // and results sent back over the WebSocket. diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 8abcd00e189f..0ceeb0f4d3f6 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -18,6 +18,7 @@ import { SessionSummary } from "./summary" import type { Provider } from "@/provider/provider" import { Question } from "@/question" import { KiloSessionProcessor, type ReviewTelemetry } from "@/kilocode/session/processor" // kilocode_change +import { KiloSessionOverflow } from "@/kilocode/session/overflow" // kilocode_change import { Suggestion } from "@/kilocode/suggestion" // kilocode_change import { NotFoundError } from "@/storage/storage" // kilocode_change import { errorMessage } from "@/util/error" @@ -785,6 +786,12 @@ export const layer: Layer.Layer< }) const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) { + // kilocode_change start - internal preflight signal, not a provider error + if (e instanceof KiloSessionOverflow.PreflightError) { + ctx.needsCompaction = true + return + } + // kilocode_change end slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined }) const error = parse(e) // kilocode_change start @@ -831,7 +838,10 @@ export const layer: Layer.Layer< ctx.currentText = undefined ctx.reasoningMap = {} ctx.step = { reasoning: false, text: false, tool: false } // kilocode_change - const stream = llm.stream(streamInput) + const stream = llm.stream({ + ...streamInput, + preflight: !ctx.assistantMessage.summary, // kilocode_change + }) yield* stream.pipe( Stream.tap((event) => handleEvent(event)), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8cb7112e3e26..09ea52ca4200 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1820,7 +1820,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the agent: lastUser.agent, model: lastUser.model, auto: true, - overflow: !handle.message.finish, + // kilocode_change - preflight compaction replays the pending turn without treating media as provider overflow + overflow: !handle.message.finish && handle.compactError?.() !== undefined, }) } // kilocode_change start — break out so a newer queued prompt can take over diff --git a/packages/opencode/test/kilocode/session-overflow.test.ts b/packages/opencode/test/kilocode/session-overflow.test.ts index 83890141bb47..71a9d7b37d47 100644 --- a/packages/opencode/test/kilocode/session-overflow.test.ts +++ b/packages/opencode/test/kilocode/session-overflow.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test" +import type { ModelMessage } from "ai" import { Config } from "@/config/config" import type { Provider } from "@/provider/provider" +import { KiloLLM } from "@/kilocode/session/llm" +import { KiloSessionOverflow } from "@/kilocode/session/overflow" import type { MessageV2 } from "@/session/message-v2" -import { isOverflow } from "@/session/overflow" +import { isOverflow, usable } from "@/session/overflow" function cfg(compaction?: Config.Info["compaction"]) { return Config.Info.zod.parse({ compaction }) @@ -76,3 +79,193 @@ describe("Kilo auto-compaction threshold", () => { expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(150_000) })).toBe(false) }) }) + +describe("Kilo request estimation", () => { + test("skips output estimation when no output cap can use it", () => { + const mdl = model({ context: 200_000, output: 32_000 }) + + expect(KiloLLM.needsEstimate({ model: mdl, configured: undefined })).toBe(false) + expect(KiloLLM.needsEstimate({ model: mdl, configured: 0 })).toBe(false) + expect(KiloLLM.needsEstimate({ model: model({ context: 0, output: 32_000 }), configured: 32_000 })).toBe(false) + expect(KiloLLM.needsEstimate({ model: mdl, configured: 32_000 })).toBe(true) + }) +}) + +describe("Kilo preflight compaction", () => { + test("triggers from estimated outgoing context without provider usage", () => { + const conf = cfg({ threshold_percent: 75 }) + const mdl = model({ context: 200_000, output: 32_000 }) + const messages = [{ role: "user" as const, content: "x".repeat(600_000) }] + + expect( + KiloSessionOverflow.shouldCompact({ + cfg: conf, + model: mdl, + usable: usable({ cfg: conf, model: mdl }), + messages, + tools: {}, + }), + ).toBe(true) + }) + + test("includes tool schemas in the outgoing estimate", () => { + const conf = cfg({ threshold_percent: 50 }) + const mdl = model({ context: 10_000, output: 1_000 }) + + expect( + KiloSessionOverflow.shouldCompact({ + cfg: conf, + model: mdl, + usable: usable({ cfg: conf, model: mdl }), + messages: [{ role: "user", content: "hello" }], + tools: { + search: { + description: "search", + inputSchema: { type: "object", description: "x".repeat(20_000) }, + }, + }, + }), + ).toBe(true) + }) + + test("uses the model input limit for the preflight percentage", () => { + const conf = cfg({ threshold_percent: 75 }) + const mdl = model({ context: 400_000, input: 200_000, output: 32_000 }) + + expect( + KiloSessionOverflow.shouldCompact({ + cfg: conf, + model: mdl, + usable: usable({ cfg: conf, model: mdl }), + messages: [{ role: "user", content: "x".repeat(500_000) }], + tools: {}, + }), + ).toBe(true) + }) + + test("does not preflight compact a current turn after tool execution", () => { + const conf = cfg({ threshold_percent: 75 }) + const mdl = model({ context: 200_000, output: 32_000 }) + const messages = [ + { role: "user", content: "x".repeat(600_000) }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-1", toolName: "bash", input: { cmd: "pwd" } }], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { type: "text", value: "done" }, + }, + ], + }, + ] satisfies ModelMessage[] + + expect( + KiloSessionOverflow.shouldCompact({ + cfg: conf, + model: mdl, + usable: usable({ cfg: conf, model: mdl }), + messages, + tools: {}, + }), + ).toBe(false) + }) + + test("does not preflight compact without an explicit percentage", () => { + const conf = cfg({}) + const mdl = model({ context: 200_000, output: 32_000 }) + const messages = [{ role: "user" as const, content: "x".repeat(600_000) }] + + expect( + KiloSessionOverflow.shouldCompact({ + cfg: conf, + model: mdl, + usable: usable({ cfg: conf, model: mdl }), + messages, + tools: {}, + }), + ).toBe(false) + }) + + test("does not preflight compact when automatic compaction is disabled", () => { + const conf = cfg({ auto: false, threshold_percent: 75 }) + const mdl = model({ context: 200_000, output: 32_000 }) + const messages = [{ role: "user" as const, content: "x".repeat(600_000) }] + + expect( + KiloSessionOverflow.shouldCompact({ + cfg: conf, + model: mdl, + usable: usable({ cfg: conf, model: mdl }), + messages, + tools: {}, + }), + ).toBe(false) + }) + + test("does not treat encoded media size as context tokens", () => { + const conf = cfg({ threshold_percent: 75 }) + const mdl = model({ context: 200_000, output: 32_000 }) + const messages = [ + { + role: "user", + content: [ + { type: "text", text: "describe this image" }, + { type: "file", mediaType: "image/png", data: "x".repeat(600_000) }, + ], + }, + ] satisfies ModelMessage[] + + expect( + KiloSessionOverflow.shouldCompact({ + cfg: conf, + model: mdl, + usable: usable({ cfg: conf, model: mdl }), + messages, + tools: {}, + }), + ).toBe(false) + }) + + test("normalizes provider image payloads in the estimate", () => { + const messages = [ + { + role: "user", + content: [{ type: "image", image: `data:image/png;base64,${"x".repeat(600_000)}` }], + }, + ] satisfies ModelMessage[] + + const usage = KiloSessionOverflow.measure({ messages, tools: {} }) + expect(usage.normalized).toBeLessThan(100) + expect(usage.raw).toBeGreaterThan(100_000) + }) + + test("still compacts oversized text when the request includes media", () => { + const conf = cfg({ threshold_percent: 75 }) + const mdl = model({ context: 200_000, output: 32_000 }) + const messages = [ + { + role: "user", + content: [ + { type: "text", text: "x".repeat(600_000) }, + { type: "file", mediaType: "image/png", data: "image" }, + ], + }, + ] satisfies ModelMessage[] + + expect( + KiloSessionOverflow.shouldCompact({ + cfg: conf, + model: mdl, + usable: usable({ cfg: conf, model: mdl }), + messages, + tools: {}, + }), + ).toBe(true) + }) +}) diff --git a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts index 20c1c3d03c65..c0183aee30ac 100644 --- a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts @@ -309,6 +309,47 @@ const file = Effect.fn("prompt-safety.file")(function* ( }) describe("SessionPrompt compaction safety", () => { + it.live("compacts estimated outgoing context before the provider request", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Preflight compaction", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + const old = yield* user(chat.id, "x".repeat(240_000)) + yield* assistant(chat.id, old.id, { text: "old answer" }) + const current = yield* user(chat.id, "continue") + yield* file(chat.id, current.id, { mime: "image/png", name: "current.png", body: "CURRENTIMAGE" }) + yield* llm.text("compacted history") + yield* llm.text("final answer") + + const result = yield* prompt.loop({ sessionID: chat.id }) + + expect(yield* llm.calls).toBe(2) + expect(result.parts.some((part) => part.type === "text" && part.text === "final answer")).toBe(true) + const inputs = yield* llm.inputs + expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("CURRENTIMAGE") + const msgs = yield* sessions.messages({ sessionID: chat.id }) + expect(msgs.some((msg) => msg.info.role === "assistant" && msg.info.summary === true)).toBe(true) + }), + { + git: true, + config: (url) => ({ + ...providerCfg(url), + compaction: { + auto: true, + threshold_percent: 70, + tail_turns: 0, + preserve_recent_tokens: 0, + }, + }), + }, + ), + ) + it.live("trims plain-text summary history before provider request", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { From 18c3020ab24970cd91d2c8b88e0f914cd81b73aa Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 8 Jun 2026 18:06:17 +0200 Subject: [PATCH 2/3] fix(cli): annotate proactive compaction hooks --- packages/opencode/src/session/processor.ts | 4 +++- packages/opencode/src/session/prompt.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 0ceeb0f4d3f6..9d1a246bf426 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -838,10 +838,12 @@ export const layer: Layer.Layer< ctx.currentText = undefined ctx.reasoningMap = {} ctx.step = { reasoning: false, text: false, tool: false } // kilocode_change + // kilocode_change start const stream = llm.stream({ ...streamInput, - preflight: !ctx.assistantMessage.summary, // kilocode_change + preflight: !ctx.assistantMessage.summary, }) + // kilocode_change end yield* stream.pipe( Stream.tap((event) => handleEvent(event)), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 09ea52ca4200..f383a3151165 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1821,7 +1821,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the model: lastUser.model, auto: true, // kilocode_change - preflight compaction replays the pending turn without treating media as provider overflow - overflow: !handle.message.finish && handle.compactError?.() !== undefined, + overflow: !handle.message.finish && handle.compactError?.() !== undefined, // kilocode_change }) } // kilocode_change start — break out so a newer queued prompt can take over From 9a1f424e35fa6a17e73eba06d6aeaf5c225bfca1 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 8 Jun 2026 19:52:13 +0200 Subject: [PATCH 3/3] fix(cli): harden proactive compaction replay --- .../opencode/src/kilocode/session/overflow.ts | 7 +++++-- packages/opencode/src/session/compaction.ts | 19 +++++++------------ .../test/kilocode/session-overflow.test.ts | 13 +++++++++++++ .../session-prompt-compaction-safety.test.ts | 3 +++ 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/kilocode/session/overflow.ts b/packages/opencode/src/kilocode/session/overflow.ts index f30601e78632..20f15a6cafcf 100644 --- a/packages/opencode/src/kilocode/session/overflow.ts +++ b/packages/opencode/src/kilocode/session/overflow.ts @@ -43,8 +43,11 @@ export namespace KiloSessionOverflow { 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 - const text = typeof value === "string" ? value : (JSON.stringify(value) ?? "") - extra += Math.max(0, Token.estimate(text) - MEDIA_TOKENS) + const tokens = + value instanceof Uint8Array + ? Math.ceil(value.byteLength / 4) + : Token.estimate(typeof value === "string" ? value : (JSON.stringify(value) ?? "")) + extra += Math.max(0, tokens - MEDIA_TOKENS) return MEDIA }) const messages = Token.estimate(normalized) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index b0fd0afdc47d..acc79442e744 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -380,17 +380,9 @@ export const layer: Layer.Layer< parts: MessageV2.Part[] } | undefined - // kilocode_change start - preflight compaction leaves an empty assistant before its marker - const idx = input.messages.findIndex((m) => m.info.id === input.parentID) - const previous = input.messages[idx - 1] - const pending = - input.auto && - previous?.info.role === "assistant" && - !previous.info.finish && - !previous.info.error && - previous.parts.length === 0 - if (input.overflow || pending) { - // kilocode_change end + // kilocode_change start - false is preflight replay; undefined disables replay + if (input.overflow !== undefined) { + const idx = input.messages.findIndex((m) => m.info.id === input.parentID) for (let i = idx - 1; i >= 0; i--) { const msg = input.messages[i] if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) { @@ -406,6 +398,7 @@ export const layer: Layer.Layer< messages = input.messages } } + // kilocode_change end const agent = yield* agents.get("compaction") const model = agent.model @@ -559,8 +552,9 @@ export const layer: Layer.Layer< KiloSessionPromptQueue.retarget(input.sessionID, replayMsg.id) // kilocode_change - expose replay to scope() for (const part of replay.parts) { if (part.type === "compaction") continue + // kilocode_change start - preserve media for preflight replay but strip it after provider overflow const replayPart = - input.overflow && part.type === "file" && MessageV2.isMedia(part.mime) // kilocode_change + input.overflow && part.type === "file" && MessageV2.isMedia(part.mime) ? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` } : part yield* session.updatePart({ @@ -569,6 +563,7 @@ export const layer: Layer.Layer< messageID: replayMsg.id, sessionID: input.sessionID, }) + // kilocode_change end } } diff --git a/packages/opencode/test/kilocode/session-overflow.test.ts b/packages/opencode/test/kilocode/session-overflow.test.ts index 71a9d7b37d47..201ab85454ca 100644 --- a/packages/opencode/test/kilocode/session-overflow.test.ts +++ b/packages/opencode/test/kilocode/session-overflow.test.ts @@ -245,6 +245,19 @@ describe("Kilo preflight compaction", () => { expect(usage.raw).toBeGreaterThan(100_000) }) + test("accounts for binary provider image payloads in the raw estimate", () => { + const messages = [ + { + role: "user", + content: [{ type: "image", image: new Uint8Array(600_000) }], + }, + ] satisfies ModelMessage[] + + const usage = KiloSessionOverflow.measure({ messages, tools: {} }) + expect(usage.normalized).toBeLessThan(100) + expect(usage.raw).toBeGreaterThan(100_000) + }) + test("still compacts oversized text when the request includes media", () => { const conf = cfg({ threshold_percent: 75 }) const mdl = model({ context: 200_000, output: 32_000 }) diff --git a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts index c0183aee30ac..90ff2eace58a 100644 --- a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts @@ -334,6 +334,9 @@ describe("SessionPrompt compaction safety", () => { expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("CURRENTIMAGE") const msgs = yield* sessions.messages({ sessionID: chat.id }) expect(msgs.some((msg) => msg.info.role === "assistant" && msg.info.summary === true)).toBe(true) + const marker = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "compaction") + expect(marker?.type).toBe("compaction") + if (marker?.type === "compaction") expect(marker.overflow).toBe(false) }), { git: true,