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:
@@ -25,6 +25,7 @@
|
|||||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。
|
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。
|
||||||
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
||||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。
|
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。
|
||||||
|
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
|
||||||
- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。
|
- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。
|
||||||
- [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,并保留父子任务审计。
|
- [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,并保留父子任务审计。
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
import {
|
import {
|
||||||
estimateTextTokens,
|
estimateTextTokens,
|
||||||
|
planPrefixCompression,
|
||||||
planContextCompression
|
planContextCompression
|
||||||
} from './context-compression'
|
} from './context-compression'
|
||||||
|
|
||||||
@@ -39,6 +40,39 @@ describe('context compression planning', () => {
|
|||||||
).toBeUndefined()
|
).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', () => {
|
it('preserves recent complete turns within the raw token budget', () => {
|
||||||
const history = [
|
const history = [
|
||||||
{ role: 'user' as const, content: `old-user-${'a'.repeat(8_000)}` },
|
{ 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))
|
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', () => {
|
it('uses an optional model context limit as an earlier trigger', () => {
|
||||||
const history = [
|
const history = [
|
||||||
{ role: 'user' as const, content: 'a'.repeat(16_000) },
|
{ role: 'user' as const, content: 'a'.repeat(16_000) },
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export type ContextCompressionPlan = {
|
|||||||
effectiveTriggerTokens: number
|
effectiveTriggerTokens: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PrefixCompressionPlan<T> = {
|
||||||
|
earlierUnits: T[]
|
||||||
|
recentUnits: T[]
|
||||||
|
estimatedInputTokens: number
|
||||||
|
effectiveTriggerTokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contextSummaryTokenBudget = 8_192
|
||||||
|
|
||||||
function groupConversationTurns(
|
function groupConversationTurns(
|
||||||
messages: readonly CompressibleConversationMessage[]
|
messages: readonly CompressibleConversationMessage[]
|
||||||
): CompressibleConversationMessage[][] {
|
): CompressibleConversationMessage[][] {
|
||||||
@@ -40,54 +49,122 @@ function groupConversationTurns(
|
|||||||
return turns
|
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: {
|
export function planContextCompression(input: {
|
||||||
history: readonly CompressibleConversationMessage[]
|
history: readonly CompressibleConversationMessage[]
|
||||||
prompt: string
|
prompt: string
|
||||||
summaryTokens?: number
|
summaryTokens?: number
|
||||||
settings: ContextCompressionSettings
|
settings: ContextCompressionSettings
|
||||||
contextWindowTokens?: number
|
contextWindowTokens?: number
|
||||||
|
allowCompressLatestTurn?: boolean
|
||||||
|
effectiveTriggerTokens?: number
|
||||||
|
triggerContextTokens?: number
|
||||||
}): ContextCompressionPlan | undefined {
|
}): ContextCompressionPlan | undefined {
|
||||||
const estimatedInputTokens = estimateContextInputTokens({
|
const estimatedInputTokens = estimateContextInputTokens({
|
||||||
history: input.history,
|
history: input.history,
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
summaryTokens: input.summaryTokens
|
summaryTokens: input.summaryTokens
|
||||||
})
|
})
|
||||||
const effectiveTriggerTokens = getEffectiveContextTriggerTokens({
|
const effectiveTriggerTokens =
|
||||||
triggerTokens: input.settings.triggerTokens,
|
input.effectiveTriggerTokens ??
|
||||||
contextWindowTokens: input.contextWindowTokens
|
getEffectiveContextTriggerTokens({
|
||||||
})
|
triggerTokens: input.settings.triggerTokens,
|
||||||
if (estimatedInputTokens < effectiveTriggerTokens) {
|
contextWindowTokens: input.contextWindowTokens
|
||||||
|
})
|
||||||
|
const planningInputTokens = Math.max(
|
||||||
|
estimatedInputTokens,
|
||||||
|
input.triggerContextTokens ?? 0
|
||||||
|
)
|
||||||
|
if (planningInputTokens < effectiveTriggerTokens) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fixedContextTokens = estimateContextInputTokens({
|
||||||
|
history: [],
|
||||||
|
prompt: input.prompt,
|
||||||
|
summaryTokens: contextSummaryTokenBudget
|
||||||
|
})
|
||||||
const turns = groupConversationTurns(input.history)
|
const turns = groupConversationTurns(input.history)
|
||||||
const recentTurns: CompressibleConversationMessage[][] = []
|
const plan = planPrefixCompression({
|
||||||
const recentRawTokenBudget = Math.min(
|
units: turns,
|
||||||
input.settings.recentRawTokens,
|
estimatedInputTokens: planningInputTokens,
|
||||||
Math.max(4_000, effectiveTriggerTokens - 8_000)
|
effectiveTriggerTokens,
|
||||||
)
|
recentRawTokens: input.settings.recentRawTokens,
|
||||||
let recentTokens = 0
|
estimateUnitTokens: estimateMessagesTokens,
|
||||||
while (turns.length > 0) {
|
allowCompressLatestUnit: input.allowCompressLatestTurn,
|
||||||
const turn = turns.at(-1)!
|
maximumRecentRawTokens: Math.max(
|
||||||
const turnTokens = estimateMessagesTokens(turn)
|
0,
|
||||||
if (
|
effectiveTriggerTokens - fixedContextTokens
|
||||||
recentTurns.length > 0 &&
|
)
|
||||||
recentTokens + turnTokens > recentRawTokenBudget
|
})
|
||||||
) {
|
if (!plan) {
|
||||||
break
|
|
||||||
}
|
|
||||||
recentTurns.unshift(turns.pop()!)
|
|
||||||
recentTokens += turnTokens
|
|
||||||
}
|
|
||||||
const earlierMessages = turns.flat()
|
|
||||||
if (earlierMessages.length === 0) {
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
earlierMessages,
|
earlierMessages: plan.earlierUnits.flat(),
|
||||||
recentMessages: recentTurns.flat(),
|
recentMessages: plan.recentUnits.flat(),
|
||||||
estimatedInputTokens,
|
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', () => {
|
describe.runIf(enabled)('runtime end-to-end', () => {
|
||||||
let workspace = ''
|
let workspace = ''
|
||||||
|
|
||||||
@@ -214,6 +283,104 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
|||||||
120_000
|
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(
|
it(
|
||||||
'compresses real direct-model history and preserves earlier and recent facts',
|
'compresses real direct-model history and preserves earlier and recent facts',
|
||||||
async () => {
|
async () => {
|
||||||
@@ -247,7 +414,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
|||||||
content: [
|
content: [
|
||||||
'The project codename is ORBIT-739.',
|
'The project codename is ORBIT-739.',
|
||||||
'Background notes:',
|
'Background notes:',
|
||||||
'alpha '.repeat(5_000)
|
'alpha '.repeat(8_000)
|
||||||
].join('\n')
|
].join('\n')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -255,7 +422,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
|||||||
content: [
|
content: [
|
||||||
'I will remember the project codename.',
|
'I will remember the project codename.',
|
||||||
'Acknowledgement notes:',
|
'Acknowledgement notes:',
|
||||||
'gamma '.repeat(4_000)
|
'gamma '.repeat(6_500)
|
||||||
].join('\n')
|
].join('\n')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -263,7 +430,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
|||||||
content: [
|
content: [
|
||||||
'The deploy region is AP-SOUTH-7.',
|
'The deploy region is AP-SOUTH-7.',
|
||||||
'Recent notes:',
|
'Recent notes:',
|
||||||
'beta '.repeat(3_000)
|
'beta '.repeat(5_000)
|
||||||
].join('\n')
|
].join('\n')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -312,6 +479,172 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
|||||||
120_000
|
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(
|
it(
|
||||||
'discovers and plans GoodBuddy configuration through a real model',
|
'discovers and plans GoodBuddy configuration through a real model',
|
||||||
async () => {
|
async () => {
|
||||||
@@ -399,13 +732,21 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
model: modelName,
|
model: modelName,
|
||||||
protocol,
|
protocol,
|
||||||
authentication: 'api-key'
|
authentication: 'api-key',
|
||||||
|
contextCompression: {
|
||||||
|
settings: {
|
||||||
|
...defaultContextCompressionSettings,
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
contextWindowTokens: 32_000
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
|
const events: RuntimeEvent[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = collectText(
|
const result = (async () => {
|
||||||
runtime.run(
|
for await (const event of runtime.run(
|
||||||
{
|
{
|
||||||
requestId: crypto.randomUUID(),
|
requestId: crypto.randomUUID(),
|
||||||
conversationId: 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.'
|
'Write a detailed technical essay of at least 3000 words.'
|
||||||
},
|
},
|
||||||
abortController.signal
|
abortController.signal
|
||||||
)
|
)) {
|
||||||
)
|
events.push(event)
|
||||||
|
}
|
||||||
|
})()
|
||||||
setTimeout(() => abortController.abort(), 50)
|
setTimeout(() => abortController.abort(), 50)
|
||||||
await expect(result).rejects.toMatchObject({
|
await expect(result).rejects.toMatchObject({
|
||||||
name: 'AbortError'
|
name: 'AbortError'
|
||||||
})
|
})
|
||||||
|
expect(events).not.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'context-metrics'
|
||||||
|
})
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await runtime.dispose()
|
await runtime.dispose()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ describe('AssistantDatabase', () => {
|
|||||||
database.close()
|
database.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('migrates existing databases to schema version 19', async () => {
|
it('migrates existing databases to schema version 20', async () => {
|
||||||
const directory = await mkdtemp(
|
const directory = await mkdtemp(
|
||||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||||
)
|
)
|
||||||
@@ -127,7 +127,7 @@ describe('AssistantDatabase', () => {
|
|||||||
user_version: number
|
user_version: number
|
||||||
}
|
}
|
||||||
).user_version
|
).user_version
|
||||||
).toBe(19)
|
).toBe(20)
|
||||||
expect(
|
expect(
|
||||||
current
|
current
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -231,7 +231,7 @@ describe('AssistantDatabase', () => {
|
|||||||
user_version: number
|
user_version: number
|
||||||
}
|
}
|
||||||
).user_version
|
).user_version
|
||||||
).toBe(19)
|
).toBe(20)
|
||||||
expect(
|
expect(
|
||||||
current
|
current
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -1281,6 +1281,12 @@ describe('AssistantDatabase', () => {
|
|||||||
],
|
],
|
||||||
createdAt: 1_775_000_001_000,
|
createdAt: 1_775_000_001_000,
|
||||||
state: 'streaming',
|
state: 'streaming',
|
||||||
|
contextCompression: {
|
||||||
|
state: 'completed',
|
||||||
|
scope: 'conversation',
|
||||||
|
estimatedBeforeTokens: 22_000,
|
||||||
|
estimatedAfterTokens: 9_000
|
||||||
|
},
|
||||||
artifactIds: [
|
artifactIds: [
|
||||||
'00000000-0000-4000-8000-000000000216'
|
'00000000-0000-4000-8000-000000000216'
|
||||||
],
|
],
|
||||||
@@ -1342,6 +1348,12 @@ describe('AssistantDatabase', () => {
|
|||||||
state: 'error',
|
state: 'error',
|
||||||
status: expect.stringContaining('意外中断'),
|
status: expect.stringContaining('意外中断'),
|
||||||
reasoning: '先分析发布范围',
|
reasoning: '先分析发布范围',
|
||||||
|
contextCompression: {
|
||||||
|
state: 'completed',
|
||||||
|
scope: 'conversation',
|
||||||
|
estimatedBeforeTokens: 22_000,
|
||||||
|
estimatedAfterTokens: 9_000
|
||||||
|
},
|
||||||
blocks: [
|
blocks: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'reasoning',
|
type: 'reasoning',
|
||||||
@@ -1451,6 +1463,24 @@ describe('AssistantDatabase', () => {
|
|||||||
header: {
|
header: {
|
||||||
id: conversationId,
|
id: conversationId,
|
||||||
projectId: project.id,
|
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: '增量对话(已完成)',
|
title: '增量对话(已完成)',
|
||||||
updatedAt: 1_775_000_001_000
|
updatedAt: 1_775_000_001_000
|
||||||
},
|
},
|
||||||
@@ -1461,7 +1491,22 @@ describe('AssistantDatabase', () => {
|
|||||||
content: '生成完成',
|
content: '生成完成',
|
||||||
createdAt: 1_775_000_000_001,
|
createdAt: 1_775_000_000_001,
|
||||||
state: 'complete' as const,
|
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,
|
id: newMessageId,
|
||||||
@@ -1478,12 +1523,41 @@ describe('AssistantDatabase', () => {
|
|||||||
|
|
||||||
expect(database.getConversation(conversationId)).toMatchObject({
|
expect(database.getConversation(conversationId)).toMatchObject({
|
||||||
title: '增量对话(已完成)',
|
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: [
|
messages: [
|
||||||
{
|
{
|
||||||
id: streamingMessageId,
|
id: streamingMessageId,
|
||||||
content: '生成完成',
|
content: '生成完成',
|
||||||
state: 'complete',
|
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,
|
id: newMessageId,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { DatabaseSync } from 'node:sqlite'
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
import {
|
import {
|
||||||
|
conversationSnapshotSchema,
|
||||||
expertCreateSchema,
|
expertCreateSchema,
|
||||||
normalizeInteractiveWorkMode
|
normalizeInteractiveWorkMode
|
||||||
} from '../../shared/assistant-contracts'
|
} from '../../shared/assistant-contracts'
|
||||||
@@ -107,6 +108,7 @@ type ConversationRow = {
|
|||||||
project_id: string | null
|
project_id: string | null
|
||||||
runtime_selection_json: string | null
|
runtime_selection_json: string | null
|
||||||
knowledge_retrieval_mode: 'auto' | 'always' | null
|
knowledge_retrieval_mode: 'auto' | 'always' | null
|
||||||
|
context_state_json: string | null
|
||||||
title: string
|
title: string
|
||||||
channel: ProjectChannel | null
|
channel: ProjectChannel | null
|
||||||
external_account_id: string | null
|
external_account_id: string | null
|
||||||
@@ -173,6 +175,8 @@ type MessageMetadata = {
|
|||||||
status?: string
|
status?: string
|
||||||
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
|
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
|
||||||
blocks?: ConversationSnapshot['messages'][number]['blocks']
|
blocks?: ConversationSnapshot['messages'][number]['blocks']
|
||||||
|
contextCompression?: ConversationSnapshot['messages'][number]['contextCompression']
|
||||||
|
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
|
||||||
tools?: ConversationSnapshot['messages'][number]['tools']
|
tools?: ConversationSnapshot['messages'][number]['tools']
|
||||||
sources?: string[]
|
sources?: string[]
|
||||||
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
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(
|
function toConversationSnapshot(
|
||||||
conversation: ConversationRow,
|
conversation: ConversationRow,
|
||||||
messages: MessageRow[]
|
messages: MessageRow[]
|
||||||
@@ -764,6 +807,7 @@ function toConversationSnapshot(
|
|||||||
),
|
),
|
||||||
knowledgeRetrievalMode:
|
knowledgeRetrievalMode:
|
||||||
conversation.knowledge_retrieval_mode ?? undefined,
|
conversation.knowledge_retrieval_mode ?? undefined,
|
||||||
|
...parseConversationContextState(conversation.context_state_json),
|
||||||
...(conversation.channel &&
|
...(conversation.channel &&
|
||||||
conversation.conversation_type &&
|
conversation.conversation_type &&
|
||||||
conversation.account_display
|
conversation.account_display
|
||||||
@@ -796,6 +840,8 @@ function toConversationSnapshot(
|
|||||||
status: interrupted
|
status: interrupted
|
||||||
? interruptedMessageStatus
|
? interruptedMessageStatus
|
||||||
: metadata.status,
|
: metadata.status,
|
||||||
|
contextCompression: metadata.contextCompression,
|
||||||
|
contextCompressions: metadata.contextCompressions,
|
||||||
tools: interrupted
|
tools: interrupted
|
||||||
? interruptActiveTools(metadata.tools)
|
? interruptActiveTools(metadata.tools)
|
||||||
: metadata.tools,
|
: metadata.tools,
|
||||||
@@ -817,6 +863,8 @@ function serializeConversationMessageMetadata(
|
|||||||
status: message.status,
|
status: message.status,
|
||||||
reasoning: message.reasoning,
|
reasoning: message.reasoning,
|
||||||
blocks: message.blocks,
|
blocks: message.blocks,
|
||||||
|
contextCompression: message.contextCompression,
|
||||||
|
contextCompressions: message.contextCompressions,
|
||||||
tools: message.tools,
|
tools: message.tools,
|
||||||
sources: message.sources,
|
sources: message.sources,
|
||||||
sourceReferences: message.sourceReferences,
|
sourceReferences: message.sourceReferences,
|
||||||
@@ -1334,7 +1382,7 @@ export class AssistantDatabase {
|
|||||||
const conversations = database
|
const conversations = database
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, project_id, runtime_selection_json,
|
`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,
|
external_account_id, external_conversation_id,
|
||||||
conversation_type, account_display, updated_at
|
conversation_type, account_display, updated_at
|
||||||
FROM conversations
|
FROM conversations
|
||||||
@@ -1369,7 +1417,7 @@ export class AssistantDatabase {
|
|||||||
const conversation = database
|
const conversation = database
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, project_id, runtime_selection_json,
|
`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,
|
external_account_id, external_conversation_id,
|
||||||
conversation_type, account_display, updated_at
|
conversation_type, account_display, updated_at
|
||||||
FROM conversations
|
FROM conversations
|
||||||
@@ -1497,8 +1545,9 @@ export class AssistantDatabase {
|
|||||||
const insertConversation = database.prepare(
|
const insertConversation = database.prepare(
|
||||||
`INSERT INTO conversations
|
`INSERT INTO conversations
|
||||||
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
||||||
work_mode, title, status, created_at, updated_at)
|
context_state_json, work_mode, title, status, created_at,
|
||||||
VALUES (?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||||
)
|
)
|
||||||
const insertMessage = database.prepare(
|
const insertMessage = database.prepare(
|
||||||
`INSERT INTO messages
|
`INSERT INTO messages
|
||||||
@@ -1518,6 +1567,7 @@ export class AssistantDatabase {
|
|||||||
? JSON.stringify(conversation.runtimeSelection)
|
? JSON.stringify(conversation.runtimeSelection)
|
||||||
: null,
|
: null,
|
||||||
conversation.knowledgeRetrievalMode ?? null,
|
conversation.knowledgeRetrievalMode ?? null,
|
||||||
|
serializeConversationContextState(conversation),
|
||||||
conversation.title,
|
conversation.title,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
updatedAt
|
updatedAt
|
||||||
@@ -1532,18 +1582,7 @@ export class AssistantDatabase {
|
|||||||
message.content,
|
message.content,
|
||||||
message.state,
|
message.state,
|
||||||
sequence,
|
sequence,
|
||||||
JSON.stringify({
|
serializeConversationMessageMetadata(message),
|
||||||
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
|
|
||||||
}),
|
|
||||||
new Date(message.createdAt).toISOString()
|
new Date(message.createdAt).toISOString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1563,14 +1602,15 @@ export class AssistantDatabase {
|
|||||||
const insertConversation = database.prepare(
|
const insertConversation = database.prepare(
|
||||||
`INSERT INTO conversations
|
`INSERT INTO conversations
|
||||||
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
||||||
work_mode, title, status, created_at, updated_at)
|
context_state_json, work_mode, title, status, created_at,
|
||||||
VALUES (?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||||
)
|
)
|
||||||
const updateConversation = database.prepare(
|
const updateConversation = database.prepare(
|
||||||
`UPDATE conversations
|
`UPDATE conversations
|
||||||
SET project_id = ?, runtime_selection_json = ?,
|
SET project_id = ?, runtime_selection_json = ?,
|
||||||
knowledge_retrieval_mode = ?, title = ?, status = 'active',
|
knowledge_retrieval_mode = ?, context_state_json = ?,
|
||||||
updated_at = ?
|
title = ?, status = 'active', updated_at = ?
|
||||||
WHERE id = ? AND channel IS NULL`
|
WHERE id = ? AND channel IS NULL`
|
||||||
)
|
)
|
||||||
const findMessage = database.prepare(
|
const findMessage = database.prepare(
|
||||||
@@ -1623,6 +1663,7 @@ export class AssistantDatabase {
|
|||||||
? JSON.stringify(header.runtimeSelection)
|
? JSON.stringify(header.runtimeSelection)
|
||||||
: null,
|
: null,
|
||||||
header.knowledgeRetrievalMode ?? null,
|
header.knowledgeRetrievalMode ?? null,
|
||||||
|
serializeConversationContextState(header),
|
||||||
header.title,
|
header.title,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
header.id
|
header.id
|
||||||
@@ -1638,6 +1679,7 @@ export class AssistantDatabase {
|
|||||||
? JSON.stringify(header.runtimeSelection)
|
? JSON.stringify(header.runtimeSelection)
|
||||||
: null,
|
: null,
|
||||||
header.knowledgeRetrievalMode ?? null,
|
header.knowledgeRetrievalMode ?? null,
|
||||||
|
serializeConversationContextState(header),
|
||||||
header.title,
|
header.title,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
updatedAt
|
updatedAt
|
||||||
@@ -4718,12 +4760,12 @@ export class AssistantDatabase {
|
|||||||
const version = database
|
const version = database
|
||||||
.prepare('PRAGMA user_version')
|
.prepare('PRAGMA user_version')
|
||||||
.get() as { user_version: number }
|
.get() as { user_version: number }
|
||||||
if (version.user_version > 19) {
|
if (version.user_version > 20) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
|
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (version.user_version === 19) {
|
if (version.user_version === 20) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (version.user_version < 1) {
|
if (version.user_version < 1) {
|
||||||
@@ -4750,6 +4792,7 @@ export class AssistantDatabase {
|
|||||||
knowledge_retrieval_mode IS NULL OR
|
knowledge_retrieval_mode IS NULL OR
|
||||||
knowledge_retrieval_mode IN ('auto', 'always')
|
knowledge_retrieval_mode IN ('auto', 'always')
|
||||||
),
|
),
|
||||||
|
context_state_json TEXT,
|
||||||
work_mode TEXT NOT NULL DEFAULT 'ask'
|
work_mode TEXT NOT NULL DEFAULT 'ask'
|
||||||
CHECK(work_mode IN ('ask', 'execute')),
|
CHECK(work_mode IN ('ask', 'execute')),
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
@@ -5633,6 +5676,28 @@ export class AssistantDatabase {
|
|||||||
throw error
|
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 {
|
private requireDatabase(): DatabaseSync {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
|||||||
).count
|
).count
|
||||||
check.close()
|
check.close()
|
||||||
migrated.close()
|
migrated.close()
|
||||||
expect(version).toBe(19)
|
expect(version).toBe(20)
|
||||||
expect(heartbeatTableCount).toBe(3)
|
expect(heartbeatTableCount).toBe(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+335
-23
@@ -2331,7 +2331,7 @@ describe('App', () => {
|
|||||||
expect(screen.getByText('正在分析真实推理内容')).toBeVisible()
|
expect(screen.getByText('正在分析真实推理内容')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows live context usage and keeps explicit compression status', async () => {
|
it('updates context usage after model responses and keeps compression status', async () => {
|
||||||
const settings = await api.settings.getRuntime()
|
const settings = await api.settings.getRuntime()
|
||||||
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
|
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
|
||||||
...settings,
|
...settings,
|
||||||
@@ -2350,14 +2350,12 @@ describe('App', () => {
|
|||||||
})
|
})
|
||||||
render(<App />)
|
render(<App />)
|
||||||
|
|
||||||
|
await screen.findByLabelText('向 GoodBuddy 提问')
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(/上下文 ≈.+ \/ 32\.0K · \d+%/u)
|
screen.queryByRole('progressbar', {
|
||||||
).toBeInTheDocument()
|
|
||||||
expect(
|
|
||||||
screen.getByRole('progressbar', {
|
|
||||||
name: '当前上下文使用量'
|
name: '当前上下文使用量'
|
||||||
})
|
})
|
||||||
).toBeInTheDocument()
|
).not.toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText('只读问答,不修改文件')
|
screen.queryByText('只读问答,不修改文件')
|
||||||
).not.toBeInTheDocument()
|
).not.toBeInTheDocument()
|
||||||
@@ -2369,8 +2367,10 @@ describe('App', () => {
|
|||||||
target: { value: '中'.repeat(1_000) }
|
target: { value: '中'.repeat(1_000) }
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/上下文 ≈5\.\dK \/ 32\.0K/u)
|
screen.queryByRole('progressbar', {
|
||||||
).toBeInTheDocument()
|
name: '当前上下文使用量'
|
||||||
|
})
|
||||||
|
).not.toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByLabelText('发送'))
|
fireEvent.click(screen.getByLabelText('发送'))
|
||||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||||
const request = run.mock.calls[0]?.[0]
|
const request = run.mock.calls[0]?.[0]
|
||||||
@@ -2378,10 +2378,26 @@ describe('App', () => {
|
|||||||
throw new Error('Missing request')
|
throw new Error('Missing request')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
agentListener?.({
|
||||||
|
requestId: request.requestId,
|
||||||
|
type: 'context-metrics',
|
||||||
|
contextTokens: 22_000,
|
||||||
|
effectiveTriggerTokens: 20_000,
|
||||||
|
contextWindowTokens: 32_000,
|
||||||
|
compressionEnabled: true,
|
||||||
|
source: 'provider'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
screen.getByText('本次调用 22.0K / 32.0K · 69%')
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
agentListener?.({
|
agentListener?.({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
type: 'context-compression',
|
type: 'context-compression',
|
||||||
|
scope: 'conversation',
|
||||||
state: 'started',
|
state: 'started',
|
||||||
estimatedBeforeTokens: 22_000,
|
estimatedBeforeTokens: 22_000,
|
||||||
effectiveTriggerTokens: 20_000,
|
effectiveTriggerTokens: 20_000,
|
||||||
@@ -2410,6 +2426,7 @@ describe('App', () => {
|
|||||||
agentListener?.({
|
agentListener?.({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
type: 'context-compression',
|
type: 'context-compression',
|
||||||
|
scope: 'conversation',
|
||||||
state: 'completed',
|
state: 'completed',
|
||||||
estimatedBeforeTokens: 22_000,
|
estimatedBeforeTokens: 22_000,
|
||||||
estimatedAfterTokens: 9_000,
|
estimatedAfterTokens: 9_000,
|
||||||
@@ -2419,25 +2436,105 @@ describe('App', () => {
|
|||||||
coveredMessageCount: 2,
|
coveredMessageCount: 2,
|
||||||
summaryTokens: 1_000
|
summaryTokens: 1_000
|
||||||
})
|
})
|
||||||
agentListener?.({
|
|
||||||
requestId: request.requestId,
|
|
||||||
type: 'context-metrics',
|
|
||||||
estimatedInputTokens: 9_000,
|
|
||||||
effectiveTriggerTokens: 20_000,
|
|
||||||
contextWindowTokens: 32_000,
|
|
||||||
compressionEnabled: true,
|
|
||||||
recentRawTokens: 32_000,
|
|
||||||
coveredMessageCount: 2,
|
|
||||||
summaryTokens: 1_000
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('已压缩较早对话 · ≈22.0K → ≈9.0K')
|
screen.getByText(
|
||||||
|
'已压缩较早对话(估算) · ≈22.0K → ≈9.0K'
|
||||||
|
)
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('上下文 ≈9.0K / 32.0K · 28%')
|
screen.getByText(
|
||||||
|
'压缩后对话估算 ≈9.0K / 32.0K · 28%'
|
||||||
|
)
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
agentListener?.({
|
||||||
|
requestId: request.requestId,
|
||||||
|
type: 'context-compression',
|
||||||
|
scope: 'agent-run',
|
||||||
|
state: 'started',
|
||||||
|
estimatedBeforeTokens: 24_000,
|
||||||
|
effectiveTriggerTokens: 20_000,
|
||||||
|
contextWindowTokens: 32_000,
|
||||||
|
recentRawTokens: 4_000,
|
||||||
|
compressionCount: 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
screen.getByText('正在整理 Agent 执行上下文…')
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
agentListener?.({
|
||||||
|
requestId: request.requestId,
|
||||||
|
type: 'context-compression',
|
||||||
|
scope: 'agent-run',
|
||||||
|
state: 'completed',
|
||||||
|
estimatedBeforeTokens: 24_000,
|
||||||
|
estimatedAfterTokens: 11_000,
|
||||||
|
effectiveTriggerTokens: 20_000,
|
||||||
|
contextWindowTokens: 32_000,
|
||||||
|
recentRawTokens: 4_000,
|
||||||
|
compressionCount: 2
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'已压缩较早对话(估算) · ≈22.0K → ≈9.0K'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'压缩后对话估算 ≈9.0K / 32.0K · 28%'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
agentListener?.({
|
||||||
|
requestId: request.requestId,
|
||||||
|
type: 'context-compression',
|
||||||
|
scope: 'conversation',
|
||||||
|
state: 'completed',
|
||||||
|
estimatedBeforeTokens: 24_000,
|
||||||
|
estimatedAfterTokens: 8_500,
|
||||||
|
effectiveTriggerTokens: 20_000,
|
||||||
|
contextWindowTokens: 32_000,
|
||||||
|
recentRawTokens: 4_000,
|
||||||
|
coveredMessageCount: 4
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'已压缩较早对话(估算) · ≈24.0K → ≈8.5K'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'压缩后对话估算 ≈8.5K / 32.0K · 27%'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
Array.from(
|
||||||
|
document.querySelectorAll(
|
||||||
|
'.context-compression-event__label'
|
||||||
|
)
|
||||||
|
).map((element) => element.textContent)
|
||||||
|
).toEqual([
|
||||||
|
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K',
|
||||||
|
'已压缩较早对话(估算) · ≈24.0K → ≈8.5K'
|
||||||
|
])
|
||||||
act(() => {
|
act(() => {
|
||||||
agentListener?.({
|
agentListener?.({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
@@ -2445,10 +2542,183 @@ describe('App', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('已压缩较早对话 · ≈22.0K → ≈9.0K')
|
screen.getByText(
|
||||||
|
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K'
|
||||||
|
)
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not present the compression threshold as a context-window percentage', async () => {
|
||||||
|
const settings = await api.settings.getRuntime()
|
||||||
|
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
|
||||||
|
...settings,
|
||||||
|
provider: 'model',
|
||||||
|
modelProfiles: settings.modelProfiles.map((profile) => ({
|
||||||
|
...profile,
|
||||||
|
contextWindowTokens: undefined
|
||||||
|
})),
|
||||||
|
contextCompression: {
|
||||||
|
enabled: true,
|
||||||
|
triggerTokens: 20_000,
|
||||||
|
recentRawTokens: 4_000,
|
||||||
|
modelSource: { kind: 'current' },
|
||||||
|
summaryPrompt: 'Preserve important facts.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
|
||||||
|
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
|
||||||
|
target: { value: '检查上下文显示' }
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByLabelText('发送'))
|
||||||
|
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||||
|
const request = run.mock.calls[0]?.[0]
|
||||||
|
if (!request) {
|
||||||
|
throw new Error('Missing request')
|
||||||
|
}
|
||||||
|
act(() => {
|
||||||
|
agentListener?.({
|
||||||
|
requestId: request.requestId,
|
||||||
|
type: 'context-metrics',
|
||||||
|
contextTokens: 22_000,
|
||||||
|
effectiveTriggerTokens: 20_000,
|
||||||
|
compressionEnabled: true,
|
||||||
|
source: 'provider'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('本次调用 22.0K · 压缩线 20.0K')
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('progressbar', {
|
||||||
|
name: '当前上下文使用量'
|
||||||
|
})
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('110%')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
agentListener?.({
|
||||||
|
requestId: request.requestId,
|
||||||
|
type: 'context-compression',
|
||||||
|
scope: 'conversation',
|
||||||
|
state: 'completed',
|
||||||
|
estimatedBeforeTokens: 22_000,
|
||||||
|
estimatedAfterTokens: 9_400,
|
||||||
|
effectiveTriggerTokens: 20_000,
|
||||||
|
recentRawTokens: 4_000,
|
||||||
|
coveredMessageCount: 2
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'压缩后对话估算 ≈9.4K · 压缩线 20.0K'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.queryByText('本次调用 22.0K · 压缩线 20.0K')
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores persisted context usage and compression state after restart', async () => {
|
||||||
|
const settings = await api.settings.getRuntime()
|
||||||
|
const profile = settings.modelProfiles[0]!
|
||||||
|
const conversationId =
|
||||||
|
'00000000-0000-4000-8000-000000000451'
|
||||||
|
const compressionState = {
|
||||||
|
coveredHistoryDigest: 'a'.repeat(64),
|
||||||
|
coveredMessageCount: 2,
|
||||||
|
coveredFromMessageId:
|
||||||
|
'00000000-0000-4000-8000-000000000452',
|
||||||
|
coveredThroughMessageId:
|
||||||
|
'00000000-0000-4000-8000-000000000453',
|
||||||
|
summary: 'Persisted conversation summary'
|
||||||
|
}
|
||||||
|
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
|
||||||
|
...settings,
|
||||||
|
provider: 'model',
|
||||||
|
defaultModelProfileId: profile.id,
|
||||||
|
modelProfiles: settings.modelProfiles.map((candidate) => ({
|
||||||
|
...candidate,
|
||||||
|
contextWindowTokens: 32_000
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: conversationId,
|
||||||
|
projectId,
|
||||||
|
runtimeSelection: {
|
||||||
|
provider: 'model',
|
||||||
|
profileId: profile.id
|
||||||
|
},
|
||||||
|
contextMetrics: {
|
||||||
|
runtimeSelectionKey: `model:${profile.id}`,
|
||||||
|
contextTokens: 9_000,
|
||||||
|
effectiveTriggerTokens: 20_000,
|
||||||
|
contextWindowTokens: 32_000,
|
||||||
|
compressionEnabled: true,
|
||||||
|
source: 'estimated',
|
||||||
|
basis: 'conversation'
|
||||||
|
},
|
||||||
|
contextCompressionState: compressionState,
|
||||||
|
title: '已压缩会话',
|
||||||
|
updatedAt: 1_775_000_000_000,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: '00000000-0000-4000-8000-000000000452',
|
||||||
|
role: 'user',
|
||||||
|
content: '此前问题',
|
||||||
|
createdAt: 1_775_000_000_000,
|
||||||
|
state: 'complete'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '00000000-0000-4000-8000-000000000453',
|
||||||
|
role: 'assistant',
|
||||||
|
content: '此前回答',
|
||||||
|
createdAt: 1_775_000_000_001,
|
||||||
|
state: 'complete',
|
||||||
|
contextCompression: {
|
||||||
|
state: 'completed',
|
||||||
|
scope: 'conversation',
|
||||||
|
estimatedBeforeTokens: 22_000,
|
||||||
|
estimatedAfterTokens: 9_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
render(<App />)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
'压缩后对话估算 ≈9.0K / 32.0K · 28%'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'已压缩较早对话(估算) · ≈22.0K → ≈9.0K'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||||
|
target: { value: '继续工作' }
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByLabelText('发送'))
|
||||||
|
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||||
|
expect(run.mock.calls[0]?.[0].contextCompressionState).toEqual(
|
||||||
|
compressionState
|
||||||
|
)
|
||||||
|
expect(run.mock.calls[0]?.[0]).toMatchObject({
|
||||||
|
historyMessageIds: [
|
||||||
|
'00000000-0000-4000-8000-000000000452',
|
||||||
|
'00000000-0000-4000-8000-000000000453'
|
||||||
|
],
|
||||||
|
currentUserMessageId: expect.any(String),
|
||||||
|
currentAssistantMessageId: expect.any(String)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps a tool failure in details and hides retry after continuing', async () => {
|
it('keeps a tool failure in details and hides retry after continuing', async () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
|
|
||||||
@@ -2747,6 +3017,48 @@ describe('App', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('locks agent context controls while a response is running', async () => {
|
||||||
|
render(<App />)
|
||||||
|
|
||||||
|
const expertButton = composerMenuTrigger('专家角色')
|
||||||
|
const modeButton = composerMenuTrigger('工作模式')
|
||||||
|
const runtimeButton = await screen.findByRole('button', {
|
||||||
|
name: /sonnet-5/u
|
||||||
|
})
|
||||||
|
expect(expertButton).toBeEnabled()
|
||||||
|
expect(modeButton).toBeEnabled()
|
||||||
|
expect(runtimeButton).toBeEnabled()
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||||
|
target: { value: '检查运行上下文锁定' }
|
||||||
|
})
|
||||||
|
openComposerMenu('专家角色')
|
||||||
|
fireEvent.click(await screen.findByLabelText('发送'))
|
||||||
|
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('menu', { name: '专家角色' })
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
expect(expertButton).toBeDisabled()
|
||||||
|
expect(modeButton).toBeDisabled()
|
||||||
|
expect(runtimeButton).toBeDisabled()
|
||||||
|
|
||||||
|
const request = run.mock.calls[0]?.[0]
|
||||||
|
act(() => {
|
||||||
|
if (!request) {
|
||||||
|
throw new Error('Missing request')
|
||||||
|
}
|
||||||
|
agentListener?.({
|
||||||
|
requestId: request.requestId,
|
||||||
|
type: 'done'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(expertButton).toBeEnabled())
|
||||||
|
expect(modeButton).toBeEnabled()
|
||||||
|
expect(runtimeButton).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps sent documents and images in conversation history', async () => {
|
it('keeps sent documents and images in conversation history', async () => {
|
||||||
const documentAttachment = {
|
const documentAttachment = {
|
||||||
id: '00000000-0000-4000-8000-000000000301',
|
id: '00000000-0000-4000-8000-000000000301',
|
||||||
@@ -4134,7 +4446,7 @@ describe('App', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect(mode).toBeEnabled()
|
expect(mode).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('terminalizes tools and activity when a request is cancelled', async () => {
|
it('terminalizes tools and activity when a request is cancelled', async () => {
|
||||||
|
|||||||
+200
-128
@@ -61,19 +61,13 @@ import type {
|
|||||||
BrowserLiveState,
|
BrowserLiveState,
|
||||||
ContextAttachment,
|
ContextAttachment,
|
||||||
ContextFileSelectionProgress,
|
ContextFileSelectionProgress,
|
||||||
KnowledgeRetrievalMode,
|
|
||||||
KnowledgeSearchReference,
|
KnowledgeSearchReference,
|
||||||
KnowledgeSnapshot,
|
KnowledgeSnapshot,
|
||||||
RuntimeSettings
|
RuntimeSettings
|
||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
import {
|
import {
|
||||||
defaultContextCompressionSettings,
|
|
||||||
maximumPastedImageBytes
|
maximumPastedImageBytes
|
||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
import {
|
|
||||||
estimateContextInputTokens,
|
|
||||||
getEffectiveContextTriggerTokens
|
|
||||||
} from '../../shared/context-window'
|
|
||||||
import {
|
import {
|
||||||
agentRuntimeSelectionKey,
|
agentRuntimeSelectionKey,
|
||||||
agentRuntimeSelectionSchema,
|
agentRuntimeSelectionSchema,
|
||||||
@@ -98,6 +92,7 @@ import type {
|
|||||||
ConversationMessage,
|
ConversationMessage,
|
||||||
ConversationSnapshot,
|
ConversationSnapshot,
|
||||||
ConversationAttachment,
|
ConversationAttachment,
|
||||||
|
ConversationContextCompressionMarker,
|
||||||
ConversationMessageBlock,
|
ConversationMessageBlock,
|
||||||
LocalConversationHeader,
|
LocalConversationHeader,
|
||||||
LocalConversationSaveBatch,
|
LocalConversationSaveBatch,
|
||||||
@@ -108,6 +103,7 @@ import type {
|
|||||||
} from '../../shared/assistant-contracts'
|
} from '../../shared/assistant-contracts'
|
||||||
import {
|
import {
|
||||||
conversationAttachmentSchema,
|
conversationAttachmentSchema,
|
||||||
|
conversationContextCompressionMarkerSchema,
|
||||||
conversationMessageBlocksSchema,
|
conversationMessageBlocksSchema,
|
||||||
interactiveWorkModes,
|
interactiveWorkModes,
|
||||||
normalizeInteractiveWorkMode,
|
normalizeInteractiveWorkMode,
|
||||||
@@ -174,6 +170,7 @@ import { ReleaseNotesDialog } from './ReleaseNotesDialog'
|
|||||||
import { scheduleIdleRoutePreload } from './idle-route-preload'
|
import { scheduleIdleRoutePreload } from './idle-route-preload'
|
||||||
import { createPreloadableComponent } from './preloadable-component'
|
import { createPreloadableComponent } from './preloadable-component'
|
||||||
import { formatTime, type TimeFormatLocale } from './time-format'
|
import { formatTime, type TimeFormatLocale } from './time-format'
|
||||||
|
import { formatCompactTokens } from './token-format'
|
||||||
import {
|
import {
|
||||||
pruneKeepAliveEntries,
|
pruneKeepAliveEntries,
|
||||||
touchKeepAliveEntry,
|
touchKeepAliveEntry,
|
||||||
@@ -432,14 +429,7 @@ function supportsSubagentSmartRouting(
|
|||||||
return workMode === 'ask'
|
return workMode === 'ask'
|
||||||
}
|
}
|
||||||
|
|
||||||
type Conversation = {
|
type Conversation = Omit<ConversationSnapshot, 'messages'> & {
|
||||||
id: string
|
|
||||||
projectId?: string
|
|
||||||
runtimeSelection?: AgentRuntimeSelection
|
|
||||||
knowledgeRetrievalMode?: KnowledgeRetrievalMode
|
|
||||||
remote?: ConversationSnapshot['remote']
|
|
||||||
title: string
|
|
||||||
updatedAt: number
|
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,13 +440,6 @@ type ActiveRun = {
|
|||||||
runtimeSelectionKey: string
|
runtimeSelectionKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConversationContextMetrics = Omit<
|
|
||||||
Extract<AgentEvent, { type: 'context-metrics' }>,
|
|
||||||
'requestId' | 'type'
|
|
||||||
> & {
|
|
||||||
runtimeSelectionKey: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkspaceView =
|
type WorkspaceView =
|
||||||
| 'chat'
|
| 'chat'
|
||||||
| 'magic-notes'
|
| 'magic-notes'
|
||||||
@@ -1068,14 +1051,17 @@ function isConversation(value: unknown): value is Conversation {
|
|||||||
entry.state === 'complete' ||
|
entry.state === 'complete' ||
|
||||||
entry.state === 'error') &&
|
entry.state === 'error') &&
|
||||||
(entry.contextCompression === undefined ||
|
(entry.contextCompression === undefined ||
|
||||||
(typeof entry.contextCompression === 'object' &&
|
conversationContextCompressionMarkerSchema.safeParse(
|
||||||
entry.contextCompression !== null &&
|
entry.contextCompression
|
||||||
['compressing', 'completed', 'failed'].includes(
|
).success) &&
|
||||||
String(
|
(entry.contextCompressions === undefined ||
|
||||||
(
|
(Array.isArray(entry.contextCompressions) &&
|
||||||
entry.contextCompression as Record<string, unknown>
|
entry.contextCompressions.length <= 2 &&
|
||||||
).state
|
entry.contextCompressions.every(
|
||||||
)
|
(compression) =>
|
||||||
|
conversationContextCompressionMarkerSchema.safeParse(
|
||||||
|
compression
|
||||||
|
).success
|
||||||
))) &&
|
))) &&
|
||||||
(entry.artifactIds === undefined ||
|
(entry.artifactIds === undefined ||
|
||||||
(Array.isArray(entry.artifactIds) &&
|
(Array.isArray(entry.artifactIds) &&
|
||||||
@@ -1103,6 +1089,8 @@ function toConversationSnapshots(
|
|||||||
projectId: conversation.projectId,
|
projectId: conversation.projectId,
|
||||||
runtimeSelection: conversation.runtimeSelection,
|
runtimeSelection: conversation.runtimeSelection,
|
||||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||||
|
contextMetrics: conversation.contextMetrics,
|
||||||
|
contextCompressionState: conversation.contextCompressionState,
|
||||||
title: conversation.title,
|
title: conversation.title,
|
||||||
updatedAt: conversation.updatedAt,
|
updatedAt: conversation.updatedAt,
|
||||||
messages: conversation.messages
|
messages: conversation.messages
|
||||||
@@ -1122,6 +1110,7 @@ function toConversationMessage(message: Message): ConversationMessage {
|
|||||||
state: message.state,
|
state: message.state,
|
||||||
status: message.status,
|
status: message.status,
|
||||||
contextCompression: message.contextCompression,
|
contextCompression: message.contextCompression,
|
||||||
|
contextCompressions: message.contextCompressions,
|
||||||
tools: message.tools,
|
tools: message.tools,
|
||||||
sources: message.sources,
|
sources: message.sources,
|
||||||
sourceReferences: message.sourceReferences,
|
sourceReferences: message.sourceReferences,
|
||||||
@@ -1139,6 +1128,8 @@ function toLocalConversationHeader(
|
|||||||
projectId: conversation.projectId,
|
projectId: conversation.projectId,
|
||||||
runtimeSelection: conversation.runtimeSelection,
|
runtimeSelection: conversation.runtimeSelection,
|
||||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||||
|
contextMetrics: conversation.contextMetrics,
|
||||||
|
contextCompressionState: conversation.contextCompressionState,
|
||||||
title: conversation.title,
|
title: conversation.title,
|
||||||
updatedAt: conversation.updatedAt
|
updatedAt: conversation.updatedAt
|
||||||
}
|
}
|
||||||
@@ -1307,14 +1298,6 @@ function formatAttachmentSize(size: number): string {
|
|||||||
return `${Math.max(1, Math.ceil(size / 1024))} KB`
|
return `${Math.max(1, Math.ceil(size / 1024))} KB`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCompactContextTokens(tokens: number): string {
|
|
||||||
if (tokens < 1_000) {
|
|
||||||
return tokens.toLocaleString()
|
|
||||||
}
|
|
||||||
const value = tokens / 1_000
|
|
||||||
return `${value >= 100 ? Math.round(value) : value.toFixed(1)}K`
|
|
||||||
}
|
|
||||||
|
|
||||||
const composerTextareaMinHeight = 72
|
const composerTextareaMinHeight = 72
|
||||||
const composerTextareaMaxHeight = 220
|
const composerTextareaMaxHeight = 220
|
||||||
|
|
||||||
@@ -1778,8 +1761,6 @@ function App(): React.JSX.Element {
|
|||||||
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
|
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
|
||||||
const [runtimeStatusKey, setRuntimeStatusKey] = useState('')
|
const [runtimeStatusKey, setRuntimeStatusKey] = useState('')
|
||||||
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
|
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
|
||||||
const [contextMetricsByConversation, setContextMetricsByConversation] =
|
|
||||||
useState<Record<string, ConversationContextMetrics>>({})
|
|
||||||
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
|
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
|
||||||
const [composerMenuOpen, setComposerMenuOpen] = useState<
|
const [composerMenuOpen, setComposerMenuOpen] = useState<
|
||||||
'expert' | 'mode' | undefined
|
'expert' | 'mode' | undefined
|
||||||
@@ -3138,25 +3119,103 @@ function App(): React.JSX.Element {
|
|||||||
const { requestId: _requestId, type: _type, ...metrics } = event
|
const { requestId: _requestId, type: _type, ...metrics } = event
|
||||||
void _requestId
|
void _requestId
|
||||||
void _type
|
void _type
|
||||||
setContextMetricsByConversation((current) => ({
|
setConversations((current) =>
|
||||||
...current,
|
current.map((conversation) =>
|
||||||
[run.conversationId]: {
|
conversation.id === run.conversationId
|
||||||
...metrics,
|
? {
|
||||||
runtimeSelectionKey: run.runtimeSelectionKey
|
...conversation,
|
||||||
}
|
contextMetrics: {
|
||||||
}))
|
...metrics,
|
||||||
|
basis: 'model-call',
|
||||||
|
runtimeSelectionKey: run.runtimeSelectionKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: conversation
|
||||||
|
)
|
||||||
|
)
|
||||||
} else if (event.type === 'context-compression') {
|
} else if (event.type === 'context-compression') {
|
||||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
const estimatedAfterTokens = event.estimatedAfterTokens
|
||||||
...message,
|
const conversationScoped = event.scope !== 'agent-run'
|
||||||
contextCompression: {
|
const scope = event.scope ?? 'conversation'
|
||||||
|
const marker: ConversationContextCompressionMarker = {
|
||||||
state:
|
state:
|
||||||
event.state === 'started'
|
event.state === 'started'
|
||||||
? 'compressing'
|
? 'compressing'
|
||||||
: 'completed',
|
: event.state,
|
||||||
|
scope,
|
||||||
estimatedBeforeTokens: event.estimatedBeforeTokens,
|
estimatedBeforeTokens: event.estimatedBeforeTokens,
|
||||||
estimatedAfterTokens: event.estimatedAfterTokens
|
estimatedAfterTokens: event.estimatedAfterTokens,
|
||||||
|
compressionCount: event.compressionCount
|
||||||
}
|
}
|
||||||
}))
|
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||||
|
const current =
|
||||||
|
message.contextCompressions ??
|
||||||
|
(message.contextCompression
|
||||||
|
? [message.contextCompression]
|
||||||
|
: [])
|
||||||
|
const existingIndex = current.findIndex(
|
||||||
|
(compression) =>
|
||||||
|
(compression.scope ?? 'conversation') === scope
|
||||||
|
)
|
||||||
|
const contextCompressions =
|
||||||
|
existingIndex >= 0
|
||||||
|
? [
|
||||||
|
...current.filter(
|
||||||
|
(_compression, index) =>
|
||||||
|
index !== existingIndex
|
||||||
|
),
|
||||||
|
marker
|
||||||
|
]
|
||||||
|
: [...current, marker]
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
contextCompression: undefined,
|
||||||
|
contextCompressions
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (
|
||||||
|
conversationScoped &&
|
||||||
|
event.state === 'completed' &&
|
||||||
|
estimatedAfterTokens !== undefined
|
||||||
|
) {
|
||||||
|
setConversations((current) =>
|
||||||
|
current.map((conversation) =>
|
||||||
|
conversation.id === run.conversationId
|
||||||
|
? {
|
||||||
|
...conversation,
|
||||||
|
contextMetrics: {
|
||||||
|
runtimeSelectionKey: run.runtimeSelectionKey,
|
||||||
|
contextTokens: estimatedAfterTokens,
|
||||||
|
effectiveTriggerTokens:
|
||||||
|
event.effectiveTriggerTokens,
|
||||||
|
contextWindowTokens:
|
||||||
|
event.contextWindowTokens,
|
||||||
|
compressionEnabled: true,
|
||||||
|
source: 'estimated',
|
||||||
|
basis: 'conversation'
|
||||||
|
},
|
||||||
|
contextCompressionState:
|
||||||
|
event.conversationState ??
|
||||||
|
conversation.contextCompressionState
|
||||||
|
}
|
||||||
|
: conversation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (
|
||||||
|
conversationScoped &&
|
||||||
|
event.conversationState
|
||||||
|
) {
|
||||||
|
setConversations((current) =>
|
||||||
|
current.map((conversation) =>
|
||||||
|
conversation.id === run.conversationId
|
||||||
|
? {
|
||||||
|
...conversation,
|
||||||
|
contextCompressionState: event.conversationState
|
||||||
|
}
|
||||||
|
: conversation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
} else if (event.type === 'status') {
|
} else if (event.type === 'status') {
|
||||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||||
...message,
|
...message,
|
||||||
@@ -3457,6 +3516,17 @@ function App(): React.JSX.Element {
|
|||||||
state: 'failed' as const
|
state: 'failed' as const
|
||||||
}
|
}
|
||||||
: message.contextCompression,
|
: message.contextCompression,
|
||||||
|
contextCompressions:
|
||||||
|
event.type === 'error'
|
||||||
|
? message.contextCompressions?.map((compression) =>
|
||||||
|
compression.state === 'compressing'
|
||||||
|
? {
|
||||||
|
...compression,
|
||||||
|
state: 'failed' as const
|
||||||
|
}
|
||||||
|
: compression
|
||||||
|
)
|
||||||
|
: message.contextCompressions,
|
||||||
approval: undefined,
|
approval: undefined,
|
||||||
question: undefined,
|
question: undefined,
|
||||||
tools: toolTerminalState
|
tools: toolTerminalState
|
||||||
@@ -4965,6 +5035,12 @@ function App(): React.JSX.Element {
|
|||||||
const conversationId = activeConversation.id
|
const conversationId = activeConversation.id
|
||||||
const attachmentSnapshot = attachments.slice(0, 8)
|
const attachmentSnapshot = attachments.slice(0, 8)
|
||||||
const historySnapshot = activeConversation.messages
|
const historySnapshot = activeConversation.messages
|
||||||
|
const retainedHistorySnapshot = historySnapshot
|
||||||
|
.filter(
|
||||||
|
(message) =>
|
||||||
|
message.state === 'complete' && message.content.trim()
|
||||||
|
)
|
||||||
|
.slice(-500)
|
||||||
const projectIdSnapshot = activeProjectId || undefined
|
const projectIdSnapshot = activeProjectId || undefined
|
||||||
const knowledgeRetrievalModeSnapshot =
|
const knowledgeRetrievalModeSnapshot =
|
||||||
activeConversation.knowledgeRetrievalMode ?? 'auto'
|
activeConversation.knowledgeRetrievalMode ?? 'auto'
|
||||||
@@ -4976,6 +5052,8 @@ function App(): React.JSX.Element {
|
|||||||
const selectedExpertSnapshot =
|
const selectedExpertSnapshot =
|
||||||
runtime.capability === 'image-generation' ? '' : selectedExpertId
|
runtime.capability === 'image-generation' ? '' : selectedExpertId
|
||||||
const workModeSnapshot = effectiveWorkMode
|
const workModeSnapshot = effectiveWorkMode
|
||||||
|
setComposerMenuOpen(undefined)
|
||||||
|
setRuntimeMenuOpen(false)
|
||||||
preparingConversations.current.add(conversationId)
|
preparingConversations.current.add(conversationId)
|
||||||
setConversationActivity(conversationId, true)
|
setConversationActivity(conversationId, true)
|
||||||
setInput('')
|
setInput('')
|
||||||
@@ -5097,16 +5175,17 @@ function App(): React.JSX.Element {
|
|||||||
contextIds: attachmentSnapshot.map(
|
contextIds: attachmentSnapshot.map(
|
||||||
(attachment) => attachment.id
|
(attachment) => attachment.id
|
||||||
),
|
),
|
||||||
history: historySnapshot
|
contextCompressionState:
|
||||||
.filter(
|
activeConversation.contextCompressionState,
|
||||||
(message) =>
|
history: retainedHistorySnapshot.map((message) => ({
|
||||||
message.state === 'complete' && message.content.trim()
|
role: message.role,
|
||||||
)
|
content: message.content
|
||||||
.slice(-500)
|
})),
|
||||||
.map((message) => ({
|
historyMessageIds: retainedHistorySnapshot.map(
|
||||||
role: message.role,
|
(message) => message.id
|
||||||
content: message.content
|
),
|
||||||
}))
|
currentUserMessageId: userMessage.id,
|
||||||
|
currentAssistantMessageId: assistantMessage.id
|
||||||
})
|
})
|
||||||
for (const attachment of attachmentSnapshot) {
|
for (const attachment of attachmentSnapshot) {
|
||||||
void window.goodbuddy.context.remove(attachment.id)
|
void window.goodbuddy.context.remove(attachment.id)
|
||||||
@@ -5651,55 +5730,38 @@ function App(): React.JSX.Element {
|
|||||||
) {
|
) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const compression =
|
const latest = activeConversation.contextMetrics
|
||||||
runtimeSettings.contextCompression ??
|
|
||||||
defaultContextCompressionSettings
|
|
||||||
const latest = contextMetricsByConversation[activeConversation.id]
|
|
||||||
const applicableLatest =
|
const applicableLatest =
|
||||||
latest?.runtimeSelectionKey === activeRuntimeSelectionKey
|
latest?.runtimeSelectionKey === activeRuntimeSelectionKey
|
||||||
? latest
|
? latest
|
||||||
: undefined
|
: undefined
|
||||||
const history = activeConversation.messages
|
if (!applicableLatest) {
|
||||||
.filter(
|
return undefined
|
||||||
(message) =>
|
}
|
||||||
message.state === 'complete' && message.content.trim()
|
const contextTokens = applicableLatest.contextTokens
|
||||||
)
|
|
||||||
.map((message) => ({
|
|
||||||
role: message.role,
|
|
||||||
content: message.content
|
|
||||||
}))
|
|
||||||
const coveredMessageCount = Math.min(
|
|
||||||
applicableLatest?.coveredMessageCount ?? 0,
|
|
||||||
history.length
|
|
||||||
)
|
|
||||||
const estimatedInputTokens =
|
|
||||||
isRunning && applicableLatest
|
|
||||||
? applicableLatest.estimatedInputTokens
|
|
||||||
: estimateContextInputTokens({
|
|
||||||
history: history.slice(coveredMessageCount),
|
|
||||||
prompt: input,
|
|
||||||
summaryTokens: applicableLatest?.summaryTokens ?? 0
|
|
||||||
})
|
|
||||||
const effectiveTriggerTokens =
|
const effectiveTriggerTokens =
|
||||||
getEffectiveContextTriggerTokens({
|
applicableLatest.effectiveTriggerTokens
|
||||||
triggerTokens: compression.triggerTokens,
|
|
||||||
contextWindowTokens: profile.contextWindowTokens
|
|
||||||
})
|
|
||||||
const denominatorTokens =
|
const denominatorTokens =
|
||||||
profile.contextWindowTokens ??
|
applicableLatest.contextWindowTokens
|
||||||
(compression.enabled ? effectiveTriggerTokens : undefined)
|
|
||||||
const percentage =
|
const percentage =
|
||||||
denominatorTokens === undefined
|
denominatorTokens === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: Math.round(
|
: Math.round(
|
||||||
(estimatedInputTokens / denominatorTokens) * 100
|
(contextTokens / denominatorTokens) * 100
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
estimatedInputTokens,
|
contextTokens,
|
||||||
effectiveTriggerTokens,
|
effectiveTriggerTokens,
|
||||||
contextWindowTokens: profile.contextWindowTokens,
|
contextWindowTokens: applicableLatest.contextWindowTokens,
|
||||||
compressionEnabled: compression.enabled,
|
compressionEnabled: applicableLatest.compressionEnabled,
|
||||||
|
source: applicableLatest.source,
|
||||||
|
basis:
|
||||||
|
applicableLatest.basis ??
|
||||||
|
(applicableLatest.source === 'estimated' &&
|
||||||
|
activeConversation.contextCompressionState
|
||||||
|
? 'conversation'
|
||||||
|
: 'model-call'),
|
||||||
denominatorTokens,
|
denominatorTokens,
|
||||||
percentage
|
percentage
|
||||||
}
|
}
|
||||||
@@ -5707,9 +5769,6 @@ function App(): React.JSX.Element {
|
|||||||
activeConversation,
|
activeConversation,
|
||||||
activeRuntimeSelection,
|
activeRuntimeSelection,
|
||||||
activeRuntimeSelectionKey,
|
activeRuntimeSelectionKey,
|
||||||
contextMetricsByConversation,
|
|
||||||
input,
|
|
||||||
isRunning,
|
|
||||||
runtimeSettings
|
runtimeSettings
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -6658,6 +6717,7 @@ function App(): React.JSX.Element {
|
|||||||
ariaLabel={t('composer.expertLabel')}
|
ariaLabel={t('composer.expertLabel')}
|
||||||
className="composer-picker--expert"
|
className="composer-picker--expert"
|
||||||
disabled={
|
disabled={
|
||||||
|
isRunning ||
|
||||||
runtime?.capability === 'image-generation'
|
runtime?.capability === 'image-generation'
|
||||||
}
|
}
|
||||||
icon={<Bot aria-hidden="true" size={15} />}
|
icon={<Bot aria-hidden="true" size={15} />}
|
||||||
@@ -6670,6 +6730,7 @@ function App(): React.JSX.Element {
|
|||||||
<ComposerMenuSelect
|
<ComposerMenuSelect
|
||||||
ariaLabel={t('composer.modeLabel')}
|
ariaLabel={t('composer.modeLabel')}
|
||||||
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
|
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
|
||||||
|
disabled={isRunning}
|
||||||
icon={
|
icon={
|
||||||
effectiveWorkMode === 'execute' ? (
|
effectiveWorkMode === 'execute' ? (
|
||||||
<ShieldCheck aria-hidden="true" size={15} />
|
<ShieldCheck aria-hidden="true" size={15} />
|
||||||
@@ -6979,7 +7040,7 @@ function App(): React.JSX.Element {
|
|||||||
title={
|
title={
|
||||||
composerContextMetrics.compressionEnabled
|
composerContextMetrics.compressionEnabled
|
||||||
? t('composer.context.compressionTrigger', {
|
? t('composer.context.compressionTrigger', {
|
||||||
tokens: formatCompactContextTokens(
|
tokens: formatCompactTokens(
|
||||||
composerContextMetrics.effectiveTriggerTokens
|
composerContextMetrics.effectiveTriggerTokens
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -6988,33 +7049,44 @@ function App(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<span className="composer-context-meter__summary">
|
<span className="composer-context-meter__summary">
|
||||||
{composerContextMetrics.denominatorTokens === undefined
|
{composerContextMetrics.denominatorTokens === undefined
|
||||||
? t('composer.context.tokenCount', {
|
? t(
|
||||||
used: formatCompactContextTokens(
|
composerContextMetrics.basis === 'conversation'
|
||||||
composerContextMetrics.estimatedInputTokens
|
? composerContextMetrics.compressionEnabled
|
||||||
|
? 'composer.context.conversationThresholdUsage'
|
||||||
|
: 'composer.context.conversationTokenCount'
|
||||||
|
: composerContextMetrics.compressionEnabled
|
||||||
|
? composerContextMetrics.source === 'provider'
|
||||||
|
? 'composer.context.confirmedThresholdUsage'
|
||||||
|
: 'composer.context.thresholdUsage'
|
||||||
|
: composerContextMetrics.source === 'provider'
|
||||||
|
? 'composer.context.confirmedTokenCount'
|
||||||
|
: 'composer.context.tokenCount',
|
||||||
|
{
|
||||||
|
used: formatCompactTokens(
|
||||||
|
composerContextMetrics.contextTokens
|
||||||
|
),
|
||||||
|
total: formatCompactTokens(
|
||||||
|
composerContextMetrics.effectiveTriggerTokens
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
})
|
: t(
|
||||||
: composerContextMetrics.contextWindowTokens ===
|
composerContextMetrics.basis === 'conversation'
|
||||||
undefined
|
? 'composer.context.conversationWindowUsage'
|
||||||
? t('composer.context.thresholdUsage', {
|
: composerContextMetrics.source === 'provider'
|
||||||
used: formatCompactContextTokens(
|
? 'composer.context.confirmedWindowUsage'
|
||||||
composerContextMetrics.estimatedInputTokens
|
: 'composer.context.windowUsage',
|
||||||
),
|
{
|
||||||
total: formatCompactContextTokens(
|
used: formatCompactTokens(
|
||||||
composerContextMetrics.denominatorTokens
|
composerContextMetrics.contextTokens
|
||||||
),
|
),
|
||||||
percentage:
|
total: formatCompactTokens(
|
||||||
composerContextMetrics.percentage ?? 0
|
composerContextMetrics.denominatorTokens
|
||||||
})
|
),
|
||||||
: t('composer.context.windowUsage', {
|
percentage:
|
||||||
used: formatCompactContextTokens(
|
composerContextMetrics.percentage ?? 0
|
||||||
composerContextMetrics.estimatedInputTokens
|
}
|
||||||
),
|
)}
|
||||||
total: formatCompactContextTokens(
|
|
||||||
composerContextMetrics.denominatorTokens
|
|
||||||
),
|
|
||||||
percentage:
|
|
||||||
composerContextMetrics.percentage ?? 0
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
{composerContextMetrics.denominatorTokens !== undefined && (
|
{composerContextMetrics.denominatorTokens !== undefined && (
|
||||||
<div
|
<div
|
||||||
@@ -7024,7 +7096,7 @@ function App(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuenow={Math.min(
|
aria-valuenow={Math.min(
|
||||||
composerContextMetrics.estimatedInputTokens,
|
composerContextMetrics.contextTokens,
|
||||||
composerContextMetrics.denominatorTokens
|
composerContextMetrics.denominatorTokens
|
||||||
)}
|
)}
|
||||||
className="composer-context-meter__track"
|
className="composer-context-meter__track"
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe('ChatTimeline', () => {
|
|||||||
expect(unchangedDetails).toHaveAttribute('open')
|
expect(unchangedDetails).toHaveAttribute('open')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('places compression progress between the user and assistant messages', () => {
|
it('keeps Agent and conversation compression markers below the assistant message', () => {
|
||||||
const messages: Message[] = [
|
const messages: Message[] = [
|
||||||
{
|
{
|
||||||
id: 'user-message',
|
id: 'user-message',
|
||||||
@@ -99,11 +99,21 @@ describe('ChatTimeline', () => {
|
|||||||
id: 'assistant-message',
|
id: 'assistant-message',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: 'Answer',
|
content: 'Answer',
|
||||||
contextCompression: {
|
contextCompressions: [
|
||||||
state: 'completed',
|
{
|
||||||
estimatedBeforeTokens: 22_000,
|
state: 'completed',
|
||||||
estimatedAfterTokens: 9_000
|
scope: 'agent-run',
|
||||||
},
|
estimatedBeforeTokens: 24_000,
|
||||||
|
estimatedAfterTokens: 11_000,
|
||||||
|
compressionCount: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
state: 'completed',
|
||||||
|
scope: 'conversation',
|
||||||
|
estimatedBeforeTokens: 22_000,
|
||||||
|
estimatedAfterTokens: 9_000
|
||||||
|
}
|
||||||
|
],
|
||||||
createdAt: 1_775_000_001_000,
|
createdAt: 1_775_000_001_000,
|
||||||
state: 'complete'
|
state: 'complete'
|
||||||
}
|
}
|
||||||
@@ -128,11 +138,19 @@ describe('ChatTimeline', () => {
|
|||||||
)
|
)
|
||||||
expect(children.map((element) => element.className)).toEqual([
|
expect(children.map((element) => element.className)).toEqual([
|
||||||
'message message--user',
|
'message message--user',
|
||||||
|
'message message--assistant',
|
||||||
'context-compression-event context-compression-event--completed',
|
'context-compression-event context-compression-event--completed',
|
||||||
'message message--assistant'
|
'context-compression-event context-compression-event--completed'
|
||||||
])
|
])
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('status')
|
screen.getByText(
|
||||||
).toHaveTextContent('已压缩较早对话 · ≈22.0K → ≈9.0K')
|
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'已压缩较早对话(估算) · ≈22.0K → ≈9.0K'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
AssistantArtifact,
|
AssistantArtifact,
|
||||||
ConversationAttachment,
|
ConversationAttachment,
|
||||||
|
ConversationContextCompressionMarker,
|
||||||
|
ConversationMessage,
|
||||||
ConversationMessageBlock,
|
ConversationMessageBlock,
|
||||||
ConversationToolActivity
|
ConversationToolActivity
|
||||||
} from '../../shared/assistant-contracts'
|
} from '../../shared/assistant-contracts'
|
||||||
import { AgentQuestionCard } from './AgentQuestionCard'
|
import { AgentQuestionCard } from './AgentQuestionCard'
|
||||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||||
import { formatTime, type TimeFormatLocale } from './time-format'
|
import { formatTime, type TimeFormatLocale } from './time-format'
|
||||||
|
import { formatCompactTokens } from './token-format'
|
||||||
|
|
||||||
export type ToolActivity = ConversationToolActivity
|
export type ToolActivity = ConversationToolActivity
|
||||||
|
|
||||||
@@ -51,11 +54,8 @@ export type Message = {
|
|||||||
createdAt: number
|
createdAt: number
|
||||||
state: 'streaming' | 'complete' | 'error'
|
state: 'streaming' | 'complete' | 'error'
|
||||||
status?: string
|
status?: string
|
||||||
contextCompression?: {
|
contextCompression?: ConversationMessage['contextCompression']
|
||||||
state: 'compressing' | 'completed' | 'failed'
|
contextCompressions?: ConversationMessage['contextCompressions']
|
||||||
estimatedBeforeTokens: number
|
|
||||||
estimatedAfterTokens?: number
|
|
||||||
}
|
|
||||||
tools?: ToolActivity[]
|
tools?: ToolActivity[]
|
||||||
subagents?: SubagentActivity[]
|
subagents?: SubagentActivity[]
|
||||||
approval?: {
|
approval?: {
|
||||||
@@ -79,14 +79,6 @@ export type ImageViewerItem = {
|
|||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCompactTokens(tokens: number): string {
|
|
||||||
if (tokens < 1_000) {
|
|
||||||
return tokens.toLocaleString()
|
|
||||||
}
|
|
||||||
const value = tokens / 1_000
|
|
||||||
return `${value >= 100 ? Math.round(value) : value.toFixed(1)}K`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessageBlockRenderItem =
|
type MessageBlockRenderItem =
|
||||||
| {
|
| {
|
||||||
kind: 'block'
|
kind: 'block'
|
||||||
@@ -281,43 +273,42 @@ function ChatMessageRowView({
|
|||||||
retryContent
|
retryContent
|
||||||
}: ChatMessageRowProps): React.JSX.Element {
|
}: ChatMessageRowProps): React.JSX.Element {
|
||||||
const { t } = useTranslation('app')
|
const { t } = useTranslation('app')
|
||||||
|
const compressionMarkers =
|
||||||
|
message.contextCompressions ??
|
||||||
|
(message.contextCompression ? [message.contextCompression] : [])
|
||||||
|
const compressionLabel = (
|
||||||
|
compression: ConversationContextCompressionMarker
|
||||||
|
): string =>
|
||||||
|
compression.state === 'compressing'
|
||||||
|
? compression.scope === 'agent-run'
|
||||||
|
? t('chat.contextCompression.agentCompressing')
|
||||||
|
: t('chat.contextCompression.compressing')
|
||||||
|
: compression.state === 'completed' &&
|
||||||
|
compression.estimatedAfterTokens !== undefined
|
||||||
|
? compression.scope === 'agent-run'
|
||||||
|
? t('chat.contextCompression.agentCompleted', {
|
||||||
|
before: formatCompactTokens(
|
||||||
|
compression.estimatedBeforeTokens
|
||||||
|
),
|
||||||
|
after: formatCompactTokens(
|
||||||
|
compression.estimatedAfterTokens
|
||||||
|
),
|
||||||
|
count: compression.compressionCount ?? 1
|
||||||
|
})
|
||||||
|
: t('chat.contextCompression.completed', {
|
||||||
|
before: formatCompactTokens(
|
||||||
|
compression.estimatedBeforeTokens
|
||||||
|
),
|
||||||
|
after: formatCompactTokens(
|
||||||
|
compression.estimatedAfterTokens
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: compression.scope === 'agent-run'
|
||||||
|
? t('chat.contextCompression.agentFailed')
|
||||||
|
: t('chat.contextCompression.failed')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{message.role === 'assistant' && message.contextCompression && (
|
|
||||||
<div
|
|
||||||
aria-live="polite"
|
|
||||||
className={`context-compression-event context-compression-event--${message.contextCompression.state}`}
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<span className="context-compression-event__line" />
|
|
||||||
<span className="context-compression-event__label">
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={
|
|
||||||
message.contextCompression.state === 'compressing'
|
|
||||||
? 'message__status-dot message__status-dot--active'
|
|
||||||
: 'message__status-dot'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{message.contextCompression.state === 'compressing'
|
|
||||||
? t('chat.contextCompression.compressing')
|
|
||||||
: message.contextCompression.state === 'completed' &&
|
|
||||||
message.contextCompression.estimatedAfterTokens !==
|
|
||||||
undefined
|
|
||||||
? t('chat.contextCompression.completed', {
|
|
||||||
before: formatCompactTokens(
|
|
||||||
message.contextCompression.estimatedBeforeTokens
|
|
||||||
),
|
|
||||||
after: formatCompactTokens(
|
|
||||||
message.contextCompression.estimatedAfterTokens
|
|
||||||
)
|
|
||||||
})
|
|
||||||
: t('chat.contextCompression.failed')}
|
|
||||||
</span>
|
|
||||||
<span className="context-compression-event__line" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<article
|
<article
|
||||||
className={`message message--${message.role}`}
|
className={`message message--${message.role}`}
|
||||||
ref={(element) => onArticleRef(message.id, element)}
|
ref={(element) => onArticleRef(message.id, element)}
|
||||||
@@ -803,6 +794,29 @@ function ChatMessageRowView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
{message.role === 'assistant' &&
|
||||||
|
compressionMarkers.map((compression, index) => (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className={`context-compression-event context-compression-event--${compression.state}`}
|
||||||
|
key={`${compression.scope ?? 'conversation'}:${index}`}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span className="context-compression-event__line" />
|
||||||
|
<span className="context-compression-event__label">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={
|
||||||
|
compression.state === 'compressing'
|
||||||
|
? 'message__status-dot message__status-dot--active'
|
||||||
|
: 'message__status-dot'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{compressionLabel(compression)}
|
||||||
|
</span>
|
||||||
|
<span className="context-compression-event__line" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,8 +191,12 @@ export const app = {
|
|||||||
contextCompression: {
|
contextCompression: {
|
||||||
compressing: 'Compressing earlier conversation…',
|
compressing: 'Compressing earlier conversation…',
|
||||||
completed:
|
completed:
|
||||||
'Earlier conversation compressed · ≈{{before}} → ≈{{after}}',
|
'Earlier conversation compressed (estimated) · ≈{{before}} → ≈{{after}}',
|
||||||
failed: 'Earlier conversation compression failed'
|
failed: 'Earlier conversation compression failed',
|
||||||
|
agentCompressing: 'Compacting Agent execution context…',
|
||||||
|
agentCompleted:
|
||||||
|
'Agent context compacted {{count}} time(s) (estimated) · ≈{{before}} → ≈{{after}}',
|
||||||
|
agentFailed: 'Agent execution context compression failed'
|
||||||
},
|
},
|
||||||
sources: 'Sources: {{sources}}',
|
sources: 'Sources: {{sources}}',
|
||||||
citations: {
|
citations: {
|
||||||
@@ -322,10 +326,22 @@ export const app = {
|
|||||||
sendTitle: 'Send message',
|
sendTitle: 'Send message',
|
||||||
shortcut: 'Quick access: ',
|
shortcut: 'Quick access: ',
|
||||||
context: {
|
context: {
|
||||||
tokenCount: 'Context ≈{{used}}',
|
confirmedTokenCount: 'Latest call {{used}}',
|
||||||
windowUsage: 'Context ≈{{used}} / {{total}} · {{percentage}}%',
|
tokenCount: 'Estimated latest call ≈{{used}}',
|
||||||
|
confirmedWindowUsage:
|
||||||
|
'Latest call {{used}} / {{total}} · {{percentage}}%',
|
||||||
|
windowUsage:
|
||||||
|
'Estimated latest call ≈{{used}} / {{total}} · {{percentage}}%',
|
||||||
|
confirmedThresholdUsage:
|
||||||
|
'Latest call {{used}} · Compression at {{total}}',
|
||||||
thresholdUsage:
|
thresholdUsage:
|
||||||
'Compression threshold ≈{{used}} / {{total}} · {{percentage}}%',
|
'Estimated latest call ≈{{used}} · Compression at {{total}}',
|
||||||
|
conversationTokenCount:
|
||||||
|
'Estimated compressed conversation ≈{{used}}',
|
||||||
|
conversationWindowUsage:
|
||||||
|
'Estimated compressed conversation ≈{{used}} / {{total}} · {{percentage}}%',
|
||||||
|
conversationThresholdUsage:
|
||||||
|
'Estimated compressed conversation ≈{{used}} · Compression at {{total}}',
|
||||||
progressLabel: 'Current context usage',
|
progressLabel: 'Current context usage',
|
||||||
compressionTrigger: 'Automatic compression at ≈{{tokens}}'
|
compressionTrigger: 'Automatic compression at ≈{{tokens}}'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -185,8 +185,13 @@ export const app = {
|
|||||||
},
|
},
|
||||||
contextCompression: {
|
contextCompression: {
|
||||||
compressing: '正在压缩较早对话…',
|
compressing: '正在压缩较早对话…',
|
||||||
completed: '已压缩较早对话 · ≈{{before}} → ≈{{after}}',
|
completed:
|
||||||
failed: '较早对话压缩失败'
|
'已压缩较早对话(估算) · ≈{{before}} → ≈{{after}}',
|
||||||
|
failed: '较早对话压缩失败',
|
||||||
|
agentCompressing: '正在整理 Agent 执行上下文…',
|
||||||
|
agentCompleted:
|
||||||
|
'Agent 执行期间已压缩上下文 {{count}} 次(估算) · ≈{{before}} → ≈{{after}}',
|
||||||
|
agentFailed: 'Agent 执行上下文压缩失败'
|
||||||
},
|
},
|
||||||
sources: '来源:{{sources}}',
|
sources: '来源:{{sources}}',
|
||||||
citations: {
|
citations: {
|
||||||
@@ -313,10 +318,21 @@ export const app = {
|
|||||||
sendTitle: '发送消息',
|
sendTitle: '发送消息',
|
||||||
shortcut: '快捷唤起:',
|
shortcut: '快捷唤起:',
|
||||||
context: {
|
context: {
|
||||||
tokenCount: '上下文 ≈{{used}}',
|
confirmedTokenCount: '本次调用 {{used}}',
|
||||||
windowUsage: '上下文 ≈{{used}} / {{total}} · {{percentage}}%',
|
tokenCount: '本次调用估算 ≈{{used}}',
|
||||||
|
confirmedWindowUsage:
|
||||||
|
'本次调用 {{used}} / {{total}} · {{percentage}}%',
|
||||||
|
windowUsage:
|
||||||
|
'本次调用估算 ≈{{used}} / {{total}} · {{percentage}}%',
|
||||||
|
confirmedThresholdUsage:
|
||||||
|
'本次调用 {{used}} · 压缩线 {{total}}',
|
||||||
thresholdUsage:
|
thresholdUsage:
|
||||||
'距压缩阈值 ≈{{used}} / {{total}} · {{percentage}}%',
|
'本次调用估算 ≈{{used}} · 压缩线 {{total}}',
|
||||||
|
conversationTokenCount: '压缩后对话估算 ≈{{used}}',
|
||||||
|
conversationWindowUsage:
|
||||||
|
'压缩后对话估算 ≈{{used}} / {{total}} · {{percentage}}%',
|
||||||
|
conversationThresholdUsage:
|
||||||
|
'压缩后对话估算 ≈{{used}} · 压缩线 {{total}}',
|
||||||
progressLabel: '当前上下文使用量',
|
progressLabel: '当前上下文使用量',
|
||||||
compressionTrigger: '自动压缩线:≈{{tokens}}'
|
compressionTrigger: '自动压缩线:≈{{tokens}}'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function formatCompactTokens(tokens: number): string {
|
||||||
|
if (tokens < 1_000) {
|
||||||
|
return tokens.toLocaleString()
|
||||||
|
}
|
||||||
|
const value = tokens / 1_000
|
||||||
|
return `${value >= 100 ? Math.round(value) : value.toFixed(1)}K`
|
||||||
|
}
|
||||||
@@ -137,6 +137,20 @@ export type ConversationMessageBlock = z.infer<
|
|||||||
typeof conversationMessageBlockSchema
|
typeof conversationMessageBlockSchema
|
||||||
>
|
>
|
||||||
|
|
||||||
|
export const conversationContextCompressionMarkerSchema = z
|
||||||
|
.object({
|
||||||
|
state: z.enum(['compressing', 'completed', 'failed']),
|
||||||
|
scope: z.enum(['conversation', 'agent-run']).optional(),
|
||||||
|
estimatedBeforeTokens: z.number().int().nonnegative(),
|
||||||
|
estimatedAfterTokens: z.number().int().nonnegative().optional(),
|
||||||
|
compressionCount: z.number().int().positive().optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type ConversationContextCompressionMarker = z.infer<
|
||||||
|
typeof conversationContextCompressionMarkerSchema
|
||||||
|
>
|
||||||
|
|
||||||
export const conversationMessageSchema = z
|
export const conversationMessageSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: assistantIdSchema,
|
id: assistantIdSchema,
|
||||||
@@ -147,13 +161,11 @@ export const conversationMessageSchema = z
|
|||||||
createdAt: z.number().int().nonnegative(),
|
createdAt: z.number().int().nonnegative(),
|
||||||
state: z.enum(['streaming', 'complete', 'error']),
|
state: z.enum(['streaming', 'complete', 'error']),
|
||||||
status: z.string().max(4_000).optional(),
|
status: z.string().max(4_000).optional(),
|
||||||
contextCompression: z
|
contextCompression:
|
||||||
.object({
|
conversationContextCompressionMarkerSchema.optional(),
|
||||||
state: z.enum(['compressing', 'completed', 'failed']),
|
contextCompressions: z
|
||||||
estimatedBeforeTokens: z.number().int().nonnegative(),
|
.array(conversationContextCompressionMarkerSchema)
|
||||||
estimatedAfterTokens: z.number().int().nonnegative().optional()
|
.max(2)
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.optional(),
|
.optional(),
|
||||||
tools: z.array(conversationToolActivitySchema).max(100).optional(),
|
tools: z.array(conversationToolActivitySchema).max(100).optional(),
|
||||||
sources: z.array(z.string().max(8_192)).max(100).optional(),
|
sources: z.array(z.string().max(8_192)).max(100).optional(),
|
||||||
@@ -222,12 +234,54 @@ export type ConversationMessage = z.infer<
|
|||||||
typeof conversationMessageSchema
|
typeof conversationMessageSchema
|
||||||
>
|
>
|
||||||
|
|
||||||
|
export const conversationContextMetricsSchema = z
|
||||||
|
.object({
|
||||||
|
runtimeSelectionKey: z.string().trim().min(1).max(1_000),
|
||||||
|
contextTokens: z.number().int().nonnegative().max(50_000_000),
|
||||||
|
effectiveTriggerTokens: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.nonnegative()
|
||||||
|
.max(10_000_000),
|
||||||
|
contextWindowTokens: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.nonnegative()
|
||||||
|
.max(10_000_000)
|
||||||
|
.optional(),
|
||||||
|
compressionEnabled: z.boolean(),
|
||||||
|
source: z.enum(['provider', 'estimated']),
|
||||||
|
basis: z.enum(['model-call', 'conversation']).optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type ConversationContextMetrics = z.infer<
|
||||||
|
typeof conversationContextMetricsSchema
|
||||||
|
>
|
||||||
|
|
||||||
|
export const conversationContextCompressionStateSchema = z
|
||||||
|
.object({
|
||||||
|
coveredHistoryDigest: z.string().regex(/^[0-9a-f]{64}$/u),
|
||||||
|
coveredMessageCount: z.number().int().nonnegative().max(500),
|
||||||
|
coveredFromMessageId: assistantIdSchema.optional(),
|
||||||
|
coveredThroughMessageId: assistantIdSchema.optional(),
|
||||||
|
summary: z.string().trim().min(1).max(100_000)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type ConversationContextCompressionState = z.infer<
|
||||||
|
typeof conversationContextCompressionStateSchema
|
||||||
|
>
|
||||||
|
|
||||||
export const conversationSnapshotSchema = z
|
export const conversationSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: assistantIdSchema,
|
id: assistantIdSchema,
|
||||||
projectId: assistantIdSchema.optional(),
|
projectId: assistantIdSchema.optional(),
|
||||||
runtimeSelection: agentRuntimeSelectionSchema.optional(),
|
runtimeSelection: agentRuntimeSelectionSchema.optional(),
|
||||||
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
|
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
|
||||||
|
contextMetrics: conversationContextMetricsSchema.optional(),
|
||||||
|
contextCompressionState:
|
||||||
|
conversationContextCompressionStateSchema.optional(),
|
||||||
remote: z
|
remote: z
|
||||||
.object({
|
.object({
|
||||||
channel: projectChannelSchema,
|
channel: projectChannelSchema,
|
||||||
|
|||||||
+26
-7
@@ -17,6 +17,7 @@ import type {
|
|||||||
} from './capability-contracts'
|
} from './capability-contracts'
|
||||||
import {
|
import {
|
||||||
assistantIdSchema,
|
assistantIdSchema,
|
||||||
|
conversationContextCompressionStateSchema,
|
||||||
legacyWorkModeSchema,
|
legacyWorkModeSchema,
|
||||||
type AssistantProject,
|
type AssistantProject,
|
||||||
type AssistantArtifact,
|
type AssistantArtifact,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
type TokenUsageSummary,
|
type TokenUsageSummary,
|
||||||
type ConversationSnapshot,
|
type ConversationSnapshot,
|
||||||
type ConversationAttachment,
|
type ConversationAttachment,
|
||||||
|
type ConversationContextCompressionState,
|
||||||
type LocalConversationSaveBatch,
|
type LocalConversationSaveBatch,
|
||||||
type WorkspaceChanges,
|
type WorkspaceChanges,
|
||||||
type WorkspaceDirectoryListing,
|
type WorkspaceDirectoryListing,
|
||||||
@@ -220,7 +222,12 @@ export const agentRequestSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
)
|
)
|
||||||
.max(500)
|
.max(500)
|
||||||
.optional()
|
.optional(),
|
||||||
|
historyMessageIds: z.array(z.string().uuid()).max(500).optional(),
|
||||||
|
currentUserMessageId: z.string().uuid().optional(),
|
||||||
|
currentAssistantMessageId: z.string().uuid().optional(),
|
||||||
|
contextCompressionState:
|
||||||
|
conversationContextCompressionStateSchema.optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((request, context) => {
|
.superRefine((request, context) => {
|
||||||
@@ -236,6 +243,17 @@ export const agentRequestSchema = z
|
|||||||
message: '会话历史总长度不能超过 2,000,000 个字符'
|
message: '会话历史总长度不能超过 2,000,000 个字符'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
request.historyMessageIds &&
|
||||||
|
request.historyMessageIds.length !==
|
||||||
|
(request.history?.length ?? 0)
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['historyMessageIds'],
|
||||||
|
message: '会话历史消息 ID 必须与历史消息一一对应'
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AgentRequest = z.input<typeof agentRequestSchema>
|
export type AgentRequest = z.input<typeof agentRequestSchema>
|
||||||
@@ -919,25 +937,26 @@ export type AgentEvent =
|
|||||||
| {
|
| {
|
||||||
requestId: string
|
requestId: string
|
||||||
type: 'context-metrics'
|
type: 'context-metrics'
|
||||||
estimatedInputTokens: number
|
contextTokens: number
|
||||||
effectiveTriggerTokens: number
|
effectiveTriggerTokens: number
|
||||||
contextWindowTokens?: number
|
contextWindowTokens?: number
|
||||||
compressionEnabled: boolean
|
compressionEnabled: boolean
|
||||||
recentRawTokens: number
|
source: 'provider' | 'estimated'
|
||||||
coveredMessageCount: number
|
|
||||||
summaryTokens: number
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
requestId: string
|
requestId: string
|
||||||
type: 'context-compression'
|
type: 'context-compression'
|
||||||
state: 'started' | 'completed'
|
scope?: 'conversation' | 'agent-run'
|
||||||
|
state: 'started' | 'completed' | 'failed'
|
||||||
estimatedBeforeTokens: number
|
estimatedBeforeTokens: number
|
||||||
estimatedAfterTokens?: number
|
estimatedAfterTokens?: number
|
||||||
effectiveTriggerTokens: number
|
effectiveTriggerTokens: number
|
||||||
contextWindowTokens?: number
|
contextWindowTokens?: number
|
||||||
recentRawTokens: number
|
recentRawTokens: number
|
||||||
coveredMessageCount: number
|
coveredMessageCount?: number
|
||||||
|
compressionCount?: number
|
||||||
summaryTokens?: number
|
summaryTokens?: number
|
||||||
|
conversationState?: ConversationContextCompressionState
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
requestId: string
|
requestId: string
|
||||||
|
|||||||
Reference in New Issue
Block a user