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
+382 -71
View File
@@ -9,19 +9,65 @@ import { state, KEYS } from '../state/state.js';
import {
executeTool,
getEnabledToolDefinitions,
getRelevantToolDefinitions,
needsConfirmation,
initPlanTracker,
getPlanTracker,
formatPlanStatus,
clearPlanTracker,
} from './tool-registry.js';
import {
compactOldToolResult,
detectOscillation,
recordToolCallHistory,
detectConsecutiveIdentical,
setUserGoal,
checkGoalAlignment,
resetAllSafetyState,
checkRateLimit,
classifyError,
calculateBackoff,
validatePathSandbox,
isToolCircuitBroken,
recordToolFailure,
recordToolSuccess,
// R88: 工具结果元数据
addResultMetadata,
// R97: 错误模式学习
recordErrorPattern,
// R104: 工具结果去重
isDuplicateToolResult,
// R109: 工具参数消毒
sanitizeToolArgs,
// R112: 诊断系统
collectDiagnostics,
// R113: 命令安全检查
checkCommandSafety,
// R95: 按工具类型智能截断
smartTruncateByToolType,
// R99: 工具结果引用解析
checkArchivedReferences,
} from './agent-safety.js';
import { search, formatMemoryContext, loadAllEntries } from './memory-service.js';
import { showToast } from '../components/toast.js';
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
import { generateId } from '../utils/utils.js';
import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD, recordActualTokens } from './context-manager.js';
import {
buildContext, estimateTokens, shouldAutoCompress, compressWithLLM,
AUTO_COMPRESS_THRESHOLD, recordActualTokens, predictContextOverflow, recordTokenUsage,
// R91: 上下文压力分级评估
getContextPressureLevel,
// R93: Token 预算追踪器
recordBudgetUsage, setTokenBudgetNumCtx, resetTokenBudget,
// R96: 消息角色压缩
mergeConsecutiveMessages,
// R98: 压缩触发阈值优化
getTrendAwareCompressThreshold,
// R100: Token 使用统计报告
generateTokenReport, formatTokenReport,
} from './context-manager.js';
import { runCompletionGate } from './completion-gate.js';
import { executeHooks } from './hooks.js';
import { recordIteration, recordToolCall, recordCompletionGate, startSessionMetrics, endSessionMetrics } from './agent-metrics.js';
@@ -43,6 +89,12 @@ const MAX_MESSAGES = 300; // 上下文硬上限,超过则强制压
const API_MAX_RETRIES = 2; // Ollama API 调用失败重试次数
const MAX_PARALLEL_TOOLS = 5; // R6: 单批次最大并行工具数
// R51-R54: 工具结果离线存储、震荡检测、死循环检测已迁移至 agent-safety.ts
// R56: 目标对齐验证也已迁移至 agent-safety.ts
// R55: 语义工具检索 — 缓存当前轮过滤后的工具定义,避免 handleThinking 重复计算
let _filteredTools: import('../types.js').ToolDefinition[] = [];
/**
* 计算增量压缩触发阈值(按 token 数而非消息条数)
* 当上下文 token 占比超过 numCtx 的 30% 时触发压缩
@@ -145,45 +197,91 @@ const SIDE_EFFECT_TOOLS = new Set([
* 防止单个工具调用挂起导致整个 Agent Loop 卡死
* 0 = 不限制(仅依赖全局 AbortController
*/
const TOOL_TIMEOUT_MAP: Record<string, number> = {
read_file: 30_000, // 读取文件 30s
write_file: 15_000, // 写入文件 15s
edit_file: 15_000, // 编辑文件 15s
list_directory: 10_000, // 列目录 10s
search_files: 60_000, // 搜索文件 60s
create_directory: 5_000, // 创建目录 5s
delete_file: 10_000, // 删除文件 10s
move_file: 15_000, // 移动文件 15s
copy_file: 30_000, // 复制文件 30s(可能大文件)
get_file_info: 5_000, // 获取文件信息 5s
tree: 15_000, // 目录树 15s
diff_files: 15_000, // 文件差异 15s
replace_in_files: 30_000, // 批量替换 30s
read_multiple_files: 60_000,// 批量读取 60s
web_search: 30_000, // 网页搜索 30s
web_fetch: 60_000, // 网页抓取 60s
download_file: 120_000, // 下载文件 120s
git: 30_000, // Git 操作 30s
compress: 60_000, // 压缩 60s
memory: 5_000, // 记忆操作 5s
session_list: 5_000, // 会话列表 5s
session_read: 10_000, // 会话读取 10s
datetime: 1_000, // 时间查询 1s
calculator: 3_000, // 计算器 3s
random: 1_000, // 随机数 1s
uuid: 1_000, // UUID 1s
json_format: 3_000, // JSON 格式化 3s
hash: 3_000, // 哈希计算 3s
default: 60_000, // 默认 60s
/** R3/R108: 工具超时配置(基础超时 + 动态调整) */
const TOOL_TIMEOUT_MAP: Record<string, { base: number; grade: 'fast' | 'medium' | 'slow' | 'long'; description: string }> = {
// fast (<5秒)
datetime: { base: 1_000, grade: 'fast', description: '时间查询' },
random: { base: 1_000, grade: 'fast', description: '随机数' },
uuid: { base: 1_000, grade: 'fast', description: 'UUID' },
calculator: { base: 3_000, grade: 'fast', description: '计算器' },
json_format: { base: 3_000, grade: 'fast', description: 'JSON 格式化' },
hash: { base: 3_000, grade: 'fast', description: '哈希计算' },
create_directory: { base: 5_000, grade: 'fast', description: '创建目录' },
get_file_info: { base: 5_000, grade: 'fast', description: '获取文件信息' },
memory: { base: 5_000, grade: 'fast', description: '记忆操作' },
session_list: { base: 5_000, grade: 'fast', description: '会话列表' },
// medium (5-15秒)
list_directory: { base: 10_000, grade: 'medium', description: '列目录' },
delete_file: { base: 10_000, grade: 'medium', description: '删除文件' },
write_file: { base: 15_000, grade: 'medium', description: '写入文件' },
edit_file: { base: 15_000, grade: 'medium', description: '编辑文件' },
move_file: { base: 15_000, grade: 'medium', description: '移动文件' },
tree: { base: 15_000, grade: 'medium', description: '目录树' },
diff_files: { base: 15_000, grade: 'medium', description: '文件差异' },
session_read: { base: 10_000, grade: 'medium', description: '会话读取' },
web_search: { base: 30_000, grade: 'medium', description: '网页搜索' },
git: { base: 30_000, grade: 'medium', description: 'Git 操作' },
// slow (15-60秒)
read_file: { base: 30_000, grade: 'slow', description: '读取文件' },
copy_file: { base: 30_000, grade: 'slow', description: '复制文件' },
replace_in_files: { base: 30_000, grade: 'slow', description: '批量替换' },
search_files: { base: 60_000, grade: 'slow', description: '搜索文件' },
read_multiple_files: { base: 60_000, grade: 'slow', description: '批量读取' },
web_fetch: { base: 60_000, grade: 'slow', description: '网页抓取' },
compress: { base: 60_000, grade: 'slow', description: '压缩' },
// long (>60秒)
download_file: { base: 120_000, grade: 'long', description: '下载文件' },
default: { base: 60_000, grade: 'medium', description: '默认工具' },
};
/** R108: 根据工具参数动态调整超时时间 */
function getAdjustedToolTimeout(toolName: string, args: Record<string, unknown>): number {
const config = TOOL_TIMEOUT_MAP[toolName] ?? TOOL_TIMEOUT_MAP.default;
let timeout = config.base;
// run_command 特殊处理:根据命令类型调整
if (toolName === 'run_command' && args.command) {
const cmd = String(args.command);
if (cmd.includes('npm install') || cmd.includes('pip install') || cmd.includes('apt-get install')) {
timeout = 300_000; // 包安装:5分钟
} else if (cmd.includes('build') || cmd.includes('compile') || cmd.includes('make')) {
timeout = 180_000; // 编译:3分钟
} else if (cmd.includes('test')) {
timeout = 120_000; // 测试:2分钟
} else {
timeout = 120_000; // 默认命令:2分钟
}
}
// web_fetch 特殊处理:视频/大型资源
if (toolName === 'web_fetch' && args.url) {
const url = String(args.url);
if (url.includes('youtube') || url.includes('video') || url.includes('mp4')) {
timeout = 90_000;
}
}
// write_file 特殊处理:大文件
if (toolName === 'write_file' && args.content) {
const contentLen = String(args.content).length;
if (contentLen > 100_000) {
timeout = 30_000; // 大文件写入
}
}
return timeout;
}
/** R3: 带超时的工具执行包装器 */
async function executeToolWithTimeout(
toolName: string,
args: Record<string, unknown>,
abortSignal?: AbortSignal,
): Promise<ToolResult> {
const timeoutMs = TOOL_TIMEOUT_MAP[toolName] ?? TOOL_TIMEOUT_MAP.default;
const timeoutMs = getAdjustedToolTimeout(toolName, args);
if (timeoutMs <= 0) {
return executeTool(toolName, args);
}
@@ -1084,9 +1182,15 @@ async function handleInit(
): Promise<void> {
// 新一轮对话,清空工具缓存
toolResultCache.clear();
resetAllSafetyState(); // R51-R56: 重置所有安全状态
resetTokenBudget(); // R93: 重置 Token 预算追踪
setTokenBudgetNumCtx(getEffectiveNumCtx()); // R93: 设置当前预算 numCtx
setUserGoal(userContent); // R56: 设置用户原始目标
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
const tools = getEnabledToolDefinitions();
// R55: 语义工具检索 — 根据用户查询过滤相关工具,减少 token 占用
const tools = getRelevantToolDefinitions(userContent);
_filteredTools = tools; // 缓存供 handleThinking 使用
const useTools = tools.length > 0;
ctx.messages.length = 0;
@@ -1568,7 +1672,7 @@ async function handleThinking(
num_ctx: getEffectiveNumCtx(),
temperature: state.get<number>('temperature', 0.7)
},
...(getEnabledToolDefinitions().length > 0 && { tools: getEnabledToolDefinitions() })
...(_filteredTools.length > 0 && { tools: _filteredTools })
},
(chunk: OllamaStreamChunk) => {
if (chunk.message?.thinking) {
@@ -1710,6 +1814,8 @@ async function handleThinking(
if (estimatedThisLoop > 0) {
recordActualTokens(ctx.loopPromptEvalCount, ctx.loopEvalCount, estimatedThisLoop, model);
}
// R93: 记录 Token 预算使用
recordBudgetUsage(ctx.loopCount, ctx.loopPromptEvalCount, ctx.loopEvalCount, estimatedThisLoop);
}
} catch { /* ignore */ }
@@ -1845,6 +1951,25 @@ async function handleExecuting(
toolResultCache.delete(cacheKey);
}
// R109: 工具参数消毒 — 防止通过工具参数注入恶意内容
call.function.arguments = sanitizeToolArgs(call.function.name, call.function.arguments);
// R113: 命令安全检查 — 对 run_command 进行风险评估
if (call.function.name === 'run_command') {
const cmdStr = String(call.function.arguments?.command || '');
if (cmdStr) {
const cmdSafety = checkCommandSafety(cmdStr);
if (cmdSafety.riskLevel === 'forbidden') {
logWarn(`R113: 命令安全拦截: ${cmdSafety.reason}`);
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: cmdSafety.reason || '命令被安全规则拦截' },
status: 'error' as const, timestamp: Date.now()
}, null];
}
}
}
// P2-4: 工具参数前置校验 — 参数无效直接返回错误,不执行实际工具,节省一轮迭代
const paramError = validateToolArgs(call.function.name, call.function.arguments);
if (paramError) {
@@ -1856,6 +1981,62 @@ async function handleExecuting(
}, null];
}
// R77: 工具调用速率限制 — 防止快速连续调用同一工具浪费资源
const rateLimitResult = checkRateLimit(call.function.name);
if (!rateLimitResult.allowed) {
const waitSec = Math.ceil(rateLimitResult.retryAfterMs / 1000);
logWarn(`R77: 速率限制拦截: ${call.function.name},需等待 ${waitSec}s`);
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: `工具「${call.function.name}」调用频率过高,请等待 ${waitSec} 秒后重试。` },
status: 'error' as const, timestamp: Date.now()
}, null];
}
// R87: 工具熔断器检查 — 连续失败后自动禁用工具
const circuitBreaker = isToolCircuitBroken(call.function.name);
if (circuitBreaker.broken) {
const waitSec = Math.ceil(circuitBreaker.remainingMs / 1000);
logWarn(`R87: 熔断器拦截: ${call.function.name},冷却中(还需 ${waitSec}s`);
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: `工具「${call.function.name}」因连续失败已被暂时禁用,请等待 ${waitSec} 秒后重试,或使用其他方法。` },
status: 'error' as const, timestamp: Date.now()
}, null];
}
// R79: 路径沙箱验证 — 确保文件操作不超出工作空间边界
const FILE_PATH_TOOLS = new Set([
'read_file', 'write_file', 'edit_file', 'delete_file', 'create_directory',
'list_directory', 'search_files', 'get_file_info', 'tree', 'compress',
'move_file', 'copy_file', 'replace_in_files', 'download_file',
]);
if (FILE_PATH_TOOLS.has(call.function.name)) {
const workspaceDir = getWorkspaceDirPath();
if (workspaceDir) {
const pathArg = String(call.function.arguments?.path || call.function.arguments?.source || call.function.arguments?.destination || call.function.arguments?.file1 || '');
if (pathArg) {
const sandboxResult = validatePathSandbox(pathArg, workspaceDir);
if (!sandboxResult.valid) {
logWarn(`R79: 路径沙箱拦截: ${call.function.name}(${pathArg}) — ${sandboxResult.reason}`);
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: sandboxResult.reason || '路径不在工作空间范围内' },
status: 'error' as const, timestamp: Date.now()
}, null];
}
// R79: 使用标准化路径替换原始路径,防止路径遍历
if (sandboxResult.normalizedPath && sandboxResult.normalizedPath !== pathArg) {
if (call.function.arguments?.path) {
call.function.arguments.path = sandboxResult.normalizedPath;
} else if (call.function.arguments?.source) {
call.function.arguments.source = sandboxResult.normalizedPath;
}
}
}
}
}
// P2-10 修复:queueMicrotask 替代 rAF,在隐藏窗口下不会降频
await new Promise<void>(r => { queueMicrotask(r); });
callbacks.onToolCallStart(call);
@@ -1887,8 +2068,9 @@ async function handleExecuting(
}
}
// ── P0-1 修复:区分瞬态错误和永久错误 ──
// ── R78: 增强错误分类重试 — 使用 classifyError 区分瞬态/永久/安全错误 ──
let lastError = '';
let classifiedError: import('./agent-safety.js').ClassifiedError | null = null;
for (let retry = 0; retry <= MAX_RETRIES; retry++) {
// 重试前检查中止信号
if (isAborted()) {
@@ -1903,6 +2085,12 @@ async function handleExecuting(
state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal)
.catch(err => ({ success: false, error: err?.message || String(err) }) as ToolResult);
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
// R87: 记录工具成功/失败到熔断器
if (result.success) {
recordToolSuccess(call.function.name);
} else {
recordToolFailure(call.function.name);
}
// 记录 write_file 成功路径及内容指纹,供 FileWriteDedup Hook 做内容级去重
if (call.function.name === 'write_file' && result.success && call.function.arguments?.path) {
const { addWrittenFile } = await import('./hooks.js');
@@ -1914,27 +2102,47 @@ async function handleExecuting(
}, result.success ? cacheKey : null];
} catch (err) {
lastError = (err as Error).message;
// 永久性错误不重试:文件不存在、权限拒绝、路径不可达
if (isPermanentError(lastError)) {
logWarn(`工具永久错误(不重试): ${call.function.name}`, lastError);
// R78: 使用错误分类系统替代原有的 isPermanentError
classifiedError = classifyError(lastError);
// 安全错误或永久错误:不重试
if (!classifiedError.shouldRetry) {
logWarn(`R78: 工具${classifiedError.class}错误(不重试): ${call.function.name}`, classifiedError.userMessage);
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: lastError },
result: { success: false, error: classifiedError.userMessage },
status: 'error' as const, timestamp: Date.now()
}, null];
}
// R78: 检查是否超过最大重试次数
if (retry >= (classifiedError.maxRetries || MAX_RETRIES)) {
logError(`R78: 工具执行失败(已达最大重试 ${classifiedError.maxRetries}: ${call.function.name}`, lastError);
// R97: 记录错误模式并获取改进建议
const errorSuggestion = recordErrorPattern(call.function.name, lastError);
if (errorSuggestion) {
logWarn(`R97: ${errorSuggestion}`);
}
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: classifiedError.userMessage },
status: 'error' as const, timestamp: Date.now()
}, null];
}
if (retry < MAX_RETRIES) {
// 瞬态错误:指数退避重试
const delay = 500 * Math.pow(2, retry);
logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}${delay}ms 后)`, lastError);
// R78: 使用 calculateBackoff 计算指数退避延迟
const delay = calculateBackoff(retry, classifiedError.backoffMs);
logWarn(`R78: 工具重试 ${retry + 1}/${classifiedError.maxRetries}: ${call.function.name}${classifiedError.class}错误,${delay}ms 后)`, lastError);
await new Promise(r => setTimeout(r, delay));
continue;
}
logError(`工具执行失败(已达最大重试): ${call.function.name}`, lastError);
logError(`工具执行失败(已达最大重试): ${call.function.name}`, lastError);
// R97: 记录错误模式
recordErrorPattern(call.function.name, lastError);
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: lastError },
status: 'error' as const, timestamp: Date.now()
result: { success: false, error: lastError }, status: 'error' as const, timestamp: Date.now()
}, null];
}
}
@@ -1962,9 +2170,23 @@ async function handleExecuting(
for (const [record, cacheKey] of results) {
ctx.allToolRecords.push(record);
// R88: 为工具结果添加元数据提示(token 估算)
const formattedResult = addResultMetadata(formatToolResultForModel(record.name, record.result!));
// R104: 检测重复工具结果
if (record.status === 'success') {
const dedup = isDuplicateToolResult(record.name, formattedResult);
if (dedup.duplicate) {
logInfo(`R104: 检测到重复工具结果: ${record.name},添加标记`);
}
}
// R92: 工具结果格式标准化 — 添加统一头信息(工具名、状态、耗时、结果大小)
const resultDuration = Date.now() - record.timestamp;
const resultSize = formattedResult.length;
const sizeCategory = resultSize > 10000 ? 'large' : resultSize > 2000 ? 'medium' : 'small';
const r92Header = `[工具:${record.name} 状态:${record.status} 耗时:${resultDuration}ms 大小:${sizeCategory}(${resultSize}字符)]`;
ctx.messages.push({
role: 'tool', tool_name: record.name,
content: `<<<TOOL_RESULT_START name="${record.name}">>>\n${formatToolResultForModel(record.name, record.result!)}\n<<<TOOL_RESULT_END>>>`
content: `<<<TOOL_RESULT_START name="${record.name}">>>\n${r92Header}\n${formattedResult}\n<<<TOOL_RESULT_END>>>`
});
// P1-3 优化:移除逐次 nudge 注入,改为 AGENT.md 通用规则
// 原逻辑:memory/session_read/session_list/spawn_task 后注入额外 user 消息
@@ -2006,13 +2228,15 @@ async function handleObserving(
): Promise<void> {
// 中止检查
if (isAborted()) return;
// 硬限制工具消息数量 — 保留最近 80 条(token感知:压力大时自动缩减)
// R91: 上下文压力分级评估 — 根据压力等级选择压缩策略
const numCtx = getEffectiveNumCtx();
const pressureInfo = getContextPressureLevel(ctx.messages, numCtx);
{
const numCtx = getEffectiveNumCtx();
const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
const pressureRatio = numCtx > 0 ? currentTokens / numCtx : 0;
// 压力小时保留 80 条,压力大时缩减到 40 条
const maxToolMsgs = pressureRatio > 0.5 ? 40 : 80;
// R91: 根据压力等级动态调整工具消息保留数量
const maxToolMsgs = pressureInfo.level === 'critical' ? 30
: pressureInfo.level === 'high' ? 40
: pressureInfo.level === 'medium' ? 60
: 80;
const toolIndices: number[] = [];
for (let i = 0; i < ctx.messages.length; i++) {
if (ctx.messages[i].role === 'tool') toolIndices.push(i);
@@ -2022,7 +2246,7 @@ async function handleObserving(
for (let i = toRemove.length - 1; i >= 0; i--) {
ctx.messages.splice(toRemove[i], 1);
}
logInfo(`工具消息裁剪: ${toRemove.length} 条旧结果已移除 (保留最近 ${maxToolMsgs} 条, 压力 ${(pressureRatio * 100).toFixed(0)}%)`);
logInfo(`R91: 工具消息裁剪: ${toRemove.length} 条旧结果已移除 (压力:${pressureInfo.level}, 保留 ${maxToolMsgs})`);
}
}
@@ -2034,6 +2258,75 @@ async function handleObserving(
// 保存本轮工具调用
ctx.prevToolCalls = [...ctx.toolCalls];
// R52: 状态震荡检测 — 检测 A→B→A→B 模式
for (const call of ctx.toolCalls) {
recordToolCallHistory(call.function.name, call.function.arguments);
}
if (detectOscillation()) {
logWarn('R52: 检测到工具调用震荡模式(A→B→A→B),注入终止信号');
ctx.messages.push({
role: 'user',
content: '⚠️ 检测到你在两种操作之间来回切换但没有进展。请停下来分析当前状态,换一种策略,或基于已有结果给出最终回答。',
ephemeral: true,
});
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 2);
}
// R54: 增强死循环检测 — 连续 3 次完全相同的工具调用(文档标准)
const consecutive = detectConsecutiveIdentical(3);
if (consecutive.detected) {
logWarn(`R54: 检测到连续 ${consecutive.count} 次相同调用: ${consecutive.toolName},强制终止`);
ctx.messages.push({
role: 'user',
content: `⛔ 检测到连续 ${consecutive.count} 次用完全相同参数调用「${consecutive.toolName}」。这已被系统识别为死循环。立即停止重复调用,基于已有结果给出最终回答。`,
});
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 1);
}
// R53: 主动上下文压缩 — 使用预测系统提前触发压缩
{
const numCtx = getEffectiveNumCtx();
const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
recordTokenUsage(currentTokens, numCtx);
const prediction = predictContextOverflow(numCtx);
if (prediction.level === 'critical' || (prediction.level === 'warning' && prediction.turnsToOverflow <= 2)) {
logWarn(`R53: 主动压缩触发 — ${prediction.message}`);
transition(ctx, S.COMPRESSING);
return;
}
}
// R51: 工具结果离线存储 — 将旧的大工具结果精简为引用
if (ctx.loopCount > 5 && ctx.loopCount % 3 === 0) {
const compactBefore = ctx.messages.length - 10; // 保留最近 10 条不动
let compactedCount = 0;
for (let i = 0; i < ctx.messages.length && i < compactBefore; i++) {
const m = ctx.messages[i];
if (m.role === 'tool' && m.content && m.content.length > 1500 && !m.content.startsWith('[工具结果已归档]')) {
ctx.messages[i] = compactOldToolResult(m);
compactedCount++;
}
}
if (compactedCount > 0) {
logInfo(`R51: 工具结果归档: ${compactedCount} 条旧结果已精简为引用`);
}
}
// R56: 目标对齐验证 — 每 8 轮检查 Agent 是否偏离用户原始目标
if (ctx.loopCount > 0 && ctx.loopCount % 8 === 0) {
const recentToolNames = ctx.allToolRecords.slice(-8).map(r => r.name);
const recentContent = ctx.messages.slice(-10).map(m => m.content || '').join(' ').slice(0, 2000);
const alignment = checkGoalAlignment(recentToolNames, recentContent, ctx.loopCount);
if (!alignment.aligned && alignment.suggestion) {
logWarn(`R56: 目标对齐检测 — ${alignment.reason}`);
ctx.messages.push({
role: 'user',
content: `[目标对齐提醒] ${alignment.suggestion}`,
ephemeral: true,
});
}
}
// P2-1: 工具调用失败恢复指引 — 同一工具同一参数连续失败2次以上时注入恢复建议
{
const errorRecords = ctx.allToolRecords.filter(r => r.status === 'error');
@@ -2057,30 +2350,43 @@ async function handleObserving(
}
}
// R112: 每 15 轮输出诊断报告 + R100: Token 使用统计报告
if (ctx.loopCount > 0 && ctx.loopCount % 15 === 0) {
try {
const { collectDiagnostics, formatDiagnosticsReport } = await import('./agent-safety.js');
const diag = collectDiagnostics();
logInfo('R112: 诊断报告\n' + formatDiagnosticsReport(diag));
// R100: Token 使用统计报告
const tokenReport = generateTokenReport(getEffectiveNumCtx());
logInfo('R100: ' + formatTokenReport(tokenReport));
} catch { /* 诊断失败不影响主流程 */ }
}
// 记录本轮迭代度量
recordIteration(ctx);
// ── post_iteration Hook ──
executeHooks('post_iteration', ctx, { iterationIndex: ctx.loopCount });
// ── P1-7: 智能工具结果截断 — 保留 JSON 结构,token 感知软上限 ──
// 仅在上下文压力较大时触发(token使用率 > 50%),且截断阈值按当前压力动态调整
// R95: 按工具类型智能截断 — 根据压力等级和工具类型选择截断策略
if (ctx.loopCount > 10 && ctx.loopCount % 5 === 0) {
const numCtx = getEffectiveNumCtx();
const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
const pressureRatio = numCtx > 0 ? currentTokens / numCtx : 0;
if (pressureRatio > 0.5) {
if (pressureInfo.level === 'high' || pressureInfo.level === 'critical') {
// 上下文压力大时,截断旧的工具结果(保留最近15条不动)
// 截断阈值按压力动态调整50%压力=8000字符,80%压力=3000字符
const maxLen = Math.max(2000, Math.floor(8000 - (pressureRatio - 0.5) * 10000));
// R91: 截断阈值按压力等级动态调整
const maxLen = pressureInfo.level === 'critical' ? 3000 : 5000;
const truncateBefore = ctx.messages.length - 15;
let truncatedCount = 0;
for (let i = 0; i < ctx.messages.length && i < truncateBefore; i++) {
const m = ctx.messages[i];
if (m.role === 'tool' && m.content && m.content.length > maxLen) {
ctx.messages[i] = { ...m, content: smartTruncate(m.content, maxLen) };
// R95: 使用按工具类型截断替代通用截断
ctx.messages[i] = { ...m, content: smartTruncateByToolType(m.tool_name || 'unknown', m.content, maxLen) };
truncatedCount++;
}
}
logInfo(`工具结果截断: 压力 ${(pressureRatio * 100).toFixed(0)}%, 阈值 ${maxLen} 字符`);
if (truncatedCount > 0) {
logInfo(`R95: 工具结果按类型截断: ${truncatedCount} 条, 压力 ${pressureInfo.level}, 阈值 ${maxLen} 字符`);
}
}
}
@@ -2139,14 +2445,15 @@ async function handleObserving(
}
}
// ── P0-4: 增量压缩 — 按token使用率触发,防止 Ollama context 超限 ──
const numCtx = getEffectiveNumCtx();
const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
const tokenUsageRatio = numCtx > 0 ? currentTokens / numCtx : 0;
// 当 token 使用率超过 30% 或消息数超过动态阈值时触发压缩
const compressThreshold = getIncrementalCompressThreshold(numCtx);
if (tokenUsageRatio > 0.3 || ctx.messages.length >= compressThreshold) {
logWarn(`增量压缩触发: token使用率 ${(tokenUsageRatio * 100).toFixed(0)}%, 消息数 ${ctx.messages.length}/${compressThreshold}`);
// R96: 消息角色压缩 — 合并连续相同角色消息,减少消息条数开销
if (pressureInfo.level === 'medium' || pressureInfo.level === 'high') {
ctx.messages = mergeConsecutiveMessages(ctx.messages);
}
// R98: 趋势感知压缩触发 — 结合趋势预测动态调整压缩阈值
const compressDecision = getTrendAwareCompressThreshold(numCtx, ctx.messages);
if (compressDecision.shouldCompress) {
logWarn(`R98: 压缩触发 (${compressDecision.urgency}) — ${compressDecision.reason}`);
transition(ctx, S.COMPRESSING);
return;
}
@@ -2340,6 +2647,10 @@ async function handleReflecting(
} else {
recordCompletionGate(true);
}
// R90: 记录完成门控评分到日志
if (gateResult.score < 100) {
logInfo(`R90: Completion Gate 评分: ${gateResult.score}/100`);
}
} catch { /* Gate 异常不影响主流程 */ }
logInfo('模型停止工具调用 → ReAct Agent Loop 结束');
+63
View File
@@ -101,9 +101,72 @@ export function endSessionMetrics(): SessionMetrics | null {
`${formatPercent(getToolSuccessRate(metrics))} 成功率`
);
currentMetrics = null;
// R84: 持久化度量历史到 localStorage
persistMetricsHistory();
return metrics;
}
// ═══════════════════════════════════════════════════════════════
// R84: 度量持久化 — localStorage 存储与恢复
// ═══════════════════════════════════════════════════════════════
const METRICS_STORAGE_KEY = '_metona_agent_metrics';
const METRICS_HISTORY_MAX = 100;
/** R84: 将度量历史保存到 localStorage */
function persistMetricsHistory(): void {
try {
const recent = sessionMetricsHistory.slice(-METRICS_HISTORY_MAX);
const serialized = recent.map(m => ({
sessionId: m.sessionId,
model: m.model,
duration: m.endTime - m.startTime,
iterations: m.totalIterations,
toolCallCount: m.toolCalls.length,
toolSuccessRate: getToolSuccessRate(m),
completionGatePassed: m.completionGatePassed,
errorPatterns: m.errorPatterns,
inputTokens: m.totalInputTokens,
outputTokens: m.totalOutputTokens,
timestamp: m.endTime,
}));
localStorage.setItem(METRICS_STORAGE_KEY, JSON.stringify(serialized));
} catch {
// localStorage 满或不可用时静默忽略
}
}
/** R84: 从 localStorage 恢复度量历史 */
export function loadMetricsHistory(): void {
try {
const raw = localStorage.getItem(METRICS_STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as Array<Record<string, any>>;
for (const item of parsed) {
sessionMetricsHistory.push({
sessionId: item.sessionId || 'unknown',
model: item.model || 'unknown',
startTime: item.timestamp ? item.timestamp - (item.duration || 0) : 0,
endTime: item.timestamp || 0,
totalIterations: item.iterations || 0,
toolCalls: [],
totalInputTokens: item.inputTokens || 0,
totalOutputTokens: item.outputTokens || 0,
completionGatePassed: item.completionGatePassed || false,
errorPatterns: item.errorPatterns || [],
});
}
logInfo(`R84: 恢复了 ${sessionMetricsHistory.length} 条历史度量`);
} catch {
// 解析失败静默忽略
}
}
/** R84: 获取度量历史(供 UI 仪表盘使用) */
export function getMetricsHistory(): SessionMetrics[] {
return [...sessionMetricsHistory];
}
// ═══════════════════════════════════════════════════════════════
// 度量计算
// ═══════════════════════════════════════════════════════════════
File diff suppressed because it is too large Load Diff
+93 -8
View File
@@ -105,6 +105,32 @@ const contextEfficiencyCheck: CompletionCheck = {
},
};
/** R83: Plan Mode 完成检查 — 验证所有计划步骤是否已标记完成 */
const planModeCompletionCheck: CompletionCheck = {
name: 'planModeCompletion',
description: 'Plan Mode 下检查所有计划步骤是否已完成',
check: async (ctx: LoopContext) => {
if (ctx.mode !== 'plan') return { passed: true, reason: '' };
// 从 state 获取 Plan Tracker
try {
const { getPlanTracker } = await import('./tool-registry.js');
const tracker = getPlanTracker();
if (!tracker.active || tracker.steps.length === 0) return { passed: true, reason: '' };
if (tracker.done < tracker.total) {
const remaining = tracker.total - tracker.done;
const undoneSteps = tracker.steps.filter(s => !s.done).map(s => s.label).slice(0, 3);
return {
passed: false,
reason: `Plan Mode: 还有 ${remaining} 步未完成(${undoneSteps.join('、')}${remaining > 3 ? '...' : ''})。请继续执行剩余步骤,或说明为什么这些步骤无法完成。`
};
}
return { passed: true, reason: '' };
} catch {
return { passed: true, reason: '' };
}
},
};
// ═══════════════════════════════════════════════════════════════
// 检查项注册与管理
// ═══════════════════════════════════════════════════════════════
@@ -114,6 +140,7 @@ const DEFAULT_ENABLED_CHECKS = new Set([
'contentQuality',
'toolResultReview',
'notThinking',
'planModeCompletion',
]);
/** 注册表 */
@@ -122,6 +149,7 @@ const checkRegistry = new Map<string, CompletionCheck>([
['toolResultReview', toolResultReviewCheck],
['notThinking', notThinkingCheck],
['contextEfficiency', contextEfficiencyCheck],
['planModeCompletion', planModeCompletionCheck],
]);
/** 已启用的检查项 */
@@ -165,54 +193,104 @@ export function getEnabledChecks(): CompletionCheck[] {
export interface GateResult {
passed: boolean;
reason: string;
/** 各项检查的详细结果 */
/** R90: 各项检查的详细结果 */
details: Array<{ name: string; passed: boolean; reason: string; durationMs: number }>;
/** R90/R127: 完成门控评分(0-100 */
score: number;
/** R90/R127: 评分明细 */
scoreBreakdown: Array<{ check: string; passed: boolean; points: number }>;
}
/** R127: 各检查项的权重分配 */
const CHECK_WEIGHTS: Record<string, number> = {
contentQuality: 30,
toolResultReview: 25,
notThinking: 20,
contextEfficiency: 15,
planModeCompletion: 10,
};
/**
* 运行完成门控检查
* R90: 返回评分结果,评分基于各检查项的权重
*/
export async function runCompletionGate(ctx: LoopContext): Promise<GateResult> {
const checks = getEnabledChecks();
if (checks.length === 0) {
return { passed: true, reason: '', details: [] };
return { passed: true, reason: '', details: [], score: 100, scoreBreakdown: [] };
}
const details: GateResult['details'] = [];
const scoreBreakdown: GateResult['scoreBreakdown'] = [];
let totalScore = 0;
let maxScore = 0;
for (const check of checks) {
const start = Date.now();
try {
const result = await check.check(ctx);
const durationMs = Date.now() - start;
const weight = CHECK_WEIGHTS[check.name] ?? 10;
maxScore += weight;
details.push({
name: check.name,
passed: result.passed,
reason: result.reason,
durationMs: Date.now() - start,
durationMs,
});
if (!result.passed) {
scoreBreakdown.push({
check: check.name,
passed: result.passed,
points: result.passed ? weight : 0,
});
if (result.passed) {
totalScore += weight;
} else {
logWarn(`Completion Gate: "${check.name}" — ${result.reason}`);
return { passed: false, reason: result.reason, details };
return {
passed: false,
reason: result.reason,
details,
score: Math.round((totalScore / Math.max(1, maxScore)) * 100),
scoreBreakdown,
};
}
} catch (err) {
const weight = CHECK_WEIGHTS[check.name] ?? 10;
maxScore += weight;
details.push({
name: check.name,
passed: true,
reason: `检查异常: ${(err as Error).message}`,
durationMs: Date.now() - start,
});
scoreBreakdown.push({
check: check.name,
passed: true,
points: weight,
});
totalScore += weight;
}
}
return { passed: true, reason: '', details };
return {
passed: true,
reason: '',
details,
score: Math.round((totalScore / Math.max(1, maxScore)) * 100),
scoreBreakdown,
};
}
/**
* 生成门控报告(供日志/调试使用)
* R127: 生成门控报告(供日志/调试使用)
*/
export function formatGateReport(result: GateResult): string {
const status = result.passed ? '✅ 通过' : '❌ 未通过';
let report = `Completion Gate ${status}`;
let report = `Completion Gate ${status} (评分: ${result.score}/100)`;
if (!result.passed) {
report += `${result.reason}`;
}
@@ -221,5 +299,12 @@ export function formatGateReport(result: GateResult): string {
const icon = d.passed ? '✅' : '❌';
report += `\n${icon} ${d.name}: ${d.reason || '通过'} (${d.durationMs}ms)`;
}
// R127: 评分明细
if (result.scoreBreakdown.length > 0) {
report += `\n${'─'.repeat(40)}\n评分明细:`;
for (const s of result.scoreBreakdown) {
report += `\n ${s.passed ? '✅' : '❌'} ${s.check}: ${s.points}`;
}
}
return report;
}
+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;
}
+230 -3
View File
@@ -41,6 +41,9 @@ export interface MemoryEntry {
content: string;
importance: number; // 1-10
tags: string[];
// R106: 访问追踪 — 用于 TTL 衰减时的保留策略
lastAccessed?: number; // 最后访问时间(毫秒时间戳)
accessCount?: number; // 访问次数
}
export interface MemorySearchResult extends MemoryEntry {
@@ -283,8 +286,54 @@ export function serializeMemoryMd(entries: MemoryEntry[]): string {
// 搜索
// ═══════════════════════════════════════════════════════════════
/**
* R101: 计算词项的 IDF(逆文档频率)权重
* 在所有记忆条目中出现频率越低的词,IDF 越高
*/
function computeIDF(entries: MemoryEntry[], words: string[]): Map<string, number> {
const idfMap = new Map<string, number>();
const N = entries.length;
if (N === 0) return idfMap;
for (const word of words) {
let docFreq = 0;
for (const entry of entries) {
if (entry.content.toLowerCase().includes(word)) docFreq++;
}
// IDF = log(N / (df + 1)),加 1 防止除零
const idf = Math.log((N + 1) / (docFreq + 1)) + 1;
idfMap.set(word, idf);
}
return idfMap;
}
/**
* R101: 模糊匹配 — 检查两个短字符串的编辑距离是否在阈值内
* 仅对长度 <= 10 的短词使用,避免性能开销
*/
function fuzzyMatch(a: string, b: string, maxDistance: number): boolean {
if (a === b) return true;
if (Math.abs(a.length - b.length) > maxDistance) return false;
// 简化编辑距离(Levenshtein
const matrix: number[][] = [];
for (let i = 0; i <= a.length; i++) matrix[i] = [i];
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost,
);
}
}
return matrix[a.length][b.length] <= maxDistance;
}
/**
* 关键词搜索记忆
* R101: 增强 — IDF 权重 + 模糊匹配 + 多词短语奖励
*/
export function searchMemory(entries: MemoryEntry[], query: string, limit = 0): MemorySearchResult[] {
if (!query || entries.length === 0) return [];
@@ -292,6 +341,9 @@ export function searchMemory(entries: MemoryEntry[], query: string, limit = 0):
const queryLower = query.toLowerCase();
const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1);
// R101: 预计算 IDF 权重
const idfMap = computeIDF(entries, queryWords);
// rule 和 preference 类型的记忆全局生效,始终注入
// M5: 限制全局注入数量,避免大量 rule/preference 膨胀搜索结果
const MAX_GLOBAL_INJECT = 10;
@@ -310,28 +362,70 @@ export function searchMemory(entries: MemoryEntry[], query: string, limit = 0):
const contentLower = entry.content.toLowerCase();
const tagsLower = entry.tags.map(t => t.toLowerCase());
// 精确匹配查询字符串
if (contentLower.includes(queryLower)) score += 50;
// R101: 多词短语连续匹配奖励
if (queryWords.length >= 2 && contentLower.includes(queryWords.join(' '))) {
score += 25;
}
for (const tag of tagsLower) {
if (queryLower.includes(tag) || tag.includes(queryLower)) score += 30;
for (const word of queryWords) {
if (tag.includes(word)) score += 15;
if (tag.includes(word)) {
// R101: 使用 IDF 权重
score += 15 * (idfMap.get(word) || 1);
}
// R101: 对短词进行模糊匹配
if (word.length <= 10 && tag.length <= 15 && fuzzyMatch(word, tag, 1)) {
score += 8;
}
}
}
for (const word of queryWords) {
if (contentLower.includes(word)) score += 10;
if (contentLower.includes(word)) {
// R101: 使用 IDF 权重
score += 10 * (idfMap.get(word) || 1);
}
// R101: 对短词进行模糊匹配
if (word.length <= 10) {
// 检查内容中是否有编辑距离为 1 的近似词
const words = contentLower.split(/\s+/);
for (const cw of words) {
if (cw.length <= 12 && fuzzyMatch(word, cw, 1)) {
score += 5;
break; // 每个查询词只奖励一次
}
}
}
}
score *= (0.5 + entry.importance / 20);
if (score > 0) scored.push({ ...entry, score });
if (score > 0) scored.push({ ...entry, score: Math.round(score) });
}
// 去重 + 排序
const seen = new Set<string>();
const merged: MemorySearchResult[] = [];
const accessedIds: string[] = []; // R106: 需要记录访问的 ID
for (const item of [...alwaysInclude, ...scored.sort((a, b) => b.score - a.score)]) {
if (!seen.has(item.id)) {
seen.add(item.id);
merged.push(item);
// R106: 记录访问(但不持久化,只影响本次搜索后的衰减判断)
accessedIds.push(item.id);
}
}
// R106: 更新原始 entries 中的访问追踪(影响未来的 TTL 衰减)
// 注意:这不会立即持久化,在 writeMemoryFile 时才生效
for (const id of accessedIds) {
const entry = entries.find(e => e.id === id);
if (entry) {
entry.lastAccessed = Date.now();
entry.accessCount = (entry.accessCount || 0) + 1;
}
}
@@ -443,6 +537,8 @@ export async function loadAllEntries(): Promise<MemoryEntry[]> {
/** 搜索记忆(读取 MEMORY.md → 解析 → 搜索) */
export async function search(query: string, limit = 0): Promise<MemorySearchResult[]> {
try {
// R57: 触发 TTL 衰减检查(带节流,不会每次搜索都执行)
maybeRunTTLDecay().catch(() => {}); // 非阻塞,失败不影响搜索
const entries = await loadAllEntries();
return searchMemory(entries, query, limit);
} catch (err) {
@@ -812,6 +908,137 @@ function autoCleanEntries(entries: MemoryEntry[]): MemoryEntry[] {
return entries.filter(e => !evictIds.has(e.id));
}
// ═══════════════════════════════════════════════════════════════
// R57: 记忆 TTL 衰减 — 基于时间的自动重要性衰减
// ═══════════════════════════════════════════════════════════════
/** R57: 上次 TTL 衰减执行时间(避免每次搜索都触发文件写入) */
let _lastTTLDecayTime = 0;
const TTL_DECAY_INTERVAL = 6 * 3600 * 1000; // 每 6 小时最多执行一次衰减
/** R57: 从记忆 ID 中提取创建日期 */
function extractDateFromId(id: string): number {
const m = id.match(/mem_(\d{4})(\d{2})(\d{2})_/);
if (m) {
return new Date(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3])).getTime();
}
return Date.now(); // 无法解析时视为刚创建
}
/**
* R106: 记录条目访问 — 增强访问频率追踪用于 TTL 衰减时的保留策略
* 在搜索匹配、检索时调用
*/
export function recordMemoryAccess(entryId: string, entries: MemoryEntry[]): void {
const entry = entries.find(e => e.id === entryId);
if (entry) {
entry.lastAccessed = Date.now();
entry.accessCount = (entry.accessCount || 0) + 1;
}
}
/**
* R57: 对记忆条目应用 TTL 衰减
*
* 衰减规则:
* - rule 类型:完全免疫衰减(永久规则)
* - preference 类型:轻微衰减(用户偏好通常长期有效)
* - fact 类型:按年龄衰减,低重要性的旧事实优先遗忘
* - importance 已经 ≤ 2 的 fact 且超过 60 天:自动移除
* - R106: 访问频率作为保留因素(最近访问或高频访问的条目优先保留)
*
* @param entries 当前所有记忆条目
* @returns { decayed: MemoryEntry[], removed: number, changed: boolean }
*/
export function applyTTLDecay(entries: MemoryEntry[]): { decayed: MemoryEntry[]; removed: number; changed: boolean } {
const now = Date.now();
const ONE_DAY = 24 * 3600 * 1000;
const removed: string[] = [];
let changed = false;
const decayed = entries.filter(entry => {
const createdAt = extractDateFromId(entry.id);
const ageDays = (now - createdAt) / ONE_DAY;
const lastAccessed = entry.lastAccessed || createdAt;
const accessCount = entry.accessCount || 0;
const daysSinceAccess = (now - lastAccessed) / ONE_DAY;
// R106: 访问频率保护 — 最近 30 天内有访问或访问次数 >= 3 的条目优先保留
const accessProtected = daysSinceAccess < 30 || accessCount >= 3;
// rule 类型永久保留
if (entry.type === 'rule') return true;
// preference 类型:90 天以上且 importance ≤ 3 才移除(访问频率可豁免)
if (entry.type === 'preference') {
if (ageDays > 90 && entry.importance <= 3 && !accessProtected) {
removed.push(entry.id);
changed = true;
return false;
}
return true;
}
// fact 类型:按年龄和重要性衰减
// 衰减公式:每 30 天 importance - 1(仅对 importance < 8 的条目)
if (entry.type === 'fact') {
// R106: 访问频率保护 — 最近访问过的不易删除
if (accessProtected && ageDays < 120) {
// 120 天内访问过的 fact 豁免删除
return true;
}
// 超过 60 天且 importance ≤ 2 → 自动移除
if (ageDays > 60 && entry.importance <= 2 && !accessProtected) {
removed.push(entry.id);
changed = true;
return false;
}
// 超过 90 天且 importance ≤ 4 → 自动移除
if (ageDays > 90 && entry.importance <= 4 && !accessProtected) {
removed.push(entry.id);
changed = true;
return false;
}
// 高重要性的 fact 永久保留
if (entry.importance >= 8) return true;
return true;
}
return true;
});
if (removed.length > 0) {
logMemory('TTL 衰减', `自动移除 ${removed.length} 条过期记忆(fact/preference 类型,低重要性+超龄)`);
}
return { decayed, removed: removed.length, changed };
}
/**
* R57: 执行 TTL 衰减并持久化(带节流保护)
* 在搜索记忆时调用,如果距离上次衰减超过 TTL_DECAY_INTERVAL 则执行
*/
async function maybeRunTTLDecay(): Promise<void> {
const now = Date.now();
if (now - _lastTTLDecayTime < TTL_DECAY_INTERVAL) return;
_lastTTLDecayTime = now;
try {
const entries = await loadAllEntries();
if (entries.length === 0) return;
const { decayed, removed, changed } = applyTTLDecay(entries);
if (changed && removed > 0) {
const fileContent = decayed.length > 0 ? serializeMemoryMd(decayed) : '';
await writeMemoryFile(fileContent);
logMemory('TTL 衰减', `已持久化: 移除 ${removed} 条,剩余 ${decayed.length}`);
}
} catch (err) {
logWarn('TTL 衰减执行失败', (err as Error).message);
}
}
/**
* 检查记忆系统是否可用(桌面环境且工作空间已设置)
*/
+75
View File
@@ -9,6 +9,8 @@ import { OllamaAPI } from '../api/ollama.js';
import { TOOL_DEFINITIONS } from './tool-registry.js';
import { getEnabledToolDefinitions } from './tool-registry.js';
import { logInfo, logWarn, logError } from './log-service.js';
import { validatePathSandbox, checkRateLimit, isToolCircuitBroken, recordToolFailure, recordToolSuccess, sanitizeToolArgs, checkCommandSafety } from './agent-safety.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
import type { ToolResult, ToolCall, ToolDefinition } from '../types.js';
const SUB_AGENT_MAX_LOOPS = 10; // 子代理最多 10 轮
@@ -169,9 +171,82 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
// 工具执行前再次检查中止信号
if (subAgentAC.signal.aborted) break;
// R89: 子 Agent 熔断器检查 — 连续失败后自动禁用工具
const circuitBreaker = isToolCircuitBroken(tc.name);
if (circuitBreaker.broken) {
const waitSec = Math.ceil(circuitBreaker.remainingMs / 1000);
logWarn(`R89: 子 Agent 熔断器拦截: ${tc.name},冷却中(还需 ${waitSec}s`);
messages.push({
role: 'tool',
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: `工具「${tc.name}」因连续失败已被暂时禁用,请等待 ${waitSec} 秒后重试。` })}\n<<<TOOL_RESULT_END>>>`,
tool_name: tc.name
});
continue;
}
// R109: 子 Agent 参数消毒
tc.arguments = sanitizeToolArgs(tc.name, tc.arguments);
// R113: 子 Agent 命令安全检查
if (tc.name === 'run_command') {
const cmdStr = String(tc.arguments?.command || '');
if (cmdStr) {
const cmdSafety = checkCommandSafety(cmdStr);
if (cmdSafety.riskLevel === 'forbidden') {
logWarn(`R113: 子 Agent 命令安全拦截: ${cmdSafety.reason}`);
messages.push({
role: 'tool',
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: cmdSafety.reason || '命令被安全规则拦截' })}\n<<<TOOL_RESULT_END>>>`,
tool_name: tc.name
});
continue;
}
}
}
// R82: 子 Agent 速率限制 — 防止子代理快速连续调用同一工具
const rateLimit = checkRateLimit(tc.name);
if (!rateLimit.allowed) {
const waitSec = Math.ceil(rateLimit.retryAfterMs / 1000);
logWarn(`R82: 子 Agent 速率限制: ${tc.name},等待 ${waitSec}s`);
messages.push({
role: 'tool',
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: `调用频率过高,等待 ${waitSec}s` })}\n<<<TOOL_RESULT_END>>>`,
tool_name: tc.name
});
continue;
}
// R81: 子 Agent 路径沙箱 — 确保文件操作不超出工作空间
const SUB_FILE_TOOLS = new Set(['read_file', 'list_directory', 'search_files', 'web_fetch']);
if (SUB_FILE_TOOLS.has(tc.name)) {
const wsDir = getWorkspaceDirPath();
if (wsDir) {
const pathArg = String(tc.arguments?.path || '');
if (pathArg) {
const sandbox = validatePathSandbox(pathArg, wsDir);
if (!sandbox.valid) {
logWarn(`R81: 子 Agent 路径沙箱拦截: ${tc.name}(${pathArg}) — ${sandbox.reason}`);
messages.push({
role: 'tool',
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: sandbox.reason || '路径不在工作空间范围内' })}\n<<<TOOL_RESULT_END>>>`,
tool_name: tc.name
});
continue;
}
}
}
}
try {
const { executeTool } = await import('./tool-registry.js');
const result = await executeTool(tc.name, tc.arguments);
// R89: 记录工具成功/失败到熔断器
if (result.success) {
recordToolSuccess(tc.name);
} else {
recordToolFailure(tc.name);
}
const resultStr = formatResult(tc.name, result);
messages.push({
role: 'tool',
+85
View File
@@ -754,6 +754,91 @@ export function getEnabledToolDefinitions(): ToolDefinition[] {
return [...builtIn, ..._mcpToolCache];
}
// R55: 语义工具检索 — 根据用户查询过滤相关工具,减少上下文占用
/** 核心工具集 — 始终包含的关键工具 */
const CORE_TOOLS = new Set([
'read_file', 'write_file', 'list_directory', 'search_files', 'edit_file',
'run_command', 'web_search', 'web_fetch', 'memory', 'git',
]);
/** 工具关键词映射 — 用于语义匹配 */
const TOOL_KEYWORDS: Record<string, string[]> = {
read_file: ['read', 'file', '读取', '文件', '看', '查看', '内容'],
write_file: ['write', 'file', '写入', '保存', '创建文件', '输出'],
list_directory: ['list', 'directory', '目录', '文件夹', '列'],
search_files: ['search', 'find', '搜索', '查找', 'grep', 'find'],
create_directory: ['create', 'directory', 'mkdir', '创建目录', '新建'],
delete_file: ['delete', 'remove', '删除', 'rm', '移除'],
run_command: ['run', 'command', 'shell', '执行', '命令', '终端'],
move_file: ['move', 'rename', '移动', '重命名', 'mv'],
copy_file: ['copy', '复制', 'cp'],
web_fetch: ['fetch', 'url', '网页', '抓取', '获取'],
web_search: ['search', 'web', '搜索', '联网', '查', 'google', 'bing'],
edit_file: ['edit', 'replace', '编辑', '替换', '修改'],
get_file_info: ['info', 'stat', '信息', '属性', '详情'],
tree: ['tree', '结构', '树', '目录结构'],
download_file: ['download', '下载'],
diff_files: ['diff', 'compare', '对比', '差异'],
replace_in_files: ['replace', 'batch', '批量替换'],
read_multiple_files: ['read', 'multiple', '批量读取'],
git: ['git', 'commit', 'push', 'pull', 'branch', '仓库'],
compress: ['compress', 'zip', 'tar', '压缩', '解压', 'extract'],
memory: ['memory', '记忆', 'remember', 'save', 'rule', '规则'],
session_list: ['session', 'history', '会话', '历史'],
session_read: ['session', 'read', '读取会话'],
spawn_task: ['sub', 'agent', 'delegate', '子代理', '委派'],
plan_track: ['plan', 'track', '计划', '进度'],
browser_open: ['browser', 'open', '浏览器', '打开网页'],
browser_screenshot: ['screenshot', '截图', '屏幕'],
browser_evaluate: ['evaluate', 'javascript', 'js', '执行JS'],
browser_extract: ['extract', '提取', '内容'],
browser_click: ['click', '点击'],
browser_type: ['type', 'input', '输入'],
browser_scroll: ['scroll', '滚动'],
browser_wait: ['wait', '等待'],
browser_close: ['close', '关闭浏览器'],
datetime: ['time', 'date', '时间', '日期'],
calculator: ['calculate', 'math', '计算', '算'],
random: ['random', '随机'],
uuid: ['uuid', 'guid', '唯一'],
json_format: ['json', 'format', '格式化'],
hash: ['hash', 'sha', 'md5', '哈希'],
};
/** R55: 根据用户查询语义过滤工具定义,减少 token 占用 */
export function getRelevantToolDefinitions(userQuery: string): ToolDefinition[] {
const allTools = getEnabledToolDefinitions();
// 查询为空或很短时返回全部
if (!userQuery || userQuery.length < 5) return allTools;
const queryLower = userQuery.toLowerCase();
const relevantTools = new Set<string>();
// 核心工具始终包含
for (const t of CORE_TOOLS) relevantTools.add(t);
// 语义匹配
for (const [toolName, keywords] of Object.entries(TOOL_KEYWORDS)) {
if (relevantTools.has(toolName)) continue;
for (const kw of keywords) {
if (queryLower.includes(kw.toLowerCase())) {
relevantTools.add(toolName);
break;
}
}
}
// 如果匹配到的工具太少,返回全部(避免遗漏)
if (relevantTools.size < 8) return allTools;
const filtered = allTools.filter(t => relevantTools.has(t.function.name));
// 确保不过度过滤 — 至少保留 60% 的工具
if (filtered.length < allTools.length * 0.6) return allTools;
return filtered;
}
/** MCP 工具缓存 */
let _mcpToolCache: ToolDefinition[] = [];