Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-context-preflight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Compact sessions at the configured context percentage before sending an oversized provider request.
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
26 changes: 8 additions & 18 deletions packages/opencode/src/kilocode/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).
Expand All @@ -39,26 +40,15 @@ export namespace KiloLLM {
messages: ModelMessage[]
tools: Record<string, { description?: string; inputSchema?: unknown }>
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).
Expand Down
74 changes: 74 additions & 0 deletions packages/opencode/src/kilocode/session/overflow.ts
Original file line number Diff line number Diff line change
@@ -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<string, { description?: string; inputSchema?: unknown }>
}

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
Expand All @@ -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) ?? "")
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
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)
}
}
15 changes: 12 additions & 3 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
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")) {
Expand Down Expand Up @@ -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({
Expand Down
48 changes: 40 additions & 8 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -71,6 +73,7 @@ export type StreamInput = {
tools: Record<string, Tool>
retries?: number
toolChoice?: "auto" | "required" | "none"
preflight?: boolean // kilocode_change - enable proactive threshold compaction for normal session turns
}

export type StreamRequest = StreamInput & {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -831,7 +838,12 @@ 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)
// kilocode_change start
const stream = llm.stream({
...streamInput,
preflight: !ctx.assistantMessage.summary,
})
// kilocode_change end

yield* stream.pipe(
Stream.tap((event) => handleEvent(event)),
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
// kilocode_change start — break out so a newer queued prompt can take over
Expand Down
Loading
Loading