aps-agent/server/app/context-builder.ts

136 lines
5.2 KiB
TypeScript
Raw Normal View History

/**
* 上下文构建(任务 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<ContextBudget>;
}
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,
};
}