01dca82a45
Replace the boolean compactedSessions set with a lastCompactedTokens map gated on 15% regrowth so sessions can re-compact after tokens keep climbing, cut the summarize timeout from 120s to 60s to avoid long-hang stalls, and surface a warning toast when compaction fails so users aren't left staring at a silently stuck session.
219 lines
6.4 KiB
TypeScript
219 lines
6.4 KiB
TypeScript
import { log } from "../shared/logger"
|
|
import type { OhMyOpenCodeConfig } from "../config"
|
|
import {
|
|
resolveActualContextLimit,
|
|
type ContextLimitModelCacheState,
|
|
} from "../shared/context-limit-resolver"
|
|
|
|
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
|
|
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
|
|
|
|
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000
|
|
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
|
|
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000
|
|
const PREEMPTIVE_COMPACTION_REGROWTH_RATIO = 1.15
|
|
|
|
declare function setTimeout(handler: () => void, timeout?: number): unknown
|
|
declare function clearTimeout(timeoutID: unknown): void
|
|
|
|
interface TokenInfo {
|
|
input: number
|
|
output: number
|
|
reasoning: number
|
|
cache: { read: number; write: number }
|
|
}
|
|
|
|
interface CachedCompactionState {
|
|
providerID: string
|
|
modelID: string
|
|
tokens: TokenInfo
|
|
}
|
|
|
|
async function withTimeout<TValue>(
|
|
promise: Promise<TValue>,
|
|
timeoutMs: number,
|
|
errorMessage: string,
|
|
): Promise<TValue> {
|
|
let timeoutID: unknown
|
|
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
timeoutID = setTimeout(() => {
|
|
reject(new Error(errorMessage))
|
|
}, timeoutMs)
|
|
})
|
|
|
|
return await Promise.race([promise, timeoutPromise]).finally(() => {
|
|
clearTimeout(timeoutID)
|
|
})
|
|
}
|
|
|
|
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?: ContextLimitModelCacheState,
|
|
) {
|
|
const compactionInProgress = new Set<string>()
|
|
const lastCompactedTokens = new Map<string, number>()
|
|
const lastCompactionTime = new Map<string, number>()
|
|
const tokenCache = new Map<string, CachedCompactionState>()
|
|
|
|
const postCompactionMonitor = createPostCompactionDegradationMonitor({
|
|
client: ctx.client,
|
|
directory: ctx.directory,
|
|
pluginConfig,
|
|
tokenCache,
|
|
compactionInProgress,
|
|
})
|
|
|
|
const toolExecuteAfter = async (
|
|
input: { tool: string; sessionID: string; callID: string },
|
|
_output: { title: string; output: string; metadata: unknown }
|
|
) => {
|
|
const { sessionID } = input
|
|
if (compactionInProgress.has(sessionID)) return
|
|
|
|
const lastTime = lastCompactionTime.get(sessionID)
|
|
if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return
|
|
|
|
const cached = tokenCache.get(sessionID)
|
|
if (!cached) return
|
|
|
|
const actualLimit = resolveActualContextLimit(
|
|
cached.providerID,
|
|
cached.modelID,
|
|
modelCacheState,
|
|
)
|
|
|
|
if (actualLimit === null) {
|
|
log("[preemptive-compaction] Skipping preemptive compaction: unknown context limit for model", {
|
|
providerID: cached.providerID,
|
|
modelID: cached.modelID,
|
|
})
|
|
return
|
|
}
|
|
|
|
const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0)
|
|
|
|
const previousCompactedTokens = lastCompactedTokens.get(sessionID)
|
|
if (
|
|
previousCompactedTokens !== undefined
|
|
&& totalInputTokens < previousCompactedTokens * PREEMPTIVE_COMPACTION_REGROWTH_RATIO
|
|
) {
|
|
return
|
|
}
|
|
|
|
const usageRatio = totalInputTokens / actualLimit
|
|
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
|
|
|
|
compactionInProgress.add(sessionID)
|
|
lastCompactionTime.set(sessionID, Date.now())
|
|
|
|
try {
|
|
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
|
|
pluginConfig,
|
|
sessionID,
|
|
cached.providerID,
|
|
cached.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`,
|
|
)
|
|
|
|
lastCompactedTokens.set(sessionID, totalInputTokens)
|
|
} catch (error) {
|
|
log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) })
|
|
ctx.client.tui.showToast({
|
|
body: {
|
|
title: "Compaction failed",
|
|
message: `Preemptive compaction timed out or failed for session. Context may grow large. Error: ${String(error)}`,
|
|
variant: "warning",
|
|
duration: 8000,
|
|
},
|
|
}).catch(() => {})
|
|
} 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 sessionID = (props?.info as { id?: string } | undefined)?.id
|
|
if (sessionID) {
|
|
compactionInProgress.delete(sessionID)
|
|
lastCompactedTokens.delete(sessionID)
|
|
lastCompactionTime.delete(sessionID)
|
|
tokenCache.delete(sessionID)
|
|
postCompactionMonitor.clear(sessionID)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (event.type === "session.compacted") {
|
|
const sessionID = (props?.sessionID as string | undefined)
|
|
?? (props?.info as { id?: string } | undefined)?.id
|
|
if (sessionID) {
|
|
postCompactionMonitor.onSessionCompacted(sessionID)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (event.type === "message.updated") {
|
|
const info = props?.info as {
|
|
id?: string
|
|
role?: string
|
|
sessionID?: string
|
|
providerID?: string
|
|
modelID?: string
|
|
finish?: boolean
|
|
tokens?: TokenInfo
|
|
} | undefined
|
|
|
|
if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return
|
|
|
|
if (info.providerID && info.tokens) {
|
|
tokenCache.set(info.sessionID, {
|
|
providerID: info.providerID,
|
|
modelID: info.modelID ?? "",
|
|
tokens: info.tokens,
|
|
})
|
|
}
|
|
lastCompactedTokens.delete(info.sessionID)
|
|
|
|
await postCompactionMonitor.onAssistantMessageUpdated({
|
|
sessionID: info.sessionID,
|
|
id: info.id,
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
"tool.execute.after": toolExecuteAfter,
|
|
event: eventHandler,
|
|
}
|
|
}
|