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

136 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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