feat: preserve pages and compress context

This commit is contained in:
mesalogo
2026-08-15 20:44:47 +08:00
parent 1031ea618b
commit c2b9ce4ff0
23 changed files with 1972 additions and 507 deletions
+24 -6
View File
@@ -72,19 +72,37 @@ describe('context compression planning', () => {
it('uses an optional model context limit as an earlier trigger', () => {
const history = [
{ role: 'user' as const, content: 'a'.repeat(14_000) },
{ role: 'assistant' as const, content: 'b'.repeat(14_000) },
{ role: 'user' as const, content: 'c'.repeat(14_000) },
{ role: 'assistant' as const, content: 'd'.repeat(14_000) }
{ role: 'user' as const, content: 'a'.repeat(16_000) },
{ role: 'assistant' as const, content: 'b'.repeat(16_000) },
{ role: 'user' as const, content: 'c'.repeat(16_000) },
{ role: 'assistant' as const, content: 'd'.repeat(16_000) }
]
const plan = planContextCompression({
history,
prompt: 'Continue',
settings: compressionSettings(),
contextWindowTokens: 30_000
contextWindowTokens: 32_000
})
expect(plan?.effectiveTriggerTokens).toBe(18_000)
expect(plan?.effectiveTriggerTokens).toBe(20_000)
expect(plan?.earlierMessages.length).toBeGreaterThan(0)
})
it('defensively clamps legacy undersized context limits', () => {
const history = [
{ role: 'user' as const, content: 'a'.repeat(40_000) },
{ role: 'assistant' as const, content: 'b'.repeat(40_000) },
{ role: 'user' as const, content: 'c'.repeat(40_000) },
{ role: 'assistant' as const, content: 'd'.repeat(40_000) }
]
const plan = planContextCompression({
history,
prompt: 'Continue',
settings: compressionSettings(),
contextWindowTokens: 10_000
})
expect(plan?.effectiveTriggerTokens).toBe(20_000)
expect(plan?.earlierMessages.length).toBeGreaterThan(0)
})
})
+20 -43
View File
@@ -1,4 +1,14 @@
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'
@@ -12,34 +22,6 @@ export type ContextCompressionPlan = {
effectiveTriggerTokens: number
}
const reservedOutputAndSafetyTokens = 12_000
const estimatedRequestOverheadTokens = 4_000
export function estimateTextTokens(value: string): number {
let asciiCharacters = 0
let nonAsciiCharacters = 0
for (const character of value) {
if (character.codePointAt(0)! <= 0x7f) {
asciiCharacters += 1
} else {
nonAsciiCharacters += 1
}
}
return Math.max(
1,
Math.ceil(asciiCharacters / 4 + nonAsciiCharacters)
)
}
export function estimateMessagesTokens(
messages: readonly CompressibleConversationMessage[]
): number {
return messages.reduce(
(total, message) => total + estimateTextTokens(message.content) + 4,
0
)
}
function groupConversationTurns(
messages: readonly CompressibleConversationMessage[]
): CompressibleConversationMessage[][] {
@@ -61,24 +43,19 @@ function groupConversationTurns(
export function planContextCompression(input: {
history: readonly CompressibleConversationMessage[]
prompt: string
summaryTokens?: number
settings: ContextCompressionSettings
contextWindowTokens?: number
}): ContextCompressionPlan | undefined {
const estimatedInputTokens =
estimateMessagesTokens(input.history) +
estimateTextTokens(input.prompt) +
estimatedRequestOverheadTokens
const contextLimitedTrigger =
input.contextWindowTokens === undefined
? input.settings.triggerTokens
: Math.max(
8_000,
input.contextWindowTokens - reservedOutputAndSafetyTokens
)
const effectiveTriggerTokens = Math.min(
input.settings.triggerTokens,
contextLimitedTrigger
)
const estimatedInputTokens = estimateContextInputTokens({
history: input.history,
prompt: input.prompt,
summaryTokens: input.summaryTokens
})
const effectiveTriggerTokens = getEffectiveContextTriggerTokens({
triggerTokens: input.settings.triggerTokens,
contextWindowTokens: input.contextWindowTokens
})
if (estimatedInputTokens < effectiveTriggerTokens) {
return undefined
}
+22 -1
View File
@@ -379,9 +379,30 @@ describe('ModelAgentRuntime', () => {
expect(answerMessages).toContain('new-user-')
expect(answerMessages).not.toContain('old-user-')
expect(events).toContainEqual(
expect.objectContaining({
type: 'context-compression',
state: 'started',
estimatedBeforeTokens: expect.any(Number)
})
)
expect(events).toContainEqual(
expect.objectContaining({
type: 'context-compression',
state: 'completed',
estimatedAfterTokens: expect.any(Number)
})
)
expect(events).toContainEqual(
expect.objectContaining({
type: 'context-metrics',
coveredMessageCount: 4,
summaryTokens: expect.any(Number)
})
)
expect(events).not.toContainEqual(
expect.objectContaining({
type: 'status',
message: '较早的对话已压缩,正在生成回答'
message: '正在准备直连模型上下文'
})
)
expect(events).toContainEqual(
+105 -41
View File
@@ -42,8 +42,13 @@ import {
import { readBoundedResponseText } from './bounded-response'
import {
formatConversationForSummary,
planContextCompression
planContextCompression,
estimateMessagesTokens
} from './context-compression'
import {
estimateContextInputTokens,
getEffectiveContextTriggerTokens
} from '../../shared/context-window'
type ConversationMessage = {
role: 'user' | 'assistant'
@@ -1784,23 +1789,19 @@ export class ModelAgentRuntime implements AgentRuntime {
return { summary: summary.trim(), usageEvents }
}
private async prepareCompressedRequest(
private async *prepareCompressedRequest(
request: AgentExecutionRequest,
signal: AbortSignal
): Promise<{
): AsyncGenerator<RuntimeEvent, {
request: AgentExecutionRequest
compressed: boolean
usageEvents: RuntimeModelUsageEvent[]
}> {
}, void> {
const compression = this.options.contextCompression
if (
!compression?.settings.enabled ||
!request.history?.length
) {
return { request, compressed: false, usageEvents: [] }
if (!compression) {
return { request, compressed: false }
}
const history = request.history
const history = request.history ?? []
let state = this.conversationSummaries.get(request.conversationId)
if (
state &&
@@ -1815,13 +1816,42 @@ export class ModelAgentRuntime implements AgentRuntime {
const remainingHistory = history.slice(
state?.coveredMessageCount ?? 0
)
const requestPrompt = [
request.trustedInstructions ?? '',
request.prompt
].join('\n')
const currentSummaryTokens = state
? estimateMessagesTokens(this.summaryHistory(state.summary))
: 0
const effectiveTriggerTokens =
getEffectiveContextTriggerTokens({
triggerTokens: compression.settings.triggerTokens,
contextWindowTokens: compression.contextWindowTokens
})
const estimatedInputTokens = estimateContextInputTokens({
history: remainingHistory,
prompt: requestPrompt,
summaryTokens: currentSummaryTokens
})
yield {
requestId: request.requestId,
type: 'context-metrics',
estimatedInputTokens,
effectiveTriggerTokens,
contextWindowTokens: compression.contextWindowTokens,
compressionEnabled: compression.settings.enabled,
recentRawTokens: compression.settings.recentRawTokens,
coveredMessageCount: state?.coveredMessageCount ?? 0,
summaryTokens: currentSummaryTokens
}
if (!compression.settings.enabled || history.length === 0) {
return { request, compressed: false }
}
const plan = planContextCompression({
history: remainingHistory,
prompt: [
state?.summary ?? '',
request.trustedInstructions ?? '',
request.prompt
].join('\n'),
prompt: requestPrompt,
summaryTokens: currentSummaryTokens,
settings: compression.settings,
contextWindowTokens: compression.contextWindowTokens
})
@@ -1836,20 +1866,29 @@ export class ModelAgentRuntime implements AgentRuntime {
]
},
compressed: false,
usageEvents: []
}
: { request, compressed: false, usageEvents: [] }
: { request, compressed: false }
}
const coveredMessageCount =
(state?.coveredMessageCount ?? 0) +
plan.earlierMessages.length
yield {
requestId: request.requestId,
type: 'context-compression',
state: 'started',
estimatedBeforeTokens: plan.estimatedInputTokens,
effectiveTriggerTokens: plan.effectiveTriggerTokens,
contextWindowTokens: compression.contextWindowTokens,
recentRawTokens: compression.settings.recentRawTokens,
coveredMessageCount
}
const summarized = await this.summarizeEarlierHistory(
request,
plan.earlierMessages,
state?.summary,
signal
)
const coveredMessageCount =
(state?.coveredMessageCount ?? 0) +
plan.earlierMessages.length
state = {
coveredMessageCount,
coveredHistoryDigest: this.historyDigest(
@@ -1858,6 +1897,40 @@ export class ModelAgentRuntime implements AgentRuntime {
summary: summarized.summary
}
this.conversationSummaries.set(request.conversationId, state)
for (const usageEvent of summarized.usageEvents) {
yield usageEvent
}
const summaryTokens = estimateMessagesTokens(
this.summaryHistory(state.summary)
)
const estimatedAfterTokens = estimateContextInputTokens({
history: plan.recentMessages,
prompt: requestPrompt,
summaryTokens
})
yield {
requestId: request.requestId,
type: 'context-compression',
state: 'completed',
estimatedBeforeTokens: plan.estimatedInputTokens,
estimatedAfterTokens,
effectiveTriggerTokens: plan.effectiveTriggerTokens,
contextWindowTokens: compression.contextWindowTokens,
recentRawTokens: compression.settings.recentRawTokens,
coveredMessageCount,
summaryTokens
}
yield {
requestId: request.requestId,
type: 'context-metrics',
estimatedInputTokens: estimatedAfterTokens,
effectiveTriggerTokens: plan.effectiveTriggerTokens,
contextWindowTokens: compression.contextWindowTokens,
compressionEnabled: true,
recentRawTokens: compression.settings.recentRawTokens,
coveredMessageCount,
summaryTokens
}
return {
request: {
...request,
@@ -1866,8 +1939,7 @@ export class ModelAgentRuntime implements AgentRuntime {
...plan.recentMessages
]
},
compressed: true,
usageEvents: summarized.usageEvents
compressed: true
}
}
@@ -2791,29 +2863,21 @@ export class ModelAgentRuntime implements AgentRuntime {
throw new Error('当前模型连接未启用图像输入')
}
if (
this.options.contextCompression?.settings.enabled &&
request.history?.length
) {
yield {
requestId: request.requestId,
type: 'status',
message: '正在准备直连模型上下文'
}
}
const prepared = await this.prepareCompressedRequest(
const preparation = this.prepareCompressedRequest(
request,
signal
)
for (const usageEvent of prepared.usageEvents) {
yield usageEvent
let prepared: {
request: AgentExecutionRequest
compressed: boolean
}
if (prepared.compressed) {
yield {
requestId: request.requestId,
type: 'status',
message: '较早的对话已压缩,正在生成回答'
while (true) {
const result = await preparation.next()
if (result.done) {
prepared = result.value
break
}
yield result.value
}
const executionRequest = prepared.request
+15 -9
View File
@@ -226,10 +226,9 @@ describe.runIf(enabled)('runtime end-to-end', () => {
contextCompression: {
settings: {
...defaultContextCompressionSettings,
enabled: true,
triggerTokens: 8_000,
recentRawTokens: 4_000
}
enabled: true
},
contextWindowTokens: 32_000
}
})
const events: RuntimeEvent[] = []
@@ -248,7 +247,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
content: [
'The project codename is ORBIT-739.',
'Background notes:',
'alpha '.repeat(1_200)
'alpha '.repeat(5_000)
].join('\n')
},
{
@@ -256,7 +255,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
content: [
'I will remember the project codename.',
'Acknowledgement notes:',
'gamma '.repeat(1_000)
'gamma '.repeat(4_000)
].join('\n')
},
{
@@ -264,7 +263,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
content: [
'The deploy region is AP-SOUTH-7.',
'Recent notes:',
'beta '.repeat(900)
'beta '.repeat(3_000)
].join('\n')
},
{
@@ -289,8 +288,15 @@ describe.runIf(enabled)('runtime end-to-end', () => {
.join('')
expect(events).toContainEqual(
expect.objectContaining({
type: 'status',
message: '较早的对话已压缩,正在生成回答'
type: 'context-compression',
state: 'started'
})
)
expect(events).toContainEqual(
expect.objectContaining({
type: 'context-compression',
state: 'completed',
estimatedAfterTokens: expect.any(Number)
})
)
expect(events).toContainEqual(
+55
View File
@@ -156,6 +156,61 @@ describe('RuntimeSettingsStore', () => {
})
})
it('rejects undersized model context windows and repairs legacy values', async () => {
const { filePath, store } = await createStore()
const profileId = '00000000-0000-4000-8000-000000000062'
const profile = {
id: profileId,
name: 'Small context',
baseUrl: 'https://model.example/v1',
modelName: 'small-model',
protocol: 'openai-responses' as const,
authentication: 'api-key' as const,
supportsImageInput: false,
contextWindowTokens: 10_000,
imageGenerationQuality: 'auto' as const,
apiKey: { action: 'keep' as const }
}
expect(() =>
runtimeSettingsInputSchema.parse(
settings({
modelProfiles: [profile],
defaultModelProfileId: profileId
})
)
).toThrow()
await store.update(
settings({
modelProfiles: [
{
...profile,
contextWindowTokens: 32_000
}
],
defaultModelProfileId: profileId
})
)
const persisted = JSON.parse(
await readFile(filePath, 'utf8')
) as {
modelProfiles: Array<{ contextWindowTokens?: number }>
}
persisted.modelProfiles[0]!.contextWindowTokens = 10_000
await writeFile(filePath, JSON.stringify(persisted), 'utf8')
const migrated = new RuntimeSettingsStore(filePath, cipher, {})
await expect(migrated.getPublicSettings()).resolves.toMatchObject({
modelProfiles: [
expect.objectContaining({
id: profileId,
contextWindowTokens: undefined
})
]
})
})
it('configures bundled runtimes from the default model profile', async () => {
const { store } = await createStore()
+7 -1
View File
@@ -14,6 +14,7 @@ import {
imageGenerationQualitySchema,
isAgentRuntimeModelProtocol,
isDeepSeekHarnessModelProfile,
minimumModelContextWindowTokens,
modelAuthenticationSchema,
modelProtocolSchema,
runtimeModelSourceSchema,
@@ -555,7 +556,12 @@ function migrateVersion10(
function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
const modelProfiles = settings.modelProfiles.map((profile) => ({
...profile,
baseUrl: normalizeModelBaseUrl(profile.baseUrl)
baseUrl: normalizeModelBaseUrl(profile.baseUrl),
contextWindowTokens:
profile.contextWindowTokens === undefined ||
profile.contextWindowTokens >= minimumModelContextWindowTokens
? profile.contextWindowTokens
: undefined
}))
const fallbackProfileId = compatibleTextProfileId({
modelProfiles,