1c2caa09df
compactedSessions permanently blocked re-compaction after first success, causing unbounded context growth (e.g. 500k on Kimi K2.5 with 256k limit). - Clear compactedSessions flag on new message.updated so compaction can re-trigger when context exceeds threshold again - Use modelContextLimitsCache for model-specific context limits instead of always falling back to 200k for non-Anthropic providers
179 lines
5.2 KiB
TypeScript
179 lines
5.2 KiB
TypeScript
import { log } from "../shared/logger"
|
|
import type { OhMyOpenCodeConfig } from "../config"
|
|
|
|
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
|
|
const DEFAULT_ACTUAL_LIMIT = 200_000
|
|
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000
|
|
|
|
type ModelCacheStateLike = {
|
|
anthropicContext1MEnabled: boolean
|
|
modelContextLimitsCache?: Map<string, number>
|
|
}
|
|
|
|
function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number {
|
|
return (modelCacheState?.anthropicContext1MEnabled ?? false) ||
|
|
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
|
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
|
? 1_000_000
|
|
: DEFAULT_ACTUAL_LIMIT
|
|
}
|
|
|
|
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
|
|
|
|
interface TokenInfo {
|
|
input: number
|
|
output: number
|
|
reasoning: number
|
|
cache: { read: number; write: number }
|
|
}
|
|
|
|
interface CachedCompactionState {
|
|
providerID: string
|
|
modelID: string
|
|
tokens: TokenInfo
|
|
}
|
|
|
|
function withTimeout<TValue>(
|
|
promise: Promise<TValue>,
|
|
timeoutMs: number,
|
|
errorMessage: string,
|
|
): Promise<TValue> {
|
|
let timeoutID: ReturnType<typeof setTimeout> | undefined
|
|
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
timeoutID = setTimeout(() => {
|
|
reject(new Error(errorMessage))
|
|
}, timeoutMs)
|
|
})
|
|
|
|
return Promise.race([promise, timeoutPromise]).finally(() => {
|
|
if (timeoutID !== undefined) {
|
|
clearTimeout(timeoutID)
|
|
}
|
|
})
|
|
}
|
|
|
|
function isAnthropicProvider(providerID: string): boolean {
|
|
return providerID === "anthropic" || providerID === "google-vertex-anthropic"
|
|
}
|
|
|
|
type PluginInput = {
|
|
client: {
|
|
session: {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
messages: (...args: any[]) => any
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
summarize: (...args: any[]) => any
|
|
}
|
|
tui: {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
showToast: (...args: any[]) => any
|
|
}
|
|
}
|
|
directory: string
|
|
}
|
|
|
|
export function createPreemptiveCompactionHook(
|
|
ctx: PluginInput,
|
|
pluginConfig: OhMyOpenCodeConfig,
|
|
modelCacheState?: ModelCacheStateLike,
|
|
) {
|
|
const compactionInProgress = new Set<string>()
|
|
const compactedSessions = new Set<string>()
|
|
const tokenCache = new Map<string, CachedCompactionState>()
|
|
|
|
const toolExecuteAfter = async (
|
|
input: { tool: string; sessionID: string; callID: string },
|
|
_output: { title: string; output: string; metadata: unknown }
|
|
) => {
|
|
const { sessionID } = input
|
|
if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return
|
|
|
|
const cached = tokenCache.get(sessionID)
|
|
if (!cached) return
|
|
|
|
const modelSpecificLimit = !isAnthropicProvider(cached.providerID)
|
|
? modelCacheState?.modelContextLimitsCache?.get(`${cached.providerID}/${cached.modelID}`)
|
|
: undefined
|
|
const actualLimit = isAnthropicProvider(cached.providerID)
|
|
? getAnthropicActualLimit(modelCacheState)
|
|
: modelSpecificLimit ?? DEFAULT_ACTUAL_LIMIT
|
|
|
|
const lastTokens = cached.tokens
|
|
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
|
|
const usageRatio = totalInputTokens / actualLimit
|
|
|
|
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD) return
|
|
|
|
const modelID = cached.modelID
|
|
if (!modelID) return
|
|
|
|
compactionInProgress.add(sessionID)
|
|
|
|
try {
|
|
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
|
|
pluginConfig,
|
|
sessionID,
|
|
cached.providerID,
|
|
modelID
|
|
)
|
|
|
|
await withTimeout(
|
|
ctx.client.session.summarize({
|
|
path: { id: sessionID },
|
|
body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never,
|
|
query: { directory: ctx.directory },
|
|
}),
|
|
PREEMPTIVE_COMPACTION_TIMEOUT_MS,
|
|
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
|
|
)
|
|
|
|
compactedSessions.add(sessionID)
|
|
} catch (error) {
|
|
log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) })
|
|
} finally {
|
|
compactionInProgress.delete(sessionID)
|
|
}
|
|
}
|
|
|
|
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
const props = event.properties as Record<string, unknown> | undefined
|
|
|
|
if (event.type === "session.deleted") {
|
|
const sessionInfo = props?.info as { id?: string } | undefined
|
|
if (sessionInfo?.id) {
|
|
compactionInProgress.delete(sessionInfo.id)
|
|
compactedSessions.delete(sessionInfo.id)
|
|
tokenCache.delete(sessionInfo.id)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (event.type === "message.updated") {
|
|
const info = props?.info as {
|
|
role?: string
|
|
sessionID?: string
|
|
providerID?: string
|
|
modelID?: string
|
|
finish?: boolean
|
|
tokens?: TokenInfo
|
|
} | undefined
|
|
|
|
if (!info || info.role !== "assistant" || !info.finish) return
|
|
if (!info.sessionID || !info.providerID || !info.tokens) return
|
|
|
|
tokenCache.set(info.sessionID, {
|
|
providerID: info.providerID,
|
|
modelID: info.modelID ?? "",
|
|
tokens: info.tokens,
|
|
})
|
|
compactedSessions.delete(info.sessionID)
|
|
}
|
|
}
|
|
|
|
return {
|
|
"tool.execute.after": toolExecuteAfter,
|
|
event: eventHandler,
|
|
}
|
|
}
|