Files
goodbuddy/src/main/agent/context-compression.ts
T
mesalogo c98fe67f1a feat: add durable context compression
Long direct-model conversations mixed provider usage with local estimates,
and Agent tool rounds could remain above the configured compression target.
Compression state and status markers also did not reliably survive restarts
or bounded history rollover.

Direct-model calls now prefer provider-reported usage, compact complete
conversation turns and Agent tool rounds within reserved payload budgets,
and persist reusable summaries with scope-specific markers. The chat meter
separates latest-call usage from estimated compressed conversation size,
while failed or cancelled calls retain the last successful measurement.

Release note: 直连模型现可在长对话和多轮工具执行中自动压缩旧上下文,并分别显示本次调用用量与压缩后对话估算;摘要会自动保存并跨重启复用,无需手动操作。
2026-08-16 00:36:11 +08:00

181 lines
4.9 KiB
TypeScript

import type { ContextCompressionSettings } from '../../shared/contracts'
import {
estimateContextInputTokens,
estimateMessagesTokens,
getEffectiveContextTriggerTokens
} from '../../shared/context-window'
export {
estimateMessagesTokens,
estimateTextTokens
} from '../../shared/context-window'
export type CompressibleConversationMessage = {
role: 'user' | 'assistant'
content: string
}
export type ContextCompressionPlan = {
earlierMessages: CompressibleConversationMessage[]
recentMessages: CompressibleConversationMessage[]
estimatedInputTokens: number
effectiveTriggerTokens: number
}
export type PrefixCompressionPlan<T> = {
earlierUnits: T[]
recentUnits: T[]
estimatedInputTokens: number
effectiveTriggerTokens: number
}
export const contextSummaryTokenBudget = 8_192
function groupConversationTurns(
messages: readonly CompressibleConversationMessage[]
): CompressibleConversationMessage[][] {
const turns: CompressibleConversationMessage[][] = []
for (const message of messages) {
const current = turns.at(-1)
if (
message.role === 'assistant' &&
current?.at(-1)?.role === 'user'
) {
current.push(message)
} else {
turns.push([message])
}
}
return turns
}
export function planPrefixCompression<T>(input: {
units: readonly T[]
estimatedInputTokens: number
effectiveTriggerTokens: number
recentRawTokens: number
estimateUnitTokens: (unit: T) => number
allowCompressLatestUnit?: boolean
maximumRecentRawTokens?: number
}): PrefixCompressionPlan<T> | undefined {
if (
input.estimatedInputTokens < input.effectiveTriggerTokens ||
input.units.length === 0 ||
(input.units.length < 2 && !input.allowCompressLatestUnit)
) {
return undefined
}
const recentRawTokenBudget = Math.min(
input.recentRawTokens,
Math.max(0, input.maximumRecentRawTokens ?? Number.MAX_SAFE_INTEGER)
)
if (input.units.length === 1 && input.allowCompressLatestUnit) {
if (
input.estimateUnitTokens(input.units[0]!) <=
recentRawTokenBudget
) {
return undefined
}
return {
earlierUnits: [...input.units],
recentUnits: [],
estimatedInputTokens: input.estimatedInputTokens,
effectiveTriggerTokens: input.effectiveTriggerTokens
}
}
const earlierUnits = [...input.units]
const recentUnits: T[] = []
let recentTokens = 0
while (earlierUnits.length > 0) {
const unit = earlierUnits.at(-1)!
const unitTokens = input.estimateUnitTokens(unit)
if (
(recentUnits.length > 0 || input.allowCompressLatestUnit) &&
recentTokens + unitTokens > recentRawTokenBudget
) {
break
}
recentUnits.unshift(earlierUnits.pop()!)
recentTokens += unitTokens
}
if (earlierUnits.length === 0) {
return undefined
}
return {
earlierUnits,
recentUnits,
estimatedInputTokens: input.estimatedInputTokens,
effectiveTriggerTokens: input.effectiveTriggerTokens
}
}
export function planContextCompression(input: {
history: readonly CompressibleConversationMessage[]
prompt: string
summaryTokens?: number
settings: ContextCompressionSettings
contextWindowTokens?: number
allowCompressLatestTurn?: boolean
effectiveTriggerTokens?: number
triggerContextTokens?: number
}): ContextCompressionPlan | undefined {
const estimatedInputTokens = estimateContextInputTokens({
history: input.history,
prompt: input.prompt,
summaryTokens: input.summaryTokens
})
const effectiveTriggerTokens =
input.effectiveTriggerTokens ??
getEffectiveContextTriggerTokens({
triggerTokens: input.settings.triggerTokens,
contextWindowTokens: input.contextWindowTokens
})
const planningInputTokens = Math.max(
estimatedInputTokens,
input.triggerContextTokens ?? 0
)
if (planningInputTokens < effectiveTriggerTokens) {
return undefined
}
const fixedContextTokens = estimateContextInputTokens({
history: [],
prompt: input.prompt,
summaryTokens: contextSummaryTokenBudget
})
const turns = groupConversationTurns(input.history)
const plan = planPrefixCompression({
units: turns,
estimatedInputTokens: planningInputTokens,
effectiveTriggerTokens,
recentRawTokens: input.settings.recentRawTokens,
estimateUnitTokens: estimateMessagesTokens,
allowCompressLatestUnit: input.allowCompressLatestTurn,
maximumRecentRawTokens: Math.max(
0,
effectiveTriggerTokens - fixedContextTokens
)
})
if (!plan) {
return undefined
}
return {
earlierMessages: plan.earlierUnits.flat(),
recentMessages: plan.recentUnits.flat(),
estimatedInputTokens,
effectiveTriggerTokens: plan.effectiveTriggerTokens
}
}
export function formatConversationForSummary(
messages: readonly CompressibleConversationMessage[]
): string {
return messages
.map(
(message) =>
`${message.role === 'user' ? 'USER' : 'ASSISTANT'}:\n${message.content}`
)
.join('\n\n')
}