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: 直连模型现可在长对话和多轮工具执行中自动压缩旧上下文,并分别显示本次调用用量与压缩后对话估算;摘要会自动保存并跨重启复用,无需手动操作。
This commit is contained in:
mesalogo
2026-08-16 00:36:11 +08:00
parent b3c753fac2
commit c98fe67f1a
18 changed files with 3202 additions and 420 deletions
+101
View File
@@ -5,6 +5,7 @@ import {
} from '../../shared/contracts'
import {
estimateTextTokens,
planPrefixCompression,
planContextCompression
} from './context-compression'
@@ -39,6 +40,39 @@ describe('context compression planning', () => {
).toBeUndefined()
})
it('does not compress small history because of transient completed-call context', () => {
const history = [
{ role: 'user' as const, content: 'Earlier question' },
{ role: 'assistant' as const, content: 'Earlier answer' }
]
const plan = planContextCompression({
history,
prompt: '',
settings: compressionSettings({ triggerTokens: 20_000 }),
triggerContextTokens: 21_000,
allowCompressLatestTurn: true
})
expect(plan).toBeUndefined()
})
it('reports the conversation estimate when completed-call usage only triggers planning', () => {
const history = [
{ role: 'user' as const, content: 'a'.repeat(20_000) },
{ role: 'assistant' as const, content: 'b'.repeat(20_000) }
]
const plan = planContextCompression({
history,
prompt: '',
settings: compressionSettings({ triggerTokens: 20_000 }),
triggerContextTokens: 21_000,
allowCompressLatestTurn: true
})
expect(plan?.earlierMessages).toEqual(history)
expect(plan?.estimatedInputTokens).toBeLessThan(21_000)
})
it('preserves recent complete turns within the raw token budget', () => {
const history = [
{ role: 'user' as const, content: `old-user-${'a'.repeat(8_000)}` },
@@ -70,6 +104,73 @@ describe('context compression planning', () => {
expect(plan?.recentMessages).toEqual(history.slice(4))
})
it('keeps the newest atomic unit when planning a generic prefix', () => {
const units = [
{ id: 'round-1', tokens: 6_000 },
{ id: 'round-2', tokens: 6_000 },
{ id: 'round-3', tokens: 6_000 }
]
const plan = planPrefixCompression({
units,
estimatedInputTokens: 22_000,
effectiveTriggerTokens: 20_000,
recentRawTokens: 5_000,
estimateUnitTokens: (unit) => unit.tokens
})
expect(plan?.earlierUnits).toEqual(units.slice(0, 2))
expect(plan?.recentUnits).toEqual(units.slice(2))
})
it('does not split the only available atomic unit', () => {
expect(
planPrefixCompression({
units: [{ id: 'round-1', tokens: 25_000 }],
estimatedInputTokens: 30_000,
effectiveTriggerTokens: 20_000,
recentRawTokens: 5_000,
estimateUnitTokens: (unit) => unit.tokens
})
).toBeUndefined()
})
it('can compress the latest atomic unit after a completed response', () => {
const unit = { id: 'completed-turn', tokens: 25_000 }
const plan = planPrefixCompression({
units: [unit],
estimatedInputTokens: 30_000,
effectiveTriggerTokens: 20_000,
recentRawTokens: 5_000,
estimateUnitTokens: (candidate) => candidate.tokens,
allowCompressLatestUnit: true
})
expect(plan?.earlierUnits).toEqual([unit])
expect(plan?.recentUnits).toEqual([])
})
it('uses the remaining payload budget when preserving recent units', () => {
const units = [
{ id: 'round-1', tokens: 8_000 },
{ id: 'round-2', tokens: 8_000 },
{ id: 'round-3', tokens: 8_000 }
]
const plan = planPrefixCompression({
units,
estimatedInputTokens: 36_000,
effectiveTriggerTokens: 32_000,
recentRawTokens: 20_000,
estimateUnitTokens: (unit) => unit.tokens,
maximumRecentRawTokens: 10_000
})
expect(plan?.earlierUnits).toEqual(units.slice(0, 2))
expect(plan?.recentUnits).toEqual(units.slice(2))
})
it('uses an optional model context limit as an earlier trigger', () => {
const history = [
{ role: 'user' as const, content: 'a'.repeat(16_000) },