mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat: extract context compression to @openchatlab/node-runtime
Move compression logic from Electron to shared package with pluggable LLM adapter interface. Integrate into server agent with pre-run compression support. Electron side becomes a thin compatibility wrapper.
This commit is contained in:
1 parent
68b1c7a1b2
commit
07056a2258
8 files changed
+420
-368
No files matched your search
@@ -1,100 +1,66 @@
|
||||
/**
|
||||
* 上下文压缩服务
|
||||
* 在 Agent 推理前同步执行,将过长的对话历史压缩为摘要。
|
||||
* 上下文压缩 — Electron 薄包装
|
||||
*
|
||||
* 核心流程:
|
||||
* 1. 计算当前上下文总 token → 未超阈值则跳过
|
||||
* 2. 确定缓冲区:最近 bufferSizePercent% context window 的消息原文
|
||||
* 3. 缓冲区之前的消息(含旧 system 摘要)→ LLM 压缩为新摘要
|
||||
* 4. 写入 ai_message(role='summary'),替换旧摘要
|
||||
* 5. Thrashing 检查
|
||||
* 实际逻辑已提取到 @openchatlab/node-runtime。
|
||||
* 此处提供与旧 API 兼容的函数签名,注入 Electron 的 LLM adapter 和 logger。
|
||||
*/
|
||||
|
||||
import { countTokens, countMessagesTokens } from '../tokenizer'
|
||||
export type { CompressionConfig, CompressionResult } from '@openchatlab/node-runtime'
|
||||
export {
|
||||
checkAndCompress as sharedCheckAndCompress,
|
||||
manualCompress as sharedManualCompress,
|
||||
} from '@openchatlab/node-runtime'
|
||||
import type { CompressionConfig, CompressionResult, CompressionLlmAdapter } from '@openchatlab/node-runtime'
|
||||
import {
|
||||
getLatestSummary,
|
||||
getMessagesAfterSummary,
|
||||
getAllUserAssistantMessages,
|
||||
addSummaryMessage,
|
||||
getMessageCountAfterSummary,
|
||||
setDebugContext,
|
||||
type ContentBlock,
|
||||
type AIMessageRole,
|
||||
} from '../conversations'
|
||||
checkAndCompress as sharedCheckAndCompress,
|
||||
manualCompress as sharedManualCompress,
|
||||
} from '@openchatlab/node-runtime'
|
||||
import { buildPiModel, findModelDefinition } from '../llm'
|
||||
import type { AIServiceConfig } from '../llm/types'
|
||||
import { completeSimple, type TextContent as PiTextContent } from '@mariozechner/pi-ai'
|
||||
import { aiLogger, isDebugMode } from '../logger'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface CompressionConfig {
|
||||
enabled: boolean
|
||||
/** 触发压缩的 token 阈值百分比(相对于 context window),默认 75 */
|
||||
tokenThresholdPercent: number
|
||||
/** 保留最近消息的缓冲区大小(相对于 context window 的百分比),默认 20 */
|
||||
bufferSizePercent: number
|
||||
/** 单次工具返回的最大上下文占比(相对于 context window 的百分比),默认 35 */
|
||||
maxToolResultPercent?: number
|
||||
}
|
||||
|
||||
export interface CompressionResult {
|
||||
compressed: boolean
|
||||
reason:
|
||||
| 'skipped_disabled'
|
||||
| 'skipped_below_threshold'
|
||||
| 'skipped_idempotent'
|
||||
| 'success'
|
||||
| 'fallback_truncated'
|
||||
| 'thrashing'
|
||||
| 'error'
|
||||
tokensBefore?: number
|
||||
tokensAfter?: number
|
||||
summaryContent?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const INITIAL_COMPRESSION_PROMPT = `You are a context compression assistant. Compress the conversation below into a structured summary.
|
||||
|
||||
STRICT RULES:
|
||||
- Output ONLY the summary content. No greetings, no preamble, no meta-commentary, no word/token counts.
|
||||
- Use the same language as the conversation.
|
||||
- Maximum output length: {maxTokens} tokens. Be concise.
|
||||
- NEVER reproduce any single message verbatim. Always paraphrase and compress.
|
||||
- Cover ALL topics discussed — no single topic should exceed 30% of the summary.
|
||||
- Organize by topic/thread, using brief headers (e.g. "## Topic").
|
||||
- Preserve: key facts, conclusions, user preferences, data points, names, important timestamps, action items.
|
||||
- Omit: pleasantries, filler, redundant back-and-forth, detailed tables (summarize their conclusions instead).
|
||||
|
||||
CONVERSATION:
|
||||
{messages}`
|
||||
|
||||
const PROGRESSIVE_COMPRESSION_PROMPT = `You are a context compression assistant performing an INCREMENTAL summary update.
|
||||
|
||||
You will receive:
|
||||
1. A [PREVIOUS SUMMARY] — this represents the compressed history of earlier conversation. Its content MUST be preserved in your output.
|
||||
2. [NEW MESSAGES] — recent messages that need to be merged into the summary.
|
||||
|
||||
STRICT RULES:
|
||||
- Output ONLY the updated summary. No greetings, no preamble, no meta-commentary.
|
||||
- Use the same language as the conversation.
|
||||
- Maximum output length: {maxTokens} tokens. Be concise.
|
||||
- CRITICAL: You MUST retain ALL key points from the previous summary. Do not discard prior context.
|
||||
- NEVER reproduce any single message verbatim. Always paraphrase and compress.
|
||||
- Merge new information into appropriate existing topic sections, or add new sections.
|
||||
- Cover ALL topics — no single topic should exceed 30% of the summary.
|
||||
- Organize by topic/thread, using brief headers (e.g. "## Topic").
|
||||
- Preserve: key facts, conclusions, user preferences, data points, names, important timestamps, action items.
|
||||
- Omit: pleasantries, filler, redundant back-and-forth, detailed tables (summarize their conclusions instead).
|
||||
|
||||
{messages}`
|
||||
import { aiLogger } from '../logger'
|
||||
import { getManager } from '../conversations'
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW = 128000
|
||||
|
||||
// ==================== 核心压缩逻辑 ====================
|
||||
function createLlmAdapter(activeAIConfig: AIServiceConfig): CompressionLlmAdapter {
|
||||
const modelDef = findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '')
|
||||
const contextWindow = modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW
|
||||
const piModel = buildPiModel(activeAIConfig)
|
||||
|
||||
return {
|
||||
contextWindow,
|
||||
compress: async (prompt: string, maxTokens: number) => {
|
||||
try {
|
||||
const result = await completeSimple(
|
||||
piModel,
|
||||
{
|
||||
systemPrompt: undefined,
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }] as any,
|
||||
},
|
||||
{ apiKey: activeAIConfig.apiKey, maxTokens }
|
||||
)
|
||||
const text = result.content
|
||||
.filter((item): item is PiTextContent => item.type === 'text')
|
||||
.map((item) => item.text)
|
||||
.join('')
|
||||
return text || null
|
||||
} catch (error) {
|
||||
aiLogger.warn('Compression', 'LLM compression attempt failed', { error: String(error) })
|
||||
return null
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const electronLogger = {
|
||||
info: (cat: string, msg: string, extra?: Record<string, unknown>) => aiLogger.info(cat, msg, extra),
|
||||
warn: (cat: string, msg: string, extra?: Record<string, unknown>) => aiLogger.warn(cat, msg, extra),
|
||||
error: (cat: string, msg: string, extra?: Record<string, unknown>) => aiLogger.error(cat, msg, extra),
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并执行上下文压缩(同步,在 Agent 推理前调用)
|
||||
* 兼容旧 API:检查并执行上下文压缩
|
||||
*/
|
||||
export async function checkAndCompress(
|
||||
conversationId: string,
|
||||
@@ -102,171 +68,18 @@ export async function checkAndCompress(
|
||||
systemPrompt: string,
|
||||
activeAIConfig: AIServiceConfig
|
||||
): Promise<CompressionResult> {
|
||||
if (!config.enabled) {
|
||||
return { compressed: false, reason: 'skipped_disabled' }
|
||||
}
|
||||
|
||||
try {
|
||||
const contextWindow = resolveContextWindow(config, activeAIConfig)
|
||||
const thresholdTokens = Math.floor(contextWindow * (config.tokenThresholdPercent / 100) * 0.95)
|
||||
|
||||
// 收集当前上下文消息
|
||||
const summary = getLatestSummary(conversationId)
|
||||
|
||||
let messages: Array<{ role: AIMessageRole; content: string; timestamp: number }>
|
||||
if (summary) {
|
||||
// 从 summary_meta 获取 buffer 边界,取 >= boundary 的消息(buffer + 新消息)
|
||||
const metaBlock = summary.contentBlocks?.find(
|
||||
(b): b is Extract<ContentBlock, { type: 'summary_meta' }> => b.type === 'summary_meta'
|
||||
)
|
||||
const boundary = metaBlock?.bufferBoundaryTimestamp ?? summary.timestamp
|
||||
messages = getMessagesAfterSummary(conversationId, boundary - 1)
|
||||
} else {
|
||||
messages = getAllUserAssistantMessages(conversationId)
|
||||
}
|
||||
|
||||
// 构建 token 计算的消息列表
|
||||
const historyForTokenCount: Array<{ role: string; content: string }> = []
|
||||
if (summary) {
|
||||
historyForTokenCount.push({ role: 'assistant', content: summary.content })
|
||||
}
|
||||
for (const msg of messages) {
|
||||
historyForTokenCount.push({ role: msg.role, content: msg.content })
|
||||
}
|
||||
|
||||
const currentTokens = countMessagesTokens(historyForTokenCount, systemPrompt)
|
||||
|
||||
aiLogger.info('Compression', `Token check: ${currentTokens} / ${thresholdTokens} (${contextWindow} window)`, {
|
||||
conversationId,
|
||||
messageCount: messages.length,
|
||||
hasSummary: !!summary,
|
||||
})
|
||||
|
||||
if (currentTokens < thresholdTokens) {
|
||||
return { compressed: false, reason: 'skipped_below_threshold', tokensBefore: currentTokens }
|
||||
}
|
||||
|
||||
// 确定缓冲区(保留最近 N% 的消息)
|
||||
const bufferTokenBudget = Math.floor(contextWindow * (config.bufferSizePercent / 100))
|
||||
const { bufferMessages, messagesToCompress } = splitMessagesForCompression(messages, bufferTokenBudget)
|
||||
|
||||
aiLogger.info('Compression', `Split result`, {
|
||||
totalMessages: messages.length,
|
||||
messagesToCompress: messagesToCompress.length,
|
||||
bufferMessages: bufferMessages.length,
|
||||
bufferTokenBudget,
|
||||
hasPreviousSummary: !!summary,
|
||||
})
|
||||
|
||||
// 待压缩消息数过少时跳过,避免生成低质量摘要(仅保留 1-2 条消息时 LLM 倾向于原文照搬)
|
||||
const MIN_MESSAGES_TO_COMPRESS = 3
|
||||
if (messagesToCompress.length < MIN_MESSAGES_TO_COMPRESS) {
|
||||
aiLogger.info(
|
||||
'Compression',
|
||||
`Skipping: only ${messagesToCompress.length} messages to compress (min: ${MIN_MESSAGES_TO_COMPRESS})`
|
||||
)
|
||||
return { compressed: false, reason: 'skipped_below_threshold', tokensBefore: currentTokens }
|
||||
}
|
||||
|
||||
// 构建压缩输入文本
|
||||
const isProgressive = !!summary
|
||||
const compressInput = buildCompressionInput(messagesToCompress, summary)
|
||||
const targetTokens = Math.min(Math.floor(contextWindow * 0.1), 16384)
|
||||
|
||||
aiLogger.info('Compression', `Compression params`, {
|
||||
mode: isProgressive ? 'progressive' : 'initial',
|
||||
targetTokens,
|
||||
inputLength: compressInput.length,
|
||||
inputTokensEstimate: countTokens(compressInput),
|
||||
})
|
||||
|
||||
// 使用默认助手模型压缩,失败则强制截断
|
||||
let summaryText: string | null = null
|
||||
|
||||
summaryText = await tryCompressWithConfig(activeAIConfig, compressInput, targetTokens, isProgressive)
|
||||
|
||||
if (!summaryText) {
|
||||
aiLogger.warn('Compression', 'LLM compression failed, falling back to truncation')
|
||||
summaryText = forceTruncate(compressInput, targetTokens)
|
||||
aiLogger.info('Compression', 'Truncation fallback applied', {
|
||||
outputLength: summaryText.length,
|
||||
})
|
||||
} else {
|
||||
aiLogger.info('Compression', 'LLM compression succeeded', {
|
||||
outputLength: summaryText.length,
|
||||
outputTokensEstimate: countTokens(summaryText),
|
||||
})
|
||||
}
|
||||
|
||||
// 写入 summary:时间戳 = NOW(UI 中显示在触发压缩的位置)
|
||||
// buffer 边界 + 压缩消息数存入 content_blocks 的 summary_meta block
|
||||
const bufferBoundary =
|
||||
bufferMessages.length > 0
|
||||
? bufferMessages[0].timestamp
|
||||
: messagesToCompress[messagesToCompress.length - 1]!.timestamp + 1
|
||||
|
||||
const summaryMeta = {
|
||||
bufferBoundaryTimestamp: bufferBoundary,
|
||||
compressedMessageCount: messagesToCompress.length,
|
||||
}
|
||||
|
||||
aiLogger.info('Compression', 'Writing summary', {
|
||||
bufferBoundary,
|
||||
compressedCount: messagesToCompress.length,
|
||||
bufferCount: bufferMessages.length,
|
||||
})
|
||||
|
||||
const summaryMsg = addSummaryMessage(conversationId, summaryText, summaryMeta)
|
||||
|
||||
// DEBUG 模式:记录发给压缩 LLM 的完整上下文(与 assistant 的 debug_context 格式一致)
|
||||
if (isDebugMode()) {
|
||||
try {
|
||||
const template = isProgressive ? PROGRESSIVE_COMPRESSION_PROMPT : INITIAL_COMPRESSION_PROMPT
|
||||
const fullPrompt = template.replace('{maxTokens}', String(targetTokens)).replace('{messages}', compressInput)
|
||||
const debugMessages = [{ role: 'user', content: fullPrompt }]
|
||||
setDebugContext(summaryMsg.id, JSON.stringify(debugMessages, null, 2))
|
||||
} catch {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// Thrashing 检查:压缩后重新计算 token(summary + buffer 消息)
|
||||
const afterTokenCount: Array<{ role: string; content: string }> = [
|
||||
{ role: 'assistant', content: summaryText },
|
||||
...bufferMessages.map((m) => ({ role: m.role, content: m.content })),
|
||||
]
|
||||
const tokensAfter = countMessagesTokens(afterTokenCount, systemPrompt)
|
||||
|
||||
if (tokensAfter >= thresholdTokens) {
|
||||
aiLogger.warn(
|
||||
'Compression',
|
||||
`Thrashing detected: ${tokensAfter} tokens after compression still >= ${thresholdTokens}`
|
||||
)
|
||||
return {
|
||||
compressed: true,
|
||||
reason: 'thrashing',
|
||||
tokensBefore: currentTokens,
|
||||
tokensAfter,
|
||||
summaryContent: summaryText,
|
||||
}
|
||||
}
|
||||
|
||||
aiLogger.info('Compression', `Compressed: ${currentTokens} → ${tokensAfter} tokens`)
|
||||
return {
|
||||
compressed: true,
|
||||
reason: 'success',
|
||||
tokensBefore: currentTokens,
|
||||
tokensAfter,
|
||||
summaryContent: summaryText,
|
||||
}
|
||||
} catch (error) {
|
||||
aiLogger.error('Compression', 'Compression failed', { error: String(error) })
|
||||
return { compressed: false, reason: 'error', error: String(error) }
|
||||
}
|
||||
return sharedCheckAndCompress(
|
||||
conversationId,
|
||||
config,
|
||||
systemPrompt,
|
||||
createLlmAdapter(activeAIConfig),
|
||||
getManager(),
|
||||
electronLogger
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动压缩(用户手动触发,含幂等检查)
|
||||
* 兼容旧 API:手动压缩
|
||||
*/
|
||||
export async function manualCompress(
|
||||
conversationId: string,
|
||||
@@ -274,123 +87,12 @@ export async function manualCompress(
|
||||
systemPrompt: string,
|
||||
activeAIConfig: AIServiceConfig
|
||||
): Promise<CompressionResult> {
|
||||
const messageCount = getMessageCountAfterSummary(conversationId)
|
||||
if (messageCount < 5) {
|
||||
return { compressed: false, reason: 'skipped_idempotent' }
|
||||
}
|
||||
|
||||
// 手动压缩忽略阈值,强制执行
|
||||
const overrideConfig = { ...config, enabled: true, tokenThresholdPercent: 0 }
|
||||
return checkAndCompress(conversationId, overrideConfig, systemPrompt, activeAIConfig)
|
||||
}
|
||||
|
||||
// ==================== 内部辅助函数 ====================
|
||||
|
||||
function resolveContextWindow(_config: CompressionConfig, activeAIConfig: AIServiceConfig): number {
|
||||
const modelDef = findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '')
|
||||
return modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW
|
||||
}
|
||||
|
||||
interface SplitResult {
|
||||
bufferMessages: Array<{ role: string; content: string; timestamp: number }>
|
||||
messagesToCompress: Array<{ role: string; content: string; timestamp: number }>
|
||||
}
|
||||
|
||||
function splitMessagesForCompression(
|
||||
messages: Array<{ role: string; content: string; timestamp: number }>,
|
||||
bufferTokenBudget: number
|
||||
): SplitResult {
|
||||
let bufferTokens = 0
|
||||
let splitIndex = messages.length
|
||||
|
||||
// 从最近的消息向前累计,直到达到缓冲区预算
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msgTokens = countTokens(messages[i].content) + 4
|
||||
if (bufferTokens + msgTokens > bufferTokenBudget) {
|
||||
splitIndex = i + 1
|
||||
break
|
||||
}
|
||||
bufferTokens += msgTokens
|
||||
if (i === 0) {
|
||||
splitIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bufferMessages: messages.slice(splitIndex),
|
||||
messagesToCompress: messages.slice(0, splitIndex),
|
||||
}
|
||||
}
|
||||
|
||||
function buildCompressionInput(
|
||||
messagesToCompress: Array<{ role: string; content: string }>,
|
||||
existingSummary: { content: string } | null
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (existingSummary) {
|
||||
parts.push(`[PREVIOUS SUMMARY — MUST PRESERVE]\n${existingSummary.content}\n`)
|
||||
parts.push(`[NEW MESSAGES — SUMMARIZE AND MERGE]`)
|
||||
}
|
||||
|
||||
for (const msg of messagesToCompress) {
|
||||
const roleLabel = msg.role === 'user' ? 'User' : 'Assistant'
|
||||
parts.push(`${roleLabel}: ${msg.content}`)
|
||||
}
|
||||
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
async function tryCompressWithConfig(
|
||||
aiConfig: AIServiceConfig,
|
||||
input: string,
|
||||
targetTokens: number,
|
||||
isProgressive: boolean
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const piModel = buildPiModel(aiConfig)
|
||||
const template = isProgressive ? PROGRESSIVE_COMPRESSION_PROMPT : INITIAL_COMPRESSION_PROMPT
|
||||
const prompt = template.replace('{maxTokens}', String(targetTokens)).replace('{messages}', input)
|
||||
|
||||
const result = await completeSimple(
|
||||
piModel,
|
||||
{
|
||||
systemPrompt: undefined,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: prompt }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
] as any,
|
||||
},
|
||||
{
|
||||
apiKey: aiConfig.apiKey,
|
||||
maxTokens: targetTokens,
|
||||
}
|
||||
)
|
||||
|
||||
const text = result.content
|
||||
.filter((item): item is PiTextContent => item.type === 'text')
|
||||
.map((item) => item.text)
|
||||
.join('')
|
||||
|
||||
return text || null
|
||||
} catch (error) {
|
||||
aiLogger.warn('Compression', 'LLM compression attempt failed', { error: String(error) })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function forceTruncate(input: string, targetTokens: number): string {
|
||||
const lines = input.split('\n')
|
||||
const result: string[] = []
|
||||
let tokens = 0
|
||||
for (const line of lines) {
|
||||
const lineTokens = countTokens(line)
|
||||
if (tokens + lineTokens > targetTokens) break
|
||||
result.push(line)
|
||||
tokens += lineTokens
|
||||
}
|
||||
return result.join('\n') || input.slice(0, targetTokens * 3)
|
||||
return sharedManualCompress(
|
||||
conversationId,
|
||||
config,
|
||||
systemPrompt,
|
||||
createLlmAdapter(activeAIConfig),
|
||||
getManager(),
|
||||
electronLogger
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export type { AIConversation, AIMessage, AIMessageRole, ContentBlock, TokenUsage
|
||||
|
||||
let manager: AIConversationManager | null = null
|
||||
|
||||
function getManager(): AIConversationManager {
|
||||
export function getManager(): AIConversationManager {
|
||||
if (!manager) {
|
||||
manager = new AIConversationManager(getAiDataDir(), {
|
||||
logger: {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 上下文压缩核心逻辑(平台无关)
|
||||
*
|
||||
* 通过 CompressionLlmAdapter 抽象 LLM 调用,
|
||||
* 通过 AIConversationManager 操作对话数据。
|
||||
*/
|
||||
|
||||
import { countTokens, countMessagesTokens } from '../tokenizer'
|
||||
import type { AIConversationManager, ContentBlock, AIMessageRole } from '../conversations'
|
||||
import type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './types'
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW = 128000
|
||||
|
||||
const INITIAL_COMPRESSION_PROMPT = `You are a context compression assistant. Compress the conversation below into a structured summary.
|
||||
|
||||
STRICT RULES:
|
||||
- Output ONLY the summary content. No greetings, no preamble, no meta-commentary, no word/token counts.
|
||||
- Use the same language as the conversation.
|
||||
- Maximum output length: {maxTokens} tokens. Be concise.
|
||||
- NEVER reproduce any single message verbatim. Always paraphrase and compress.
|
||||
- Cover ALL topics discussed — no single topic should exceed 30% of the summary.
|
||||
- Organize by topic/thread, using brief headers (e.g. "## Topic").
|
||||
- Preserve: key facts, conclusions, user preferences, data points, names, important timestamps, action items.
|
||||
- Omit: pleasantries, filler, redundant back-and-forth, detailed tables (summarize their conclusions instead).
|
||||
|
||||
CONVERSATION:
|
||||
{messages}`
|
||||
|
||||
const PROGRESSIVE_COMPRESSION_PROMPT = `You are a context compression assistant performing an INCREMENTAL summary update.
|
||||
|
||||
You will receive:
|
||||
1. A [PREVIOUS SUMMARY] — this represents the compressed history of earlier conversation. Its content MUST be preserved in your output.
|
||||
2. [NEW MESSAGES] — recent messages that need to be merged into the summary.
|
||||
|
||||
STRICT RULES:
|
||||
- Output ONLY the updated summary. No greetings, no preamble, no meta-commentary.
|
||||
- Use the same language as the conversation.
|
||||
- Maximum output length: {maxTokens} tokens. Be concise.
|
||||
- CRITICAL: You MUST retain ALL key points from the previous summary. Do not discard prior context.
|
||||
- NEVER reproduce any single message verbatim. Always paraphrase and compress.
|
||||
- Merge new information into appropriate existing topic sections, or add new sections.
|
||||
- Cover ALL topics — no single topic should exceed 30% of the summary.
|
||||
- Organize by topic/thread, using brief headers (e.g. "## Topic").
|
||||
- Preserve: key facts, conclusions, user preferences, data points, names, important timestamps, action items.
|
||||
- Omit: pleasantries, filler, redundant back-and-forth, detailed tables (summarize their conclusions instead).
|
||||
|
||||
{messages}`
|
||||
|
||||
const defaultLogger: CompressionLogger = {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
}
|
||||
|
||||
export async function checkAndCompress(
|
||||
conversationId: string,
|
||||
config: CompressionConfig,
|
||||
systemPrompt: string,
|
||||
llmAdapter: CompressionLlmAdapter,
|
||||
convManager: AIConversationManager,
|
||||
logger: CompressionLogger = defaultLogger
|
||||
): Promise<CompressionResult> {
|
||||
if (!config.enabled) {
|
||||
return { compressed: false, reason: 'skipped_disabled' }
|
||||
}
|
||||
|
||||
try {
|
||||
const contextWindow = llmAdapter.contextWindow || DEFAULT_CONTEXT_WINDOW
|
||||
const thresholdTokens = Math.floor(contextWindow * (config.tokenThresholdPercent / 100) * 0.95)
|
||||
|
||||
const summary = convManager.getLatestSummary(conversationId)
|
||||
|
||||
let messages: Array<{ role: AIMessageRole; content: string; timestamp: number }>
|
||||
if (summary) {
|
||||
const metaBlock = summary.contentBlocks?.find(
|
||||
(b): b is Extract<ContentBlock, { type: 'summary_meta' }> => b.type === 'summary_meta'
|
||||
)
|
||||
const boundary = metaBlock?.bufferBoundaryTimestamp ?? summary.timestamp
|
||||
messages = convManager.getMessagesAfterSummary(conversationId, boundary - 1)
|
||||
} else {
|
||||
messages = convManager.getAllUserAssistantMessages(conversationId)
|
||||
}
|
||||
|
||||
const historyForTokenCount: Array<{ role: string; content: string }> = []
|
||||
if (summary) {
|
||||
historyForTokenCount.push({ role: 'assistant', content: summary.content })
|
||||
}
|
||||
for (const msg of messages) {
|
||||
historyForTokenCount.push({ role: msg.role, content: msg.content })
|
||||
}
|
||||
|
||||
const currentTokens = countMessagesTokens(historyForTokenCount, systemPrompt)
|
||||
|
||||
logger.info('Compression', `Token check: ${currentTokens} / ${thresholdTokens} (${contextWindow} window)`, {
|
||||
conversationId,
|
||||
messageCount: messages.length,
|
||||
hasSummary: !!summary,
|
||||
})
|
||||
|
||||
if (currentTokens < thresholdTokens) {
|
||||
return { compressed: false, reason: 'skipped_below_threshold', tokensBefore: currentTokens }
|
||||
}
|
||||
|
||||
const bufferTokenBudget = Math.floor(contextWindow * (config.bufferSizePercent / 100))
|
||||
const { bufferMessages, messagesToCompress } = splitMessagesForCompression(messages, bufferTokenBudget)
|
||||
|
||||
const MIN_MESSAGES_TO_COMPRESS = 3
|
||||
if (messagesToCompress.length < MIN_MESSAGES_TO_COMPRESS) {
|
||||
return { compressed: false, reason: 'skipped_below_threshold', tokensBefore: currentTokens }
|
||||
}
|
||||
|
||||
const isProgressive = !!summary
|
||||
const compressInput = buildCompressionInput(messagesToCompress, summary)
|
||||
const targetTokens = Math.min(Math.floor(contextWindow * 0.1), 16384)
|
||||
|
||||
const template = isProgressive ? PROGRESSIVE_COMPRESSION_PROMPT : INITIAL_COMPRESSION_PROMPT
|
||||
const prompt = template.replace('{maxTokens}', String(targetTokens)).replace('{messages}', compressInput)
|
||||
|
||||
let summaryText = await llmAdapter.compress(prompt, targetTokens)
|
||||
|
||||
if (!summaryText) {
|
||||
logger.warn('Compression', 'LLM compression failed, falling back to truncation')
|
||||
summaryText = forceTruncate(compressInput, targetTokens)
|
||||
}
|
||||
|
||||
const bufferBoundary =
|
||||
bufferMessages.length > 0
|
||||
? bufferMessages[0].timestamp
|
||||
: messagesToCompress[messagesToCompress.length - 1]!.timestamp + 1
|
||||
|
||||
convManager.addSummaryMessage(conversationId, summaryText, {
|
||||
bufferBoundaryTimestamp: bufferBoundary,
|
||||
compressedMessageCount: messagesToCompress.length,
|
||||
})
|
||||
|
||||
const afterTokenCount: Array<{ role: string; content: string }> = [
|
||||
{ role: 'assistant', content: summaryText },
|
||||
...bufferMessages.map((m) => ({ role: m.role, content: m.content })),
|
||||
]
|
||||
const tokensAfter = countMessagesTokens(afterTokenCount, systemPrompt)
|
||||
|
||||
if (tokensAfter >= thresholdTokens) {
|
||||
logger.warn(
|
||||
'Compression',
|
||||
`Thrashing detected: ${tokensAfter} tokens after compression still >= ${thresholdTokens}`
|
||||
)
|
||||
return {
|
||||
compressed: true,
|
||||
reason: 'thrashing',
|
||||
tokensBefore: currentTokens,
|
||||
tokensAfter,
|
||||
summaryContent: summaryText,
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Compression', `Compressed: ${currentTokens} → ${tokensAfter} tokens`)
|
||||
return {
|
||||
compressed: true,
|
||||
reason: 'success',
|
||||
tokensBefore: currentTokens,
|
||||
tokensAfter,
|
||||
summaryContent: summaryText,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Compression', 'Compression failed', { error: String(error) })
|
||||
return { compressed: false, reason: 'error', error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function manualCompress(
|
||||
conversationId: string,
|
||||
config: CompressionConfig,
|
||||
systemPrompt: string,
|
||||
llmAdapter: CompressionLlmAdapter,
|
||||
convManager: AIConversationManager,
|
||||
logger?: CompressionLogger
|
||||
): Promise<CompressionResult> {
|
||||
const messageCount = convManager.getMessageCountAfterSummary(conversationId)
|
||||
if (messageCount < 5) {
|
||||
return { compressed: false, reason: 'skipped_idempotent' }
|
||||
}
|
||||
|
||||
const overrideConfig = { ...config, enabled: true, tokenThresholdPercent: 0 }
|
||||
return checkAndCompress(conversationId, overrideConfig, systemPrompt, llmAdapter, convManager, logger)
|
||||
}
|
||||
|
||||
// ==================== Internal Helpers ====================
|
||||
|
||||
function splitMessagesForCompression(
|
||||
messages: Array<{ role: string; content: string; timestamp: number }>,
|
||||
bufferTokenBudget: number
|
||||
): {
|
||||
bufferMessages: Array<{ role: string; content: string; timestamp: number }>
|
||||
messagesToCompress: Array<{ role: string; content: string; timestamp: number }>
|
||||
} {
|
||||
let bufferTokens = 0
|
||||
let splitIndex = messages.length
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msgTokens = countTokens(messages[i].content) + 4
|
||||
if (bufferTokens + msgTokens > bufferTokenBudget) {
|
||||
splitIndex = i + 1
|
||||
break
|
||||
}
|
||||
bufferTokens += msgTokens
|
||||
if (i === 0) {
|
||||
splitIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bufferMessages: messages.slice(splitIndex),
|
||||
messagesToCompress: messages.slice(0, splitIndex),
|
||||
}
|
||||
}
|
||||
|
||||
function buildCompressionInput(
|
||||
messagesToCompress: Array<{ role: string; content: string }>,
|
||||
existingSummary: { content: string } | null
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (existingSummary) {
|
||||
parts.push(`[PREVIOUS SUMMARY — MUST PRESERVE]\n${existingSummary.content}\n`)
|
||||
parts.push(`[NEW MESSAGES — SUMMARIZE AND MERGE]`)
|
||||
}
|
||||
|
||||
for (const msg of messagesToCompress) {
|
||||
const roleLabel = msg.role === 'user' ? 'User' : 'Assistant'
|
||||
parts.push(`${roleLabel}: ${msg.content}`)
|
||||
}
|
||||
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
function forceTruncate(input: string, targetTokens: number): string {
|
||||
const lines = input.split('\n')
|
||||
const result: string[] = []
|
||||
let tokens = 0
|
||||
for (const line of lines) {
|
||||
const lineTokens = countTokens(line)
|
||||
if (tokens + lineTokens > targetTokens) break
|
||||
result.push(line)
|
||||
tokens += lineTokens
|
||||
}
|
||||
return result.join('\n') || input.slice(0, targetTokens * 3)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 上下文压缩模块(平台无关)
|
||||
*/
|
||||
|
||||
export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './types'
|
||||
export { checkAndCompress, manualCompress } from './compressor'
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface CompressionConfig {
|
||||
enabled: boolean
|
||||
/** 触发压缩的 token 阈值百分比(相对于 context window),默认 75 */
|
||||
tokenThresholdPercent: number
|
||||
/** 保留最近消息的缓冲区大小(相对于 context window 的百分比),默认 20 */
|
||||
bufferSizePercent: number
|
||||
/** 单次工具返回的最大上下文占比(相对于 context window 的百分比),默认 35 */
|
||||
maxToolResultPercent?: number
|
||||
}
|
||||
|
||||
export interface CompressionResult {
|
||||
compressed: boolean
|
||||
reason:
|
||||
| 'skipped_disabled'
|
||||
| 'skipped_below_threshold'
|
||||
| 'skipped_idempotent'
|
||||
| 'success'
|
||||
| 'fallback_truncated'
|
||||
| 'thrashing'
|
||||
| 'error'
|
||||
tokensBefore?: number
|
||||
tokensAfter?: number
|
||||
summaryContent?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface CompressionLogger {
|
||||
info(category: string, message: string, extra?: Record<string, unknown>): void
|
||||
warn(category: string, message: string, extra?: Record<string, unknown>): void
|
||||
error(category: string, message: string, extra?: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 抽象 LLM 压缩调用接口。
|
||||
* 平台侧提供具体实现(使用 pi-ai 的 completeSimple 等)。
|
||||
*/
|
||||
export interface CompressionLlmAdapter {
|
||||
/**
|
||||
* 调用 LLM 进行压缩,返回压缩后的文本,失败返回 null。
|
||||
*/
|
||||
compress(prompt: string, maxTokens: number): Promise<string | null>
|
||||
/** 解析模型的 context window 大小 */
|
||||
contextWindow: number
|
||||
}
|
||||
@@ -26,6 +26,10 @@ export type { SkillManagerLogger } from './skill-manager'
|
||||
export { createActivateSkillTool } from './activate-skill-tool'
|
||||
export type { ActivateSkillToolOptions, ActivateSkillTool, ActivateSkillToolResult } from './activate-skill-tool'
|
||||
|
||||
// Compression
|
||||
export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './compression'
|
||||
export { checkAndCompress, manualCompress } from './compression'
|
||||
|
||||
// Preprocessor
|
||||
export type {
|
||||
PreprocessConfig,
|
||||
|
||||
@@ -36,6 +36,10 @@ export { parseAssistantFile, serializeAssistant, parseSkillFile, extractSkillId
|
||||
export { AIConversationManager } from './ai'
|
||||
export { countTokens, countMessagesTokens } from './ai'
|
||||
|
||||
// Compression
|
||||
export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './ai'
|
||||
export { checkAndCompress, manualCompress } from './ai'
|
||||
|
||||
// SkillManager
|
||||
export { SkillManager } from './ai'
|
||||
export type { SkillManagerLogger, ActivateSkillToolOptions, ActivateSkillTool, ActivateSkillToolResult } from './ai'
|
||||
|
||||
@@ -7,8 +7,15 @@
|
||||
|
||||
import { Agent as PiAgentCore } from '@mariozechner/pi-agent-core'
|
||||
import type { AgentEvent as PiAgentEvent } from '@mariozechner/pi-agent-core'
|
||||
import { type Message as PiMessage, type Usage as PiUsage, streamSimple } from '@mariozechner/pi-ai'
|
||||
import type { AIConversationManager } from '@openchatlab/node-runtime'
|
||||
import {
|
||||
type Message as PiMessage,
|
||||
type Usage as PiUsage,
|
||||
streamSimple,
|
||||
completeSimple,
|
||||
type TextContent as PiTextContent,
|
||||
} from '@mariozechner/pi-ai'
|
||||
import type { AIConversationManager, CompressionConfig, CompressionLlmAdapter } from '@openchatlab/node-runtime'
|
||||
import { checkAndCompress } from '@openchatlab/node-runtime'
|
||||
|
||||
import { getDefaultAssistantConfig, buildPiModel } from './llm-config'
|
||||
|
||||
@@ -38,6 +45,7 @@ export interface RunAgentOptions {
|
||||
locale?: string
|
||||
assistantSystemPrompt?: string
|
||||
skillMenu?: string | null
|
||||
compressionConfig?: CompressionConfig
|
||||
tools?: import('@mariozechner/pi-agent-core').AgentTool[]
|
||||
aiDataDir: string
|
||||
convManager: AIConversationManager
|
||||
@@ -128,6 +136,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
|
||||
locale = 'zh-CN',
|
||||
assistantSystemPrompt,
|
||||
skillMenu,
|
||||
compressionConfig,
|
||||
tools = [],
|
||||
aiDataDir,
|
||||
convManager,
|
||||
@@ -146,6 +155,42 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
|
||||
const maxToolRounds = 5
|
||||
|
||||
const systemPrompt = buildSystemPrompt(chatType, assistantSystemPrompt, locale, skillMenu)
|
||||
|
||||
if (compressionConfig?.enabled) {
|
||||
onEvent({ type: 'status', status: { phase: 'compressing', round: 0, toolsUsed: 0 } })
|
||||
const llmAdapter: CompressionLlmAdapter = {
|
||||
contextWindow: piModel.contextWindow ?? 128000,
|
||||
compress: async (prompt: string, maxTokens: number) => {
|
||||
try {
|
||||
const result = await completeSimple(
|
||||
piModel,
|
||||
{
|
||||
systemPrompt: undefined,
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }] as any,
|
||||
},
|
||||
{ apiKey: llmConfig.apiKey, maxTokens }
|
||||
)
|
||||
const text = result.content
|
||||
.filter((item): item is PiTextContent => item.type === 'text')
|
||||
.map((item) => item.text)
|
||||
.join('')
|
||||
return text || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
}
|
||||
const compressionResult = await checkAndCompress(
|
||||
conversationId,
|
||||
compressionConfig,
|
||||
systemPrompt,
|
||||
llmAdapter,
|
||||
convManager
|
||||
)
|
||||
if (compressionResult.compressed) {
|
||||
onEvent({ type: 'status', status: { phase: 'compression_done', round: 0, toolsUsed: 0 } })
|
||||
}
|
||||
}
|
||||
const totalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
||||
const toolsUsed: string[] = []
|
||||
let toolRounds = 0
|
||||
|
||||
Reference in new issue
Block a user