v0.16.8: Agent Loop P0-P3 修复 + 死代码清理 + 版本号升级

- P0: executeToolWithTimeout 重写为 Promise.race,实现真正超时中断
- P1 #2: R8 紧急压缩限制最多 2 次,防止溢出→压缩→重试无限循环
- P1 #3: 新增 snapshotSafetyState/restoreSafetyState,Sub-Agent 在隔离环境中运行
- P1 #4: persistLoopContext 中止时持久化 TERMINATED 状态
- P1 #5: Completion Gate 失败时注入纠正提示并重试(最多 1 次)
- P2 #6: executeSingleTool 重试逻辑兜底返回完善
- P2 #7: 压缩阈值提高(0.3→0.5, 0.8→0.85)+ compressedThisCycle 防同轮重复压缩
- P2 #8: 路径依赖检查优先于 ALWAYS_PARALLEL,防止读写同路径被错误并行
- P2 #9: R105 消息年龄追踪改为内容哈希 key(后被确认为死代码并清理)
- P3 #11: ctx_tokens 统一语义为 prompt_eval_count
- P3 #12: 自动记忆提取捕获 signal 引用,避免竞态
- P3 #13: verifyToolResult 返回警告字符串供 Completion Gate 使用
- 注入消毒增强:零宽字符移除、全角→半角、新增覆盖模式
- 死代码清理:R105 上下文年龄追踪系列函数(无任何调用点)
- 版本号 0.16.7 → 0.16.8(package.json/lock, menu.ts, index.html, README.md)
This commit is contained in:
紫影233
2026-07-16 14:31:19 +08:00
parent 4b1b64dddc
commit 2ac07208b5
10 changed files with 230 additions and 118 deletions
+3 -3
View File
@@ -14,7 +14,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-v0.16.7-E8734A?style=flat-square" alt="version"> <img src="https://img.shields.io/badge/version-v0.16.8-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron"> <img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript"> <img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license"> <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
@@ -253,7 +253,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
``` ```
产出:`release/Metona Ollama Setup v0.16.7.exe` 产出:`release/Metona Ollama Setup v0.16.8.exe`
## 🛠️ 常用命令 ## 🛠️ 常用命令
@@ -501,7 +501,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
``` ```
Output: `release/Metona Ollama Setup v0.16.7.exe` Output: `release/Metona Ollama Setup v0.16.8.exe`
## 🛠️ Common Commands ## 🛠️ Common Commands
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.16.7", "version": "0.16.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.16.7", "version": "0.16.8",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.16.7", "version": "0.16.8",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"author": "thzxx", "author": "thzxx",
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, { dialog.showMessageBox(mainWindow!, {
type: 'info', type: 'info',
title: '关于 Metona Ollama', title: '关于 Metona Ollama',
message: 'Metona Ollama Desktop v0.16.7', message: 'Metona Ollama Desktop v0.16.8',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath() icon: getIconPath()
}); });
+1 -1
View File
@@ -28,7 +28,7 @@
<div class="header-left"> <div class="header-left">
<img class="logo" src="./assets/icons/llama.png" alt="logo" /> <img class="logo" src="./assets/icons/llama.png" alt="logo" />
<span class="app-title">Metona Ollama</span> <span class="app-title">Metona Ollama</span>
<span class="app-version">v0.16.7</span> <span class="app-version">v0.16.8</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助"> <button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/> <circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
+138 -58
View File
@@ -87,20 +87,30 @@ const MAX_PARALLEL_TOOLS = 5; // R6: 单批次最大并行工具数
// R55: 语义工具检索 — 缓存当前轮过滤后的工具定义,避免 handleThinking 重复计算 // R55: 语义工具检索 — 缓存当前轮过滤后的工具定义,避免 handleThinking 重复计算
let _filteredTools: import('../types.js').ToolDefinition[] = []; let _filteredTools: import('../types.js').ToolDefinition[] = [];
/** S1/S6: 清洗不可信文本,移除提示词注入模式 */ /** S1/S6:
* P3 #10 Unicode/
*/
function sanitizeUntrustedInput(text: string): string { function sanitizeUntrustedInput(text: string): string {
if (!text) return ''; if (!text) return '';
return text // 先标准化:移除零宽字符、全角→半角,避免同形字符绕过
.replace(/ignore\s+(all\s+)?previous/gi, '...') let normalized = text
.replace(/forget\s+(all\s+)?instructions/gi, '...') .replace(/[\u200B-\u200D\uFEFF\u00AD]/g, '') // 零宽字符
.replace(/[\uFF01-\uFF5E]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xFEE0)); // 全角→半角
return normalized
.replace(/ignore\s+(all\s+)?(previous|prior|above)\s*(instructions?|prompts?|rules?)?/gi, '...')
.replace(/forget\s+(all\s+)?(instructions?|prompts?|rules?|everything)/gi, '...')
.replace(/you\s+are\s+now\s+a/gi, '...') .replace(/you\s+are\s+now\s+a/gi, '...')
.replace(/new\s+system\s*prompt/gi, '...') .replace(/new\s+system\s*prompt/gi, '...')
.replace(/override\s+(your|the)\s+/gi, '...') .replace(/override\s+(your|the|all)\s+/gi, '...')
.replace(/disregard\s+(all|any|previous)/gi, '...') .replace(/disregard\s+(all|any|previous|prior)\s*(instructions?|rules?)?/gi, '...')
.replace(/act\s+as\s+(if|a|an)\s+(you\s+are|different)/gi, '...')
.replace(/忽略.{0,4}(之前|前面|以上|所有).{0,4}(指令|提示|规则|系统)/g, '...') .replace(/忽略.{0,4}(之前|前面|以上|所有).{0,4}(指令|提示|规则|系统)/g, '...')
.replace(/忘记.{0,4}(所有|之前|前面).{0,4}(指令|提示)/g, '...') .replace(/忘记.{0,4}(所有|之前|前面).{0,4}(指令|提示|规则)/g, '...')
.replace(/你现在是一个/g, '...') .replace(/你现在是一个/g, '...')
.replace(/覆盖.{0,4}(系统|你的).{0,4}(提示|指令|规则)/g, '...'); .replace(/覆盖.{0,4}(系统|你的).{0,4}(提示|指令|规则)/g, '...')
.replace(/从现在起.{0,4}(你是|你将|请)/g, '...')
.replace(/system\s*:\s*/gi, '...')
.replace(/\[system\]/gi, '...');
} }
/** ── LoopState 常量 ── */ /** ── LoopState 常量 ── */
@@ -308,7 +318,9 @@ function getAdjustedToolTimeout(toolName: string, args: Record<string, unknown>)
return timeout; return timeout;
} }
/** R3: 带超时的工具执行包装器 */ /** R3:
* P0 executeTool AbortSignal使 Promise.race
*/
async function executeToolWithTimeout( async function executeToolWithTimeout(
toolName: string, toolName: string,
args: Record<string, unknown>, args: Record<string, unknown>,
@@ -319,35 +331,51 @@ async function executeToolWithTimeout(
return executeTool(toolName, args); return executeTool(toolName, args);
} }
const timeoutAC = new AbortController(); // 外部已中止
const timer = setTimeout(() => { if (abortSignal?.aborted) {
timeoutAC.abort(new Error(`工具 ${toolName} 执行超时 (${timeoutMs / 1000}s)`));
}, timeoutMs);
// 如果外部 abort 触发,也中止工具
const onExternalAbort = () => { timeoutAC.abort(); };
if (abortSignal) {
if (abortSignal.aborted) {
clearTimeout(timer);
return { success: false, error: '用户中止' }; return { success: false, error: '用户中止' };
} }
let timer: ReturnType<typeof setTimeout> | null = null;
let onExternalAbort: (() => void) | null = null;
return new Promise<ToolResult>((resolve) => {
let settled = false;
const cleanup = () => {
if (timer) { clearTimeout(timer); timer = null; }
if (abortSignal && onExternalAbort) {
abortSignal.removeEventListener('abort', onExternalAbort);
}
};
const settle = (result: ToolResult) => {
if (settled) return;
settled = true;
cleanup();
resolve(result);
};
// 超时
timer = setTimeout(() => {
settle({ success: false, error: `工具 ${toolName} 执行超时 (${timeoutMs / 1000}s),请尝试拆分任务或优化参数` });
}, timeoutMs);
// 外部中止
if (abortSignal) {
if (abortSignal.aborted) {
settle({ success: false, error: '用户中止' });
return;
}
onExternalAbort = () => settle({ success: false, error: '用户中止' });
abortSignal.addEventListener('abort', onExternalAbort, { once: true }); abortSignal.addEventListener('abort', onExternalAbort, { once: true });
} }
try { // 工具执行
const result = await executeTool(toolName, args); executeTool(toolName, args)
return result; .then(result => settle(result))
} catch (err) { .catch(err => settle({ success: false, error: (err as Error)?.message || String(err) }));
if (timeoutAC.signal.aborted && !abortSignal?.aborted) { });
return { success: false, error: `工具 ${toolName} 执行超时 (${timeoutMs / 1000}s),请尝试拆分任务或优化参数` };
}
return { success: false, error: (err as Error)?.message || String(err) };
} finally {
clearTimeout(timer);
if (abortSignal) {
abortSignal.removeEventListener('abort', onExternalAbort);
}
}
} }
/** P0-2: / /** P0-2: /
@@ -1167,6 +1195,8 @@ function transition(ctx: LoopContext, newState: LoopState): void {
/** 持久化 LoopContext 到 state(用于暂停/恢复) */ /** 持久化 LoopContext 到 state(用于暂停/恢复) */
function persistLoopContext(ctx: LoopContext): void { function persistLoopContext(ctx: LoopContext): void {
// P1 #4 修复:中止时持久化 TERMINATED 状态,避免恢复时状态不一致
const persistedState = isAborted() ? 'TERMINATED' : ctx.state;
state.set('_loopContext', { state.set('_loopContext', {
sessionId: ctx.sessionId, sessionId: ctx.sessionId,
loopCount: ctx.loopCount, loopCount: ctx.loopCount,
@@ -1175,6 +1205,7 @@ function persistLoopContext(ctx: LoopContext): void {
totalPromptEvalCount: ctx.totalPromptEvalCount, totalPromptEvalCount: ctx.totalPromptEvalCount,
allToolRecords: ctx.allToolRecords, allToolRecords: ctx.allToolRecords,
startTime: ctx.startTime, startTime: ctx.startTime,
state: persistedState,
}); });
} }
@@ -1483,11 +1514,12 @@ async function verifyToolResult(
toolName: string, toolName: string,
args: Record<string, unknown>, args: Record<string, unknown>,
result: Record<string, unknown>, result: Record<string, unknown>,
): Promise<void> { ): Promise<string | null> {
if (!TOOLS_NEED_VERIFY.has(toolName) || !result.success) return; // P3 #13 修复:返回警告字符串供 Completion Gate 使用
if (!TOOLS_NEED_VERIFY.has(toolName) || !result.success) return null;
const bridge = window.metonaDesktop; const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) return; if (!bridge?.isDesktop) return null;
try { try {
switch (toolName) { switch (toolName) {
@@ -1499,8 +1531,10 @@ async function verifyToolResult(
const actual = String(read.content || ''); const actual = String(read.content || '');
if (actual.length === 0) { if (actual.length === 0) {
logWarn(`核验 write_file: ${path} 内容为空`); logWarn(`核验 write_file: ${path} 内容为空`);
return `write_file 核验: ${path} 写入后内容为空`;
} else if (Math.abs(actual.length - expected.length) > expected.length * 0.5) { } else if (Math.abs(actual.length - expected.length) > expected.length * 0.5) {
logWarn(`核验 write_file: ${path} 期望 ${expected.length}B 实际 ${actual.length}B`); logWarn(`核验 write_file: ${path} 期望 ${expected.length}B 实际 ${actual.length}B`);
return `write_file 核验: ${path} 写入内容大小不符 (期望 ${expected.length}B, 实际 ${actual.length}B)`;
} else { } else {
logInfo(`核验 write_file ✅: ${path} (${actual.length}B)`); logInfo(`核验 write_file ✅: ${path} (${actual.length}B)`);
} }
@@ -1515,6 +1549,7 @@ async function verifyToolResult(
const actual = String(read.content || ''); const actual = String(read.content || '');
if (!actual.includes(newText)) { if (!actual.includes(newText)) {
logWarn(`核验 edit_file: ${path} 不包含期望的新内容`); logWarn(`核验 edit_file: ${path} 不包含期望的新内容`);
return `edit_file 核验: ${path} 编辑后文件不包含期望的新内容`;
} else { } else {
logInfo(`核验 edit_file ✅: ${path}`); logInfo(`核验 edit_file ✅: ${path}`);
} }
@@ -1532,6 +1567,7 @@ async function verifyToolResult(
const info = await bridge.tool.execute('get_file_info', { path: p }); const info = await bridge.tool.execute('get_file_info', { path: p });
if (info.success) { if (info.success) {
logWarn(`核验 delete_file: ${p} 仍然存在,删除未生效`); logWarn(`核验 delete_file: ${p} 仍然存在,删除未生效`);
return `delete_file 核验: ${p} 删除后仍然存在`;
} else { } else {
logInfo(`核验 delete_file ✅: ${p} 已不存在`); logInfo(`核验 delete_file ✅: ${p} 已不存在`);
} }
@@ -1545,6 +1581,7 @@ async function verifyToolResult(
logInfo(`核验 create_directory ✅: ${path}`); logInfo(`核验 create_directory ✅: ${path}`);
} else { } else {
logWarn(`核验 create_directory: ${path} 未成功创建`); logWarn(`核验 create_directory: ${path} 未成功创建`);
return `create_directory 核验: ${path} 目录未成功创建`;
} }
break; break;
} }
@@ -1557,10 +1594,12 @@ async function verifyToolResult(
]); ]);
if (srcInfo.success) { if (srcInfo.success) {
logWarn(`核验 move_file: 源文件 ${src} 仍然存在`); logWarn(`核验 move_file: 源文件 ${src} 仍然存在`);
return `move_file 核验: 源文件 ${src} 移动后仍然存在`;
} else if (destInfo.success) { } else if (destInfo.success) {
logInfo(`核验 move_file ✅: ${src}${dest}`); logInfo(`核验 move_file ✅: ${src}${dest}`);
} else { } else {
logWarn(`核验 move_file: ${src}${dest} 源和目标均不存在`); logWarn(`核验 move_file: ${src}${dest} 源和目标均不存在`);
return `move_file 核验: ${src}${dest} 移动后源和目标均不存在`;
} }
break; break;
} }
@@ -1571,6 +1610,7 @@ async function verifyToolResult(
logInfo(`核验 copy_file ✅: ${dest} (${(destInfo as any).size}B)`); logInfo(`核验 copy_file ✅: ${dest} (${(destInfo as any).size}B)`);
} else { } else {
logWarn(`核验 copy_file: 目标 ${dest} 不存在`); logWarn(`核验 copy_file: 目标 ${dest} 不存在`);
return `copy_file 核验: 目标 ${dest} 复制后不存在`;
} }
break; break;
} }
@@ -1581,11 +1621,13 @@ async function verifyToolResult(
logInfo(`核验 download_file ✅: ${dest} (${(info as any).size}B)`); logInfo(`核验 download_file ✅: ${dest} (${(info as any).size}B)`);
} else { } else {
logWarn(`核验 download_file: ${dest} 不存在或为空`); logWarn(`核验 download_file: ${dest} 不存在或为空`);
return `download_file 核验: ${dest} 下载后不存在或为空`;
} }
break; break;
} }
} }
} catch { /* 核验失败不阻塞主流程 */ } } catch { /* 核验失败不阻塞主流程 */ }
return null;
} }
/** /**
@@ -1600,6 +1642,9 @@ async function handleThinking(
ctx.loopCount++; ctx.loopCount++;
logAgentLoop(ctx.loopCount, ctx.maxLoops); logAgentLoop(ctx.loopCount, ctx.maxLoops);
// P2 #7: 每轮重置压缩标志,允许新一轮的压缩触发
ctx.compressedThisCycle = false;
ctx.content = ''; ctx.content = '';
ctx.thinking = ''; ctx.thinking = '';
ctx.toolCalls.length = 0; ctx.toolCalls.length = 0;
@@ -1821,10 +1866,13 @@ async function handleThinking(
apiLastErr = err as Error; apiLastErr = err as Error;
// R8: 检测 Ollama 上下文溢出错误 — 自动压缩并重试 // R8: 检测 Ollama 上下文溢出错误 — 自动压缩并重试
// P1 #2 修复:限制紧急压缩最多 2 次,防止溢出→压缩→重试无限循环
const MAX_EMERGENCY_COMPRESS = 2;
const errMsg = apiLastErr.message || ''; const errMsg = apiLastErr.message || '';
const isContextOverflow = /context.*length|prompt.*too.*long|context.*window|maximum.*context|num_ctx|上下文.*超/i.test(errMsg); const isContextOverflow = /context.*length|prompt.*too.*long|context.*window|maximum.*context|num_ctx|上下文.*超/i.test(errMsg);
if (isContextOverflow && ctx.messages.length > 10) { if (isContextOverflow && ctx.messages.length > 10 && ctx.emergencyCompressCount < MAX_EMERGENCY_COMPRESS) {
logWarn(`R8: 检测到上下文溢出错误,触发紧急压缩`, errMsg.slice(0, 100)); ctx.emergencyCompressCount++;
logWarn(`R8: 检测到上下文溢出错误,触发紧急压缩 (${ctx.emergencyCompressCount}/${MAX_EMERGENCY_COMPRESS})`, errMsg.slice(0, 100));
try { try {
const compressAC = new AbortController(); const compressAC = new AbortController();
const compressed = await compressWithLLM(ctx.messages, api as any, model, { abortController: compressAC }); const compressed = await compressWithLLM(ctx.messages, api as any, model, { abortController: compressAC });
@@ -1839,6 +1887,8 @@ async function handleThinking(
} catch (compressErr) { } catch (compressErr) {
logWarn('R8: 紧急压缩失败', (compressErr as Error).message); logWarn('R8: 紧急压缩失败', (compressErr as Error).message);
} }
} else if (isContextOverflow && ctx.emergencyCompressCount >= MAX_EMERGENCY_COMPRESS) {
logWarn(`R8: 紧急压缩已达上限 (${MAX_EMERGENCY_COMPRESS}),不再重试`);
} }
if (apiAttempt < API_MAX_RETRIES) { if (apiAttempt < API_MAX_RETRIES) {
@@ -1959,10 +2009,9 @@ async function handleExecuting(
for (const call of ctx.toolCalls) { for (const call of ctx.toolCalls) {
if (currentBatch.length === 0) { if (currentBatch.length === 0) {
currentBatch.push(call); currentBatch.push(call);
} else if (ALWAYS_PARALLEL.has(call.function.name)) {
currentBatch.push(call);
} else if (hasPathDependency(call, currentBatch)) { } else if (hasPathDependency(call, currentBatch)) {
// 存在路径依赖 → 新开批次(串行化) // P2 #8 修复:路径依赖检查优先于 ALWAYS_PARALLEL
// 防止 read_file 在 write_file 同路径时被错误并行
batches.push(currentBatch); batches.push(currentBatch);
currentBatch = [call]; currentBatch = [call];
} else { } else {
@@ -2186,25 +2235,24 @@ async function handleExecuting(
}, null]; }, null];
} }
if (retry < MAX_RETRIES) { if (retry < (classifiedError.maxRetries || MAX_RETRIES)) {
// R78: 使用 calculateBackoff 计算指数退避延迟 // R78: 使用 calculateBackoff 计算指数退避延迟
const delay = calculateBackoff(retry, classifiedError.backoffMs); const delay = calculateBackoff(retry, classifiedError.backoffMs);
logWarn(`R78: 工具重试 ${retry + 1}/${classifiedError.maxRetries}: ${call.function.name}${classifiedError.class}错误,${delay}ms 后)`, lastError); logWarn(`R78: 工具重试 ${retry + 1}/${classifiedError.maxRetries}: ${call.function.name}${classifiedError.class}错误,${delay}ms 后)`, lastError);
await new Promise(r => setTimeout(r, delay)); await new Promise(r => setTimeout(r, delay));
continue; continue;
} }
logError(`工具执行失败(已达最大重试): ${call.function.name}`, lastError); // P2 #6 修复:此处不可达(路径A已拦截),但作为安全兆底
// R97: 记录错误模式
recordErrorPattern(call.function.name, lastError);
return [{ return [{
name: call.function.name, arguments: call.function.arguments, name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: lastError }, status: 'error' as const, timestamp: Date.now() result: { success: false, error: classifiedError.userMessage }, status: 'error' as const, timestamp: Date.now()
}, null]; }, null];
} }
} }
// P2 #6 修复:循环结束兆底返回
return [{ return [{
name: call.function.name, arguments: call.function.arguments, name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: lastError }, status: 'error' as const, timestamp: Date.now() result: { success: false, error: lastError || '工具执行失败' }, status: 'error' as const, timestamp: Date.now()
}, null]; }, null];
}; };
@@ -2252,8 +2300,13 @@ async function handleExecuting(
// 记录度量 // 记录度量
recordToolCall(record.name, record.status, Date.now() - record.timestamp); recordToolCall(record.name, record.status, Date.now() - record.timestamp);
// ── 通用工具结果核验 — 所有写类工具执行后验证 ── // ── 通用工具结果核验 — 所有写类工具执行后验证 ──
// P3 #13 修复:收集核验警告到 ctx.verifyWarnings,供 Completion Gate 使用
if (record.status === 'success') { if (record.status === 'success') {
verifyToolResult(record.name, record.arguments, record.result!); const warning = await verifyToolResult(record.name, record.arguments, record.result!);
if (warning) {
ctx.verifyWarnings.push(warning);
logWarn(warning);
}
} }
// ── post_tool Hook ── // ── post_tool Hook ──
executeHooks('post_tool', ctx, { toolName: record.name, toolArgs: record.arguments, toolResult: record.result! }).catch(err => logWarn('post_tool hook 异常', String(err))); executeHooks('post_tool', ctx, { toolName: record.name, toolArgs: record.arguments, toolResult: record.result! }).catch(err => logWarn('post_tool hook 异常', String(err)));
@@ -2282,7 +2335,8 @@ async function handleObserving(
ctx: LoopContext, ctx: LoopContext,
currentSession: ChatSession | null, currentSession: ChatSession | null,
): Promise<void> { ): Promise<void> {
// 中止检查 // 中止检查 — P1 #4 修复:不在此处 transition,让主循环的 isAborted() 检查
// 抛出 AbortError 触发 catch 块中的 onDone。持久化状态一致性由 persistLoopContext 处理。
if (isAborted()) return; if (isAborted()) return;
// R91: 上下文压力分级评估 — 根据压力等级选择压缩策略 // R91: 上下文压力分级评估 — 根据压力等级选择压缩策略
const numCtx = getEffectiveNumCtx(); const numCtx = getEffectiveNumCtx();
@@ -2472,7 +2526,8 @@ async function handleReflecting(
callbacks: AgentCallbacks, callbacks: AgentCallbacks,
currentSession: ChatSession | null, currentSession: ChatSession | null,
): Promise<void> { ): Promise<void> {
// 中止检查 // 中止检查 — P1 #4 修复:不在此处 transition,让主循环的 isAborted() 检查
// 抛出 AbortError 触发 catch 块中的 onDone。持久化状态一致性由 persistLoopContext 处理。
if (isAborted()) return; if (isAborted()) return;
// ── Plan Mode: 第一轮回复 → 检测是否为计划 → 等待用户确认 ── // ── Plan Mode: 第一轮回复 → 检测是否为计划 → 等待用户确认 ──
@@ -2602,7 +2657,23 @@ async function handleReflecting(
const gateResult = await runCompletionGate(ctx); const gateResult = await runCompletionGate(ctx);
if (!gateResult.passed) { if (!gateResult.passed) {
recordCompletionGate(false); recordCompletionGate(false);
logWarn(`Completion Gate: ${gateResult.reason}(仅记录,不阻断)`); logWarn(`Completion Gate: ${gateResult.reason}`);
// P1 #5 修复:Gate 失败时注入纠正提示并重试(最多 1 次),而非仅记录日志
const MAX_GATE_RETRIES = 1;
if (ctx.completionGateFailCount < MAX_GATE_RETRIES && ctx.loopCount < ctx.maxLoops) {
ctx.completionGateFailCount++;
logInfo(`Completion Gate: 注入纠正提示,重试 (${ctx.completionGateFailCount}/${MAX_GATE_RETRIES})`);
ctx.messages.push({
role: 'user',
content: `[系统提示] ${gateResult.reason}\n请基于已有信息补充完整你的回答,不要重复之前的工具调用。`,
ephemeral: true,
});
transition(ctx, S.THINKING);
return;
} else {
logWarn(`Completion Gate: 已达重试上限或循环上限,接受当前结果`);
}
} else { } else {
recordCompletionGate(true); recordCompletionGate(true);
} }
@@ -2623,8 +2694,9 @@ async function handleReflecting(
} }
// ── 自动记忆提取(异步,不阻塞用户看到回复)── // ── 自动记忆提取(异步,不阻塞用户看到回复)──
const abortController = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER); // P3 #12 修复:在 cleanupAbortController 之前捕获 signal 引用,避免竞态
if (!abortController?.signal.aborted) { const memorySignal = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal;
if (!memorySignal?.aborted) {
const _api = state.get<OllamaAPI>(KEYS.API); const _api = state.get<OllamaAPI>(KEYS.API);
const _model = state.get<string>('_defaultModel', ''); const _model = state.get<string>('_defaultModel', '');
// A1: 使用完整会话消息而非压缩后的 ctx.messages // A1: 使用完整会话消息而非压缩后的 ctx.messages
@@ -2635,7 +2707,7 @@ async function handleReflecting(
// 使用 setTimeout 延迟执行,确保 onDone 先触发,用户先看到回复 // 使用 setTimeout 延迟执行,确保 onDone 先触发,用户先看到回复
setTimeout(() => { setTimeout(() => {
import('./memory-service.js').then(({ extractAndSaveMemories }) => { import('./memory-service.js').then(({ extractAndSaveMemories }) => {
extractAndSaveMemories(msgsForMemory, _api, _model, abortController?.signal).catch(() => {}); extractAndSaveMemories(msgsForMemory, _api, _model, memorySignal).catch(() => {});
}).catch(() => {}); }).catch(() => {});
}, 500); }, 500);
} }
@@ -2686,12 +2758,14 @@ async function handleCompressing(
function makeStats(ctx: LoopContext) { function makeStats(ctx: LoopContext) {
// P0 修复:返回本轮独立值而非累计值,避免与中间消息重复计算 // P0 修复:返回本轮独立值而非累计值,避免与中间消息重复计算
// ctx_tokens 优先使用 Ollama 返回的实际 prompt_eval_count,回退到增强估算 // P3 #11 修复:ctx_tokens 统一语义——总是表示“当前上下文窗口 token 估算
// 优先使用 Ollama 返回的 prompt_eval_count(本轮输入 token),这是最准确的
let ctxTokens: number; let ctxTokens: number;
if (ctx.loopPromptEvalCount > 0) { if (ctx.loopPromptEvalCount > 0) {
ctxTokens = ctx.loopPromptEvalCount + (ctx.loopEvalCount || 0); // 使用本轮实际 prompt_eval_count 作为上下文 token 的近似值
ctxTokens = ctx.loopPromptEvalCount;
} else { } else {
// 增强估算:消息内容 + tool_calls/images 开销 // 回退到增强估算:消息内容 + tool_calls/images 开销
ctxTokens = ctx.messages.reduce((sum, m) => { ctxTokens = ctx.messages.reduce((sum, m) => {
let t = estimateTokens(m.content || ''); let t = estimateTokens(m.content || '');
if (m.tool_calls?.length) t += m.tool_calls.length * 50; if (m.tool_calls?.length) t += m.tool_calls.length * 50;
@@ -2747,6 +2821,10 @@ export async function runAgentLoop(
planRetries: 0, planRetries: 0,
startTime: Date.now(), startTime: Date.now(),
lastLoopStats: undefined, lastLoopStats: undefined,
emergencyCompressCount: 0,
completionGateFailCount: 0,
verifyWarnings: [],
compressedThisCycle: false,
}; };
state.set('_loopState', S.INIT); state.set('_loopState', S.INIT);
@@ -2794,8 +2872,10 @@ export async function runAgentLoop(
const usageRatio = numCtx > 0 ? estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx : 0; const usageRatio = numCtx > 0 ? estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx : 0;
// 上下文使用率过高时触发紧急压缩(不限制轮次,而是回收上下文空间) // 上下文使用率过高时触发紧急压缩(不限制轮次,而是回收上下文空间)
if (usageRatio > 0.8 && ctx.state === S.THINKING) { // P2 #7 修复:提高阈值到 0.85 + 添加 compressedThisCycle 防同一轮重复压缩
if (usageRatio > 0.85 && ctx.state === S.THINKING && !ctx.compressedThisCycle) {
logWarn(`上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 触发紧急压缩`); logWarn(`上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 触发紧急压缩`);
ctx.compressedThisCycle = true;
transition(ctx, S.COMPRESSING); // transition 内部会同步 state 和 _loopState transition(ctx, S.COMPRESSING); // transition 内部会同步 state 和 _loopState
} }
+55 -42
View File
@@ -552,7 +552,7 @@ export function addResultMetadata(formattedResult: string): string {
// R97: 错误模式学习 — 从重复错误中学习并生成改进建议 // R97: 错误模式学习 — 从重复错误中学习并生成改进建议
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
interface ErrorPatternEntry { export interface ErrorPatternEntry {
toolName: string; toolName: string;
errorSignature: string; errorSignature: string;
count: number; count: number;
@@ -624,7 +624,7 @@ export function recordErrorPattern(toolName: string, errorMsg: string): string |
// R104: 工具结果去重 — 相似结果检测,减少上下文中的冗余 // R104: 工具结果去重 — 相似结果检测,减少上下文中的冗余
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
interface ToolResultFingerprint { export interface ToolResultFingerprint {
toolName: string; toolName: string;
contentHash: string; contentHash: string;
timestamp: number; timestamp: number;
@@ -671,43 +671,6 @@ export function isDuplicateToolResult(toolName: string, resultContent: string):
return { duplicate: false }; return { duplicate: false };
} }
// ═══════════════════════════════════════════════════════════════
// R105: 上下文年龄追踪 — 跟踪消息年龄以优化压缩决策
// ═══════════════════════════════════════════════════════════════
const _messageTimestamps = new Map<number, number>(); // message index → creation timestamp
/** R105: 记录消息创建时间 */
export function recordMessageAge(msgIndex: number): void {
_messageTimestamps.set(msgIndex, Date.now());
}
/** R105: 获取消息年龄(毫秒),-1 表示未记录 */
export function getMessageAge(msgIndex: number): number {
const ts = _messageTimestamps.get(msgIndex);
if (!ts) return -1;
return Date.now() - ts;
}
/** R105: 获取年龄超过阈值的消息索引列表 */
export function getAgedMessageIndices(thresholdMs: number): number[] {
const now = Date.now();
const result: number[] = [];
for (const [idx, ts] of _messageTimestamps) {
if (now - ts > thresholdMs) result.push(idx);
}
return result.sort((a, b) => a - b);
}
/** R105: 清理已删除消息的时间戳记录 */
export function cleanupMessageTimestamps(validIndices: Set<number>): void {
for (const key of _messageTimestamps.keys()) {
if (!validIndices.has(key)) {
_messageTimestamps.delete(key);
}
}
}
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// R109: 工具参数消毒 — 防止通过工具参数注入恶意内容 // R109: 工具参数消毒 — 防止通过工具参数注入恶意内容
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -1052,13 +1015,63 @@ export function resetAllSafetyState(): void {
_errorPatterns.clear(); _errorPatterns.clear();
// R104: 清理结果指纹 // R104: 清理结果指纹
_resultFingerprints.length = 0; _resultFingerprints.length = 0;
// R105: 清理消息时间戳
_messageTimestamps.clear();
// R118: 清理性能分析数据 // R118: 清理性能分析数据
_loopTimings.length = 0; _loopTimings.length = 0;
logInfo('Agent Safety: 所有安全状态已重置'); logInfo('Agent Safety: 所有安全状态已重置');
} }
// ═══════════════════════════════════════════════════════════════
// P1 #3: 安全状态快照/恢复 — 供 Sub-Agent 隔离使用
// ═══════════════════════════════════════════════════════════════
export interface SafetyStateSnapshot {
toolResultStore: Map<string, { toolName: string; fullContent: string; timestamp: number }>;
toolCallHistory: string[];
userGoal: string;
toolCallTimestamps: Map<string, number[]>;
toolFailureCount: Map<string, number>;
circuitBreakerTripped: Map<string, number>;
errorPatterns: Map<string, ErrorPatternEntry>;
resultFingerprints: ToolResultFingerprint[];
loopTimings: LoopTiming[];
}
/** P1 #3: 快照当前安全状态(供 Sub-Agent 在隔离环境中使用) */
export function snapshotSafetyState(): SafetyStateSnapshot {
return {
toolResultStore: new Map(_toolResultStore),
toolCallHistory: [..._toolCallHistory],
userGoal: _userGoal,
toolCallTimestamps: new Map(_toolCallTimestamps),
toolFailureCount: new Map(_toolFailureCount),
circuitBreakerTripped: new Map(_circuitBreakerTripped),
errorPatterns: new Map(_errorPatterns),
resultFingerprints: [..._resultFingerprints],
loopTimings: [..._loopTimings],
};
}
/** P1 #3: 从快照恢复安全状态(Sub-Agent 执行完毕后恢复主 Agent 状态) */
export function restoreSafetyState(snapshot: SafetyStateSnapshot): void {
_toolResultStore.clear();
snapshot.toolResultStore.forEach((v, k) => _toolResultStore.set(k, v));
_toolCallHistory.length = 0;
_toolCallHistory.push(...snapshot.toolCallHistory);
_userGoal = snapshot.userGoal;
_toolCallTimestamps.clear();
snapshot.toolCallTimestamps.forEach((v, k) => _toolCallTimestamps.set(k, [...v]));
_toolFailureCount.clear();
snapshot.toolFailureCount.forEach((v, k) => _toolFailureCount.set(k, v));
_circuitBreakerTripped.clear();
snapshot.circuitBreakerTripped.forEach((v, k) => _circuitBreakerTripped.set(k, v));
_errorPatterns.clear();
snapshot.errorPatterns.forEach((v, k) => _errorPatterns.set(k, v));
_resultFingerprints.length = 0;
_resultFingerprints.push(...snapshot.resultFingerprints);
_loopTimings.length = 0;
_loopTimings.push(...snapshot.loopTimings);
}
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// R116: 错误恢复建议增强 — 提供上下文化的错误恢复建议 // R116: 错误恢复建议增强 — 提供上下文化的错误恢复建议
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -1195,7 +1208,7 @@ export function formatErrorRecovery(suggestion: ErrorRecoverySuggestion): string
// R118: Agent 循环性能分析 — 识别 Agent Loop 瓶颈 // R118: Agent 循环性能分析 — 识别 Agent Loop 瓶颈
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
interface LoopTiming { export interface LoopTiming {
loop: number; loop: number;
phase: string; phase: string;
durationMs: number; durationMs: number;
+12 -8
View File
@@ -217,16 +217,20 @@ export function getTokenCalibration(): { ratio: number; samples: number } {
return { ratio: _tokenCalibrationRatio, samples: _calibrationSamples }; return { ratio: _tokenCalibrationRatio, samples: _calibrationSamples };
} }
/** 自动压缩阈值:当消息 token 占 context window 比例超过此值时触发自动压缩 */ /** token context window
export const AUTO_COMPRESS_THRESHOLD = 0.3; * P2 #7 0.3 0.5
*/
export const AUTO_COMPRESS_THRESHOLD = 0.5;
/** R14: 自适应压缩阈值 — 根据模型上下文长度动态调整 */ /** R14:
* P2 #7
*/
export function getAdaptiveCompressThreshold(numCtx: number): number { export function getAdaptiveCompressThreshold(numCtx: number): number {
// 小上下文模型(<8K):更早触发压缩(40%),留更多余量 // 小上下文模型(<8K):更早触发压缩(55%),留余量
// 中等上下文(8K-32K):标准阈值(30% // 中等上下文(8K-32K):标准阈值(50%
// 大上下文(>32K):稍晚触发(25%),避免过于频繁压缩 // 大上下文(>32K):稍晚触发(45%),避免过于频繁压缩
if (numCtx < 8192) return 0.4; if (numCtx < 8192) return 0.55;
if (numCtx > 32768) return 0.25; if (numCtx > 32768) return 0.45;
return AUTO_COMPRESS_THRESHOLD; return AUTO_COMPRESS_THRESHOLD;
} }
+8 -1
View File
@@ -9,7 +9,7 @@ import { OllamaAPI } from '../api/ollama.js';
import { TOOL_DEFINITIONS } from './tool-registry.js'; import { TOOL_DEFINITIONS } from './tool-registry.js';
import { getEnabledToolDefinitions } from './tool-registry.js'; import { getEnabledToolDefinitions } from './tool-registry.js';
import { logInfo, logWarn, logError } from './log-service.js'; import { logInfo, logWarn, logError } from './log-service.js';
import { validatePathSandbox, checkRateLimit, isToolCircuitBroken, recordToolFailure, recordToolSuccess, sanitizeToolArgs, checkCommandSafety } from './agent-safety.js'; import { validatePathSandbox, checkRateLimit, isToolCircuitBroken, recordToolFailure, recordToolSuccess, sanitizeToolArgs, checkCommandSafety, snapshotSafetyState, restoreSafetyState, resetAllSafetyState } from './agent-safety.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js'; import { getWorkspaceDirPath } from '../components/workspace-panel.js';
import type { ToolResult, ToolCall, ToolDefinition } from '../types.js'; import type { ToolResult, ToolCall, ToolDefinition } from '../types.js';
@@ -100,6 +100,11 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
const subAgentAC = new AbortController(); const subAgentAC = new AbortController();
let timeoutTimer: ReturnType<typeof setTimeout> | null = null; let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
// P1 #3 修复:快照主 Agent 安全状态,子代理在隔离环境中运行
const safetySnapshot = snapshotSafetyState();
// 重置安全状态,让子代理从干净状态开始(不受主 Agent 的熔断器/速率限制影响)
resetAllSafetyState();
// 监听主 Agent 的中止信号,联动中止子代理 // 监听主 Agent 的中止信号,联动中止子代理
const mainAC = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER); const mainAC = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER);
const onMainAbort = () => { subAgentAC.abort(); }; const onMainAbort = () => { subAgentAC.abort(); };
@@ -271,6 +276,8 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
if (mainAC) { if (mainAC) {
mainAC.signal.removeEventListener('abort', onMainAbort); mainAC.signal.removeEventListener('abort', onMainAbort);
} }
// P1 #3 修复:恢复主 Agent 安全状态
restoreSafetyState(safetySnapshot);
} }
logWarn('子 Agent 达到最大轮次', `${loopCount}`); logWarn('子 Agent 达到最大轮次', `${loopCount}`);
+8
View File
@@ -411,6 +411,14 @@ export interface LoopContext {
prompt_eval_count?: number; prompt_eval_count?: number;
total_duration?: number; total_duration?: number;
}; };
/** R8 紧急压缩重试计数(防止溢出→压缩→重试无限循环) */
emergencyCompressCount: number;
/** Completion Gate 连续失败次数(达到上限后不再重试) */
completionGateFailCount: number;
/** 工具结果核验警告列表(供 Completion Gate 使用) */
verifyWarnings: string[];
/** 上下文压缩标记 — 防止同一轮重复触发压缩 */
compressedThisCycle: boolean;
} }
/** Agent 运行模式 */ /** Agent 运行模式 */