v0.16.0: Agent ReAct Loop 深度优化 R51-R128 (上下文管理/安全治理/性能分析/会话持久化)

This commit is contained in:
thzxx
2026-07-11 23:31:27 +08:00
parent 9a631ebf15
commit 64b7d307e7
15 changed files with 4173 additions and 91 deletions
+963 -1
View File
@@ -903,7 +903,13 @@ function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaM
return [...systemMsgs, ...recentMsgs];
}
// 按重要性降序排列,取能装下的最大数量
// R94: 按综合评分降序排列(重要性 + 时近性),取能装下的最大数量
const totalOlder = olderMsgs.length;
scored.forEach((s, idx) => {
// R94: 时近性因子 — 越靠近最近窗口的消息得分越高(0~2 分加成)
const recencyRatio = totalOlder > 1 ? idx / (totalOlder - 1) : 1;
s.importance += Math.round(recencyRatio * 2);
});
scored.sort((a, b) => b.importance - a.importance);
let usedTokens = 0;
@@ -924,3 +930,959 @@ function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaM
return result;
}
// ═══════════════════════════════════════════════════════════════
// R91: 上下文压力分级评估 — 三级压力系统指导压缩策略选择
// ═══════════════════════════════════════════════════════════════
export type ContextPressureLevel = 'low' | 'medium' | 'high' | 'critical';
export interface ContextPressureInfo {
level: ContextPressureLevel;
tokenUsageRatio: number; // 0-1
messageCount: number;
recommendedActions: string[]; // 建议的压缩动作
}
/**
* R91: 评估当前上下文压力等级
* - low (<30%): 无需压缩
* - medium (30-50%): 轻量压缩(归档旧工具结果、清理 ephemeral)
* - high (50-70%): 中等压缩(截断工具结果、合并消息)
* - critical (>70%): LLM 压缩
*/
export function getContextPressureLevel(
messages: OllamaMessage[],
numCtx: number,
): ContextPressureInfo {
const totalTokens = messages.reduce((sum, m) => {
let t = estimateTokens(m.content || '');
if (m.tool_calls?.length) {
for (const tc of m.tool_calls) {
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
t += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20;
}
}
if (m.images?.length) t += m.images.length * 100;
return sum + t;
}, 0);
const ratio = numCtx > 0 ? totalTokens / numCtx : 0;
const msgCount = messages.length;
const actions: string[] = [];
let level: ContextPressureLevel;
if (ratio > 0.7) {
level = 'critical';
actions.push('llm_compress', 'truncate_results', 'compact_old', 'merge_messages', 'clear_ephemeral');
} else if (ratio > 0.5) {
level = 'high';
actions.push('truncate_results', 'compact_old', 'merge_messages');
} else if (ratio > 0.3) {
level = 'medium';
actions.push('compact_old', 'clear_ephemeral');
} else {
level = 'low';
if (msgCount > 60) actions.push('compact_old');
}
return { level, tokenUsageRatio: ratio, messageCount: msgCount, recommendedActions: actions };
}
// ═══════════════════════════════════════════════════════════════
// R93: Token 预算追踪器 — 实时追踪输入/输出 token 与预算比例
// ═══════════════════════════════════════════════════════════════
interface TokenBudgetEntry {
loop: number;
inputTokens: number; // prompt_eval_count
outputTokens: number; // eval_count
estimatedTokens: number; // 本地估算值
timestamp: number;
}
const _tokenBudgetHistory: TokenBudgetEntry[] = [];
const MAX_BUDGET_ENTRIES = 50;
let _totalInputTokens = 0;
let _totalOutputTokens = 0;
let _budgetNumCtx = 131072;
/** R93: 设置当前预算的 numCtx */
export function setTokenBudgetNumCtx(numCtx: number): void {
_budgetNumCtx = numCtx;
}
/** R93: 记录一轮的 token 消耗 */
export function recordBudgetUsage(
loop: number,
inputTokens: number,
outputTokens: number,
estimatedTokens: number,
): void {
_tokenBudgetHistory.push({
loop, inputTokens, outputTokens, estimatedTokens, timestamp: Date.now(),
});
if (_tokenBudgetHistory.length > MAX_BUDGET_ENTRIES) {
_tokenBudgetHistory.shift();
}
_totalInputTokens += inputTokens;
_totalOutputTokens += outputTokens;
}
/** R93: 获取 Token 预算使用情况 */
export interface TokenBudgetStatus {
totalInput: number;
totalOutput: number;
totalSpent: number;
avgInputPerLoop: number;
avgOutputPerLoop: number;
budgetNumCtx: number;
currentLoopInput: number;
budgetUtilization: number; // 当前轮输入占预算比例 0-1
trend: 'increasing' | 'stable' | 'decreasing';
history: TokenBudgetEntry[];
}
/** R93: 获取当前 Token 预算状态 */
export function getTokenBudgetStatus(): TokenBudgetStatus {
const history = [..._tokenBudgetHistory];
const currentLoop = history.length > 0 ? history[history.length - 1] : null;
// 计算趋势
let trend: 'increasing' | 'stable' | 'decreasing' = 'stable';
if (history.length >= 3) {
const recent = history.slice(-3);
const avg = recent.reduce((s, e) => s + e.inputTokens, 0) / recent.length;
const oldest = recent[0].inputTokens;
if (avg > oldest * 1.15) trend = 'increasing';
else if (avg < oldest * 0.85) trend = 'decreasing';
}
const avgInput = history.length > 0
? Math.round(_totalInputTokens / history.length)
: 0;
const avgOutput = history.length > 0
? Math.round(_totalOutputTokens / history.length)
: 0;
return {
totalInput: _totalInputTokens,
totalOutput: _totalOutputTokens,
totalSpent: _totalInputTokens + _totalOutputTokens,
avgInputPerLoop: avgInput,
avgOutputPerLoop: avgOutput,
budgetNumCtx: _budgetNumCtx,
currentLoopInput: currentLoop?.inputTokens || 0,
budgetUtilization: _budgetNumCtx > 0 && currentLoop
? currentLoop.inputTokens / _budgetNumCtx
: 0,
trend,
history,
};
}
/** R93: 重置 Token 预算追踪 */
export function resetTokenBudget(): void {
_tokenBudgetHistory.length = 0;
_totalInputTokens = 0;
_totalOutputTokens = 0;
}
// ═══════════════════════════════════════════════════════════════
// R96: 消息角色压缩 — 合并连续相同角色消息,减少消息条数开销
// ═══════════════════════════════════════════════════════════════
/**
* R96: 合并连续相同角色的非工具消息
* 规则:
* - 连续的 user 消息合并为一条(用分隔符连接)
* - 连续的 assistant 消息合并为一条(保留 tool_calls
* - tool 消息不合并(每条对应一个 tool_call)
* - system 消息不合并(已有 R17 处理)
* - ephemeral 临时消息不合并
* - compressed 消息不合并
*/
export function mergeConsecutiveMessages(messages: OllamaMessage[]): OllamaMessage[] {
if (messages.length <= 2) return messages;
const result: OllamaMessage[] = [];
let mergedCount = 0;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const last = result[result.length - 1];
// 不合并的情况
if (
!last ||
msg.role === 'tool' ||
msg.role === 'system' ||
msg.ephemeral ||
msg.compressed ||
last.role !== msg.role ||
last.ephemeral ||
last.compressed ||
msg.tool_calls?.length || // 有工具调用的 assistant 不合并
last.tool_calls?.length
) {
result.push(msg);
continue;
}
// 合并连续相同角色消息
// 限制合并后内容不超过 3000 字符,避免合并后过长
const combinedContent = (last.content || '') + '\n\n' + (msg.content || '');
if (combinedContent.length > 3000) {
result.push(msg);
continue;
}
result[result.length - 1] = {
...last,
content: combinedContent,
};
mergedCount++;
}
if (mergedCount > 0) {
logInfo(`R96: 消息角色压缩 — 合并了 ${mergedCount} 条连续同角色消息 (${messages.length}${result.length})`);
}
return result;
}
// ═══════════════════════════════════════════════════════════════
// R98: 压缩触发阈值优化 — 结合趋势预测动态调整
// ═══════════════════════════════════════════════════════════════
/**
* R98: 获取结合趋势的压缩触发阈值
* 如果 token 使用趋势在快速增长,提前触发压缩
* 如果趋势稳定或下降,延后压缩
*/
export function getTrendAwareCompressThreshold(
numCtx: number,
messages: OllamaMessage[],
): { shouldCompress: boolean; reason: string; urgency: 'low' | 'medium' | 'high' } {
const baseThreshold = getAdaptiveCompressThreshold(numCtx);
const currentTokens = messages.reduce((sum, m) => {
let t = estimateTokens(m.content || '');
if (m.tool_calls?.length) {
for (const tc of m.tool_calls) {
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
t += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20;
}
}
if (m.images?.length) t += m.images.length * 100;
return sum + t;
}, 0);
const usageRatio = numCtx > 0 ? currentTokens / numCtx : 0;
const prediction = predictContextOverflow(numCtx);
// 紧急情况:预测即将溢出
if (prediction.level === 'critical' || (prediction.level === 'warning' && prediction.turnsToOverflow <= 2)) {
return {
shouldCompress: true,
reason: `趋势预测触发: ${prediction.message}`,
urgency: 'high',
};
}
// 趋势加速增长 + 使用率超过基础阈值
if (prediction.turnsToOverflow > 0 && prediction.turnsToOverflow <= 5 && usageRatio > baseThreshold * 0.8) {
return {
shouldCompress: true,
reason: `趋势加速: ${prediction.turnsToOverflow} 轮后可能溢出,当前使用率 ${(usageRatio * 100).toFixed(0)}%`,
urgency: 'medium',
};
}
// 标准阈值触发
if (usageRatio > baseThreshold) {
return {
shouldCompress: true,
reason: `标准阈值触发: 使用率 ${(usageRatio * 100).toFixed(0)}% > 阈值 ${(baseThreshold * 100).toFixed(0)}%`,
urgency: usageRatio > 0.6 ? 'high' : 'medium',
};
}
// 消息条数硬阈值
const msgThreshold = getIncrementalCompressThresholdMessages(numCtx);
if (messages.length >= msgThreshold) {
return {
shouldCompress: true,
reason: `消息条数触发: ${messages.length} >= ${msgThreshold}`,
urgency: 'low',
};
}
return { shouldCompress: false, reason: '', urgency: 'low' };
}
/** R98: 消息条数阈值(独立函数,供 engine 复用) */
function getIncrementalCompressThresholdMessages(numCtx: number): number {
const tokenThreshold = Math.floor(numCtx * 0.3);
return Math.max(20, Math.min(120, Math.floor(tokenThreshold / 100)));
}
// ═══════════════════════════════════════════════════════════════
// R100: Token 使用统计报告 — 生成详细消耗分析
// ═══════════════════════════════════════════════════════════════
export interface TokenReport {
generatedAt: number;
session: {
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
loopCount: number;
avgInputPerLoop: number;
avgOutputPerLoop: number;
};
budget: {
numCtx: number;
currentUtilization: number;
peakUtilization: number;
trend: 'increasing' | 'stable' | 'decreasing';
};
compression: {
historyCount: number;
avgCompressionRatio: number;
lastCompressionRatio: number | null;
};
warnings: string[];
}
/**
* R100: 生成 Token 使用统计报告
*/
export function generateTokenReport(numCtx: number): TokenReport {
const budget = getTokenBudgetStatus();
const compressionHistory = getCompressionHistory();
// 计算峰值利用率
let peakUtilization = 0;
for (const entry of budget.history) {
const util = numCtx > 0 ? entry.inputTokens / numCtx : 0;
if (util > peakUtilization) peakUtilization = util;
}
const warnings: string[] = [];
if (budget.budgetUtilization > 0.7) {
warnings.push(`当前轮 token 使用率过高: ${(budget.budgetUtilization * 100).toFixed(0)}%`);
}
if (budget.trend === 'increasing' && budget.avgInputPerLoop > numCtx * 0.3) {
warnings.push(`token 消耗趋势上升,平均每轮 ${budget.avgInputPerLoop} tokens`);
}
if (compressionHistory.length > 0) {
const avgRatio = getAverageCompressionRatio();
if (avgRatio > 0.8) {
warnings.push(`压缩效率偏低: 平均压缩率 ${(avgRatio * 100).toFixed(0)}% (越低越好)`);
}
}
return {
generatedAt: Date.now(),
session: {
totalInputTokens: budget.totalInput,
totalOutputTokens: budget.totalOutput,
totalTokens: budget.totalSpent,
loopCount: budget.history.length,
avgInputPerLoop: budget.avgInputPerLoop,
avgOutputPerLoop: budget.avgOutputPerLoop,
},
budget: {
numCtx,
currentUtilization: budget.budgetUtilization,
peakUtilization,
trend: budget.trend,
},
compression: {
historyCount: compressionHistory.length,
avgCompressionRatio: getAverageCompressionRatio(),
lastCompressionRatio: compressionHistory.length > 0
? compressionHistory[compressionHistory.length - 1].compressionRatio
: null,
},
warnings,
};
}
/** R100: 格式化 Token 报告为可读字符串 */
export function formatTokenReport(report: TokenReport): string {
const lines: string[] = [
`Token Usage Report (${new Date(report.generatedAt).toLocaleTimeString()})`,
`${'─'.repeat(50)}`,
`Session:`,
` Total Input: ${report.session.totalInputTokens.toLocaleString()} tokens`,
` Total Output: ${report.session.totalOutputTokens.toLocaleString()} tokens`,
` Total Spent: ${report.session.totalTokens.toLocaleString()} tokens`,
` Loops: ${report.session.loopCount}`,
` Avg In/Loop: ${report.session.avgInputPerLoop.toLocaleString()} tokens`,
` Avg Out/Loop: ${report.session.avgOutputPerLoop.toLocaleString()} tokens`,
`Budget:`,
` numCtx: ${report.budget.numCtx.toLocaleString()}`,
` Current Usage: ${(report.budget.currentUtilization * 100).toFixed(1)}%`,
` Peak Usage: ${(report.budget.peakUtilization * 100).toFixed(1)}%`,
` Trend: ${report.budget.trend}`,
`Compression:`,
` History Count: ${report.compression.historyCount}`,
` Avg Ratio: ${(report.compression.avgCompressionRatio * 100).toFixed(0)}%`,
` Last Ratio: ${report.compression.lastCompressionRatio !== null ? (report.compression.lastCompressionRatio * 100).toFixed(0) + '%' : 'N/A'}`,
];
if (report.warnings.length > 0) {
lines.push(`Warnings:`);
for (const w of report.warnings) {
lines.push(` ⚠️ ${w}`);
}
}
return lines.join('\n');
}
// ═══════════════════════════════════════════════════════════════
// R111: 压缩策略自适应选择 — 根据上下文特征选择快速压缩或 LLM 压缩
// ═══════════════════════════════════════════════════════════════
export type CompressionStrategy = 'skip' | 'fast' | 'medium' | 'llm';
export interface CompressionDecision {
strategy: CompressionStrategy;
reason: string;
estimatedSavings: number; // 预估节省 token 数
}
/** R111: 根据上下文压力和消息特征选择最优压缩策略 */
export function chooseCompressionStrategy(
messages: OllamaMessage[],
numCtx: number,
pressureLevel: string
): CompressionDecision {
const totalTokens = estimateTokens(messages.map(m => m.content || '').join(''));
const usageRatio = numCtx > 0 ? totalTokens / numCtx : 0;
// R120: 如果上下文压力很低且消息不多,跳过压缩
if (pressureLevel === 'low' && messages.length < 30) {
return {
strategy: 'skip',
reason: `上下文压力低 (${messages.length} 条消息, ${(usageRatio * 100).toFixed(0)}%),无需压缩`,
estimatedSavings: 0,
};
}
// 统计工具结果消息占比
const toolMsgs = messages.filter(m => m.role === 'tool');
const toolRatio = messages.length > 0 ? toolMsgs.length / messages.length : 0;
// 如果工具结果占比高,使用快速压缩(截断+归档)
if (toolRatio > 0.4 && pressureLevel !== 'critical') {
const savings = Math.floor(totalTokens * 0.3);
return {
strategy: 'fast',
reason: `工具结果占比高 (${(toolRatio * 100).toFixed(0)}%),使用快速截断压缩`,
estimatedSavings: savings,
};
}
// 中等压力:中等压缩(消息合并+旧消息裁剪)
if (pressureLevel === 'medium' || pressureLevel === 'high') {
const savings = Math.floor(totalTokens * 0.4);
return {
strategy: 'medium',
reason: `中等压力 (${pressureLevel}),使用消息合并+裁剪`,
estimatedSavings: savings,
};
}
// 关键压力或高使用率:使用 LLM 摘要压缩
if (pressureLevel === 'critical' || usageRatio > 0.75) {
const savings = Math.floor(totalTokens * 0.6);
return {
strategy: 'llm',
reason: `高压力 (${pressureLevel}, ${(usageRatio * 100).toFixed(0)}%),使用 LLM 摘要压缩`,
estimatedSavings: savings,
};
}
// 默认:快速压缩
return {
strategy: 'fast',
reason: '默认快速压缩',
estimatedSavings: Math.floor(totalTokens * 0.2),
};
}
// ═══════════════════════════════════════════════════════════════
// R115: 上下文水印 — 标记不可压缩的关键信息
// ═══════════════════════════════════════════════════════════════
/** 水印标记:带有此标记的消息在压缩时会被保留 */
const WATERMARK_PREFIX = '[PRESERVE]';
const _watermarkedIndices = new Set<number>();
/** R115: 标记消息为不可压缩 */
export function watermarkMessage(index: number): void {
_watermarkedIndices.add(index);
}
/** R115: 检查消息是否被水印保护 */
export function isWatermarked(index: number): boolean {
return _watermarkedIndices.has(index);
}
/** R115: 自动为关键消息添加水印 */
export function autoWatermarkCritical(messages: OllamaMessage[]): number[] {
const protectedIndices: number[] = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const content = msg.content || '';
// 系统消息始终保护
if (msg.role === 'system') {
watermarkMessage(i);
protectedIndices.push(i);
continue;
}
// 包含错误信息的用户消息保护
if (msg.role === 'user' && (content.includes('错误') || content.includes('error') || content.includes('失败'))) {
watermarkMessage(i);
protectedIndices.push(i);
continue;
}
// 最近 5 条消息保护
if (i >= messages.length - 5) {
watermarkMessage(i);
protectedIndices.push(i);
}
}
return protectedIndices;
}
/** R115: 清除水印 */
export function clearWatermarks(): void {
_watermarkedIndices.clear();
}
/** R115: 获取受保护的消息索引列表 */
export function getWatermarkedIndices(): number[] {
return Array.from(_watermarkedIndices).sort((a, b) => a - b);
}
// ═══════════════════════════════════════════════════════════════
// R120: 上下文压缩跳过逻辑 — 不值得压缩时跳过
// ═══════════════════════════════════════════════════════════════
/** R120: 判断是否应该跳过压缩 */
export function shouldSkipCompression(
messages: OllamaMessage[],
numCtx: number,
recentCompressionRatio: number
): { skip: boolean; reason: string } {
const totalTokens = estimateTokens(messages.map(m => m.content || '').join(''));
const usageRatio = numCtx > 0 ? totalTokens / numCtx : 0;
// 如果使用率很低,跳过
if (usageRatio < 0.2) {
return { skip: true, reason: `上下文使用率极低 (${(usageRatio * 100).toFixed(0)}%),无需压缩` };
}
// 如果消息数太少,跳过
if (messages.length < 10) {
return { skip: true, reason: `消息数过少 (${messages.length} 条),无需压缩` };
}
// 如果最近压缩收益很低(压缩比 < 10%),跳过
if (recentCompressionRatio > 0.9) {
return { skip: true, reason: `最近压缩收益低 (压缩比 ${(recentCompressionRatio * 100).toFixed(0)}%),跳过` };
}
// 如果大部分消息已经被归档/压缩过,跳过
const archivedCount = messages.filter(m =>
m.content?.includes('[工具结果已归档]') || m.content?.includes('[PRESERVE]')
).length;
if (archivedCount / messages.length > 0.6) {
return { skip: true, reason: `大部分消息已归档 (${(archivedCount / messages.length * 100).toFixed(0)}%),跳过` };
}
return { skip: false, reason: '' };
}
// ═══════════════════════════════════════════════════════════════
// R121: 滑动窗口自适应大小 — 根据上下文压力动态调整窗口大小
// ═══════════════════════════════════════════════════════════════
/** R121: 根据上下文压力获取自适应滑动窗口大小 */
export function getAdaptiveWindowSize(
totalMessages: number,
pressureLevel: string,
numCtx: number
): { keepRecent: number; keepSystem: number; reason: string } {
const baseWindow = Math.min(totalMessages, 40);
switch (pressureLevel) {
case 'critical':
return {
keepRecent: Math.min(baseWindow, 15),
keepSystem: 2,
reason: '关键压力:保留最近 15 条 + 系统 2 条',
};
case 'high':
return {
keepRecent: Math.min(baseWindow, 25),
keepSystem: 3,
reason: '高压力:保留最近 25 条 + 系统 3 条',
};
case 'medium':
return {
keepRecent: Math.min(baseWindow, 35),
keepSystem: 5,
reason: '中等压力:保留最近 35 条 + 系统 5 条',
};
case 'low':
default:
return {
keepRecent: Math.min(baseWindow, 50),
keepSystem: 5,
reason: '低压力:保留最近 50 条 + 系统 5 条',
};
}
}
// ═══════════════════════════════════════════════════════════════
// R122: Token 趋势分析 — 深度分析 token 使用趋势用于预测性压缩
// ═══════════════════════════════════════════════════════════════
export interface TrendAnalysis {
trend: 'increasing' | 'decreasing' | 'stable';
avgGrowthRate: number; // 每轮平均 token 增长量
projectedOverflow: number; // 预计几轮后溢出(-1=不会)
recommendedAction: string;
confidence: number; // 0-1
}
/** R122: 分析 token 使用趋势 */
export function analyzeTokenTrend(numCtx: number): TrendAnalysis {
if (_tokenUsageTrend.length < 3) {
return {
trend: 'stable',
avgGrowthRate: 0,
projectedOverflow: -1,
recommendedAction: '数据不足,暂不推荐操作',
confidence: 0,
};
}
const points = _tokenUsageTrend;
const n = points.length;
// 计算平均增长率
let totalGrowth = 0;
let growthCount = 0;
for (let i = 1; i < n; i++) {
const growth = points[i].tokens - points[i - 1].tokens;
totalGrowth += growth;
growthCount++;
}
const avgGrowthRate = growthCount > 0 ? totalGrowth / growthCount : 0;
// 线性回归确定趋势
const xs = points.map(p => p.turn);
const ys = points.map(p => p.tokens);
const xMean = xs.reduce((s, x) => s + x, 0) / n;
const yMean = ys.reduce((s, y) => s + y, 0) / n;
let num = 0, den = 0;
for (let i = 0; i < n; i++) {
num += (xs[i] - xMean) * (ys[i] - yMean);
den += (xs[i] - xMean) ** 2;
}
const slope = den !== 0 ? num / den : 0;
// 判断趋势
let trend: TrendAnalysis['trend'];
if (slope > 100) trend = 'increasing';
else if (slope < -50) trend = 'decreasing';
else trend = 'stable';
// 预测溢出
let projectedOverflow = -1;
if (slope > 0) {
const currentTokens = points[n - 1].tokens;
const remaining = numCtx - currentTokens;
projectedOverflow = Math.ceil(remaining / slope);
if (projectedOverflow < 0) projectedOverflow = 0;
}
// 推荐操作
let recommendedAction = '';
if (trend === 'increasing' && projectedOverflow >= 0 && projectedOverflow <= 5) {
recommendedAction = `⚠️ 预计 ${projectedOverflow} 轮后上下文溢出,建议立即压缩`;
} else if (trend === 'increasing' && projectedOverflow > 5 && projectedOverflow <= 10) {
recommendedAction = `建议在接下来 2-3 轮内进行压缩(${projectedOverflow} 轮后溢出)`;
} else if (trend === 'stable') {
recommendedAction = 'Token 使用趋势稳定,无需额外操作';
} else if (trend === 'decreasing') {
recommendedAction = 'Token 使用量在下降,压缩策略生效';
}
// 置信度:基于数据点数量和趋势一致性
let confidence = Math.min(1, n / 10);
if (trend === 'stable') confidence *= 0.7;
return {
trend,
avgGrowthRate: Math.round(avgGrowthRate),
projectedOverflow,
recommendedAction,
confidence,
};
}
// ═══════════════════════════════════════════════════════════════
// R123: 会话摘要持久化 — 跨会话引用
// ═══════════════════════════════════════════════════════════════
export interface SessionSummary {
id: string;
createdAt: number;
goal: string;
summary: string;
toolsUsed: string[];
keyFindings: string[];
tokenUsage: number;
}
const SESSION_SUMMARY_KEY = 'metona_session_summaries';
const MAX_SESSION_SUMMARIES = 10;
/** R123: 保存会话摘要到 localStorage */
export function saveSessionSummary(summary: SessionSummary): void {
try {
const existing = loadSessionSummaries();
existing.unshift(summary);
if (existing.length > MAX_SESSION_SUMMARIES) {
existing.length = MAX_SESSION_SUMMARIES;
}
localStorage.setItem(SESSION_SUMMARY_KEY, JSON.stringify(existing));
logInfo(`R123: 会话摘要已保存 (${summary.id})`);
} catch (err) {
logWarn(`R123: 保存会话摘要失败: ${(err as Error).message}`);
}
}
/** R123: 加载所有会话摘要 */
export function loadSessionSummaries(): SessionSummary[] {
try {
const raw = localStorage.getItem(SESSION_SUMMARY_KEY);
if (!raw) return [];
return JSON.parse(raw) as SessionSummary[];
} catch {
return [];
}
}
/** R123: 生成当前会话摘要 */
export function generateSessionSummary(
goal: string,
messages: OllamaMessage[],
toolRecords: Array<{ name: string }>,
totalTokens: number
): SessionSummary {
const toolsUsed = [...new Set(toolRecords.map(t => t.name))];
const assistantMessages = messages.filter(m => m.role === 'assistant');
const lastAssistant = assistantMessages[assistantMessages.length - 1];
return {
id: `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
createdAt: Date.now(),
goal: goal.slice(0, 200),
summary: (lastAssistant?.content || '').slice(0, 500),
toolsUsed,
keyFindings: [],
tokenUsage: totalTokens,
};
}
/** R123: 格式化历史会话摘要供注入 */
export function formatSessionSummariesForContext(summaries: SessionSummary[]): string {
if (summaries.length === 0) return '';
const lines = ['[历史会话参考]', ''];
for (const s of summaries.slice(0, 3)) {
const date = new Date(s.createdAt).toLocaleDateString();
lines.push(`- ${date}: 目标="${s.goal.slice(0, 60)}..." | 工具=[${s.toolsUsed.join(', ')}] | 结果=${s.summary.slice(0, 100)}...`);
}
return lines.join('\n');
}
// ═══════════════════════════════════════════════════════════════
// R125: Agent 状态检查点 — 保存和恢复 Agent 状态
// ═══════════════════════════════════════════════════════════════
export interface AgentCheckpoint {
id: string;
timestamp: number;
loopCount: number;
state: string;
messagesSnapshot: OllamaMessage[];
toolRecordsCount: number;
goal: string;
}
const _checkpoints: AgentCheckpoint[] = [];
const MAX_CHECKPOINTS = 5;
/** R125: 创建状态检查点 */
export function createCheckpoint(
loopCount: number,
agentState: string,
messages: OllamaMessage[],
toolRecordsCount: number,
goal: string
): AgentCheckpoint {
const checkpoint: AgentCheckpoint = {
id: `cp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
timestamp: Date.now(),
loopCount,
state: agentState,
messagesSnapshot: messages.map(m => ({ ...m })),
toolRecordsCount,
goal,
};
_checkpoints.push(checkpoint);
if (_checkpoints.length > MAX_CHECKPOINTS) {
_checkpoints.shift();
}
logInfo(`R125: 检查点已创建 (loop=${loopCount}, state=${agentState})`);
return checkpoint;
}
/** R125: 获取最近的检查点 */
export function getLatestCheckpoint(): AgentCheckpoint | null {
return _checkpoints.length > 0 ? _checkpoints[_checkpoints.length - 1] : null;
}
/** R125: 恢复到指定检查点 */
export function restoreCheckpoint(id: string): AgentCheckpoint | null {
const cp = _checkpoints.find(c => c.id === id);
if (!cp) {
logWarn(`R125: 检查点 ${id} 不存在`);
return null;
}
logInfo(`R125: 恢复到检查点 ${id} (loop=${cp.loopCount})`);
return cp;
}
/** R125: 获取所有检查点 */
export function getAllCheckpoints(): AgentCheckpoint[] {
return [..._checkpoints];
}
/** R125: 清除所有检查点 */
export function clearCheckpoints(): void {
_checkpoints.length = 0;
}
// ═══════════════════════════════════════════════════════════════
// R126: 上下文预算分配 — 按消息类型分配上下文 token 预算
// ═══════════════════════════════════════════════════════════════
export interface ContextBudgetAllocation {
system: number; // 系统消息预算
user: number; // 用户消息预算
assistant: number; // 助手消息预算
tool: number; // 工具结果预算
memory: number; // 记忆注入预算
total: number; // 总预算
}
/** R126: 默认预算分配比例 */
const DEFAULT_BUDGET_RATIOS = {
system: 0.05, // 5%
user: 0.15, // 15%
assistant: 0.25, // 25%
tool: 0.45, // 45%
memory: 0.10, // 10%
};
/** R126: 根据消息分布动态调整预算分配 */
export function allocateContextBudget(
messages: OllamaMessage[],
numCtx: number
): ContextBudgetAllocation {
const total = numCtx;
// 统计各类型消息当前占比
const counts = { system: 0, user: 0, assistant: 0, tool: 0 };
let memorySize = 0;
for (const msg of messages) {
if (msg.role in counts) {
counts[msg.role as keyof typeof counts]++;
}
if (msg.content?.includes('[记忆注入]')) {
memorySize += estimateTokens(msg.content);
}
}
const totalMsgs = messages.length || 1;
// 动态调整:如果工具结果占比过高,增加工具预算
const toolRatio = counts.tool / totalMsgs;
const ratios = { ...DEFAULT_BUDGET_RATIOS };
if (toolRatio > 0.5) {
// 工具结果过多,从助手预算中转移一部分给工具
const shift = Math.min(0.1, (toolRatio - 0.5) * 0.3);
ratios.assistant -= shift;
ratios.tool += shift;
}
// 如果记忆注入很大,增加记忆预算
if (memorySize > numCtx * 0.1) {
const shift = Math.min(0.05, (memorySize / numCtx - 0.1) * 0.2);
ratios.tool -= shift;
ratios.memory += shift;
}
return {
system: Math.floor(total * ratios.system),
user: Math.floor(total * ratios.user),
assistant: Math.floor(total * ratios.assistant),
tool: Math.floor(total * ratios.tool),
memory: Math.floor(total * ratios.memory),
total,
};
}
/** R126: 检查消息是否超出预算 */
export function checkBudgetOverflow(
messages: OllamaMessage[],
budget: ContextBudgetAllocation
): { role: string; current: number; budget: number; overflow: number }[] {
const tokensByRole: Record<string, number> = {};
for (const msg of messages) {
tokensByRole[msg.role] = (tokensByRole[msg.role] || 0) + estimateTokens(msg.content || '');
}
const overflows: { role: string; current: number; budget: number; overflow: number }[] = [];
const budgetMap: Record<string, number> = {
system: budget.system,
user: budget.user,
assistant: budget.assistant,
tool: budget.tool,
};
for (const [role, current] of Object.entries(tokensByRole)) {
const bud = budgetMap[role] || Infinity;
if (current > bud) {
overflows.push({ role, current, budget: bud, overflow: current - bud });
}
}
return overflows;
}