/** * 上下文构建(任务 A3.8,实施设计 1.3 / 1.5)。 * * 目标:不把整段会话塞给模型。按预算取「最近若干轮 + 滚动摘要 + 钉住的引用」, * 工具执行只以引用形式进摘要(能力、状态、参数摘要、executionId),需要细节时再调能力取回。 * * 三条约束: * 1. 摘要与原始消息都留在平台:摘要条目带来源消息 id,任何结论都能追回原文 * 2. 构建是确定性的、可测的:这里不调模型(真正的模型摘要等 A4 之后按需替换) * 3. 引擎只拿到「上下文包」,不拿所有权(架构设计 5.1 固定点:状态归平台) */ import type { ChatMessage } from '@aps/contracts'; export interface ContextBudget { /** 上下文包总字符预算(粗略按字符算,够用且不引入 tokenizer 依赖) */ maxChars: number; /** 至少保留的最近消息条数 */ recentMessages: number; /** 摘要部分最多占多少字符 */ summaryMaxChars: number; /** 摘要里最多引用多少条执行记录 */ recentExecutions: number; } export const DEFAULT_CONTEXT_BUDGET: ContextBudget = { maxChars: 6000, recentMessages: 6, summaryMaxChars: 1800, recentExecutions: 6, }; /** 执行记录的引用(来自 Core,不进历史原文) */ export interface ExecutionReference { capabilityId: string; status: string; executionId: string; paramsDigest: string; createdAt: number; } export interface ContextBuildInput { messages: ChatMessage[]; executions?: ExecutionReference[]; /** 用户钉住的消息:无论多早都保留(界面在 A10.4 提供入口) */ pinnedMessageIds?: string[]; budget?: Partial; } export interface ContextBuildResult { /** 注入给引擎的上下文包;空串表示无需注入(例如会话刚开始) */ prefix: string; /** 存进检查点的摘要(含来源消息 id,便于追溯) */ summary: string; /** 摘要引用的消息 id */ summarySourceIds: string[]; /** 原样保留的最近消息 id */ includedMessageIds: string[]; /** 因预算被折叠进摘要之前的消息(原文仍在平台,未丢失) */ droppedMessageIds: string[]; estimatedChars: number; } const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max)}…(已截断)`); const roleLabel = (role: ChatMessage['role']): string => (role === 'user' ? '用户' : role === 'assistant' ? '助手' : '系统'); function renderExecution(reference: ExecutionReference): string { return `- 调用 ${reference.capabilityId} → ${reference.status}(${reference.executionId},参数摘要 ${reference.paramsDigest})`; } export function buildContext(input: ContextBuildInput): ContextBuildResult { const budget: ContextBudget = { ...DEFAULT_CONTEXT_BUDGET, ...(input.budget ?? {}) }; const messages = input.messages; const pinned = new Set(input.pinnedMessageIds ?? []); const kept: ChatMessage[] = []; const dropped: ChatMessage[] = []; let usedChars = 0; // 从最近往前收:先保证「最近若干轮」,再按预算继续吃历史 for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (!message) continue; const cost = message.text.length + 12; const isRecent = messages.length - index <= budget.recentMessages; const mustKeep = isRecent || pinned.has(message.messageId); if (mustKeep || usedChars + cost <= budget.maxChars) { kept.unshift(message); usedChars += cost; } else { dropped.unshift(message); } } // 摘要:折叠掉的消息取要点(保留前 80 字),附来源 id const summaryLines: string[] = []; let summaryChars = 0; for (const message of dropped) { const line = `- ${roleLabel(message.role)}(${message.messageId}):${clip(message.text.replace(/\s+/g, ' '), 80)}`; if (summaryChars + line.length > budget.summaryMaxChars) { summaryLines.push(`- ……另有 ${dropped.length - summaryLines.length} 条更早的消息,原始内容保留在平台会话记录里`); break; } summaryLines.push(line); summaryChars += line.length; } const executionLines = (input.executions ?? []) .slice(0, budget.recentExecutions) .map(renderExecution); const blocks: string[] = []; if (summaryLines.length > 0 || executionLines.length > 0) { blocks.push('【此前对话的摘要】'); blocks.push(...summaryLines); if (executionLines.length > 0) { blocks.push('【相关工具执行(只给引用,需要细节请调对应能力)】'); blocks.push(...executionLines); } } if (kept.length > 0) { blocks.push('【最近的对话】'); for (const message of kept) blocks.push(`${roleLabel(message.role)}:${message.text}`); } const prefix = blocks.length === 0 ? '' : `${blocks.join('\n')}\n【以上是背景,以下是用户这次的问题】\n`; const summary = summaryLines.join('\n'); return { prefix, summary, summarySourceIds: dropped.map((message) => message.messageId), includedMessageIds: kept.map((message) => message.messageId), droppedMessageIds: dropped.map((message) => message.messageId), estimatedChars: prefix.length, }; }