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
9 changes: 8 additions & 1 deletion packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1416,7 +1416,14 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a
}

export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number {
return Math.min(model.limit.output, outputTokenMax) || outputTokenMax
const capped = Math.min(model.limit.output, outputTokenMax)
if (capped) return capped
// Unset limit.output: derive a window-proportional fallback so small
// windows don't reserve most of the context for output. When limit.context
// is also unset, keep the flat fallback — the overflow layer applies its
// own conservative default and it must not be double-applied here.
if (!model.limit.context) return outputTokenMax
return Math.min(outputTokenMax, Math.max(1_024, Math.floor(model.limit.context * 0.25)))
}

type JsonRecord = Record<string, unknown>
Expand Down
15 changes: 13 additions & 2 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { NotFoundError } from "@/storage/storage"

import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow, usable } from "./overflow"
import { isOverflow as overflow, usable, shouldWarnUnsetLimit, DEFAULT_USABLE_CONTEXT } from "./overflow"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
Expand Down Expand Up @@ -166,6 +166,7 @@ export interface Interface {
readonly isOverflow: (input: {
tokens: SessionV1.Assistant["tokens"]
model: Provider.Model
sessionID?: SessionID
}) => Effect.Effect<boolean>
readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
readonly process: (input: {
Expand Down Expand Up @@ -203,12 +204,22 @@ const layer = Layer.effect(
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
tokens: SessionV1.Assistant["tokens"]
model: Provider.Model
sessionID?: SessionID
}) {
const cfg = yield* config.get()
if (input.sessionID && shouldWarnUnsetLimit({ cfg, model: input.model, sessionID: input.sessionID })) {
yield* Effect.logWarning("model reports no context limit; assuming conservative usable window", {
providerID: input.model.providerID,
modelID: input.model.id,
usable: DEFAULT_USABLE_CONTEXT,
})
}
return overflow({
cfg: yield* config.get(),
cfg,
tokens: input.tokens,
model: input.model,
outputTokenMax: flags.outputTokenMax,
sessionID: input.sessionID,
})
})

Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ const live: Layer.Layer<
auth: info,
plugin,
flags,
cfg,
isWorkflow,
})

Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/src/session/llm/request.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import type { Auth } from "@/auth"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { RuntimeFlags } from "@/effect/runtime-flags"
Expand All @@ -8,6 +9,8 @@ import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "../message-v2"
import type { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { usable } from "../overflow"
import { Token } from "@/util/token"
import { SystemPrompt } from "../system"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect, Record } from "effect"
Expand All @@ -32,6 +35,7 @@ type PrepareInput = {
readonly auth: Auth.Info | undefined
readonly plugin: Plugin.Interface
readonly flags: RuntimeFlags.Info
readonly cfg: ConfigV1.Info
readonly isWorkflow: boolean
}

Expand Down Expand Up @@ -174,6 +178,33 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
})
}

// Window-aware output clamp: never request more output than the usable
// window leaves after the estimated input. This seam is where the fully
// composed request (system + messages + resolved tools) first exists, so
// the input estimate lives here rather than in the prompt loop.
const usableWindow = usable({
cfg: input.cfg,
model: input.model,
outputTokenMax: input.flags.outputTokenMax,
sessionID: input.sessionID,
})
if (usableWindow > 0 && params.maxOutputTokens !== undefined) {
const estimated = Token.estimate(
JSON.stringify([system, input.messages, Object.entries(tools).map(([name, item]) => [name, item.description, item.inputSchema])]),
)
const granted = Math.min(params.maxOutputTokens, Math.max(256, usableWindow - estimated))
if (granted < params.maxOutputTokens) {
yield* Effect.logWarning("clamped output budget to remaining window", {
"session.id": input.sessionID,
requested: params.maxOutputTokens,
granted,
estimated,
usable: usableWindow,
})
params.maxOutputTokens = granted
}
}

const opencodeProjectID = input.model.providerID.startsWith("opencode")
? (yield* InstanceState.context).project.id
: undefined
Expand Down
63 changes: 56 additions & 7 deletions packages/opencode/src/session/overflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,65 @@ import { ProviderTransform } from "@/provider/transform"
import type { MessageV2 } from "./message-v2"

const COMPACTION_BUFFER = 20_000
const RESERVED_MINIMUM = 2_048
const RESERVED_RATIO = 0.15

export function usable(input: { cfg: ConfigV1.Info; model: Provider.Model; outputTokenMax?: number }) {
// An unset/zero context limit must not read as infinite: router and local
// providers frequently report 0, which previously disabled proactive
// compaction entirely and let every session run into provider overflow
// errors. Unless auto compaction is explicitly off, assume this conservative
// usable window, shrunk further by any session-level cap learned from
// provider overflow errors.
export const DEFAULT_USABLE_CONTEXT = 32_000

const learnedLimits = new Map<string, number>()
const warnedSessions = new Set<string>()

// Records the estimated input size of a request the provider rejected for
// context overflow. Used as an upper bound on the real window for models that
// report no context limit; only the smallest observation is kept.
export function learnContextLimit(sessionID: string, tokens: number) {
if (tokens <= 0) return
const prior = learnedLimits.get(sessionID)
if (prior === undefined || tokens < prior) learnedLimits.set(sessionID, tokens)
}

// True exactly once per session when the model reports no context limit while
// auto compaction stays enabled, so the caller can log the fallback loudly.
export function shouldWarnUnsetLimit(input: { cfg: ConfigV1.Info; model: Provider.Model; sessionID: string }) {
if (input.model.limit.context) return false
if (input.cfg.compaction?.auto === false) return false
if (warnedSessions.has(input.sessionID)) return false
warnedSessions.add(input.sessionID)
return true
}

// Compaction reserve proportional to the window: a fixed 20k reserve is ~36%
// of a 56k local window but only 10% of 200k. `compaction.reserved` config
// keeps absolute priority; the min() keeps large-window behavior unchanged.
export function reserved(cfg: ConfigV1.Info, context: number) {
return (
cfg.compaction?.reserved ??
Math.min(COMPACTION_BUFFER, Math.max(RESERVED_MINIMUM, Math.floor(context * RESERVED_RATIO)))
)
}

export function usable(input: {
cfg: ConfigV1.Info
model: Provider.Model
outputTokenMax?: number
sessionID?: string
}) {
const context = input.model.limit.context
if (context === 0) return 0
if (!context) {
if (input.cfg.compaction?.auto === false) return 0
const learned = input.sessionID ? learnedLimits.get(input.sessionID) : undefined
if (learned === undefined) return DEFAULT_USABLE_CONTEXT
return Math.min(DEFAULT_USABLE_CONTEXT, Math.max(0, learned - reserved(input.cfg, learned)))
}

const reserved =
input.cfg.compaction?.reserved ??
Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
return input.model.limit.input
? Math.max(0, input.model.limit.input - reserved)
? Math.max(0, input.model.limit.input - reserved(input.cfg, input.model.limit.input))
: Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
}

Expand All @@ -24,9 +73,9 @@ export function isOverflow(input: {
tokens: SessionV1.Assistant["tokens"]
model: Provider.Model
outputTokenMax?: number
sessionID?: string
}) {
if (input.cfg.compaction?.auto === false) return false
if (input.model.limit.context === 0) return false

const count =
input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
Expand Down
19 changes: 17 additions & 2 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import { Snapshot } from "@/snapshot"
import { Session } from "./session"
import { LLM } from "./llm"
import { MessageV2 } from "./message-v2"
import { isOverflow } from "./overflow"
import { isOverflow, learnContextLimit } from "./overflow"
import { Token } from "@/util/token"
import { PartID } from "./schema"
import type { SessionID } from "./schema"
import { SessionRetry } from "./retry"
Expand Down Expand Up @@ -72,6 +73,7 @@ interface ProcessorContext extends Input {
needsCompaction: boolean
currentText: SessionV1.TextPart | undefined
reasoningMap: Record<string, SessionV1.ReasoningPart>
lastStream: LLM.StreamInput | undefined
}

type StreamEvent = LLMEvent
Expand Down Expand Up @@ -111,6 +113,7 @@ const layer = Layer.effect(
needsCompaction: false,
currentText: undefined,
reasoningMap: {},
lastStream: undefined,
}
let aborted = false

Expand Down Expand Up @@ -476,7 +479,7 @@ const layer = Layer.effect(
.pipe(Effect.ignore, Effect.forkIn(scope))
if (
!ctx.assistantMessage.summary &&
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model, sessionID: ctx.sessionID })
) {
ctx.needsCompaction = true
}
Expand Down Expand Up @@ -612,6 +615,17 @@ const layer = Layer.effect(
yield* status.set(ctx.sessionID, { type: "idle" })
return
}
// With no configured context limit, remember the failing request's
// estimated size as a session-level upper bound so the next proactive
// overflow check compacts before the provider rejects again.
if (!input.model.limit.context && ctx.lastStream) {
const estimated = Token.estimate(JSON.stringify([ctx.lastStream.system, ctx.lastStream.messages]))
learnContextLimit(ctx.sessionID, estimated)
yield* Effect.logWarning("learned session context cap from provider overflow", {
"session.id": ctx.sessionID,
estimated,
})
}
ctx.needsCompaction = true
yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
return
Expand All @@ -629,6 +643,7 @@ const layer = Layer.effect(
"session.id": input.sessionID,
messageID: input.assistantMessage.id,
})
ctx.lastStream = streamInput
ctx.needsCompaction = false
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1161,7 +1161,7 @@ const layer = Layer.effect(
if (
lastFinished &&
lastFinished.summary !== true &&
(yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))
(yield* compaction.isOverflow({ tokens: lastFinished.tokens, model, sessionID }))
) {
yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
continue
Expand Down
Loading
Loading