v0.17.0: 安全加固 + 数据层性能重构 + 死代码清理 + 测试基建
CI / verify (push) Canceled after 0s

安全修复:
- 开启 webSecurity(CORS 改为 webRequest 允许清单精确放行 Ollama 地址)
- 新增 net-guard SSRF 防护:web_fetch/download_file/browser_open 拦截环回/内网/链路本地地址(DNS 解析后校验)
- browser_open 协议白名单(仅 http/https,阻止 file:// 绕过路径安全层)
- git 参数注入防护(用户可控参数禁止 - 开头;git add 强制 -- 分隔)
- 身份文件保护:SOUL.md/AGENT.md/USER.md 工具只读(防提示注入持久化劫持)
- 系统目录硬红线 + 工作空间/白名单不可豁免系统目录
- spawn_task 权限只降不升(封顶于用户设置 subAgentMaxPermission)
- 子代理写类工具接入主 Agent 确认管线 + 完整路径沙箱
- toast 改 textContent、HTML 导出 escapeHtml(XSS 修复)
- Agent 浏览器改用 memory: 内存分区(退出清空 cookie/storage)

数据层重构:
- sql.js 写入改防抖批量落盘(300ms 合并快照 + temp 原子替换 + 退出刷盘)
- Schema 迁移改 PRAGMA user_version 顺序迁移数组
- 消息/设置/轨迹批量写(单事务);SearXNG 配置 13 次写合并为 1 次
- 会话摘要查询(getSessionSummaries/searchSessions 单条 SQL)消除 N+1
- 导出改 getAllSessionsData 一次 IPC 取回全部行

Bug 修复:
- edit_file 替换符污染($&/$1 被特殊解释导致文件写坏)
- truncateToolResult 暴力截断拼接非法 JSON 必然崩溃
- diff 算法 100MB dp 数组 → 前缀/后缀裁剪 + LCS 限额 + 回退
- move_file 跨盘 rename 失败回退 copy+delete
- Ctrl+K 快捷键冲突(双注册);全局错误处理器双注册
- ffmpeg stderr 无限累积 + 帧进度 O(n²) 正则
- 搜索可达性预检只取响应头(Range: bytes=0-0)
- 备份导出逐字节 base64 拼接(O(n²))改 FileReader
- MCP clientInfo 版本硬编码 5.0.0 改真实版本;tools/list 支持 nextCursor 分页
- 看门狗默认值统一为 30 分钟;download_file 超时跟随用户配置

架构改进:
- 主进程工具分发注册表 tool-dispatch.ts(消除 switch 硬编码)
- agent-engine 拆分 result-formatter.ts / tool-parsing.ts(纯函数)
- 文本兜底解析白名单改从注册表派生(补齐 browser_*/diff/spawn_task/mcp_*)
- diff 工具默认启用;MODE_TOOLS 单一事实来源(tools-modal 复用)
- 记忆系统:条目缓存 + 访问统计(hits/last)持久化 + removeById 按 ID 删除
- 度量历史启动恢复 + Metrics 仪表盘接入 JSON/Prometheus 导出
- 子代理模型下拉框打开设置时刷新(此前从未填充)

死代码清理(约 1400 行):
- 删除 context-indexer 整模块、agent-safety 震荡检测/性能报告/依赖图/记忆调优/归档取回
- 删除 context-manager 水印/跳过压缩/自适应窗口/趋势分析/预算分配等未接线函数
- 删除 sanitizeToolArgs(污染 write_file 内容,防注入职责移交主进程安全层)
- infra-service 裁剪为全局错误处理器唯一定义

文档对齐:
- 新增内置 AGENT.md(工作空间同名文件可覆盖)
- README/帮助面板/DEVELOPMENT 移除失实描述(WAL/内部URL拦截/5层防御/并行白名单/Hook 数量)
- 工具数量口径统一 33;安全机制表新增 SSRF/身份保护/子代理权限等 9 项

工程化:
- Vitest + 34 个单元测试(myers-diff/calculator/net-guard/MEMORY.md 格式)
- Gitea Actions CI(typecheck + test + build)
- package.json 新增 typecheck/test 脚本
This commit is contained in:
2026-08-24 07:39:34 +08:00
parent 6b5e42a26d
commit bae993c321
55 changed files with 2873 additions and 2592 deletions
+42 -484
View File
@@ -14,6 +14,7 @@ import {
initPlanTracker,
getPlanTracker,
clearPlanTracker,
setSubAgentConfirmHandler,
} from './tool-registry.js';
import {
compactOldToolResult,
@@ -21,21 +22,21 @@ import {
classifyError,
calculateBackoff,
validatePathSandbox,
// R88: 工具结果元数据
// 工具结果元数据
addResultMetadata,
// R97: 错误模式学习
// 错误模式学习
recordErrorPattern,
// R109: 工具参数消毒
sanitizeToolArgs,
// R113: 命令安全检查
// 命令安全检查
checkCommandSafety,
// R95: 按工具类型智能截断
// 按工具类型智能截断
smartTruncateByToolType,
// R116: 错误恢复建议
// 错误恢复建议
getErrorRecoverySuggestions,
formatErrorRecovery,
} from './agent-safety.js';
import { search, formatMemoryContext } from './memory-service.js';
import { formatToolResultForModel, summarizeAuditResult } from './result-formatter.js';
import { parseToolCallsFromText } from './tool-parsing.js';
import { showToast } from '../components/toast.js';
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js';
@@ -168,15 +169,7 @@ function getOSEnvironment() {
};
}
/** 始终可并行的只读/独立工具 */
const ALWAYS_PARALLEL = new Set([
'read_file', 'list_directory', 'search_files', 'tree',
'web_search', 'browser_screenshot', 'browser_extract',
'memory', 'session_list', 'session_read',
'calculator', 'diff',
]);
/** D4: 有副作用的工具 — 同轮次去重时不返回缓存,需实际执行 */
/** 有副作用的工具 — 同轮次去重时不返回缓存,需实际执行 */
const SIDE_EFFECT_TOOLS = new Set([
'write_file', 'edit_file', 'create_directory', 'delete_file',
'move_file', 'copy_file', 'download_file',
@@ -489,158 +482,6 @@ function validateToolArgs(toolName: string, args: Record<string, unknown>): stri
return null;
}
/** 工具名白名单:用于文本解析兜底时过滤非法工具名 */
const VALID_TOOL_NAMES = new Set([
'read_file', 'write_file', 'list_directory', 'search_files', 'create_directory',
'delete_file', 'run_command', 'move_file', 'copy_file', 'web_fetch', 'web_search',
'edit_file', 'tree', 'download_file',
'read_multiple_files', 'git', 'compress',
'memory', 'session_list', 'session_read',
'calculator'
]);
/**
* 文本解析兜底:当模型没有通过 tool_calls 字段返回工具调用,
* 而是在文本中写了工具调用时,从文本中提取。
*
* P2-3 增强:支持4种格式
* 1. Action/Action Input 格式(原有)
* 2. <tool_call> XML 标签格式
* 3. ```json 代码块中含 "name" 字段
* 4. 函数调用语法 func_name({...})
*/
function parseToolCallsFromText(content: string): ToolCall[] {
const calls: ToolCall[] = [];
// 辅助函数:尝试解析 JSON 参数字符串,容错处理
const tryParseArgs = (argsStr: string): Record<string, unknown> | null => {
const TICK = String.fromCharCode(96);
const tickJson = TICK + TICK + TICK + 'json';
const tick3 = TICK + TICK + TICK;
try {
let cleaned = argsStr.split(tickJson).join('').split(tick3).join('').trim();
return JSON.parse(cleaned);
} catch {
try {
let fixed = argsStr
.replace(/'/g, '"')
.replace(/,\s*}/g, '}')
.replace(/,\s*]/g, ']')
.split(tickJson).join('')
.split(tick3).join('')
.trim();
return JSON.parse(fixed);
} catch {
return null;
}
}
};
// 辅助函数:验证工具名并添加到结果
const tryAddCall = (toolName: string, argsStr: string): boolean => {
toolName = toolName.trim();
if (!VALID_TOOL_NAMES.has(toolName)) return false;
const args = tryParseArgs(argsStr);
if (!args) {
logWarn("文本解析兜底: 工具 " + toolName + " 的参数 JSON 解析失败", argsStr.slice(0, 100));
return false;
}
calls.push({ type: 'function', function: { name: toolName, arguments: args } });
return true;
};
// ── 格式1: Action/Action Input(原有格式)──
const actionRegex = /\*{0,2}Action:?\*{0,2}\s*(\w+)\s+[\r\n\s]*\*{0,2}Action\s*Input:?\*{0,2}\s*(\{[\s\S]*?\})/gi;
let match;
while ((match = actionRegex.exec(content)) !== null) {
tryAddCall(match[1], match[2]);
}
// ── 格式2: <tool_call> XML 标签 ──
// 匹配 <tool_call>{"name": "xxx", "arguments": {...}}</tool_call>
const xmlRegex = /<tool_call>\s*([\s\S]*?)<\/tool_call>/gi;
while ((match = xmlRegex.exec(content)) !== null) {
const inner = match[1].trim().replace(/```json\s*/g, '').replace(/```/g, '').trim();
try {
const parsed = JSON.parse(inner);
const toolName = parsed.name || parsed.function?.name || '';
const toolArgs = parsed.arguments || parsed.function?.arguments || parsed.parameters || {};
if (toolName && VALID_TOOL_NAMES.has(toolName)) {
calls.push({ type: 'function', function: { name: toolName, arguments: toolArgs } });
}
} catch {
// JSON 解析失败,尝试分别提取 name 和 arguments
const nameMatch = inner.match(/"name"\s*:\s*"(\w+)"/i);
if (nameMatch) {
const argsMatch = inner.match(/"arguments"\s*:\s*(\{[\s\S]*\})/i);
if (argsMatch) tryAddCall(nameMatch[1], argsMatch[1]);
}
}
}
// ── 格式3: ```json 代码块中含 "name" 字段 ──
// 匹配 ```json\n{"name": "xxx", "arguments": {...}}\n```
const codeBlockRegex = /```(?:json)?\s*(\{[\s\S]*?"name"\s*:\s*"\w+"[\s\S]*?\})\s*```/gi;
while ((match = codeBlockRegex.exec(content)) !== null) {
const jsonStr = match[1].trim();
try {
const parsed = JSON.parse(jsonStr);
const toolName = parsed.name || '';
const toolArgs = parsed.arguments || parsed.parameters || {};
if (toolName && VALID_TOOL_NAMES.has(toolName)) {
calls.push({ type: 'function', function: { name: toolName, arguments: toolArgs } });
}
} catch {
// 解析失败忽略,其他格式可能匹配
}
}
// ── 格式4: 函数调用语法 func_name({"key": "value"}) ──
// R5: 修复嵌套大括号问题 — 使用平衡括号匹配替代 [^}]*
// 旧正则 /\b(\w+)\s*\(\s*(\{[^}]*\})\s*\)/g 无法匹配嵌套 JSON 如 {"a": {"b": 1}}
{
const funcCallStart = /\b(\w+)\s*\(\s*\{/g;
let fcMatch;
while ((fcMatch = funcCallStart.exec(content)) !== null) {
const toolName = fcMatch[1];
const braceStart = fcMatch.index + fcMatch[0].length - 1; // 指向 '{'
// 手动平衡匹配大括号
let depth = 0;
let endIdx = -1;
let inString = false;
let escapeNext = false;
for (let i = braceStart; i < content.length; i++) {
const ch = content[i];
if (escapeNext) { escapeNext = false; continue; }
if (ch === '\\') { escapeNext = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) { endIdx = i; break; }
}
}
if (endIdx > 0) {
const jsonStr = content.slice(braceStart, endIdx + 1);
// 检查后面是否有闭合括号
const afterClose = content.slice(endIdx + 1).match(/^\s*\)/);
if (afterClose) {
tryAddCall(toolName, jsonStr);
// 移动 regex 位置到匹配结束后
funcCallStart.lastIndex = endIdx + 1;
}
}
}
}
if (calls.length > 0) {
logInfo("文本解析兜底: 从回复中提取到 " + calls.length + " 个工具调用", calls.map(c => c.function.name).join(', '));
}
return calls;
}
/** R1: 工具缓存最大条目数,超出时按 LRU 策略淘汰最旧条目 */
const MAX_TOOL_CACHE_SIZE = 100;
const toolResultCache = new Map<string, { result: ToolResult; timestamp: number }>();
@@ -723,309 +564,6 @@ function isDuplicateCall(call: ToolCall, allCalls: ToolCall[]): boolean {
return false;
}
/** 生成工具审计摘要 — 用于审计日志记录 */
function summarizeAuditResult(toolName: string, result: ToolResult): string {
try {
switch (toolName) {
case 'write_file':
return `写入 ${result.path || ''} (${result.bytesWritten || 0}B${result.created ? ', 新建' : ''})`;
case 'edit_file':
return `编辑 ${result.path || ''} (${result.replaceCount || 0} 处替换)`;
case 'delete_file':
return result.batch ? `批量删除 ${result.successCount}/${result.totalPaths}` : `删除 ${result.path || ''}`;
case 'create_directory':
return `创建目录 ${result.path || ''}`;
case 'move_file':
return `移动 ${(result as any).source}${(result as any).destination}`;
case 'copy_file':
return `复制 ${(result as any).source}${(result as any).destination}`;
case 'run_command':
return `命令执行 ${result.exitCode === 0 ? '成功' : '失败'} (exit ${result.exitCode})`;
case 'git':
return `git ${result.action}`;
case 'download_file':
return `下载 ${(result as any).url}${(result as any).destination}`;
case 'compress':
return `${result.action}${(result as any).outputPath || (result as any).destination}`;
default:
return `${toolName} 完成`;
}
} catch {
return `${toolName} 完成`;
}
}
/** 格式化工具结果的通用默认路径 */
function formatDefaultToolResult(toolName: string, result: ToolResult): string {
const clean: Record<string, unknown> = {};
for (const [k, v] of Object.entries(result)) {
if (k === 'success' || k === 'formatted' || k === 'content_type' ||
k === 'status' || k === 'length' || k === 'isDirectory') continue;
clean[k] = v;
}
return JSON.stringify(clean);
}
/**
* 格式化工具结果,生成模型友好的简洁表示
*/
export function formatToolResultForModel(toolName: string, result: ToolResult): string {
if (!result.success) {
return JSON.stringify({ success: false, error: result.error || '工具执行失败' });
}
switch (toolName) {
case 'web_search': {
const raw = result.results as Array<{ title: string; url: string; snippet: string }> | undefined;
if (!raw?.length) return JSON.stringify({ success: true, message: '未找到结果' });
const top = raw.map((r, i) =>
`[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}`
).join('\n\n');
const fetched = (result as any)._fetched as Array<{ url: string; title: string; content: string }> | undefined;
const body = JSON.stringify({
success: true, query: result.query, total: result.total, shown: raw.length, results: top,
});
if (fetched && fetched.length > 0) {
return body + '\n\n' + fetched.map((f, i) =>
`\n=== 📄 已抓取 ${i + 1}/${fetched.length}: ${f.title} ===\n${f.content}\n`
).join('\n---\n');
}
return body;
}
case 'web_fetch': {
let content = (result.content as string) || '';
return JSON.stringify({ success: true, url: result.url, content });
}
case 'read_file': {
return JSON.stringify({
success: true,
path: result.path,
content: result.content,
lines: result.lines,
truncated: result.truncated,
line_range: result.line_range
});
}
case 'read_multiple_files': {
return JSON.stringify({
success: true,
files: result.files,
total: result.total
});
}
case 'list_directory': {
return JSON.stringify({
success: true,
path: result.path,
entries: result.entries,
total: result.total,
truncated: result.truncated
});
}
case 'write_file': {
return JSON.stringify({
success: true,
path: result.path,
bytesWritten: result.bytesWritten,
created: result.created
});
}
case 'run_command': {
return JSON.stringify({
success: true,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
duration: result.duration
});
}
case 'git': {
return JSON.stringify({
success: true,
action: result.action,
output: result.output,
branch: result.branch,
files: result.files,
commits: result.commits
});
}
case 'search_files': {
return JSON.stringify({
success: true,
query: result.query,
total_matches: result.total_matches,
total_files: result.total_files,
results: result.results
});
}
case 'memory': {
// D1: 去重信号改为软提醒,不触发⛔强制终止
if ((result as any).duplicate) {
return JSON.stringify({ success: true, action: 'add', duplicate: true, message: `${(result as any).message || '相同内容已存在'}` });
}
// read_all / search 结果:包裹在 JSON 中以保持与其他工具一致的格式
if ((result as any).action === 'read_all') {
const entries = ((result as any).entries || []) as Array<{ id: string; type: string; content: string; importance: number; tags: string[] }>;
if (entries.length === 0) return JSON.stringify({ success: true, action: 'read_all', message: '记忆为空,没有任何已保存的记忆条目。', total: 0 });
const grouped: Record<string, typeof entries> = {};
for (const e of entries) {
const t = e.type || 'fact';
(grouped[t] ||= []).push(e);
}
const lines: string[] = [`[记忆读取结果] 共 ${entries.length} 条记忆,按类型分组:`];
const typeLabels: Record<string, string> = { rule: '规则(必须遵守)', preference: '偏好', fact: '事实' };
for (const [t, items] of Object.entries(grouped)) {
lines.push(`\n--- ${typeLabels[t] || t} ---`);
for (const e of items) {
lines.push(` • [${e.type}] ${e.content}(重要性:${e.importance}, 标签: ${(e.tags || []).join(', ') || '无'}`);
}
}
return JSON.stringify({ success: true, action: 'read_all', formatted: lines.join('\n'), total: entries.length });
}
if ((result as any).action === 'search') {
const results = ((result as any).results || []) as Array<{ id: string; type: string; content: string; importance: number; score: number }>;
if (results.length === 0) return JSON.stringify({ success: true, action: 'search', message: '未找到匹配的记忆。', total: 0 });
const lines = [`[记忆搜索结果] 共 ${results.length} 条:`];
for (const r of results) {
lines.push(` • [${r.type || 'fact'}] ${r.content}(重要性:${r.importance}, 匹配度:${(r.score || 0).toFixed(0)}`);
}
return JSON.stringify({ success: true, action: 'search', formatted: lines.join('\n'), total: results.length });
}
// remove_batch 结果:格式化每条匹配情况
if ((result as any).action === 'remove_batch') {
const items = ((result as any).results || []) as Array<{ old_text: string; matched: boolean; entry_id?: string; error?: string }>;
const deleted = (result as any).deleted || 0;
const failed = (result as any).failed || 0;
const lines = [`[批量删除结果] 成功 ${deleted}${failed > 0 ? `, 失败 ${failed}` : ''}:`];
for (const item of items) {
if (item.matched) {
lines.push(` ✅ "${item.old_text}" → 已删除 (${item.entry_id})`);
} else {
lines.push(` ❌ "${item.old_text}" → ${item.error || '失败'}`);
}
}
return JSON.stringify({ success: (result as any).success, action: 'remove_batch', formatted: lines.join('\n'), deleted, failed });
}
// 其他 actionadd/replace/remove)→ 保留完整 JSON,走 default 逻辑
return formatDefaultToolResult(toolName, result);
}
case 'delete_file': {
// 批量删除
if ((result as any).batch) {
return JSON.stringify({
success: true,
message: `批量删除完成:成功 ${result.successCount}/${result.totalPaths} 个路径`,
batch: true,
totalPaths: result.totalPaths,
successCount: result.successCount,
failCount: result.failCount,
results: result.results,
});
}
return JSON.stringify({
success: true,
message: `已删除${(result as any).type === 'directory' ? '目录' : '文件'}${result.path}`,
path: result.path,
deleted: true,
type: (result as any).type,
deletedSize: result.deletedSize,
...((result as any).filesDeleted !== undefined && { filesDeleted: (result as any).filesDeleted }),
});
}
case 'create_directory': {
return JSON.stringify({
success: true,
message: `目录已创建:${result.path}`,
path: result.path,
created: (result as any).created,
});
}
case 'move_file': {
return JSON.stringify({
success: true,
message: `已移动:${(result as any).source}${(result as any).destination}`,
source: (result as any).source,
destination: (result as any).destination,
});
}
case 'copy_file': {
return JSON.stringify({
success: true,
message: `已复制:${(result as any).source}${(result as any).destination}`,
source: (result as any).source,
destination: (result as any).destination,
bytesCopied: (result as any).bytesCopied,
});
}
case 'download_file': {
return JSON.stringify({
success: true,
message: `已下载:${(result as any).url}${(result as any).destination}`,
url: (result as any).url,
destination: (result as any).destination,
bytesDownloaded: (result as any).bytesDownloaded,
});
}
case 'compress': {
return JSON.stringify({
success: true,
message: `已压缩:${(result as any).outputPath}`,
outputPath: (result as any).outputPath,
originalSize: (result as any).originalSize,
compressedSize: (result as any).compressedSize,
filesProcessed: (result as any).filesProcessed,
});
}
case 'diff': {
if ((result as any).identical) {
return JSON.stringify({ success: true, identical: true, message: '文件内容完全相同,无差异' });
}
return JSON.stringify({
success: true,
mode: (result as any).mode,
path1: (result as any).path1,
path2: (result as any).path2,
diff: (result as any).diff,
additions: (result as any).additions,
deletions: (result as any).deletions,
hunk_count: (result as any).hunk_count,
identical: false,
});
}
case 'tree': {
return JSON.stringify({
success: true,
message: `目录树:${result.path}${(result as any).totalEntries} 项)`,
path: result.path,
entries: result.entries,
totalEntries: (result as any).totalEntries,
truncated: result.truncated,
});
}
default: {
return formatDefaultToolResult(toolName, result);
}
}
}
export interface AgentCallbacks {
onThinking: (text: string) => void;
onContent: (text: string) => void;
@@ -1209,6 +747,24 @@ function snapshotLoopContext(ctx: LoopContext): void {
// Harness: 状态处理器
// ═══════════════════════════════════════════════════════════════
/** 读取应用内置资源(webSecurity 开启后 file:// 页面无法 fetch 相对路径,改走 IPC) */
async function readBuiltinResource(name: string): Promise<string> {
const bridge = window.metonaDesktop;
if (bridge?.readAppResource) {
try {
const r = await bridge.readAppResource(name);
if (r?.success && r.content) return r.content;
} catch { /* ignore */ }
return '';
}
// 非桌面(浏览器)模式回退相对路径 fetch
try {
const resp = await fetch('./' + name);
if (resp.ok) return await resp.text();
} catch { /* ignore */ }
return '';
}
/** P2-11: 加载自定义文件(SOUL.md / AGENT.md / USER.md),返回 system prompt 片段
* S6: 所有外部文件内容包裹在数据边界标记中,防止间接提示词注入 */
async function loadCustomFiles(workspaceDir: string, systemPromptParts: string[]): Promise<void> {
@@ -1221,15 +777,12 @@ async function loadCustomFiles(workspaceDir: string, systemPromptParts: string[]
} catch { /* ignore */ }
}
if (!soulMdContent) {
try {
const resp = await fetch('./SOUL.md');
if (resp.ok) { soulMdContent = await resp.text(); logInfo('SOUL.md 已从内置加载', `${soulMdContent.length} 字符`); }
} catch { /* ignore */ }
soulMdContent = await readBuiltinResource('SOUL.md');
if (soulMdContent) logInfo('SOUL.md 已从内置加载', `${soulMdContent.length} 字符`);
}
if (soulMdContent) systemPromptParts.unshift(`[SOUL.md]\n<<<REFERENCE_DATA_START>>>\n${sanitizeUntrustedInput(soulMdContent)}\n<<<REFERENCE_DATA_END>>>`);
// AGENT.md — 仅从工作空间加载,无内置 fallbackToken 预算截断
// 有则注入,无则跳过(不注入任何 AGENT.md 内容)
// AGENT.md — 工作空间优先,内置 fallbackToken 预算截断
let agentMdContent = '';
if (workspaceDir) {
try {
@@ -1237,6 +790,10 @@ async function loadCustomFiles(workspaceDir: string, systemPromptParts: string[]
if (r?.success && r.content) { agentMdContent = r.content; logInfo('AGENT.md 已从工作空间加载', `${r.lines || 0}`); }
} catch { /* ignore */ }
}
if (!agentMdContent) {
agentMdContent = await readBuiltinResource('AGENT.md');
if (agentMdContent) logInfo('AGENT.md 已从内置加载', `${agentMdContent.length} 字符`);
}
if (agentMdContent) systemPromptParts.push(`[AGENT.md]\n<<<REFERENCE_DATA_START>>>\n${sanitizeUntrustedInput(truncateByTokenBudget(agentMdContent, 2000))}\n<<<REFERENCE_DATA_END>>>`);
// USER.md — 仅工作空间,无内置 fallback
@@ -1911,10 +1468,7 @@ async function handleExecuting(
toolResultCache.delete(cacheKey);
}
// R109: 工具参数消毒 — 防止通过工具参数注入恶意内容
call.function.arguments = sanitizeToolArgs(call.function.name, call.function.arguments);
// R113: 命令安全检查 — 对 run_command 进行风险评估
// 命令安全检查 — 对 run_command 进行风险评估
if (call.function.name === 'run_command') {
const cmdStr = String(call.function.arguments?.command || '');
if (cmdStr) {
@@ -2719,6 +2273,8 @@ export async function runAgentLoop(
// Plan Mode 激活时注册 plan_track 工具
const { setPlanModeActive } = await import('./tool-registry.js');
setPlanModeActive(mode === 'plan');
// 子代理确认管线:与主 Agent 共用同一确认回调(finally 中清理)
setSubAgentConfirmHandler(callbacks.onConfirmTool ?? null);
// ── 状态机主循环 ──
try {
@@ -2729,8 +2285,8 @@ export async function runAgentLoop(
// Phase 2-7: THINKING → PARSING → EXECUTING → OBSERVING → REFLECTING → (COMPRESSING) → loop
while (ctx.state !== S.TERMINATED) {
// ── 看门狗 — 全局超时熔断(可通过设置 loopWatchdogMs 配置,0=禁用)──
// 默认 30 分钟,用户强调不要随意加超时限制
const WATCHDOG_MS = state.get<number>('loopWatchdogMs', 3_600_000);
// 默认 30 分钟(与设置面板默认值一致)
const WATCHDOG_MS = state.get<number>('loopWatchdogMs', 1_800_000);
if (WATCHDOG_MS > 0 && Date.now() - ctx.startTime > WATCHDOG_MS) {
logWarn(`看门狗触发: Agent Loop 运行超过 ${WATCHDOG_MS / 60000} 分钟,强制终止`);
callbacks.onDone(ctx.content || '(看门狗超时终止)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx));
@@ -2829,7 +2385,9 @@ default:
// P1-E2 修复:catch 块也需快照,确保终止状态写入 state._loopContext
snapshotLoopContext(ctx);
} finally {
// ── P1-8: Plan Mode 断点续传 — 保存完整追踪器到 session,支持跨轮次恢复 ──
// 清理子代理确认回调(防止泄漏到下一次循环外)
setSubAgentConfirmHandler(null);
// ── Plan Mode 断点续传 — 保存完整追踪器到 session,支持跨轮次恢复 ──
if (ctx.mode === 'plan') {
const tracker = getPlanTracker();
if (tracker.active && tracker.steps.length > 0) {
-35
View File
@@ -288,41 +288,6 @@ export function generateImprovementSuggestions(): ImprovementSuggestion[] {
return suggestions;
}
/**
* 将改进建议格式化为 AGENT.md 补充规则
*/
export function formatSuggestionsAsRules(suggestions: ImprovementSuggestion[]): string {
if (suggestions.length === 0) return '';
let rules = '\n\n## 自动生成的改进规则\n';
rules += '> 以下规则由 Agent Metrics 系统根据历史错误模式自动生成\n\n';
for (const s of suggestions) {
rules += `### ${s.pattern}\n`;
rules += `- **严重程度**: ${s.severity}\n`;
rules += `- **出现频率**: ${s.frequency}\n`;
rules += `- **建议**: ${s.suggestion}\n\n`;
}
return rules;
}
// ═══════════════════════════════════════════════════════════════
// 格式化输出(供仪表盘使用)
// ═══════════════════════════════════════════════════════════════
export function formatMetricsReport(metrics: AgentMetrics): string {
return [
`Agent Metrics 报告 (${new Date(metrics.collectedAt).toLocaleString()})`,
`${'─'.repeat(50)}`,
`总会话数: ${metrics.totalSessions}`,
`平均迭代/任务: ${metrics.avgIterationsPerTask}`,
`工具成功率: ${formatPercent(metrics.toolSuccessRate)}`,
`Token 效率: ${formatPercent(metrics.tokenEfficiency)}`,
`高频错误: ${metrics.frequentErrors.length > 0 ? metrics.frequentErrors.map(e => `${e.pattern}(${e.count}次)`).join(', ') : '无'}`,
].join('\n');
}
/** P3-14: 导出结构化指标(JSON 格式,兼容 Prometheus/OpenTelemetry 采集器) */
export function exportMetricsJSON(): string {
const metrics = aggregateMetrics();
+14 -459
View File
@@ -1,4 +1,4 @@
/**
/**
* Agent Safety — Agent 安全防护与行为治理模块
*
* 从 agent-engine.ts 提取的 R51-R56 功能:
@@ -13,7 +13,7 @@
*/
import type { OllamaMessage } from '../types.js';
import { logInfo, logWarn } from './log-service.js';
import { logInfo } from './log-service.js';
// ═══════════════════════════════════════════════════════════════
// R51: 工具结果离线存储
@@ -52,48 +52,17 @@ export function compactOldToolResult(msg: OllamaMessage): OllamaMessage {
}
// ═══════════════════════════════════════════════════════════════
// R52: 状态震荡检测
// 状态震荡 / 死循环检测(历史功能,检测逻辑已被移除;保留调用历史
// 数组以维持快照/恢复 API 的兼容性)
// ═══════════════════════════════════════════════════════════════
const _toolCallHistory: string[] = [];
const MAX_HISTORY_LEN = 8;
/** 检测工具调用序列是否存在震荡模式(A→B→A→B */
export function detectOscillation(): boolean {
if (_toolCallHistory.length < 4) return false;
const len = _toolCallHistory.length;
const a = _toolCallHistory[len - 4];
const b = _toolCallHistory[len - 3];
const c = _toolCallHistory[len - 2];
const d = _toolCallHistory[len - 1];
return a === c && b === d && a !== b;
}
/** 记录工具调用到历史序列 */
/** 记录工具调用到历史序列(供快照/恢复 */
export function recordToolCallHistory(toolName: string, args: Record<string, unknown>): void {
const key = `${toolName}:${JSON.stringify(args, Object.keys(args).sort()).slice(0, 100)}`;
_toolCallHistory.push(key);
if (_toolCallHistory.length > MAX_HISTORY_LEN) _toolCallHistory.shift();
}
/** 重置工具调用历史(新一轮对话开始时) */
export function resetToolCallHistory(): void {
_toolCallHistory.length = 0;
}
// ═══════════════════════════════════════════════════════════════
// R54: 增强死循环检测
// ═══════════════════════════════════════════════════════════════
/** 检测连续 N 次完全相同的工具调用 */
export function detectConsecutiveIdentical(minCount: number): { detected: boolean; toolName: string; count: number } {
if (_toolCallHistory.length < minCount) return { detected: false, toolName: '', count: 0 };
const recent = _toolCallHistory.slice(-minCount);
const allSame = recent.every(k => k === recent[0]);
if (allSame) {
return { detected: true, toolName: recent[0].split(':')[0], count: minCount };
}
return { detected: false, toolName: '', count: 0 };
if (_toolCallHistory.length > 8) _toolCallHistory.shift();
}
// R56/R63 已删除:目标对齐验证 + 速率限制
@@ -370,44 +339,13 @@ export function recordErrorPattern(toolName: string, errorMsg: string): string |
// R104 已删除:工具结果去重
// ═══════════════════════════════════════════════════════════════
// R109: 工具参数消毒 — 防止通过工具参数注入恶意内容
// R109 已移除:工具参数消毒sanitizeToolArgs
// 该实现会污染 write_file 的 content 等数据型参数(把合法文本当作注入
// 模式替换掉),安全收益不抵数据破坏风险;注入防御由以下机制承担:
// - 主进程 checkPathAllowed / checkCommandAllowed / checkPublicHttpUrl
// - 系统提示词的数据边界标记(REFERENCE_DATA / TOOL_RESULT 信封)
// ═══════════════════════════════════════════════════════════════
/** R109: 消毒工具参数中的潜在注入内容 */
export function sanitizeToolArgs(toolName: string, args: Record<string, unknown>): Record<string, unknown> {
const sanitized = { ...args };
// 对字符串参数进行消毒
const stringKeys = ['content', 'command', 'query', 'text', 'old_text', 'new_text', 'message'];
for (const key of stringKeys) {
if (typeof sanitized[key] === 'string') {
sanitized[key] = sanitizeInjectionPatterns(sanitized[key] as string);
}
}
// R109: run_command 特殊处理 — 移除命令链中的注入尝试
if (toolName === 'run_command' && typeof sanitized.command === 'string') {
// 移除命令中的 prompt injection 尝试(如 `# 删除所有文件` 伪装为注释)
sanitized.command = (sanitized.command as string)
.replace(/#\s*(?:ignore|forget|override|disregard|忽略|忘记|覆盖)\s.*$/gim, '')
.replace(/\|\s*(?:sh|bash|zsh|powershell|cmd)\b/gi, '')
.trim();
}
return sanitized;
}
/** R109: 清理潜在的 prompt injection 模式 */
function sanitizeInjectionPatterns(text: string): string {
if (!text || text.length < 20) return text;
// 仅清理明显的注入模式,保留正常文本
return text
.replace(/ignore\s+(all\s+)?previous\s+(instructions?|prompts?)/gi, '[已过滤]')
.replace(/forget\s+(all\s+)?(instructions?|prompts?|rules?)/gi, '[已过滤]')
.replace(/disregard\s+(all|any|previous)\s+(instructions?|rules?)/gi, '[已过滤]')
.replace(/忽略.{0,4}(之前|前面|以上|所有).{0,4}(指令|提示|规则|系统)/g, '[已过滤]');
}
// ═══════════════════════════════════════════════════════════════
// R112: 诊断系统 — 收集 Agent 运行状态用于调试和优化
// ═══════════════════════════════════════════════════════════════
@@ -604,62 +542,8 @@ export function smartTruncateByToolType(
}
}
// ═══════════════════════════════════════════════════════════════
// R99: 工具结果引用解析 — 从归档存储检索完整工具结果
// ═══════════════════════════════════════════════════════════════
/**
* R99: 根据 refId 检索归档的完整工具结果
* 当模型在上下文中看到 [工具结果已归档 ref=xxx] 标记时,
* 可以通过此函数获取完整内容
*/
export function retrieveToolResult(refId: string): { toolName: string; fullContent: string; timestamp: number } | null {
const entry = _toolResultStore.get(refId);
if (!entry) return null;
return { ...entry };
}
/**
* R99: 从文本中提取工具结果引用 ID
* 匹配格式: [工具结果已归档 ref=toolref_xxx_yyy]
*/
export function extractToolResultRefs(text: string): string[] {
const matches = text.match(/\[工具结果已归档\s+ref=(toolref_[\w_]+)\]/g);
if (!matches) return [];
return matches.map(m => {
const idMatch = m.match(/ref=(toolref_[\w_]+)/);
return idMatch ? idMatch[1] : '';
}).filter(Boolean);
}
/**
* R99: 检查消息中是否引用了归档的工具结果,
* 如果有则返回需要检索的引用信息
*/
export function checkArchivedReferences(messages: Array<{ content?: string }>): Array<{ refId: string; toolName: string; fullContent: string }> {
const results: Array<{ refId: string; toolName: string; fullContent: string }> = [];
const seen = new Set<string>();
for (const msg of messages) {
const content = msg.content || '';
if (!content.includes('[工具结果已归档')) continue;
const refIds = extractToolResultRefs(content);
for (const refId of refIds) {
if (seen.has(refId)) continue;
seen.add(refId);
const retrieved = retrieveToolResult(refId);
if (retrieved) {
results.push({
refId,
toolName: retrieved.toolName,
fullContent: retrieved.fullContent,
});
}
}
}
return results;
}
// R99 已移除:工具结果引用解析(retrieveToolResult / checkArchivedReferences
// 归档结果暂无工具可取回;如需查看完整结果,模型按归档提示重新调用原工具
// ═══════════════════════════════════════════════════════════════
// 统一重置(新会话开始时调用)
@@ -841,7 +725,7 @@ export function formatErrorRecovery(suggestion: ErrorRecoverySuggestion): string
}
// ═══════════════════════════════════════════════════════════════
// R118: Agent 循环性能分析 — 识别 Agent Loop 瓶颈
// R118: 循环计时数据(供安全状态快照/恢复使用;报告生成已移除)
// ═══════════════════════════════════════════════════════════════
export interface LoopTiming {
@@ -852,333 +736,4 @@ export interface LoopTiming {
}
const _loopTimings: LoopTiming[] = [];
const MAX_TIMING_ENTRIES = 200;
/** R118: 记录阶段执行时间 */
export function recordLoopTiming(loop: number, phase: string, durationMs: number): void {
_loopTimings.push({ loop, phase, durationMs, timestamp: Date.now() });
if (_loopTimings.length > MAX_TIMING_ENTRIES) {
_loopTimings.shift();
}
}
/** R118: 生成性能分析报告 */
export function generatePerformanceReport(): {
totalLoops: number;
avgLoopTime: number;
slowestPhase: string;
phaseTimings: Record<string, { avg: number; max: number; count: number }>;
bottlenecks: string[];
} {
if (_loopTimings.length === 0) {
return {
totalLoops: 0,
avgLoopTime: 0,
slowestPhase: '',
phaseTimings: {},
bottlenecks: ['无性能数据'],
};
}
// 按阶段汇总
const phaseMap: Record<string, { total: number; max: number; count: number }> = {};
const loopTotals: Record<number, number> = {};
for (const t of _loopTimings) {
if (!phaseMap[t.phase]) {
phaseMap[t.phase] = { total: 0, max: 0, count: 0 };
}
phaseMap[t.phase].total += t.durationMs;
phaseMap[t.phase].max = Math.max(phaseMap[t.phase].max, t.durationMs);
phaseMap[t.phase].count++;
loopTotals[t.loop] = (loopTotals[t.loop] || 0) + t.durationMs;
}
// 计算平均值
const phaseTimings: Record<string, { avg: number; max: number; count: number }> = {};
for (const [phase, data] of Object.entries(phaseMap)) {
phaseTimings[phase] = {
avg: Math.round(data.total / data.count),
max: data.max,
count: data.count,
};
}
// 找到最慢的阶段
let slowestPhase = '';
let slowestAvg = 0;
for (const [phase, data] of Object.entries(phaseTimings)) {
if (data.avg > slowestAvg) {
slowestAvg = data.avg;
slowestPhase = phase;
}
}
// 计算平均每轮时间
const loopTimes = Object.values(loopTotals);
const avgLoopTime = loopTimes.length > 0
? Math.round(loopTimes.reduce((s, t) => s + t, 0) / loopTimes.length)
: 0;
// 识别瓶颈
const bottlenecks: string[] = [];
if (slowestAvg > 5000) {
bottlenecks.push(`⚠️ ${slowestPhase} 阶段平均耗时 ${slowestAvg}ms,是主要瓶颈`);
}
if (avgLoopTime > 30000) {
bottlenecks.push(`⚠️ 平均每轮 ${avgLoopTime}ms,整体速度较慢`);
}
// 检查是否有异常慢的单次执行
for (const [phase, data] of Object.entries(phaseTimings)) {
if (data.max > data.avg * 3) {
bottlenecks.push(`⚠️ ${phase} 阶段最大耗时 ${data.max}ms 远超平均 ${data.avg}ms,可能存在异常`);
}
}
if (bottlenecks.length === 0) {
bottlenecks.push('✅ 未检测到明显性能瓶颈');
}
return {
totalLoops: loopTimes.length,
avgLoopTime,
slowestPhase,
phaseTimings,
bottlenecks,
};
}
/** R118: 格式化性能报告 */
export function formatPerformanceReport(): string {
const report = generatePerformanceReport();
const lines = [
`Agent Loop 性能分析 (${report.totalLoops} 轮)`,
`${'─'.repeat(40)}`,
`平均每轮耗时: ${report.avgLoopTime}ms`,
`最慢阶段: ${report.slowestPhase}`,
'',
'阶段明细:',
];
for (const [phase, data] of Object.entries(report.phaseTimings)) {
lines.push(` ${phase}: avg=${data.avg}ms max=${data.max}ms (${data.count}次)`);
}
lines.push('');
lines.push('瓶颈分析:');
for (const b of report.bottlenecks) {
lines.push(` ${b}`);
}
return lines.join('\n');
}
// ═══════════════════════════════════════════════════════════════
// R114: 工具调用依赖图 — 分析工具间依赖关系优化并行执行
// ═══════════════════════════════════════════════════════════════
interface ToolDependency {
tool: string;
dependsOn: string[]; // 依赖的其他工具(必须先完成)
produces: string[]; // 产出(文件路径等)
consumes: string[]; // 消费(文件路径等)
}
/** R114: 从工具调用序列推断依赖关系 */
export function inferToolDependencies(
toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>
): ToolDependency[] {
const dependencies: ToolDependency[] = [];
const fileProducers = new Map<string, string>(); // filePath → toolName
for (const call of toolCalls) {
const dep: ToolDependency = {
tool: call.name,
dependsOn: [],
produces: [],
consumes: [],
};
// write_file/create_directory 产生文件
if (call.name === 'write_file' && call.arguments.path) {
const path = String(call.arguments.path);
dep.produces.push(path);
fileProducers.set(path, call.name);
}
// read_file/edit_file/delete_file 消费文件
if (['read_file', 'edit_file', 'delete_file'].includes(call.name)) {
// 支持 path 单个路径和 paths 数组
const pathsToCheck: string[] = [];
if (call.arguments.path) pathsToCheck.push(String(call.arguments.path));
if (Array.isArray(call.arguments.paths)) pathsToCheck.push(...(call.arguments.paths as unknown[]).map(p => String(p)));
for (const path of pathsToCheck) {
dep.consumes.push(path);
const producer = fileProducers.get(path);
if (producer) {
dep.dependsOn.push(producer);
}
}
}
// run_command 可能消费前面产生的文件
if (call.name === 'run_command' && call.arguments.command) {
const cmd = String(call.arguments.command);
for (const [filePath, producer] of fileProducers) {
if (cmd.includes(filePath)) {
dep.consumes.push(filePath);
dep.dependsOn.push(producer);
}
}
}
dependencies.push(dep);
}
return dependencies;
}
/** R114: 基于依赖关系对工具调用分组(可并行执行的分为一组) */
export function groupToolsByDependency(
toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>
): Array<Array<{ name: string; arguments: Record<string, unknown> }>> {
const deps = inferToolDependencies(toolCalls);
const groups: Array<Array<{ name: string; arguments: Record<string, unknown> }>> = [];
const completed = new Set<string>();
let remaining = [...toolCalls.map((tc, i) => ({ ...tc, index: i }))];
while (remaining.length > 0) {
const currentBatch: Array<{ name: string; arguments: Record<string, unknown> }> = [];
const batchIndices = new Set<number>();
for (const tc of remaining) {
const dep = deps[tc.index];
// 检查所有依赖是否已完成
const canRun = dep.dependsOn.every(d => completed.has(d));
if (canRun) {
currentBatch.push({ name: tc.name, arguments: tc.arguments });
batchIndices.add(tc.index);
}
}
if (currentBatch.length === 0) {
// 没有可执行的(可能有循环依赖),强制执行剩余的
groups.push(remaining.map(tc => ({ name: tc.name, arguments: tc.arguments })));
break;
}
groups.push(currentBatch);
for (const idx of batchIndices) {
completed.add(toolCalls[idx].name);
}
remaining = remaining.filter(tc => !batchIndices.has(tc.index));
}
return groups;
}
// ═══════════════════════════════════════════════════════════════
// R117: 记忆搜索相关性调优 — 微调记忆搜索评分权重
// ═══════════════════════════════════════════════════════════════
export interface MemorySearchConfig {
idfWeight: number; // IDF 权重
fuzzyWeight: number; // 模糊匹配权重
phraseBonus: number; // 多词短语奖励
recencyBonus: number; // 时近性奖励
frequencyBonus: number; // 访问频率奖励
}
const _memorySearchConfig: MemorySearchConfig = {
idfWeight: 1.0,
fuzzyWeight: 0.5,
phraseBonus: 2.0,
recencyBonus: 0.3,
frequencyBonus: 0.2,
};
/** R117: 获取当前记忆搜索配置 */
export function getMemorySearchConfig(): MemorySearchConfig {
return { ..._memorySearchConfig };
}
/** R117: 更新记忆搜索配置 */
export function updateMemorySearchConfig(updates: Partial<MemorySearchConfig>): void {
Object.assign(_memorySearchConfig, updates);
logInfo(`R117: 记忆搜索配置已更新`, JSON.stringify(_memorySearchConfig));
}
/** R117: 根据搜索效果自动调优 */
export function autoTuneMemorySearch(
avgResultCount: number,
avgRelevanceScore: number
): { tuned: boolean; changes: string[] } {
const changes: string[] = [];
// 如果结果太多但相关性低,增加 IDF 权重
if (avgResultCount > 10 && avgRelevanceScore < 0.3) {
_memorySearchConfig.idfWeight += 0.2;
changes.push(`IDF 权重增加到 ${_memorySearchConfig.idfWeight.toFixed(1)}(提高区分度)`);
}
// 如果结果太少,降低模糊匹配阈值
if (avgResultCount < 2) {
_memorySearchConfig.fuzzyWeight += 0.1;
changes.push(`模糊匹配权重增加到 ${_memorySearchConfig.fuzzyWeight.toFixed(1)}(放宽匹配)`);
}
// 如果相关性分数高但结果少,增加频率奖励
if (avgRelevanceScore > 0.7 && avgResultCount < 5) {
_memorySearchConfig.frequencyBonus += 0.1;
changes.push(`频率奖励增加到 ${_memorySearchConfig.frequencyBonus.toFixed(1)}(优先高频条目)`);
}
return { tuned: changes.length > 0, changes };
}
// R119 已删除:工具优先级排序
// ═══════════════════════════════════════════════════════════════
// R124: 压缩上下文中工具引用解析 — 恢复被压缩的工具结果引用
// ═══════════════════════════════════════════════════════════════
/** R124: 在压缩后的上下文中解析工具引用 */
export function resolveCompressedReferences(
messages: Array<{ role: string; content: string }>
): { resolved: number; unresolved: string[] } {
let resolved = 0;
const unresolved: string[] = [];
for (const msg of messages) {
if (msg.role !== 'tool') continue;
const content = msg.content || '';
// 查找引用标记
const refMatch = content.match(/\[工具结果已归档 ref=(\S+)/);
if (refMatch) {
const refId = refMatch[1];
const stored = _toolResultStore.get(refId);
if (stored) {
resolved++;
} else {
unresolved.push(refId);
}
}
}
return { resolved, unresolved };
}
/** R124: 恢复压缩引用为完整内容(仅对指定引用) */
export function restoreCompressedReference(
refId: string,
maxLen?: number
): string | null {
const stored = _toolResultStore.get(refId);
if (!stored) return null;
const content = stored.fullContent;
if (maxLen && content.length > maxLen) {
return content.slice(0, maxLen) + `\n...(已截断,完整内容 ${content.length} 字符)`;
}
return content;
}
-230
View File
@@ -1,230 +0,0 @@
/**
* Context Indexer — 渐进式披露模块
* Harness Engineering: 三级上下文管理
*
* 索引层 (Index) — 始终保留:项目结构树 + 入口文件地图 + 技术栈摘要
* 接口层 (Interface) — 按需加载:模块 API 声明 + 类型定义 + 配置文件
* 实现层 (Implementation) — 修改时加载:具体源代码
*
* 设计理念:
* - 用目录式索引告诉智能体"去哪找",而非"全记住"
* - 上下文可从数万 Token 压至几千
* - 通过 load_context 工具按需触发接口层和实现层的加载
*/
import { logInfo, logDebug, logWarn } from './log-service.js';
import { estimateTokens } from './context-manager.js';
import type { ProjectIndex, ContextTier } from '../types.js';
// ═══════════════════════════════════════════════════════════════
// 项目索引缓存
// ═══════════════════════════════════════════════════════════════
/** 项目索引缓存(5 分钟 TTL) */
let cachedIndex: ProjectIndex | null = null;
let cacheTimestamp = 0;
const INDEX_CACHE_TTL = 5 * 60 * 1000; // 5 分钟
/** 最大索引 Token 预算 */
const MAX_INDEX_TOKENS = 2000;
/**
* 构建项目索引
* 扫描工作空间目录结构,生成精简的结构摘要
*/
export async function buildProjectIndex(workspaceDir: string): Promise<ProjectIndex> {
// 检查缓存
if (cachedIndex && Date.now() - cacheTimestamp < INDEX_CACHE_TTL) {
return cachedIndex;
}
try {
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) {
return createEmptyIndex();
}
// 利用现有 tree 工具扫描目录结构(限制深度 3 层)
const treeResult = await bridge.tool.execute('tree', {
path: workspaceDir,
max_depth: 3,
include_hidden: false,
});
let structure = '';
if (treeResult.success && treeResult.tree) {
structure = String(treeResult.tree);
// Token 预算截断
if (estimateTokens(structure) > MAX_INDEX_TOKENS) {
const lines = structure.split('\n');
structure = lines.slice(0, Math.min(lines.length, 60)).join('\n')
+ '\n... (目录结构已截断,使用 list_directory 查看完整内容)';
}
} else {
structure = '(无法读取工作空间目录结构)';
}
// 识别入口文件
const entryFiles: string[] = [];
const commonEntries = [
'package.json', 'tsconfig.json', 'vite.config.ts',
'main.ts', 'index.ts', 'index.html', 'app.ts',
'Cargo.toml', 'pyproject.toml', 'go.mod', 'CMakeLists.txt',
'README.md', 'Makefile', 'docker-compose.yml',
];
for (const entry of commonEntries) {
try {
// 跨平台路径拼接(清理尾部斜杠,统一用 posix 风格,Node.js 可容错处理)
const cleanDir = workspaceDir.replace(/[\\/]+$/, '');
const filePath = cleanDir + '/' + entry;
const checkResult = await bridge.workspace.readFile(filePath);
if (checkResult?.success) {
entryFiles.push(entry);
}
} catch { /* ignore */ }
}
// 检测技术栈
const techStack = detectTechStack(entryFiles);
const index: ProjectIndex = {
structure,
entryFiles,
techStack,
tokenCount: estimateTokens(structure),
generatedAt: Date.now(),
};
cachedIndex = index;
cacheTimestamp = Date.now();
logInfo('项目索引已构建', `${techStack.join(', ')}, ${entryFiles.length} 入口文件, ${index.tokenCount} tokens`);
return index;
} catch (err) {
logWarn('项目索引构建失败', (err as Error).message);
return createEmptyIndex();
}
}
/** 创建空索引 */
function createEmptyIndex(): ProjectIndex {
return {
structure: '(未检测到工作空间)',
entryFiles: [],
techStack: [],
tokenCount: 0,
generatedAt: Date.now(),
};
}
/** 根据入口文件检测技术栈 */
function detectTechStack(entryFiles: string[]): string[] {
const stack: string[] = [];
const fileSet = new Set(entryFiles.map(f => f.toLowerCase()));
if (fileSet.has('package.json')) stack.push('Node.js');
if (fileSet.has('tsconfig.json')) stack.push('TypeScript');
if (fileSet.has('vite.config.ts')) stack.push('Vite');
if (fileSet.has('cargo.toml')) stack.push('Rust');
if (fileSet.has('pyproject.toml')) stack.push('Python');
if (fileSet.has('go.mod')) stack.push('Go');
if (fileSet.has('cmakelists.txt')) stack.push('C/C++');
if (fileSet.has('docker-compose.yml')) stack.push('Docker');
if (fileSet.has('makefile')) stack.push('Make');
return stack.length > 0 ? stack : ['未知'];
}
/**
* 生成索引层系统提示词
* 始终保留在上下文中,告诉 AI "去哪找"
*/
export function buildIndexContext(index: ProjectIndex): string {
if (!index.structure || index.tokenCount === 0) return '';
let context = `【项目索引 — 始终可见】
项目结构:
${index.structure}
技术栈: ${index.techStack.join(', ') || '未检测'}
入口文件: ${index.entryFiles.length > 0 ? index.entryFiles.join(', ') : '未检测'}
💡 使用 list_directory 查看目录详情,使用 read_file 读取具体文件。
💡 使用 search_files 按内容搜索代码。
`;
// Token 预算控制
if (estimateTokens(context) > MAX_INDEX_TOKENS) {
context = context.slice(0, Math.floor(context.length * 0.8)) + '\n... (索引已截断)';
}
return context;
}
/**
* 构建接口层上下文(按需加载)
* @param modulePattern 模块匹配模式,如 "src/services/"
*/
export async function buildInterfaceContext(modulePattern: string, workspaceDir: string): Promise<string> {
try {
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) return '';
// 搜索模块相关的类型定义和配置文件
const searchResult = await bridge.tool.execute('search_files', {
path: workspaceDir,
query: modulePattern,
search_type: 'filename',
max_results: 10,
});
if (!searchResult.success || !(searchResult as any).results?.length) {
return `(未找到与 "${modulePattern}" 相关的接口文件)`;
}
const results = (searchResult as any).results as Array<{ path: string }>;
const paths = results.map(r => r.path).slice(0, 8);
// 批量读取接口文件(限制每文件 2000 字符)
const readResult = await bridge.tool.execute('read_multiple_files', {
paths,
max_chars_per_file: 2000,
});
if (readResult.success) {
const filesInfo = paths.map(p => ` 📄 ${p}`).join('\n');
return `【接口层 — ${modulePattern}
相关文件:
${filesInfo}
内容预览:
${JSON.stringify((readResult as any).files)}`;
}
return `【接口层 — ${modulePattern}
相关文件:${paths.join(', ')}`;
} catch (err) {
logWarn('接口层上下文加载失败', (err as Error).message);
return '';
}
}
/**
* 加载指定层级的上下文
*/
export async function loadContextByTier(
tier: ContextTier,
modulePattern: string,
workspaceDir: string,
): Promise<string> {
switch (tier) {
case 'index': {
const index = await buildProjectIndex(workspaceDir);
return buildIndexContext(index);
}
case 'interface':
return buildInterfaceContext(modulePattern, workspaceDir);
case 'implementation':
// 实现层由 Agent 自行通过 read_file 加载
return '';
}
}
+11 -382
View File
@@ -155,27 +155,17 @@ export function predictContextOverflow(numCtx: number): ContextPrediction {
return { level, currentUsage, predictedUsage, turnsToOverflow, message };
}
/** R18: 获取 token 使用趋势数据(供调试用) */
export function getTokenUsageTrend(): TokenUsagePoint[] {
return [..._tokenUsageTrend];
}
// ── Token 校准系统 ──
/** 校准比例:actualTokens / estimatedTokens,基于 Ollama 返回的实际计数动态修正 */
// ── Token 估算校准状态 ──
let _calibrationModel = '';
let _tokenCalibrationRatio = 1.0;
let _calibrationSamples = 0;
let _calibrationModel = ''; // C8: 记录校准时的模型名
const MIN_CALIBRATION_SAMPLES = 3;
const MIN_CALIBRATION_SAMPLES = 5;
/** 自动压缩触发阈值(占上下文窗口比例) */
export const AUTO_COMPRESS_THRESHOLD = 0.5;
/**
* 记录 Ollama 返回的实际 token 计数,用于校准估算器。
* 在 agent-engine.ts 每轮流式完成后调用。
* C8: 模型切换时自动重置校准比例,避免不同 tokenizer 导致估算失真
* @param actualInputTokens Ollama 返回的 prompt_eval_count
* @param actualOutputTokens Ollama 返回的 eval_count
* @param estimatedTokens 本轮消息调用 estimateTokens 的合计值
* @param modelName 当前使用的模型名
*/
export function recordActualTokens(actualInputTokens: number, actualOutputTokens: number, estimatedCount: number, modelName?: string): void {
// C8: 模型切换时重置校准
@@ -212,19 +202,7 @@ export function estimateTokens(text: string): number {
return raw;
}
/** 获取当前校准比例(供调试用) */
export function getTokenCalibration(): { ratio: number; samples: number } {
return { ratio: _tokenCalibrationRatio, samples: _calibrationSamples };
}
/** 自动压缩阈值:当消息 token 占 context window 比例超过此值时触发自动压缩
* P2 #7 修复:从 0.3 提高到 0.5,避免过于频繁的压缩导致信息丢失
*/
export const AUTO_COMPRESS_THRESHOLD = 0.5;
/** R14: 自适应压缩阈值 — 根据模型上下文长度动态调整
* P2 #7 修复:提高各档位阈值,减少不必要的压缩
*/
/** 自适应压缩阈值 — 根据模型上下文长度动态调整 */
export function getAdaptiveCompressThreshold(numCtx: number): number {
// 小上下文模型(<8K):更早触发压缩(55%),留余量
// 中等上下文(8K-32K):标准阈值(50%)
@@ -1580,235 +1558,6 @@ export function chooseCompressionStrategy(
};
}
// ═══════════════════════════════════════════════════════════════
// 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: 会话摘要持久化 — 跨会话引用
// ═══════════════════════════════════════════════════════════════
@@ -1862,7 +1611,7 @@ export function generateSessionSummary(
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(),
@@ -1886,7 +1635,7 @@ export function formatSessionSummariesForContext(summaries: SessionSummary[]): s
}
// ═══════════════════════════════════════════════════════════════
// R125: Agent 状态检查点 — 保存和恢复 Agent 状态
// R125: Agent 状态检查点 — 保存 Agent 运行状态(恢复 API 见后续迭代)
// ═══════════════════════════════════════════════════════════════
export interface AgentCheckpoint {
@@ -1919,138 +1668,18 @@ export function createCheckpoint(
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;
}
+1 -7
View File
@@ -110,13 +110,7 @@ function contentFingerprint(content: string): string {
return hash.toString(16);
}
/** 暴露写入记录供 agent-engine 清理 */
export function clearWrittenFiles(): void {
_writtenFileFingerprints.clear();
}
export function hasWrittenFile(path: string): boolean {
return _writtenFileFingerprints.has(path);
}
/** 暴露写入记录供 agent-engine 记录成功路径 */
export function addWrittenFile(path: string, content?: string): void {
_writtenFileFingerprints.set(path, content ? contentFingerprint(content) : '');
}
+12 -285
View File
@@ -1,303 +1,30 @@
/**
* Infrastructure Service - 基础设施服务 (R41-R50)
* 内存泄漏防护、全局错误处理、性能监控、配置验证、健康检查
* Infrastructure Service - 基础设施服务
* 全局错误边界:统一捕获未处理异常(渲染进程唯一定义,避免重复弹窗)
*/
import { logInfo, logWarn, logError } from './log-service.js';
// ══════════════════════════════════════════════
// R41: 内存泄漏防护 — 事件监听器管理
// ══════════════════════════════════════════════
/** R41: 已注册的事件监听器追踪表 */
const _trackedListeners = new Map<string, { target: EventTarget; type: string; listener: EventListenerOrEventListenerObject; options?: boolean | AddEventListenerOptions }>();
let _listenerIdCounter = 0;
import { logError, logInfo } from './log-service.js';
import { showToast } from '../components/toast.js';
/**
* R41: 注册并追踪事件监听器,便于统一清理
* @returns 监听器 ID,可用于单独移除
* 初始化全局错误处理(应用启动时调用一次)
* - 未捕获 JS 错误 / Promise rejection → 日志 + Toast
* - 阻止默认行为(避免原生错误对话框)
*/
export function trackEventListener(
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): string {
const id = `listener_${++_listenerIdCounter}`;
_trackedListeners.set(id, { target, type, listener, options });
target.addEventListener(type, listener, options);
return id;
}
/** R41: 移除单个事件监听器 */
export function removeTrackedListener(id: string): void {
const entry = _trackedListeners.get(id);
if (entry) {
entry.target.removeEventListener(entry.type, entry.listener, entry.options);
_trackedListeners.delete(id);
}
}
/** R41: 清理所有已追踪的事件监听器(用于页面卸载或会话切换时) */
export function cleanupAllListeners(): void {
let count = 0;
for (const [id, entry] of _trackedListeners) {
try {
entry.target.removeEventListener(entry.type, entry.listener, entry.options);
count++;
} catch { /* ignore */ }
_trackedListeners.delete(id);
}
if (count > 0) {
logInfo(`R41: 已清理 ${count} 个事件监听器`);
}
}
/** R41: 获取当前追踪的监听器数量(供调试用) */
export function getTrackedListenerCount(): number {
return _trackedListeners.size;
}
// ══════════════════════════════════════════════
// R42: 全局错误边界 — 捕获未处理的异常
// ══════════════════════════════════════════════
/** R42: 初始化全局错误处理 */
export function initGlobalErrorHandler(): void {
// 捕获未处理的 JS 错误
window.addEventListener('error', (e) => {
logError('R42: 未捕获错误', `${e.message} @ ${e.filename}:${e.lineno}:${e.colno}`);
// 阻止默认的错误处理(避免弹出丑陋的错误对话框)
logError('未捕获错误', `${e.message} @ ${e.filename}:${e.lineno}:${e.colno}`);
showToast(`发生错误: ${e.message}`, 'error', 5000);
e.preventDefault();
});
// 捕获未处理的 Promise rejection
window.addEventListener('unhandledrejection', (e) => {
const reason = e.reason;
const msg = reason instanceof Error ? reason.message : String(reason);
logError('R42: 未处理的 Promise Rejection', msg);
logError('未处理的 Promise Rejection', msg);
showToast(`操作失败: ${msg}`, 'error', 5000);
e.preventDefault();
});
logInfo('R42: 全局错误处理器已初始化');
}
// ══════════════════════════════════════════════
// R43: 性能监控 — 关键操作耗时追踪
// ══════════════════════════════════════════════
export interface PerfMetric {
name: string;
duration: number;
timestamp: number;
metadata?: Record<string, unknown>;
}
const _perfMetrics: PerfMetric[] = [];
const MAX_PERF_METRICS = 200;
const _perfTimers = new Map<string, number>();
/** R43: 开始性能计时 */
export function perfStart(name: string): void {
_perfTimers.set(name, performance.now());
}
/** R43: 结束性能计时并记录 */
export function perfEnd(name: string, metadata?: Record<string, unknown>): number {
const startTime = _perfTimers.get(name);
if (startTime === undefined) {
logWarn(`R43: perfEnd 未找到对应的 perfStart: ${name}`);
return 0;
}
const duration = performance.now() - startTime;
_perfTimers.delete(name);
_perfMetrics.push({ name, duration, timestamp: Date.now(), metadata });
// 超过上限时移除最早的
if (_perfMetrics.length > MAX_PERF_METRICS) {
_perfMetrics.shift();
}
// 慢操作警告(超过 1 秒)
if (duration > 1000) {
logWarn(`R43: 慢操作: ${name} 耗时 ${duration.toFixed(0)}ms`);
}
return duration;
}
/** R43: 获取性能指标 */
export function getPerfMetrics(): PerfMetric[] {
return [..._perfMetrics];
}
/** R43: 获取平均性能指标 */
export function getAvgPerfMetric(name: string): number {
const metrics = _perfMetrics.filter(m => m.name === name);
if (metrics.length === 0) return 0;
return metrics.reduce((sum, m) => sum + m.duration, 0) / metrics.length;
}
/** R43: 清空性能指标 */
export function clearPerfMetrics(): void {
_perfMetrics.length = 0;
_perfTimers.clear();
}
// ══════════════════════════════════════════════
// R48: 配置验证 — 启动时验证关键配置
// ══════════════════════════════════════════════
export interface ConfigValidationResult {
valid: boolean;
warnings: string[];
errors: string[];
}
/** R48: 验证应用配置 */
export function validateConfig(config: Record<string, unknown>): ConfigValidationResult {
const warnings: string[] = [];
const errors: string[] = [];
// 验证 numCtx
const numCtx = config.numCtx as number;
if (numCtx !== undefined) {
if (numCtx < 2048) {
warnings.push(`numCtx=${numCtx} 过小,可能导致上下文截断。建议至少 4096。`);
}
if (numCtx > 131072) {
warnings.push(`numCtx=${numCtx} 过大,可能导致内存不足。建议不超过 131072。`);
}
}
// 验证 temperature
const temperature = config.temperature as number;
if (temperature !== undefined) {
if (temperature < 0 || temperature > 2) {
errors.push(`temperature=${temperature} 超出有效范围 [0, 2]`);
}
}
// 验证 maxTurns
const maxTurns = config.maxTurns as number;
if (maxTurns !== undefined) {
if (maxTurns < 1) {
errors.push(`maxTurns=${maxTurns} 不能小于 1`);
}
if (maxTurns > 50) {
warnings.push(`maxTurns=${maxTurns} 过大,可能导致长时间运行。建议不超过 20。`);
}
}
// 验证 streamTimeout
const streamTimeout = config.streamTimeout as number;
if (streamTimeout !== undefined) {
if (streamTimeout < 10000) {
warnings.push(`streamTimeout=${streamTimeout} 过短,可能导致大模型生成被中断。建议至少 30000ms。`);
}
}
// 验证 subAgentTimeout
const subAgentTimeout = config.subAgentTimeout as number;
if (subAgentTimeout !== undefined && subAgentTimeout < 5000) {
warnings.push(`subAgentTimeout=${subAgentTimeout} 过短,子代理可能无法完成任务。`);
}
return {
valid: errors.length === 0,
warnings,
errors,
};
}
// ══════════════════════════════════════════════
// R50: 健康检查 — 系统健康监控
// ══════════════════════════════════════════════
export interface HealthCheckResult {
status: 'healthy' | 'degraded' | 'unhealthy';
checks: Array<{ name: string; status: 'pass' | 'fail' | 'warn'; message: string }>;
timestamp: number;
}
/** R50: 执行系统健康检查 */
export async function runHealthCheck(): Promise<HealthCheckResult> {
const checks: Array<{ name: string; status: 'pass' | 'fail' | 'warn'; message: string }> = [];
// 检查 1: 桌面 API 可用性
const bridge = window.metonaDesktop;
if (bridge?.isDesktop) {
checks.push({ name: '桌面 API', status: 'pass', message: '桌面 API 可用' });
} else {
checks.push({ name: '桌面 API', status: 'fail', message: '桌面 API 不可用(Web 模式)' });
}
// 检查 2: 数据库可用性
if (bridge?.db) {
try {
const sessions = await bridge.db.getAllSessions();
checks.push({ name: '数据库', status: 'pass', message: `数据库正常(${sessions.length} 个会话)` });
} catch (err) {
checks.push({ name: '数据库', status: 'fail', message: `数据库访问失败: ${(err as Error).message}` });
}
} else {
checks.push({ name: '数据库', status: 'warn', message: '数据库 API 不可用' });
}
// 检查 3: 内存使用
const memInfo = (performance as any).memory;
if (memInfo) {
const usedMB = (memInfo.usedJSHeapSize / 1024 / 1024).toFixed(0);
const limitMB = (memInfo.jsHeapSizeLimit / 1024 / 1024).toFixed(0);
const usageRatio = memInfo.usedJSHeapSize / memInfo.jsHeapSizeLimit;
if (usageRatio > 0.8) {
checks.push({ name: '内存', status: 'warn', message: `内存使用较高: ${usedMB}/${limitMB}MB (${(usageRatio * 100).toFixed(0)}%)` });
} else {
checks.push({ name: '内存', status: 'pass', message: `内存使用正常: ${usedMB}/${limitMB}MB` });
}
} else {
checks.push({ name: '内存', status: 'pass', message: '内存监控不可用(非 Chromium' });
}
// 检查 4: 工作空间可用性
if (bridge?.workspace) {
try {
const result = await bridge.workspace.getDir();
if (result.dir) {
checks.push({ name: '工作空间', status: 'pass', message: `工作空间: ${result.dir}` });
} else {
checks.push({ name: '工作空间', status: 'warn', message: '工作空间未设置' });
}
} catch {
checks.push({ name: '工作空间', status: 'warn', message: '工作空间访问失败' });
}
}
// 检查 5: 事件监听器数量(内存泄漏检测)
const listenerCount = _trackedListeners.size;
if (listenerCount > 100) {
checks.push({ name: '事件监听器', status: 'warn', message: `追踪的事件监听器较多: ${listenerCount} 个,可能存在内存泄漏` });
} else {
checks.push({ name: '事件监听器', status: 'pass', message: `事件监听器数量正常: ${listenerCount}` });
}
// 确定整体状态
const hasFail = checks.some(c => c.status === 'fail');
const hasWarn = checks.some(c => c.status === 'warn');
const status: 'healthy' | 'degraded' | 'unhealthy' = hasFail ? 'unhealthy' : hasWarn ? 'degraded' : 'healthy';
return { status, checks, timestamp: Date.now() };
}
/** R50: 格式化健康检查结果为可读字符串 */
export function formatHealthCheck(result: HealthCheckResult): string {
const statusIcon = result.status === 'healthy' ? '✅' : result.status === 'degraded' ? '⚠️' : '❌';
const lines = [`${statusIcon} 系统健康检查 — ${result.status.toUpperCase()}`, ''];
for (const check of result.checks) {
const icon = check.status === 'pass' ? '✅' : check.status === 'warn' ? '⚠️' : '❌';
lines.push(`${icon} ${check.name}: ${check.message}`);
}
return lines.join('\n');
logInfo('全局错误处理器已初始化');
}
+90 -25
View File
@@ -65,8 +65,11 @@ const FILE_HEADER = `# METONA MEMORY
`;
/** 条目元数据正则: ## [类型] | id: [ID] | importance: [1-10] | tags: [tag1, tag2] */
const ENTRY_HEADER_RE = /^##\s+(fact|preference|rule)\s*\|\s*id:\s*(mem_\d{8}_\d{3})\s*\|\s*importance:\s*(\d{1,2})\s*\|\s*tags:\s*(.+)$/i;
/**
* 条目元数据正则: ## [类型] | id: [ID] | importance: [1-10] | tags: [tag1, tag2] [| hits: N | last: TS]
* hits/last 为可选的访问统计后缀(R106),旧格式无此后缀也可解析。
*/
const ENTRY_HEADER_RE = /^##\s+(fact|preference|rule)\s*\|\s*id:\s*(mem_\d{8}_\d{3})\s*\|\s*importance:\s*(\d{1,2})\s*\|\s*tags:\s+(.+?)(?:\s*\|\s*hits:\s*(\d+)\s*\|\s*last:\s*(\d+))?\s*$/i;
const VALID_TYPES: MemoryType[] = ['fact', 'preference', 'rule'];
@@ -181,8 +184,13 @@ export function parseMemoryMd(content: string): MemoryEntry[] {
const importance = parseInt(headerMatch[3], 10);
const tagsStr = headerMatch[4];
const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
const hits = headerMatch[5] ? parseInt(headerMatch[5], 10) : 0;
const last = headerMatch[6] ? parseInt(headerMatch[6], 10) : undefined;
currentEntry = { id, type, content: '', importance: Math.min(10, Math.max(1, importance)), tags };
currentEntry = {
id, type, content: '', importance: Math.min(10, Math.max(1, importance)), tags,
accessCount: hits, lastAccessed: last,
};
contentLines = [];
} else if (currentEntry) {
contentLines.push(line);
@@ -260,7 +268,8 @@ export function validateMemoryMd(content: string): { valid: boolean; error?: str
}
/**
* 将条目数组序列化为 MEMORY.md 内容
* 将条目数组序列化为 MEMORY.md 内容
* 访问统计(hits/last)随条目一并持久化,使 R106 的 TTL 访问保护真正生效。
*/
export function serializeMemoryMd(entries: MemoryEntry[]): string {
let content = FILE_HEADER;
@@ -268,7 +277,10 @@ export function serializeMemoryMd(entries: MemoryEntry[]): string {
for (const entry of entries) {
if (!entry.content.trim()) continue;
const tagsStr = entry.tags.join(', ');
content += `## ${entry.type} | id: ${entry.id} | importance: ${entry.importance} | tags: ${tagsStr}\n`;
const statsSuffix = (entry.accessCount && entry.accessCount > 0)
? ` | hits: ${entry.accessCount}${entry.lastAccessed ? ` | last: ${entry.lastAccessed}` : ''}`
: '';
content += `## ${entry.type} | id: ${entry.id} | importance: ${entry.importance} | tags: ${tagsStr}${statsSuffix}\n`;
content += entry.content.trim() + '\n\n';
}
@@ -500,6 +512,43 @@ function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
return result;
}
// ═══════════════════════════════════════════════════════════════
// 条目缓存(模块级单例)
// 1. 避免每次搜索/CRUD 都重新读文件并解析
// 2. 支持访问统计(hits/last)的延迟持久化(60s 防抖写回)
// ═══════════════════════════════════════════════════════════════
let _entriesCache: MemoryEntry[] | null = null;
let _hitsFlushTimer: ReturnType<typeof setTimeout> | null = null;
const HITS_FLUSH_INTERVAL = 60_000;
/** 使条目缓存失效(memory:init 重建文件 / 工作空间切换后调用) */
export function invalidateMemoryCache(): void {
_entriesCache = null;
}
/** 访问统计防抖写回:searchMemory 更新内存计数后,延迟 60s 持久化 */
function scheduleHitsFlush(): void {
if (_hitsFlushTimer) return;
_hitsFlushTimer = setTimeout(async () => {
_hitsFlushTimer = null;
try {
if (_entriesCache && _entriesCache.length > 0) {
await writeMemoryFile(serializeMemoryMd(_entriesCache));
}
} catch {
// 写回失败不影响主流程,下次访问会再次调度
}
}, HITS_FLUSH_INTERVAL);
}
/** 写入条目(同步更新缓存) */
async function persistEntries(entries: MemoryEntry[]): Promise<void> {
_entriesCache = entries;
const fileContent = entries.length > 0 ? serializeMemoryMd(entries) : '';
await writeMemoryFile(fileContent);
}
// ═══════════════════════════════════════════════════════════════
// CRUD 操作(全部通过专用 IPC 通道读写 MEMORY.md
// ═══════════════════════════════════════════════════════════════
@@ -520,20 +569,26 @@ function generateMemoryId(existingIds?: Set<string>): string {
return `mem_${dateStr}_${String(now.getTime() % 1000).padStart(3, '0')}`;
}
/** 加载全部条目 */
/** 加载全部条目(带缓存:读文件+解析一次,后续访问走内存) */
export async function loadAllEntries(): Promise<MemoryEntry[]> {
if (_entriesCache) return _entriesCache;
const content = await readMemoryFile();
if (!content.trim()) return [];
return parseMemoryMd(content);
_entriesCache = content.trim() ? parseMemoryMd(content) : [];
return _entriesCache;
}
/** 搜索记忆(读取 MEMORY.md解析搜索 */
/** 搜索记忆(读取缓存搜索更新访问计数并调度延迟写回 */
export async function search(query: string, limit = 0): Promise<MemorySearchResult[]> {
try {
// R57: 触发 TTL 衰减检查(带节流,不会每次搜索都执行)
// 触发 TTL 衰减检查(带节流,不会每次搜索都执行)
maybeRunTTLDecay().catch(() => {}); // 非阻塞,失败不影响搜索
const entries = await loadAllEntries();
return searchMemory(entries, query, limit);
const results = searchMemory(entries, query, limit);
if (results.length > 0) {
// 访问计数已在缓存条目上更新,延迟持久化
scheduleHitsFlush();
}
return results;
} catch (err) {
logWarn('记忆搜索失败', (err as Error).message);
return [];
@@ -611,7 +666,7 @@ export async function addEntry(
throw new Error(`序列化后校验失败: ${validation.error}`);
}
await writeMemoryFile(fileContent);
await persistEntries(entries);
logMemory(`新增: ${type}`, content.slice(0, 60));
return entry;
});
@@ -659,7 +714,7 @@ export async function replaceEntry(oldText: string, newContent: string): Promise
return { success: false, message: `序列化后校验失败: ${validation.error}` };
}
await writeMemoryFile(fileContent);
await persistEntries(entries);
logMemory('替换记忆', `${target.id}: ${oldText.slice(0, 30)}${newContent.slice(0, 30)}`);
return { success: true, message: `已替换记忆: ${target.id}` };
});
@@ -687,14 +742,7 @@ export async function removeEntry(oldText: string): Promise<{ success: boolean;
}
const newEntries = entries.filter(e => e.id !== matches[0].id);
const fileContent = newEntries.length > 0 ? serializeMemoryMd(newEntries) : '';
if (fileContent) {
await writeMemoryFile(fileContent);
} else {
// 清空文件(写入空内容让主进程删除或留空)
await writeMemoryFile('');
}
await persistEntries(newEntries);
logMemory('删除记忆', `${matches[0].id}: ${oldText.slice(0, 50)}`);
return { success: true, message: `已删除记忆: ${matches[0].id}` };
@@ -756,8 +804,8 @@ export async function removeEntries(oldTexts: string[]): Promise<{
}
const newEntries = entries.filter(e => !idsToDelete.has(e.id));
const fileContent = newEntries.length > 0 ? serializeMemoryMd(newEntries) : '';
await writeMemoryFile(fileContent);
await persistEntries(newEntries);
logMemory('批量删除', `删除 ${deleted} 条, 失败 ${failed}`);
return {
@@ -770,10 +818,26 @@ export async function removeEntries(oldTexts: string[]): Promise<{
});
}
/** 按 ID 精确删除记忆(UI 面板使用,避免子串误匹配) */
export async function removeEntryById(id: string): Promise<{ success: boolean; message: string }> {
return withWriteLock(async () => {
const entries = await loadAllEntries();
const target = entries.find(e => e.id === id);
if (!target) {
return { success: false, message: `记忆 ${id} 不存在` };
}
const newEntries = entries.filter(e => e.id !== id);
await persistEntries(newEntries);
logMemory('删除记忆', `${id}: ${target.content.slice(0, 50)}`);
return { success: true, message: `已删除记忆: ${id}` };
});
}
/** 清空所有记忆
* M8: 使用写入锁串行化 */
export async function clearAll(): Promise<void> {
return withWriteLock(async () => {
_entriesCache = [];
await writeMemoryFile('');
logMemory('清空', '所有记忆已删除');
});
@@ -1023,8 +1087,7 @@ async function maybeRunTTLDecay(): Promise<void> {
const { decayed, removed, changed } = applyTTLDecay(entries);
if (changed && removed > 0) {
const fileContent = decayed.length > 0 ? serializeMemoryMd(decayed) : '';
await writeMemoryFile(fileContent);
await persistEntries(decayed);
logMemory('TTL 衰减', `已持久化: 移除 ${removed} 条,剩余 ${decayed.length}`);
}
} catch (err) {
@@ -1249,6 +1312,8 @@ export async function initMemoryFile(): Promise<{ action: string; existed: boole
return { action: 'failed', existed: false, valid: false };
}
// 主进程可能重建了文件(格式错误时备份+重建),使条目缓存失效
invalidateMemoryCache();
// 日志已由主进程打印,渲染进程补充简要汇总
switch (result.action) {
case 'created':
+311
View File
@@ -0,0 +1,311 @@
/**
* Result Formatter — 工具结果 → 模型友好格式
* 从 agent-engine.ts 拆分的纯格式化模块(无状态,便于测试与复用)。
*/
import type { ToolResult } from '../types.js';
/** 生成工具审计摘要 — 用于审计日志记录 */
function summarizeAuditResult(toolName: string, result: ToolResult): string {
try {
switch (toolName) {
case 'write_file':
return `写入 ${result.path || ''} (${result.bytesWritten || 0}B${result.created ? ', 新建' : ''})`;
case 'edit_file':
return `编辑 ${result.path || ''} (${result.replaceCount || 0} 处替换)`;
case 'delete_file':
return result.batch ? `批量删除 ${result.successCount}/${result.totalPaths}` : `删除 ${result.path || ''}`;
case 'create_directory':
return `创建目录 ${result.path || ''}`;
case 'move_file':
return `移动 ${(result as Record<string, unknown>).source}${(result as Record<string, unknown>).destination}`;
case 'copy_file':
return `复制 ${(result as Record<string, unknown>).source}${(result as Record<string, unknown>).destination}`;
case 'run_command':
return `命令执行 ${result.exitCode === 0 ? '成功' : '失败'} (exit ${result.exitCode})`;
case 'git':
return `git ${result.action}`;
case 'download_file':
return `下载 ${(result as Record<string, unknown>).url}${(result as Record<string, unknown>).destination}`;
case 'compress':
return `${result.action}${(result as Record<string, unknown>).outputPath || (result as Record<string, unknown>).destination}`;
default:
return `${toolName} 完成`;
}
} catch {
return `${toolName} 完成`;
}
}
export { summarizeAuditResult };
/** 格式化工具结果的通用默认路径 */
function formatDefaultToolResult(toolName: string, result: ToolResult): string {
const clean: Record<string, unknown> = {};
for (const [k, v] of Object.entries(result)) {
if (k === 'success' || k === 'formatted' || k === 'content_type' ||
k === 'status' || k === 'length' || k === 'isDirectory') continue;
clean[k] = v;
}
return JSON.stringify(clean);
}
/**
* 格式化工具结果,生成模型友好的简洁表示
*/
export function formatToolResultForModel(toolName: string, result: ToolResult): string {
if (!result.success) {
return JSON.stringify({ success: false, error: result.error || '工具执行失败' });
}
switch (toolName) {
case 'web_search': {
const raw = result.results as Array<{ title: string; url: string; snippet: string }> | undefined;
if (!raw?.length) return JSON.stringify({ success: true, message: '未找到结果' });
const top = raw.map((r, i) =>
`[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}`
).join('\n\n');
const fetched = (result as Record<string, unknown>)._fetched as Array<{ url: string; title: string; content: string }> | undefined;
const body = JSON.stringify({
success: true, query: result.query, total: result.total, shown: raw.length, results: top,
});
if (fetched && fetched.length > 0) {
return body + '\n\n' + fetched.map((f, i) =>
`\n=== 📄 已抓取 ${i + 1}/${fetched.length}: ${f.title} ===\n${f.content}\n`
).join('\n---\n');
}
return body;
}
case 'web_fetch': {
const content = (result.content as string) || '';
return JSON.stringify({ success: true, url: result.url, content });
}
case 'read_file': {
return JSON.stringify({
success: true,
path: result.path,
content: result.content,
lines: result.lines,
truncated: result.truncated,
line_range: result.line_range
});
}
case 'read_multiple_files': {
return JSON.stringify({
success: true,
files: result.files,
total: result.total
});
}
case 'list_directory': {
return JSON.stringify({
success: true,
path: result.path,
entries: result.entries,
total: result.total,
truncated: result.truncated
});
}
case 'write_file': {
return JSON.stringify({
success: true,
path: result.path,
bytesWritten: result.bytesWritten,
created: result.created
});
}
case 'run_command': {
return JSON.stringify({
success: true,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
duration: result.duration
});
}
case 'git': {
return JSON.stringify({
success: true,
action: result.action,
output: result.output,
branch: result.branch,
files: result.files,
commits: result.commits
});
}
case 'search_files': {
return JSON.stringify({
success: true,
query: result.query,
total_matches: result.total_matches,
total_files: result.total_files,
results: result.results
});
}
case 'memory': {
// 去重信号改为软提醒,不触发强制终止
if ((result as Record<string, unknown>).duplicate) {
return JSON.stringify({ success: true, action: 'add', duplicate: true, message: `${(result as Record<string, unknown>).message || '相同内容已存在'}` });
}
// read_all / search 结果:包裹在 JSON 中以保持与其他工具一致的格式
if ((result as Record<string, unknown>).action === 'read_all') {
const entries = ((result as Record<string, unknown>).entries || []) as Array<{ id: string; type: string; content: string; importance: number; tags: string[] }>;
if (entries.length === 0) return JSON.stringify({ success: true, action: 'read_all', message: '记忆为空,没有任何已保存的记忆条目。', total: 0 });
const grouped: Record<string, typeof entries> = {};
for (const e of entries) {
const t = e.type || 'fact';
(grouped[t] ||= []).push(e);
}
const lines: string[] = [`[记忆读取结果] 共 ${entries.length} 条记忆,按类型分组:`];
const typeLabels: Record<string, string> = { rule: '规则(必须遵守)', preference: '偏好', fact: '事实' };
for (const [t, items] of Object.entries(grouped)) {
lines.push(`\n--- ${typeLabels[t] || t} ---`);
for (const e of items) {
lines.push(` • [${e.type}] ${e.content}(重要性:${e.importance}, 标签: ${(e.tags || []).join(', ') || '无'}`);
}
}
return JSON.stringify({ success: true, action: 'read_all', formatted: lines.join('\n'), total: entries.length });
}
if ((result as Record<string, unknown>).action === 'search') {
const results = ((result as Record<string, unknown>).results || []) as Array<{ id: string; type: string; content: string; importance: number; score: number }>;
if (results.length === 0) return JSON.stringify({ success: true, action: 'search', message: '未找到匹配的记忆。', total: 0 });
const lines = [`[记忆搜索结果] 共 ${results.length} 条:`];
for (const r of results) {
lines.push(` • [${r.type || 'fact'}] ${r.content}(重要性:${r.importance}, 匹配度:${(r.score || 0).toFixed(0)}`);
}
return JSON.stringify({ success: true, action: 'search', formatted: lines.join('\n'), total: results.length });
}
// remove_batch 结果:格式化每条匹配情况
if ((result as Record<string, unknown>).action === 'remove_batch') {
const items = ((result as Record<string, unknown>).results || []) as Array<{ old_text: string; matched: boolean; entry_id?: string; error?: string }>;
const deleted = (result as Record<string, unknown>).deleted as number || 0;
const failed = (result as Record<string, unknown>).failed as number || 0;
const lines = [`[批量删除结果] 成功 ${deleted}${failed > 0 ? `, 失败 ${failed}` : ''}:`];
for (const item of items) {
if (item.matched) {
lines.push(` ✅ "${item.old_text}" → 已删除 (${item.entry_id})`);
} else {
lines.push(` ❌ "${item.old_text}" → ${item.error || '失败'}`);
}
}
return JSON.stringify({ success: (result as Record<string, unknown>).success, action: 'remove_batch', formatted: lines.join('\n'), deleted, failed });
}
// 其他 actionadd/replace/remove)→ 保留完整 JSON,走 default 逻辑
return formatDefaultToolResult(toolName, result);
}
case 'delete_file': {
// 批量删除
if ((result as Record<string, unknown>).batch) {
return JSON.stringify({
success: true,
message: `批量删除完成:成功 ${result.successCount}/${result.totalPaths} 个路径`,
batch: true,
totalPaths: result.totalPaths,
successCount: result.successCount,
failCount: result.failCount,
results: result.results,
});
}
return JSON.stringify({
success: true,
message: `已删除${(result as Record<string, unknown>).type === 'directory' ? '目录' : '文件'}${result.path}`,
path: result.path,
deleted: true,
type: (result as Record<string, unknown>).type,
deletedSize: result.deletedSize,
...((result as Record<string, unknown>).filesDeleted !== undefined && { filesDeleted: (result as Record<string, unknown>).filesDeleted }),
});
}
case 'create_directory': {
return JSON.stringify({
success: true,
message: `目录已创建:${result.path}`,
path: result.path,
created: (result as Record<string, unknown>).created,
});
}
case 'move_file': {
return JSON.stringify({
success: true,
message: `已移动:${(result as Record<string, unknown>).source}${(result as Record<string, unknown>).destination}`,
source: (result as Record<string, unknown>).source,
destination: (result as Record<string, unknown>).destination,
});
}
case 'copy_file': {
return JSON.stringify({
success: true,
message: `已复制:${(result as Record<string, unknown>).source}${(result as Record<string, unknown>).destination}`,
source: (result as Record<string, unknown>).source,
destination: (result as Record<string, unknown>).destination,
bytesCopied: (result as Record<string, unknown>).bytesCopied,
});
}
case 'download_file': {
return JSON.stringify({
success: true,
message: `已下载:${(result as Record<string, unknown>).url}${(result as Record<string, unknown>).destination}`,
url: (result as Record<string, unknown>).url,
destination: (result as Record<string, unknown>).destination,
bytesDownloaded: (result as Record<string, unknown>).bytesDownloaded,
});
}
case 'compress': {
return JSON.stringify({
success: true,
message: `已压缩:${(result as Record<string, unknown>).outputPath}`,
outputPath: (result as Record<string, unknown>).outputPath,
originalSize: (result as Record<string, unknown>).originalSize,
compressedSize: (result as Record<string, unknown>).compressedSize,
filesProcessed: (result as Record<string, unknown>).filesProcessed,
});
}
case 'diff': {
if ((result as Record<string, unknown>).identical) {
return JSON.stringify({ success: true, identical: true, message: '文件内容完全相同,无差异' });
}
return JSON.stringify({
success: true,
mode: (result as Record<string, unknown>).mode,
path1: (result as Record<string, unknown>).path1,
path2: (result as Record<string, unknown>).path2,
diff: (result as Record<string, unknown>).diff,
additions: (result as Record<string, unknown>).additions,
deletions: (result as Record<string, unknown>).deletions,
hunk_count: (result as Record<string, unknown>).hunk_count,
identical: false,
});
}
case 'tree': {
return JSON.stringify({
success: true,
message: `目录树:${result.path}${(result as Record<string, unknown>).totalEntries} 项)`,
path: result.path,
entries: result.entries,
totalEntries: (result as Record<string, unknown>).totalEntries,
truncated: result.truncated,
});
}
default: {
return formatDefaultToolResult(toolName, result);
}
}
}
+56 -27
View File
@@ -7,9 +7,9 @@
import { state, KEYS } from '../state/state.js';
import { OllamaAPI } from '../api/ollama.js';
import { TOOL_DEFINITIONS } from './tool-registry.js';
import { getEnabledToolDefinitions } from './tool-registry.js';
import { getEnabledToolDefinitions, needsConfirmation } from './tool-registry.js';
import { logInfo, logWarn, logError } from './log-service.js';
import { validatePathSandbox, sanitizeToolArgs, checkCommandSafety, snapshotSafetyState, restoreSafetyState, resetAllSafetyState, classifyError, calculateBackoff } from './agent-safety.js';
import { validatePathSandbox, checkCommandSafety, snapshotSafetyState, restoreSafetyState, resetAllSafetyState, classifyError, calculateBackoff } from './agent-safety.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
import type { ToolResult, ToolCall, ToolDefinition } from '../types.js';
@@ -68,6 +68,8 @@ export interface SubAgentOptions {
timeout?: number;
model?: string;
permission?: SubAgentPermission;
/** 工具确认回调(继承主 Agent 的确认管线,防止子代理绕过确认机制) */
confirmHandler?: (call: ToolCall) => Promise<boolean>;
}
/** 根据权限级别构建子代理系统提示词 */
@@ -101,6 +103,23 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
* @param context 附加上下文
* @param options 可选配置
*/
/** 工具结果信封(统一格式,与主 Agent 的 R92 标准一致) */
function toolResultEnvelope(toolName: string, payload: unknown): string {
return `<<<TOOL_RESULT_START name="${toolName}">>>\n${typeof payload === 'string' ? payload : JSON.stringify(payload)}\n<<<TOOL_RESULT_END>>>`;
}
/** 子代理文件路径沙箱覆盖的全部工具(与主 Agent 的 FILE_PATH_TOOLS 对齐) */
const SUB_FILE_TOOLS = new Set([
'read_file', 'write_file', 'edit_file', 'delete_file', 'create_directory',
'list_directory', 'search_files', 'tree', 'compress',
'move_file', 'copy_file', 'download_file', 'read_multiple_files',
]);
/** 从工具参数中提取首个路径类参数(path/source/destination */
function extractPathArg(args: Record<string, unknown>): string {
return String(args?.path || args?.source || args?.destination || '');
}
export async function executeSubAgent(
task: string,
context?: string,
@@ -264,21 +283,16 @@ export async function executeSubAgent(
// 工具执行前再次检查中止信号
if (subAgentAC.signal.aborted) break;
// R89/R82 已删除:子 Agent 熔断器 + 速率限制 — 剥夺 AI 试错空间
// 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}`);
logWarn(`子 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>>>`,
content: toolResultEnvelope(tc.name, { success: false, error: cmdSafety.reason || '命令被安全规则拦截' }),
tool_name: tc.name
});
continue;
@@ -286,40 +300,55 @@ export async function executeSubAgent(
}
}
// 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;
}
const pathArg = extractPathArg(tc.arguments);
if (wsDir && pathArg) {
const sandbox = validatePathSandbox(pathArg, wsDir);
if (!sandbox.valid) {
logWarn(`子 Agent 路径沙箱拦截: ${tc.name}(${pathArg}) — ${sandbox.reason}`);
messages.push({
role: 'tool',
content: toolResultEnvelope(tc.name, { success: false, error: sandbox.reason || '路径不在工作空间范围内' }),
tool_name: tc.name
});
continue;
}
}
}
// 确认管线:子代理的写类工具与主 Agent 共用确认机制,
// 防止借道子代理绕过用户确认(无确认回调时默认拒绝)
if (needsConfirmation(tc.name)) {
const callObj: ToolCall = { type: 'function', function: { name: tc.name, arguments: tc.arguments } };
const confirmed = options.confirmHandler ? await options.confirmHandler(callObj) : false;
if (!confirmed) {
logWarn(`子 Agent 工具被用户取消: ${tc.name}`);
messages.push({
role: 'tool',
content: toolResultEnvelope(tc.name, { success: false, error: '用户取消了操作' }),
tool_name: tc.name
});
continue;
}
// 确认期间用户可能中止了整个 Agent
if (subAgentAC.signal.aborted) break;
}
try {
const { executeTool } = await import('./tool-registry.js');
const result = await executeTool(tc.name, tc.arguments);
const resultStr = formatResult(tc.name, result);
messages.push({
role: 'tool',
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${resultStr}\n<<<TOOL_RESULT_END>>>`,
content: toolResultEnvelope(tc.name, resultStr),
tool_name: tc.name
});
} catch (err) {
messages.push({
role: 'tool',
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: (err as Error).message })}\n<<<TOOL_RESULT_END>>>`,
content: toolResultEnvelope(tc.name, { success: false, error: (err as Error).message }),
tool_name: tc.name
});
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Tool Parsing — 模型文本输出的工具调用解析(兜底)
* 从 agent-engine.ts 拆分的纯解析模块。
*
* 覆盖场景:模型未通过原生 tool_calls 字段返回,而是在文本中书写工具调用。
* 支持四种格式:Action/Action Input、<tool_call> XML、```json 代码块、函数调用语法。
*/
import { logInfo, logWarn } from './log-service.js';
import { TOOL_DEFINITIONS } from './tool-registry.js';
import type { ToolCall } from '../types.js';
/** 工具名白名单:从注册表派生(含 MCP 工具),不再手工维护 */
const VALID_TOOL_NAMES: Set<string> = new Set(TOOL_DEFINITIONS.map(d => d.function.name));
function isValidToolName(name: string): boolean {
return VALID_TOOL_NAMES.has(name) || name.startsWith('mcp_');
}
export function parseToolCallsFromText(content: string): ToolCall[] {
const calls: ToolCall[] = [];
// 辅助函数:尝试解析 JSON 参数字符串,容错处理
const tryParseArgs = (argsStr: string): Record<string, unknown> | null => {
const TICK = String.fromCharCode(96);
const tickJson = TICK + TICK + TICK + 'json';
const tick3 = TICK + TICK + TICK;
try {
let cleaned = argsStr.split(tickJson).join('').split(tick3).join('').trim();
return JSON.parse(cleaned);
} catch {
try {
let fixed = argsStr
.replace(/'/g, '"')
.replace(/,\s*}/g, '}')
.replace(/,\s*]/g, ']')
.split(tickJson).join('')
.split(tick3).join('')
.trim();
return JSON.parse(fixed);
} catch {
return null;
}
}
};
// 辅助函数:验证工具名并添加到结果
const tryAddCall = (toolName: string, argsStr: string): boolean => {
toolName = toolName.trim();
if (!isValidToolName(toolName)) return false;
const args = tryParseArgs(argsStr);
if (!args) {
logWarn('文本解析兜底: 工具 ' + toolName + ' 的参数 JSON 解析失败', argsStr.slice(0, 100));
return false;
}
calls.push({ type: 'function', function: { name: toolName, arguments: args } });
return true;
};
// ── 格式1: Action/Action Input ──
const actionRegex = /\*{0,2}Action:?\*{0,2}\s*(\w+)\s+[\r\n\s]*\*{0,2}Action\s*Input:?\*{0,2}\s*(\{[\s\S]*?\})/gi;
let match;
while ((match = actionRegex.exec(content)) !== null) {
tryAddCall(match[1], match[2]);
}
// ── 格式2: <tool_call> XML 标签 ──
const xmlRegex = /<tool_call>\s*([\s\S]*?)<\/tool_call>/gi;
while ((match = xmlRegex.exec(content)) !== null) {
const inner = match[1].trim().replace(/```json\s*/g, '').replace(/```/g, '').trim();
try {
const parsed = JSON.parse(inner);
const toolName = parsed.name || parsed.function?.name || '';
const toolArgs = parsed.arguments || parsed.function?.arguments || parsed.parameters || {};
if (toolName && isValidToolName(toolName)) {
calls.push({ type: 'function', function: { name: toolName, arguments: toolArgs } });
}
} catch {
// JSON 解析失败,尝试分别提取 name 和 arguments
const nameMatch = inner.match(/"name"\s*:\s*"(\w+)"/i);
if (nameMatch) {
const argsMatch = inner.match(/"arguments"\s*:\s*(\{[\s\S]*\})/i);
if (argsMatch) tryAddCall(nameMatch[1], argsMatch[1]);
}
}
}
// ── 格式3: ```json 代码块中含 "name" 字段 ──
const codeBlockRegex = /```(?:json)?\s*(\{[\s\S]*?"name"\s*:\s*"\w+"[\s\S]*?\})\s*```/gi;
while ((match = codeBlockRegex.exec(content)) !== null) {
const jsonStr = match[1].trim();
try {
const parsed = JSON.parse(jsonStr);
const toolName = parsed.name || '';
const toolArgs = parsed.arguments || parsed.parameters || {};
if (toolName && isValidToolName(toolName)) {
calls.push({ type: 'function', function: { name: toolName, arguments: toolArgs } });
}
} catch {
// 解析失败忽略,其他格式可能匹配
}
}
// ── 格式4: 函数调用语法 func_name({"key": "value"}) ──
// 使用平衡括号匹配替代 [^}]*,支持嵌套 JSON 如 {"a": {"b": 1}}
{
const funcCallStart = /\b(\w+)\s*\(\s*\{/g;
let fcMatch;
while ((fcMatch = funcCallStart.exec(content)) !== null) {
const toolName = fcMatch[1];
const braceStart = fcMatch.index + fcMatch[0].length - 1; // 指向 '{'
// 手动平衡匹配大括号
let depth = 0;
let endIdx = -1;
let inString = false;
let escapeNext = false;
for (let i = braceStart; i < content.length; i++) {
const ch = content[i];
if (escapeNext) { escapeNext = false; continue; }
if (ch === '\\') { escapeNext = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) { endIdx = i; break; }
}
}
if (endIdx > 0) {
const jsonStr = content.slice(braceStart, endIdx + 1);
// 检查后面是否有闭合括号
const afterClose = content.slice(endIdx + 1).match(/^\s*\)/);
if (afterClose) {
tryAddCall(toolName, jsonStr);
// 移动 regex 位置到匹配结束后
funcCallStart.lastIndex = endIdx + 1;
}
}
}
}
if (calls.length > 0) {
logInfo('文本解析兜底: 从回复中提取到 ' + calls.length + ' 个工具调用', calls.map(c => c.function.name).join(', '));
}
return calls;
}
+44 -19
View File
@@ -3,7 +3,8 @@
* 管理所有可用工具的定义,负责执行调度
*/
import type { ToolDefinition, ToolResult } from '../types.js';
import type { ToolDefinition, ToolResult, ToolCall } from '../types.js';
import type { SubAgentPermission } from './sub-agent.js';
import { state, KEYS } from '../state/state.js';
import { logToolStart, logToolResult, logError, logInfo, logWarn } from './log-service.js';
import { getMCPToolDefinitions } from './mcp-client.js';
@@ -577,7 +578,8 @@ permission: { type: 'string', enum: ['readonly', 'limited_write', 'full_write'],
// 支持三档开关的工具列表(auto/confirm/disabled
// 浏览器工具不需要确认,永远自动执行
const MODE_TOOLS = [
// 导出供 tools-modal.ts 等 UI 复用,保持单一事实来源
export const MODE_TOOLS = [
'run_command',
'write_file', 'create_directory', 'delete_file',
'edit_file', 'move_file', 'copy_file',
@@ -586,6 +588,17 @@ const MODE_TOOLS = [
export type ToolMode = 'auto' | 'confirm' | 'disabled';
/**
* 子代理工具确认回调(由 agent-engine 在主循环生命周期内设置)。
* 子代理与主 Agent 共用同一确认管线:全局模式为 confirm 时,
* 子代理的写类工具同样需要用户确认,防止借道子代理绕过确认机制。
*/
let _subAgentConfirmHandler: ((call: ToolCall) => Promise<boolean>) | null = null;
export function setSubAgentConfirmHandler(handler: ((call: ToolCall) => Promise<boolean>) | null): void {
_subAgentConfirmHandler = handler;
}
// 工具模式缓存:key=工具名, value=模式
const _toolModes = new Map<string, ToolMode>();
@@ -627,7 +640,7 @@ let enabledTools: Set<string> = new Set([
'run_command',
'move_file', 'copy_file', 'web_fetch', 'web_search', 'edit_file',
'tree', 'download_file',
'read_multiple_files', 'git', 'compress',
'read_multiple_files', 'git', 'compress', 'diff',
'memory',
'session_list', 'session_read', 'spawn_task',
'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract',
@@ -1053,14 +1066,18 @@ export function truncateToolResult(result: ToolResult, toolName: string): ToolRe
}
}
// 3. 兜底:如果经过上述截断后仍然超限,对整个 JSON 暴力截断
// 3. 兜底:如果经过上述截断后仍然超限,直接返回结构化预览对象。
// (旧实现拼接非法 JSON 后 JSON.parse 必然抛出异常)
let finalStr = JSON.stringify(truncated);
if (finalStr.length > MAX_TOOL_RESULT_CHARS) {
const head = finalStr.slice(0, MAX_TOOL_RESULT_CHARS - 500);
const tail = finalStr.slice(-300);
const omitted = finalStr.length - (MAX_TOOL_RESULT_CHARS - 200);
logWarn(`R22: 工具 ${toolName} 结果过大,暴力截断 (${jsonStr.length} → ~${MAX_TOOL_RESULT_CHARS} 字符)`);
return JSON.parse(head + `"... [已暴力截断 ${omitted} 字符] ..."}` + tail) as ToolResult;
const omitted = finalStr.length - MAX_TOOL_RESULT_CHARS;
logWarn(`R22: 工具 ${toolName} 结果过大,暴力截断 (${finalStr.length}${MAX_TOOL_RESULT_CHARS} 字符)`);
return {
success: truncated.success,
_truncated: true,
preview: finalStr.slice(0, MAX_TOOL_RESULT_CHARS),
_omitted_chars: omitted,
} as ToolResult;
}
logWarn(`R22: 工具 ${toolName} 结果已截断 (${jsonStr.length} → ~${finalStr.length} 字符)`);
@@ -1388,21 +1405,22 @@ const results = await search(query, limit);
if (toolName === 'session_list') {
const bridge = window.metonaDesktop;
if (!bridge?.db) return { success: false, error: '桌面 API 不可用' };
const limit = (args.limit as number) || 0; // 0 = 不限制
const limit = (args.limit as number) || 0;
const search = (args.search as string) || '';
const sessions = await bridge.db.getAllSessions();
let filtered = sessions.map((s: any) => ({
// 使用会话摘要(单条 SQL),避免为列表工具全量加载所有消息
const summaries = await bridge.db.getSessionSummaries();
let filtered = summaries.map((s: { id: string; title: string; model: string; message_count: number; created_at: number; updated_at: number }) => ({
id: s.id,
title: s.title,
model: s.model,
messageCount: 0, // 从 SQLite 获取的原始行不含 messages
messageCount: s.message_count,
createdAt: s.created_at,
updatedAt: s.updated_at
}));
if (search) {
filtered = filtered.filter((s: any) => s.title.toLowerCase().includes(search.toLowerCase()));
filtered = filtered.filter(s => s.title.toLowerCase().includes(search.toLowerCase()));
}
filtered.sort((a: any, b: any) => b.updatedAt - a.updatedAt);
filtered.sort((a: { updatedAt: number }, b: { updatedAt: number }) => b.updatedAt - a.updatedAt);
if (limit > 0) filtered = filtered.slice(0, limit);
logToolResult('session_list', true, `${filtered.length} 个会话`);
return { success: true, sessions: filtered, total: filtered.length };
@@ -1437,10 +1455,17 @@ const results = await search(query, limit);
// 设置面板存的模型名直接使用(面板加载时已验证过列表)
model = configuredModel;
}
if (!task) return { success: false, error: '缺少 task 参数' };
const permission = (args.permission as 'readonly' | 'limited_write' | 'full_write' | undefined) ?? 'readonly';
logInfo(`子代理委派: ${task.slice(0, 80)}${model ? ` (模型: ${model})` : ' (跟随当前模型)'} (权限: ${permission})`);
const result = await executeSubAgent(task, context, { model, permission });
if (!task) return { success: false, error: '缺少 task 参数' };
// 权限上限:AI 请求的权限只降不升,封顶于用户设置的 subAgentMaxPermission。
// 防止提示注入让 AI 自授 full_write 绕过权限分级。
const PERMISSION_RANK: Record<SubAgentPermission, number> = { readonly: 0, limited_write: 1, full_write: 2 };
const requested = (args.permission as SubAgentPermission | undefined) ?? 'readonly';
const maxAllowed = state.get<SubAgentPermission>('subAgentMaxPermission', 'readonly');
const permission: SubAgentPermission = (PERMISSION_RANK[requested] ?? 0) <= (PERMISSION_RANK[maxAllowed] ?? 0)
? requested
: maxAllowed;
logInfo(`子代理委派: ${task.slice(0, 80)}${model ? ` (模型: ${model})` : ' (跟随当前模型)'} (权限: ${permission}, 上限: ${maxAllowed})`);
const result = await executeSubAgent(task, context, { model, permission, confirmHandler: _subAgentConfirmHandler ?? undefined });
logToolResult('spawn_task', result.success, result.success ? `完成, ${(result as any).loops}` : result.error);
return result;
}