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:
@@ -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) },
|
||||
|
||||
@@ -22,6 +22,15 @@ export type ContextCompressionPlan = {
|
||||
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[][] {
|
||||
@@ -40,54 +49,122 @@ function groupConversationTurns(
|
||||
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 = getEffectiveContextTriggerTokens({
|
||||
triggerTokens: input.settings.triggerTokens,
|
||||
contextWindowTokens: input.contextWindowTokens
|
||||
})
|
||||
if (estimatedInputTokens < effectiveTriggerTokens) {
|
||||
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 recentTurns: CompressibleConversationMessage[][] = []
|
||||
const recentRawTokenBudget = Math.min(
|
||||
input.settings.recentRawTokens,
|
||||
Math.max(4_000, effectiveTriggerTokens - 8_000)
|
||||
)
|
||||
let recentTokens = 0
|
||||
while (turns.length > 0) {
|
||||
const turn = turns.at(-1)!
|
||||
const turnTokens = estimateMessagesTokens(turn)
|
||||
if (
|
||||
recentTurns.length > 0 &&
|
||||
recentTokens + turnTokens > recentRawTokenBudget
|
||||
) {
|
||||
break
|
||||
}
|
||||
recentTurns.unshift(turns.pop()!)
|
||||
recentTokens += turnTokens
|
||||
}
|
||||
const earlierMessages = turns.flat()
|
||||
if (earlierMessages.length === 0) {
|
||||
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,
|
||||
recentMessages: recentTurns.flat(),
|
||||
earlierMessages: plan.earlierUnits.flat(),
|
||||
recentMessages: plan.recentUnits.flat(),
|
||||
estimatedInputTokens,
|
||||
effectiveTriggerTokens
|
||||
effectiveTriggerTokens: plan.effectiveTriggerTokens
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+673
-99
File diff suppressed because it is too large
Load Diff
@@ -165,6 +165,75 @@ class RealModelConfigToolProvider implements ModelToolProviderLike {
|
||||
}
|
||||
}
|
||||
|
||||
class RealLongAgentToolProvider implements ModelToolProviderLike {
|
||||
readonly completedSteps: number[] = []
|
||||
|
||||
async listTools(): Promise<ModelToolDefinition[]> {
|
||||
const expectedStep = this.completedSteps.length + 1
|
||||
return [
|
||||
{
|
||||
name: 'record_progress',
|
||||
displayName: 'Record progress',
|
||||
description:
|
||||
expectedStep <= 3
|
||||
? `Record required progress step ${expectedStep}. Call exactly once with step ${expectedStep} before continuing.`
|
||||
: 'All required progress is recorded. Do not call this tool again.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
step: {
|
||||
type: 'integer',
|
||||
const: expectedStep
|
||||
}
|
||||
},
|
||||
required: ['step'],
|
||||
additionalProperties: false
|
||||
},
|
||||
source: 'builtin'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
getApproval() {
|
||||
return {
|
||||
scopeKey: 'real-long-agent-test',
|
||||
title: 'Record test progress',
|
||||
description: 'Record deterministic E2E progress',
|
||||
allowPermanent: false
|
||||
}
|
||||
}
|
||||
|
||||
async callTool(
|
||||
name: string,
|
||||
argumentsValue: Record<string, unknown>,
|
||||
signal: AbortSignal
|
||||
): Promise<ModelToolResult> {
|
||||
signal.throwIfAborted()
|
||||
const expectedStep = this.completedSteps.length + 1
|
||||
if (
|
||||
name !== 'record_progress' ||
|
||||
argumentsValue.step !== expectedStep ||
|
||||
expectedStep > 3
|
||||
) {
|
||||
throw new Error(
|
||||
`Unexpected progress call: ${name} ${JSON.stringify(argumentsValue)}`
|
||||
)
|
||||
}
|
||||
this.completedSteps.push(expectedStep)
|
||||
const text = [
|
||||
`STEP_${expectedStep}_RECORDED`,
|
||||
`evidence-${expectedStep} `.repeat(4_000)
|
||||
].join('\n')
|
||||
return {
|
||||
parts: [{ type: 'text', text }],
|
||||
contextBytes: Buffer.byteLength(text)
|
||||
}
|
||||
}
|
||||
|
||||
async releaseConversation(): Promise<void> {}
|
||||
async dispose(): Promise<void> {}
|
||||
}
|
||||
|
||||
describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
let workspace = ''
|
||||
|
||||
@@ -214,6 +283,104 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
120_000
|
||||
)
|
||||
|
||||
it(
|
||||
'counts a real image in provider-reported input usage',
|
||||
async () => {
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model: modelName,
|
||||
protocol,
|
||||
authentication: 'api-key',
|
||||
supportsImageInput: true,
|
||||
maxOutputTokens: 128,
|
||||
contextCompression: {
|
||||
settings: {
|
||||
...defaultContextCompressionSettings,
|
||||
enabled: true
|
||||
},
|
||||
contextWindowTokens: 32_000
|
||||
}
|
||||
})
|
||||
const baselineEvents: RuntimeEvent[] = []
|
||||
const imageEvents: RuntimeEvent[] = []
|
||||
const prompt =
|
||||
'Return exactly this text and nothing else: IMAGE_USAGE_E2E_OK'
|
||||
|
||||
try {
|
||||
for await (const event of runtime.run(
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
workMode: 'ask',
|
||||
prompt
|
||||
},
|
||||
new AbortController().signal
|
||||
)) {
|
||||
baselineEvents.push(event)
|
||||
}
|
||||
for await (const event of runtime.run(
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
workMode: 'ask',
|
||||
prompt,
|
||||
images: [
|
||||
{
|
||||
name: 'goodbuddy-icon.png',
|
||||
mediaType: 'image/png',
|
||||
data: await readFile(
|
||||
join(process.cwd(), 'build', 'icon.png'),
|
||||
'base64'
|
||||
)
|
||||
}
|
||||
]
|
||||
},
|
||||
new AbortController().signal
|
||||
)) {
|
||||
imageEvents.push(event)
|
||||
}
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
|
||||
const baselineUsage = baselineEvents.find(
|
||||
(
|
||||
event
|
||||
): event is Extract<RuntimeEvent, { type: 'model-usage' }> =>
|
||||
event.type === 'model-usage'
|
||||
)
|
||||
const imageUsage = imageEvents.find(
|
||||
(
|
||||
event
|
||||
): event is Extract<RuntimeEvent, { type: 'model-usage' }> =>
|
||||
event.type === 'model-usage'
|
||||
)
|
||||
expect(baselineUsage).toBeDefined()
|
||||
expect(imageUsage).toBeDefined()
|
||||
expect(imageUsage!.inputTokens).toBeGreaterThan(
|
||||
baselineUsage!.inputTokens
|
||||
)
|
||||
expect(
|
||||
imageEvents.filter(
|
||||
(event) => event.type === 'context-metrics'
|
||||
)
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
source: 'provider',
|
||||
contextTokens:
|
||||
imageUsage!.inputTokens +
|
||||
imageUsage!.outputTokens +
|
||||
(protocol === 'anthropic-messages'
|
||||
? imageUsage!.cacheReadTokens +
|
||||
imageUsage!.cacheWriteTokens
|
||||
: 0)
|
||||
})
|
||||
])
|
||||
},
|
||||
180_000
|
||||
)
|
||||
|
||||
it(
|
||||
'compresses real direct-model history and preserves earlier and recent facts',
|
||||
async () => {
|
||||
@@ -247,7 +414,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
content: [
|
||||
'The project codename is ORBIT-739.',
|
||||
'Background notes:',
|
||||
'alpha '.repeat(5_000)
|
||||
'alpha '.repeat(8_000)
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
@@ -255,7 +422,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
content: [
|
||||
'I will remember the project codename.',
|
||||
'Acknowledgement notes:',
|
||||
'gamma '.repeat(4_000)
|
||||
'gamma '.repeat(6_500)
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
@@ -263,7 +430,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
content: [
|
||||
'The deploy region is AP-SOUTH-7.',
|
||||
'Recent notes:',
|
||||
'beta '.repeat(3_000)
|
||||
'beta '.repeat(5_000)
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
@@ -312,6 +479,172 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
120_000
|
||||
)
|
||||
|
||||
it(
|
||||
'compresses context after a real completed response reaches the threshold',
|
||||
async () => {
|
||||
const expectedOutput = [
|
||||
'POST_RESPONSE_COMPRESSION_E2E_OK_',
|
||||
'SAFE'.repeat(16)
|
||||
].join('')
|
||||
const prompt = `Return exactly this text and nothing else: ${expectedOutput}`
|
||||
const history = [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: `baseline\n${'alpha '.repeat(8_500)}`
|
||||
},
|
||||
{
|
||||
role: 'assistant' as const,
|
||||
content: 'ack'
|
||||
}
|
||||
]
|
||||
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model: modelName,
|
||||
protocol,
|
||||
authentication: 'api-key',
|
||||
maxOutputTokens: 128,
|
||||
contextCompression: {
|
||||
settings: {
|
||||
...defaultContextCompressionSettings,
|
||||
enabled: true,
|
||||
triggerTokens: 8_000,
|
||||
recentRawTokens: 4_000
|
||||
},
|
||||
contextWindowTokens: 32_000
|
||||
}
|
||||
})
|
||||
const events: RuntimeEvent[] = []
|
||||
|
||||
try {
|
||||
for await (const event of runtime.run(
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
workMode: 'ask',
|
||||
prompt,
|
||||
history
|
||||
},
|
||||
new AbortController().signal
|
||||
)) {
|
||||
events.push(event)
|
||||
}
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
|
||||
const output = events
|
||||
.flatMap((event) =>
|
||||
event.type === 'text' ? [event.delta] : []
|
||||
)
|
||||
.join('')
|
||||
const lastTextIndex = events.reduce(
|
||||
(lastIndex, event, index) =>
|
||||
event.type === 'text' ? index : lastIndex,
|
||||
-1
|
||||
)
|
||||
const postResponseCompressionIndex = events.findIndex(
|
||||
(event) =>
|
||||
event.type === 'context-compression' &&
|
||||
event.scope === 'conversation' &&
|
||||
event.state === 'started'
|
||||
)
|
||||
expect(output).toContain(expectedOutput)
|
||||
expect(lastTextIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(postResponseCompressionIndex).toBeGreaterThan(
|
||||
lastTextIndex
|
||||
)
|
||||
expect(
|
||||
events
|
||||
.filter((event) => event.type === 'context-metrics')
|
||||
.at(-1)
|
||||
).toMatchObject({
|
||||
type: 'context-metrics',
|
||||
source: 'provider'
|
||||
})
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'context-metrics',
|
||||
source: 'estimated'
|
||||
})
|
||||
)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
},
|
||||
180_000
|
||||
)
|
||||
|
||||
it(
|
||||
'compacts a real multi-round Agent run and continues to completion',
|
||||
async () => {
|
||||
const toolProvider = new RealLongAgentToolProvider()
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model: modelName,
|
||||
protocol,
|
||||
authentication: 'api-key',
|
||||
toolProvider,
|
||||
contextCompression: {
|
||||
settings: {
|
||||
...defaultContextCompressionSettings,
|
||||
enabled: true,
|
||||
triggerTokens: 8_000,
|
||||
recentRawTokens: 4_000
|
||||
},
|
||||
contextWindowTokens: 32_000
|
||||
}
|
||||
})
|
||||
const events: RuntimeEvent[] = []
|
||||
|
||||
try {
|
||||
for await (const event of runtime.run(
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
workMode: 'execute',
|
||||
prompt:
|
||||
'Call record_progress sequentially for steps 1, 2, and 3. Wait for each result before calling the next step. After all three results, do not call tools again and reply with LONG_AGENT_COMPRESSION_E2E_OK.'
|
||||
},
|
||||
new AbortController().signal,
|
||||
async () => 'once'
|
||||
)) {
|
||||
events.push(event)
|
||||
}
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
|
||||
const output = events
|
||||
.flatMap((event) =>
|
||||
event.type === 'text' ? [event.delta] : []
|
||||
)
|
||||
.join('')
|
||||
expect(toolProvider.completedSteps).toEqual([1, 2, 3])
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'context-compression',
|
||||
scope: 'agent-run',
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
expect(output).toContain('LONG_AGENT_COMPRESSION_E2E_OK')
|
||||
expect(
|
||||
events
|
||||
.filter((event) => event.type === 'context-metrics')
|
||||
.at(-1)
|
||||
).toMatchObject({ source: 'provider' })
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'context-metrics',
|
||||
source: 'estimated'
|
||||
})
|
||||
)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
},
|
||||
240_000
|
||||
)
|
||||
|
||||
it(
|
||||
'discovers and plans GoodBuddy configuration through a real model',
|
||||
async () => {
|
||||
@@ -399,13 +732,21 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
baseUrl,
|
||||
model: modelName,
|
||||
protocol,
|
||||
authentication: 'api-key'
|
||||
authentication: 'api-key',
|
||||
contextCompression: {
|
||||
settings: {
|
||||
...defaultContextCompressionSettings,
|
||||
enabled: true
|
||||
},
|
||||
contextWindowTokens: 32_000
|
||||
}
|
||||
})
|
||||
const abortController = new AbortController()
|
||||
const events: RuntimeEvent[] = []
|
||||
|
||||
try {
|
||||
const result = collectText(
|
||||
runtime.run(
|
||||
const result = (async () => {
|
||||
for await (const event of runtime.run(
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
@@ -414,12 +755,19 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
'Write a detailed technical essay of at least 3000 words.'
|
||||
},
|
||||
abortController.signal
|
||||
)
|
||||
)
|
||||
)) {
|
||||
events.push(event)
|
||||
}
|
||||
})()
|
||||
setTimeout(() => abortController.abort(), 50)
|
||||
await expect(result).rejects.toMatchObject({
|
||||
name: 'AbortError'
|
||||
})
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'context-metrics'
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('migrates existing databases to schema version 19', async () => {
|
||||
it('migrates existing databases to schema version 20', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||
)
|
||||
@@ -127,7 +127,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(19)
|
||||
).toBe(20)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -231,7 +231,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(19)
|
||||
).toBe(20)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -1281,6 +1281,12 @@ describe('AssistantDatabase', () => {
|
||||
],
|
||||
createdAt: 1_775_000_001_000,
|
||||
state: 'streaming',
|
||||
contextCompression: {
|
||||
state: 'completed',
|
||||
scope: 'conversation',
|
||||
estimatedBeforeTokens: 22_000,
|
||||
estimatedAfterTokens: 9_000
|
||||
},
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000216'
|
||||
],
|
||||
@@ -1342,6 +1348,12 @@ describe('AssistantDatabase', () => {
|
||||
state: 'error',
|
||||
status: expect.stringContaining('意外中断'),
|
||||
reasoning: '先分析发布范围',
|
||||
contextCompression: {
|
||||
state: 'completed',
|
||||
scope: 'conversation',
|
||||
estimatedBeforeTokens: 22_000,
|
||||
estimatedAfterTokens: 9_000
|
||||
},
|
||||
blocks: [
|
||||
expect.objectContaining({
|
||||
type: 'reasoning',
|
||||
@@ -1451,6 +1463,24 @@ describe('AssistantDatabase', () => {
|
||||
header: {
|
||||
id: conversationId,
|
||||
projectId: project.id,
|
||||
contextMetrics: {
|
||||
runtimeSelectionKey: `model:${channelDefaultProfileId}`,
|
||||
contextTokens: 9_000,
|
||||
effectiveTriggerTokens: 20_000,
|
||||
contextWindowTokens: 32_000,
|
||||
compressionEnabled: true,
|
||||
source: 'estimated' as const,
|
||||
basis: 'conversation' as const
|
||||
},
|
||||
contextCompressionState: {
|
||||
coveredHistoryDigest: 'a'.repeat(64),
|
||||
coveredMessageCount: 2,
|
||||
coveredFromMessageId:
|
||||
'00000000-0000-4000-8000-000000000503',
|
||||
coveredThroughMessageId:
|
||||
'00000000-0000-4000-8000-000000000504',
|
||||
summary: '持久化摘要'
|
||||
},
|
||||
title: '增量对话(已完成)',
|
||||
updatedAt: 1_775_000_001_000
|
||||
},
|
||||
@@ -1461,7 +1491,22 @@ describe('AssistantDatabase', () => {
|
||||
content: '生成完成',
|
||||
createdAt: 1_775_000_000_001,
|
||||
state: 'complete' as const,
|
||||
status: '已完成'
|
||||
status: '已完成',
|
||||
contextCompressions: [
|
||||
{
|
||||
state: 'completed' as const,
|
||||
scope: 'agent-run' as const,
|
||||
estimatedBeforeTokens: 24_000,
|
||||
estimatedAfterTokens: 11_000,
|
||||
compressionCount: 2
|
||||
},
|
||||
{
|
||||
state: 'completed' as const,
|
||||
scope: 'conversation' as const,
|
||||
estimatedBeforeTokens: 22_000,
|
||||
estimatedAfterTokens: 9_000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: newMessageId,
|
||||
@@ -1478,12 +1523,41 @@ describe('AssistantDatabase', () => {
|
||||
|
||||
expect(database.getConversation(conversationId)).toMatchObject({
|
||||
title: '增量对话(已完成)',
|
||||
contextMetrics: {
|
||||
contextTokens: 9_000,
|
||||
source: 'estimated',
|
||||
basis: 'conversation'
|
||||
},
|
||||
contextCompressionState: {
|
||||
coveredHistoryDigest: 'a'.repeat(64),
|
||||
coveredMessageCount: 2,
|
||||
coveredFromMessageId:
|
||||
'00000000-0000-4000-8000-000000000503',
|
||||
coveredThroughMessageId:
|
||||
'00000000-0000-4000-8000-000000000504',
|
||||
summary: '持久化摘要'
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: streamingMessageId,
|
||||
content: '生成完成',
|
||||
state: 'complete',
|
||||
status: '已完成'
|
||||
status: '已完成',
|
||||
contextCompressions: [
|
||||
{
|
||||
state: 'completed',
|
||||
scope: 'agent-run',
|
||||
estimatedBeforeTokens: 24_000,
|
||||
estimatedAfterTokens: 11_000,
|
||||
compressionCount: 2
|
||||
},
|
||||
{
|
||||
state: 'completed',
|
||||
scope: 'conversation',
|
||||
estimatedBeforeTokens: 22_000,
|
||||
estimatedAfterTokens: 9_000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: newMessageId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import {
|
||||
conversationSnapshotSchema,
|
||||
expertCreateSchema,
|
||||
normalizeInteractiveWorkMode
|
||||
} from '../../shared/assistant-contracts'
|
||||
@@ -107,6 +108,7 @@ type ConversationRow = {
|
||||
project_id: string | null
|
||||
runtime_selection_json: string | null
|
||||
knowledge_retrieval_mode: 'auto' | 'always' | null
|
||||
context_state_json: string | null
|
||||
title: string
|
||||
channel: ProjectChannel | null
|
||||
external_account_id: string | null
|
||||
@@ -173,6 +175,8 @@ type MessageMetadata = {
|
||||
status?: string
|
||||
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
|
||||
blocks?: ConversationSnapshot['messages'][number]['blocks']
|
||||
contextCompression?: ConversationSnapshot['messages'][number]['contextCompression']
|
||||
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
|
||||
tools?: ConversationSnapshot['messages'][number]['tools']
|
||||
sources?: string[]
|
||||
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
||||
@@ -752,6 +756,45 @@ function interruptActiveToolBlocks(
|
||||
)
|
||||
}
|
||||
|
||||
const conversationContextStateSchema = conversationSnapshotSchema.pick({
|
||||
contextMetrics: true,
|
||||
contextCompressionState: true
|
||||
})
|
||||
|
||||
function parseConversationContextState(
|
||||
value: string | null
|
||||
): Pick<
|
||||
ConversationSnapshot,
|
||||
'contextMetrics' | 'contextCompressionState'
|
||||
> {
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const parsed = conversationContextStateSchema.safeParse(
|
||||
JSON.parse(value)
|
||||
)
|
||||
return parsed.success ? parsed.data : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeConversationContextState(
|
||||
conversation: Pick<
|
||||
ConversationSnapshot,
|
||||
'contextMetrics' | 'contextCompressionState'
|
||||
>
|
||||
): string | null {
|
||||
return conversation.contextMetrics ||
|
||||
conversation.contextCompressionState
|
||||
? JSON.stringify({
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState
|
||||
})
|
||||
: null
|
||||
}
|
||||
|
||||
function toConversationSnapshot(
|
||||
conversation: ConversationRow,
|
||||
messages: MessageRow[]
|
||||
@@ -764,6 +807,7 @@ function toConversationSnapshot(
|
||||
),
|
||||
knowledgeRetrievalMode:
|
||||
conversation.knowledge_retrieval_mode ?? undefined,
|
||||
...parseConversationContextState(conversation.context_state_json),
|
||||
...(conversation.channel &&
|
||||
conversation.conversation_type &&
|
||||
conversation.account_display
|
||||
@@ -796,6 +840,8 @@ function toConversationSnapshot(
|
||||
status: interrupted
|
||||
? interruptedMessageStatus
|
||||
: metadata.status,
|
||||
contextCompression: metadata.contextCompression,
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
tools: interrupted
|
||||
? interruptActiveTools(metadata.tools)
|
||||
: metadata.tools,
|
||||
@@ -817,6 +863,8 @@ function serializeConversationMessageMetadata(
|
||||
status: message.status,
|
||||
reasoning: message.reasoning,
|
||||
blocks: message.blocks,
|
||||
contextCompression: message.contextCompression,
|
||||
contextCompressions: message.contextCompressions,
|
||||
tools: message.tools,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
@@ -1334,7 +1382,7 @@ export class AssistantDatabase {
|
||||
const conversations = database
|
||||
.prepare(
|
||||
`SELECT id, project_id, runtime_selection_json,
|
||||
knowledge_retrieval_mode, title, channel,
|
||||
knowledge_retrieval_mode, context_state_json, title, channel,
|
||||
external_account_id, external_conversation_id,
|
||||
conversation_type, account_display, updated_at
|
||||
FROM conversations
|
||||
@@ -1369,7 +1417,7 @@ export class AssistantDatabase {
|
||||
const conversation = database
|
||||
.prepare(
|
||||
`SELECT id, project_id, runtime_selection_json,
|
||||
knowledge_retrieval_mode, title, channel,
|
||||
knowledge_retrieval_mode, context_state_json, title, channel,
|
||||
external_account_id, external_conversation_id,
|
||||
conversation_type, account_display, updated_at
|
||||
FROM conversations
|
||||
@@ -1497,8 +1545,9 @@ export class AssistantDatabase {
|
||||
const insertConversation = database.prepare(
|
||||
`INSERT INTO conversations
|
||||
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
||||
work_mode, title, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||
context_state_json, work_mode, title, status, created_at,
|
||||
updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||
)
|
||||
const insertMessage = database.prepare(
|
||||
`INSERT INTO messages
|
||||
@@ -1518,6 +1567,7 @@ export class AssistantDatabase {
|
||||
? JSON.stringify(conversation.runtimeSelection)
|
||||
: null,
|
||||
conversation.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(conversation),
|
||||
conversation.title,
|
||||
updatedAt,
|
||||
updatedAt
|
||||
@@ -1532,18 +1582,7 @@ export class AssistantDatabase {
|
||||
message.content,
|
||||
message.state,
|
||||
sequence,
|
||||
JSON.stringify({
|
||||
createdAt: message.createdAt,
|
||||
status: message.status,
|
||||
reasoning: message.reasoning,
|
||||
blocks: message.blocks,
|
||||
tools: message.tools,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||
artifactIds: message.artifactIds,
|
||||
attachments: message.attachments
|
||||
}),
|
||||
serializeConversationMessageMetadata(message),
|
||||
new Date(message.createdAt).toISOString()
|
||||
)
|
||||
}
|
||||
@@ -1563,14 +1602,15 @@ export class AssistantDatabase {
|
||||
const insertConversation = database.prepare(
|
||||
`INSERT INTO conversations
|
||||
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
||||
work_mode, title, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||
context_state_json, work_mode, title, status, created_at,
|
||||
updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||
)
|
||||
const updateConversation = database.prepare(
|
||||
`UPDATE conversations
|
||||
SET project_id = ?, runtime_selection_json = ?,
|
||||
knowledge_retrieval_mode = ?, title = ?, status = 'active',
|
||||
updated_at = ?
|
||||
knowledge_retrieval_mode = ?, context_state_json = ?,
|
||||
title = ?, status = 'active', updated_at = ?
|
||||
WHERE id = ? AND channel IS NULL`
|
||||
)
|
||||
const findMessage = database.prepare(
|
||||
@@ -1623,6 +1663,7 @@ export class AssistantDatabase {
|
||||
? JSON.stringify(header.runtimeSelection)
|
||||
: null,
|
||||
header.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(header),
|
||||
header.title,
|
||||
updatedAt,
|
||||
header.id
|
||||
@@ -1638,6 +1679,7 @@ export class AssistantDatabase {
|
||||
? JSON.stringify(header.runtimeSelection)
|
||||
: null,
|
||||
header.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(header),
|
||||
header.title,
|
||||
updatedAt,
|
||||
updatedAt
|
||||
@@ -4718,12 +4760,12 @@ export class AssistantDatabase {
|
||||
const version = database
|
||||
.prepare('PRAGMA user_version')
|
||||
.get() as { user_version: number }
|
||||
if (version.user_version > 19) {
|
||||
if (version.user_version > 20) {
|
||||
throw new Error(
|
||||
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
|
||||
)
|
||||
}
|
||||
if (version.user_version === 19) {
|
||||
if (version.user_version === 20) {
|
||||
return
|
||||
}
|
||||
if (version.user_version < 1) {
|
||||
@@ -4750,6 +4792,7 @@ export class AssistantDatabase {
|
||||
knowledge_retrieval_mode IS NULL OR
|
||||
knowledge_retrieval_mode IN ('auto', 'always')
|
||||
),
|
||||
context_state_json TEXT,
|
||||
work_mode TEXT NOT NULL DEFAULT 'ask'
|
||||
CHECK(work_mode IN ('ask', 'execute')),
|
||||
title TEXT NOT NULL,
|
||||
@@ -5633,6 +5676,28 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (version.user_version < 20) {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const conversationColumns = new Set(
|
||||
(
|
||||
database
|
||||
.prepare('PRAGMA table_info(conversations)')
|
||||
.all() as Array<{ name: string }>
|
||||
).map((column) => column.name)
|
||||
)
|
||||
if (!conversationColumns.has('context_state_json')) {
|
||||
database.exec(`
|
||||
ALTER TABLE conversations
|
||||
ADD COLUMN context_state_json TEXT;
|
||||
`)
|
||||
}
|
||||
database.exec('PRAGMA user_version = 20; COMMIT;')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requireDatabase(): DatabaseSync {
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
||||
).count
|
||||
check.close()
|
||||
migrated.close()
|
||||
expect(version).toBe(19)
|
||||
expect(version).toBe(20)
|
||||
expect(heartbeatTableCount).toBe(3)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user