Skip to content
Open
Show file tree
Hide file tree
Changes from all 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/fix-autocompaction-threshold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Fix auto-compaction triggering far below the configured context threshold. The threshold now applies to the context window shown in the UI and is anchored to provider-reported token usage plus newly added content, instead of an inflated estimate of the whole payload on models with separate input limits. New system prompts and tool schemas are counted even when a provider report is available, and cancelled responses or reports that omit input usage no longer leave later content uncounted. On models whose input limit is smaller than their context window, the reserved input safety buffer can still trigger compaction before a high configured percentage is reached.
102 changes: 75 additions & 27 deletions packages/opencode/src/kilocode/session/overflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,45 @@ function continued(messages: ModelMessage[]) {
return messages.slice(idx + 1).some((message) => message.role === "tool")
}

function size(messages: ModelMessage[]) {
let extra = 0
const json = JSON.stringify(messages, function (this: unknown, key, value: unknown) {
// Encrypted reasoning replays as an opaque value; its byte length is not a token count.
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
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
})
return { chars: Token.estimate(json), extra }
}

function pending(messages: ModelMessage[]) {
const idx = messages.findLastIndex((message) => message.role === "assistant")
return messages.slice(idx + 1)
}

// System content delivered as leading system-role messages - request prep places
// it there on every provider path. Only head-position messages are counted.
function leading(messages: ModelMessage[]) {
let chars = 0
for (const message of messages) {
if (message.role !== "system") break
chars += Token.estimate(
typeof message.content === "string" ? message.content : (JSON.stringify(message.content) ?? ""),
)
}
return chars
}

export namespace KiloSessionOverflow {
export class PreflightError extends Error {
constructor() {
Expand All @@ -34,39 +73,32 @@ export namespace KiloSessionOverflow {
return total || tokens.total || 0
}

// The estimate decides alone when the report would under-count: an unfinished
// assistant trails the finished one, or the report lacks prompt-side usage.
export function baseline(input: {
assistant?: { id: string }
finished?: { id: string; summary?: boolean; tokens: MessageV2.Assistant["tokens"] }
}) {
if (!input.finished || input.finished.summary === true) return undefined
if (input.assistant?.id !== input.finished.id) return undefined
const t = input.finished.tokens
if (t.input + t.cache.read + t.cache.write === 0) return undefined
return count(input.finished.tokens)
}

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

const context = input.model.limit.input || input.model.limit.context
const context = input.model.limit.context
if (context === 0) return input.usable

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) {
// 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
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)
const raw = messages + extra
const full = size(input.messages)
const tools = Token.estimate(
JSON.stringify(
Object.entries(input.tools).map(([name, tool]) => ({
Expand All @@ -76,9 +108,18 @@ export namespace KiloSessionOverflow {
})),
),
)
const lead = leading(input.messages)
return {
normalized: Math.ceil((messages + tools) * FACTOR),
raw: Math.ceil((raw + tools) * FACTOR),
normalized: Math.ceil((full.chars + tools) * FACTOR),
raw: Math.ceil((full.chars + full.extra + tools) * FACTOR),
// New messages only; the report already covers the rest of the previous
// request. New media is priced as its placeholder, not its bytes.
tail: Math.ceil(size(pending(input.messages)).chars * FACTOR),
// System content and tool schemas are re-sent every request and may have changed
// since the report. Adding the current copies in full double-counts unchanged
// ones - bounded over-projection, never an under-count that bypasses the
// threshold. Request prep delivers system content as leading messages.
overhead: Math.ceil((tools + lead) * FACTOR),
continuation: continued(input.messages),
}
}
Expand All @@ -96,12 +137,19 @@ export namespace KiloSessionOverflow {
cfg: Config.Info
model: Provider.Model
usable: number
} & (Payload | { tokens: number; continuation: boolean }),
reported?: number
} & (Payload | { tokens: number; tail: number; overhead?: 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)
// Baseline = report plus inflated content added since it (messages, system, tools).
// Without a usable report the full estimate decides alone.
const baseline =
typeof input.reported === "number" && input.reported > 0
? input.reported + stats.tail + (stats.overhead ?? 0)
: undefined
const projected = baseline ?? ("tokens" in stats ? stats.tokens : stats.normalized)
return projected >= limit(input)
}
}
8 changes: 7 additions & 1 deletion packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,10 @@ const live: Layer.Layer<
: base.messages
const preflight = input.preflight === true && KiloSessionOverflow.enabled({ cfg, model: input.model })
const cap = KiloLLM.needsEstimate({ model: input.model, configured: base.params.maxOutputTokens })
const usage = cap || preflight ? KiloSessionOverflow.measure({ messages: estimated, tools }) : undefined
const usage =
cap || preflight
? KiloSessionOverflow.measure({ messages: estimated, tools })
: undefined
const maxOutputTokens = KiloLLM.capOutputTokens({
model: input.model,
messages: estimated,
Expand All @@ -158,7 +161,10 @@ const live: Layer.Layer<
model: input.model,
usable: usable({ cfg, model: input.model, outputTokenMax: flags.outputTokenMax }), // kilocode_change
tokens: usage.normalized,
tail: usage.tail,
overhead: usage.overhead,
continuation: usage.continuation,
reported: input.reportedContextTokens,
})
) {
return yield* Effect.fail(new KiloSessionOverflow.PreflightError())
Expand Down
15 changes: 6 additions & 9 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1783,15 +1783,12 @@ export const layer = Layer.effect(
tools,
model,
toolChoice: format.type === "json_schema" ? "required" : undefined,
// kilocode_change start - feed the provider-reported context size from the last finished
// turn into the output-token cap, so image/vision input is measured by the provider
// rather than by encoded payload bytes (see KiloLLM.capOutputTokens). Summary messages
// are skipped like in the isOverflow check above: their reported input reflects the
// pre-compaction history, not the trimmed context of the next request.
reportedContextTokens:
lastFinished && lastFinished.summary !== true
? KiloSessionOverflow.count(lastFinished.tokens)
: undefined,
// kilocode_change start - provider-reported context size feeds the output-token cap
// (see KiloLLM.capOutputTokens); summaries and trailing unfinished assistants invalidate it.
reportedContextTokens: KiloSessionOverflow.baseline({
assistant: lastAssistant,
finished: lastFinished,
}),
// kilocode_change end
})

Expand Down
Loading
Loading