feat: preserve pages and compress context
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user