v0.16.10: 最小必要性清理 — 删除非必要校验和注入提示
按"让 AI 自己判断"原则,删除所有"工程师判断"性质的校验代码和注入提示, 只保留安全防护(路径沙箱/命令安全/参数消毒)和必要的上下文管理(压缩/截断)。 删除项: - completion-gate.ts 整个文件(notThinking/toolResultReview/contextEfficiency/planModeCompletion) - verifyToolResult 工具结果核验函数 + TOOLS_NEED_VERIFY 常量 - 跨轮次死循环检测器(recordLoopSignature/detectLoopDeadlock/resetLoopDeadlockDetector) - R77 checkRateLimit 速率限制 + R87 isToolCircuitBroken 熔断器 + R104 isDuplicateToolResult 去重 - R56 目标对齐验证 + R63 速率限制 + R87 熔断器 + R104 去重检测 + R119 优先级排序 - agent-metrics recordCompletionGate + completionGatePassed + avgCompletionScore 相关代码 - context-manager 低价值关键词黑名单 + 快速摘要改用 user role - LoopContext 的 completionGateFailCount/verifyWarnings 字段 修复项: - R76 路径沙箱 replace bug:用 startsWith 前缀锚定替代 replace,避免子串误判 - R28 命令注入检测:缩小匹配范围,仅拦截命令替换中包含危险命令的情况 总计 13 文件变更,+46/-1124 行
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-v0.16.9-E8734A?style=flat-square" alt="version">
|
||||
<img src="https://img.shields.io/badge/version-v0.16.10-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/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">
|
||||
@@ -253,7 +253,7 @@ npm start
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||
```
|
||||
|
||||
产出:`release/Metona Ollama Setup v0.16.9.exe`
|
||||
产出:`release/Metona Ollama Setup v0.16.10.exe`
|
||||
|
||||
## 🛠️ 常用命令
|
||||
|
||||
@@ -501,7 +501,7 @@ npm start
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||
```
|
||||
|
||||
Output: `release/Metona Ollama Setup v0.16.9.exe`
|
||||
Output: `release/Metona Ollama Setup v0.16.10.exe`
|
||||
|
||||
## 🛠️ Common Commands
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "metona-ollama-desktop",
|
||||
"version": "0.16.9",
|
||||
"version": "0.16.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "metona-ollama-desktop",
|
||||
"version": "0.16.9",
|
||||
"version": "0.16.10",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "metona-ollama-desktop",
|
||||
"version": "0.16.9",
|
||||
"version": "0.16.10",
|
||||
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
||||
"main": "dist/main/main.js",
|
||||
"author": "thzxx",
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ export function createMenu(): void {
|
||||
dialog.showMessageBox(mainWindow!, {
|
||||
type: 'info',
|
||||
title: '关于 Metona Ollama',
|
||||
message: 'Metona Ollama Desktop v0.16.9',
|
||||
message: 'Metona Ollama Desktop v0.16.10',
|
||||
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
|
||||
icon: getIconPath()
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="header-left">
|
||||
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
|
||||
<span class="app-title">Metona Ollama</span>
|
||||
<span class="app-version">v0.16.9</span>
|
||||
<span class="app-version">v0.16.10</span>
|
||||
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
||||
<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"/>
|
||||
|
||||
@@ -18,21 +18,14 @@ import {
|
||||
} from './tool-registry.js';
|
||||
import {
|
||||
compactOldToolResult,
|
||||
setUserGoal,
|
||||
resetAllSafetyState,
|
||||
checkRateLimit,
|
||||
classifyError,
|
||||
calculateBackoff,
|
||||
validatePathSandbox,
|
||||
isToolCircuitBroken,
|
||||
recordToolFailure,
|
||||
recordToolSuccess,
|
||||
// R88: 工具结果元数据
|
||||
addResultMetadata,
|
||||
// R97: 错误模式学习
|
||||
recordErrorPattern,
|
||||
// R104: 工具结果去重
|
||||
isDuplicateToolResult,
|
||||
// R109: 工具参数消毒
|
||||
sanitizeToolArgs,
|
||||
// R113: 命令安全检查
|
||||
@@ -60,9 +53,8 @@ import {
|
||||
// R100: Token 使用统计报告
|
||||
generateTokenReport, formatTokenReport,
|
||||
} from './context-manager.js';
|
||||
import { runCompletionGate } from './completion-gate.js';
|
||||
import { executeHooks } from './hooks.js';
|
||||
import { recordIteration, recordToolCall, recordCompletionGate, startSessionMetrics, endSessionMetrics } from './agent-metrics.js';
|
||||
import { recordIteration, recordToolCall, startSessionMetrics, endSessionMetrics } from './agent-metrics.js';
|
||||
import { getEffectiveNumCtx } from '../components/model-bar.js';
|
||||
import type {
|
||||
OllamaMessage,
|
||||
@@ -185,58 +177,12 @@ const SIDE_EFFECT_TOOLS = new Set([
|
||||
]);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 跨轮次死循环检测
|
||||
// 追踪每轮工具调用签名,检测连续重复以防止模型陷入死循环
|
||||
// 与 R104(仅记录日志)不同,本机制在达到阈值时主动干预:
|
||||
// - 连续 3 轮相同 → 注入事实性提示(不修改模型输出)
|
||||
// - 注入提示后仍连续 5 轮相同 → 硬性熔断,强制终止
|
||||
// 跨轮次死循环检测已删除 — 硬熔断过于激进,软提示干扰 AI 判断
|
||||
// AI 应自行判断是否需要继续调用相同工具
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const _loopToolSignatures: string[] = [];
|
||||
const LOOP_HISTORY_MAX = 10;
|
||||
let _deadlockHintInjected = false;
|
||||
// P1-E6 修复:待清理的记忆提取 timer 列表,finally 块统一清理
|
||||
const _pendingMemoryTimers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
/** 记录本轮工具调用签名(每轮 handleThinking 成功后调用一次) */
|
||||
function recordLoopSignature(toolCalls: ToolCall[]): void {
|
||||
if (toolCalls.length === 0) {
|
||||
_loopToolSignatures.push('__no_tools__');
|
||||
_deadlockHintInjected = false; // 无工具调用,模型已改变行为,重置提示标志
|
||||
return;
|
||||
}
|
||||
const sig = toolCalls.map(tc =>
|
||||
`${tc.function.name}:${JSON.stringify(tc.function.arguments, Object.keys(tc.function.arguments || {}).sort())}`
|
||||
).join('|');
|
||||
// 签名变化时重置提示标志(模型已改变行为)
|
||||
if (_loopToolSignatures.length > 0 && _loopToolSignatures[_loopToolSignatures.length - 1] !== sig) {
|
||||
_deadlockHintInjected = false;
|
||||
}
|
||||
_loopToolSignatures.push(sig);
|
||||
if (_loopToolSignatures.length > LOOP_HISTORY_MAX) {
|
||||
_loopToolSignatures.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/** 检测连续 N 轮相同的工具调用签名 */
|
||||
function detectLoopDeadlock(minCount: number): { detected: boolean; toolName: string; count: number } {
|
||||
if (_loopToolSignatures.length < minCount) return { detected: false, toolName: '', count: 0 };
|
||||
const recent = _loopToolSignatures.slice(-minCount);
|
||||
if (recent[0] === '__no_tools__') return { detected: false, toolName: '', count: 0 };
|
||||
const allSame = recent.every(s => s === recent[0]);
|
||||
if (allSame) {
|
||||
const firstSig = recent[0];
|
||||
const toolName = firstSig.split(':')[0] || 'unknown';
|
||||
return { detected: true, toolName, count: minCount };
|
||||
}
|
||||
return { detected: false, toolName: '', count: 0 };
|
||||
}
|
||||
|
||||
/** 重置死循环检测状态(新会话开始时调用) */
|
||||
function resetLoopDeadlockDetector(): void {
|
||||
_loopToolSignatures.length = 0;
|
||||
_deadlockHintInjected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* R3: 工具执行超时配置(毫秒)
|
||||
* 防止单个工具调用挂起导致整个 Agent Loop 卡死
|
||||
@@ -1214,8 +1160,6 @@ function snapshotLoopContext(ctx: LoopContext): void {
|
||||
// P0-P3 修复:补全关键字段,至少保证运行时调试可见
|
||||
mode: ctx.mode,
|
||||
emergencyCompressCount: ctx.emergencyCompressCount,
|
||||
completionGateFailCount: ctx.completionGateFailCount,
|
||||
verifyWarnings: ctx.verifyWarnings,
|
||||
compressedThisCycle: ctx.compressedThisCycle,
|
||||
messageCount: ctx.messages.length,
|
||||
});
|
||||
@@ -1306,11 +1250,9 @@ async function handleInit(
|
||||
): Promise<void> {
|
||||
// 新一轮对话,清空工具缓存
|
||||
toolResultCache.clear();
|
||||
resetAllSafetyState(); // R51-R56: 重置所有安全状态
|
||||
resetLoopDeadlockDetector(); // 重置跨轮次死循环检测
|
||||
resetTokenBudget(); // R93: 重置 Token 预算追踪
|
||||
setTokenBudgetNumCtx(getEffectiveNumCtx()); // R93: 设置当前预算 numCtx
|
||||
setUserGoal(userContent); // R56: 设置用户原始目标
|
||||
resetAllSafetyState(); // R51-R56: 重置所有安全状态
|
||||
resetTokenBudget(); // R93: 重置 Token 预算追踪
|
||||
setTokenBudgetNumCtx(getEffectiveNumCtx()); // R93: 设置当前预算 numCtx
|
||||
|
||||
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
|
||||
// R55: 语义工具检索 — 根据用户查询过滤相关工具,减少 token 占用
|
||||
@@ -1520,134 +1462,8 @@ function extractPlanSteps(content: string): string[] {
|
||||
return steps.slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用工具结果核验 — 对所有写类工具执行后验证
|
||||
* 读取/搜索类工具已有返回结果作为验证,此处只核验会产生副作用的操作
|
||||
*/
|
||||
const TOOLS_NEED_VERIFY = new Set([
|
||||
'write_file', 'edit_file', 'delete_file', 'create_directory',
|
||||
'move_file', 'copy_file', 'download_file',
|
||||
]);
|
||||
|
||||
async function verifyToolResult(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
result: Record<string, unknown>,
|
||||
): Promise<string | null> {
|
||||
// P3 #13 修复:返回警告字符串供 Completion Gate 使用
|
||||
if (!TOOLS_NEED_VERIFY.has(toolName) || !result.success) return null;
|
||||
|
||||
const bridge = window.metonaDesktop;
|
||||
if (!bridge?.isDesktop) return null;
|
||||
|
||||
try {
|
||||
switch (toolName) {
|
||||
case 'write_file': {
|
||||
const path = String(args.path || '');
|
||||
const expected = String(args.content || '');
|
||||
const read = await bridge.tool.execute('read_file', { path, mode: 'text' });
|
||||
if (read.success) {
|
||||
const actual = String(read.content || '');
|
||||
if (actual.length === 0) {
|
||||
logWarn(`核验 write_file: ${path} 内容为空`);
|
||||
return `write_file 核验: ${path} 写入后内容为空`;
|
||||
} else if (Math.abs(actual.length - expected.length) > expected.length * 0.5) {
|
||||
logWarn(`核验 write_file: ${path} 期望 ${expected.length}B 实际 ${actual.length}B`);
|
||||
return `write_file 核验: ${path} 写入内容大小不符 (期望 ${expected.length}B, 实际 ${actual.length}B)`;
|
||||
} else {
|
||||
logInfo(`核验 write_file ✅: ${path} (${actual.length}B)`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'edit_file': {
|
||||
const path = String(args.path || '');
|
||||
const newText = String(args.new_text || '');
|
||||
const read = await bridge.tool.execute('read_file', { path, mode: 'text' });
|
||||
if (read.success) {
|
||||
const actual = String(read.content || '');
|
||||
if (!actual.includes(newText)) {
|
||||
logWarn(`核验 edit_file: ${path} 不包含期望的新内容`);
|
||||
return `edit_file 核验: ${path} 编辑后文件不包含期望的新内容`;
|
||||
} else {
|
||||
logInfo(`核验 edit_file ✅: ${path}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete_file': {
|
||||
// 支持批量删除核验:检查 paths 数组和单个 path
|
||||
const pathsToCheck: string[] = [];
|
||||
if (args.paths && Array.isArray(args.paths)) {
|
||||
pathsToCheck.push(...args.paths.map((p: unknown) => String(p)));
|
||||
}
|
||||
if (args.path) pathsToCheck.push(String(args.path));
|
||||
for (const p of pathsToCheck) {
|
||||
const info = await bridge.tool.execute('get_file_info', { path: p });
|
||||
if (info.success) {
|
||||
logWarn(`核验 delete_file: ${p} 仍然存在,删除未生效`);
|
||||
return `delete_file 核验: ${p} 删除后仍然存在`;
|
||||
} else {
|
||||
logInfo(`核验 delete_file ✅: ${p} 已不存在`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'create_directory': {
|
||||
const path = String(args.path || '');
|
||||
const info = await bridge.tool.execute('get_file_info', { path });
|
||||
if (info.success && (info as any).type === 'directory') {
|
||||
logInfo(`核验 create_directory ✅: ${path}`);
|
||||
} else {
|
||||
logWarn(`核验 create_directory: ${path} 未成功创建`);
|
||||
return `create_directory 核验: ${path} 目录未成功创建`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'move_file': {
|
||||
const src = String(args.source || '');
|
||||
const dest = String(args.destination || '');
|
||||
const [srcInfo, destInfo] = await Promise.all([
|
||||
bridge.tool.execute('get_file_info', { path: src }),
|
||||
bridge.tool.execute('get_file_info', { path: dest }),
|
||||
]);
|
||||
if (srcInfo.success) {
|
||||
logWarn(`核验 move_file: 源文件 ${src} 仍然存在`);
|
||||
return `move_file 核验: 源文件 ${src} 移动后仍然存在`;
|
||||
} else if (destInfo.success) {
|
||||
logInfo(`核验 move_file ✅: ${src} → ${dest}`);
|
||||
} else {
|
||||
logWarn(`核验 move_file: ${src}→${dest} 源和目标均不存在`);
|
||||
return `move_file 核验: ${src}→${dest} 移动后源和目标均不存在`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'copy_file': {
|
||||
const dest = String(args.destination || '');
|
||||
const destInfo = await bridge.tool.execute('get_file_info', { path: dest });
|
||||
if (destInfo.success) {
|
||||
logInfo(`核验 copy_file ✅: ${dest} (${(destInfo as any).size}B)`);
|
||||
} else {
|
||||
logWarn(`核验 copy_file: 目标 ${dest} 不存在`);
|
||||
return `copy_file 核验: 目标 ${dest} 复制后不存在`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'download_file': {
|
||||
const dest = String(args.destination || '');
|
||||
const info = await bridge.tool.execute('get_file_info', { path: dest });
|
||||
if (info.success && (info as any).size > 0) {
|
||||
logInfo(`核验 download_file ✅: ${dest} (${(info as any).size}B)`);
|
||||
} else {
|
||||
logWarn(`核验 download_file: ${dest} 不存在或为空`);
|
||||
return `download_file 核验: ${dest} 下载后不存在或为空`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch { /* 核验失败不阻塞主流程 */ }
|
||||
return null;
|
||||
}
|
||||
// verifyToolResult 已删除 — 工具结果核验过于粗糙,误报边界情况
|
||||
// AI 应基于工具返回的 success/error 字段自行判断结果有效性
|
||||
|
||||
/**
|
||||
* THINKING 状态:调用 LLM 流式
|
||||
@@ -1668,33 +1484,6 @@ async function handleThinking(
|
||||
ctx.thinking = '';
|
||||
ctx.toolCalls.length = 0;
|
||||
|
||||
// ── 跨轮次死循环检测 ──
|
||||
// 硬性熔断:已注入提示后仍连续 5 轮相同 → 强制终止
|
||||
if (_deadlockHintInjected) {
|
||||
const hardBreak = detectLoopDeadlock(5);
|
||||
if (hardBreak.detected) {
|
||||
logWarn(`死循环熔断: 连续 ${hardBreak.count} 轮相同工具调用 (${hardBreak.toolName}),已注入提示但仍未改善,强制终止`);
|
||||
const fallbackReply = `已执行工具调用但模型未能生成最终回复。最后执行的工具: ${hardBreak.toolName}。请尝试重新描述任务或切换模型。`;
|
||||
ctx.content = fallbackReply;
|
||||
callbacks.onDone(fallbackReply, ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx));
|
||||
transition(ctx, S.TERMINATED);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 软性提示:连续 3 轮相同工具调用 → 注入事实性提示(不修改模型输出)
|
||||
if (!_deadlockHintInjected) {
|
||||
const softWarn = detectLoopDeadlock(3);
|
||||
if (softWarn.detected) {
|
||||
logWarn(`死循环提示: 连续 ${softWarn.count} 轮相同工具调用 (${softWarn.toolName}),注入事实性提示`);
|
||||
ctx.messages.push({
|
||||
role: 'user',
|
||||
content: `[系统提示] 你已连续 ${softWarn.count} 轮调用相同的工具 (${softWarn.toolName}) 并获得相同结果。任务可能已完成,请基于已有工具结果直接回复用户,不要再调用工具。`,
|
||||
ephemeral: true,
|
||||
});
|
||||
_deadlockHintInjected = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plan Mode 执行进度注入 — 仅在进度变化时注入,避免每轮重复 ──
|
||||
if (ctx.mode === 'plan' && ctx.loopCount >= 1) {
|
||||
const tracker = getPlanTracker();
|
||||
@@ -1970,9 +1759,6 @@ async function handleThinking(
|
||||
ctx.loopPromptEvalCount = 0;
|
||||
ctx.loopInferenceNs = 0;
|
||||
|
||||
// 记录本轮工具调用签名(用于跨轮次死循环检测)
|
||||
recordLoopSignature(ctx.toolCalls);
|
||||
|
||||
transition(ctx, S.PARSING);
|
||||
}
|
||||
|
||||
@@ -2108,29 +1894,8 @@ async function handleExecuting(
|
||||
}, null];
|
||||
}
|
||||
|
||||
// R77: 工具调用速率限制 — 防止快速连续调用同一工具浪费资源
|
||||
const rateLimitResult = checkRateLimit(call.function.name);
|
||||
if (!rateLimitResult.allowed) {
|
||||
const waitSec = Math.ceil(rateLimitResult.retryAfterMs / 1000);
|
||||
logWarn(`R77: 速率限制拦截: ${call.function.name},需等待 ${waitSec}s`);
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: { success: false, error: `工具「${call.function.name}」调用频率过高,请等待 ${waitSec} 秒后重试。` },
|
||||
status: 'error' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
}
|
||||
|
||||
// R87: 工具熔断器检查 — 连续失败后自动禁用工具
|
||||
const circuitBreaker = isToolCircuitBroken(call.function.name);
|
||||
if (circuitBreaker.broken) {
|
||||
const waitSec = Math.ceil(circuitBreaker.remainingMs / 1000);
|
||||
logWarn(`R87: 熔断器拦截: ${call.function.name},冷却中(还需 ${waitSec}s)`);
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: { success: false, error: `工具「${call.function.name}」因连续失败已被暂时禁用,请等待 ${waitSec} 秒后重试,或使用其他方法。` },
|
||||
status: 'error' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
}
|
||||
// R77/R87 已删除:速率限制 + 熔断器 — 剥夺 AI 试错空间,误伤密集型任务
|
||||
// AI 应基于工具返回的错误信息自行调整策略
|
||||
|
||||
// R79: 路径沙箱验证 — 确保文件操作不超出工作空间边界
|
||||
const FILE_PATH_TOOLS = new Set([
|
||||
@@ -2221,12 +1986,6 @@ async function handleExecuting(
|
||||
state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal)
|
||||
.catch(err => ({ success: false, error: err?.message || String(err) }) as ToolResult);
|
||||
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
|
||||
// R87: 记录工具成功/失败到熔断器
|
||||
if (result.success) {
|
||||
recordToolSuccess(call.function.name);
|
||||
} else {
|
||||
recordToolFailure(call.function.name);
|
||||
}
|
||||
// 记录 write_file 成功路径及内容指纹,供 FileWriteDedup Hook 做内容级去重
|
||||
if (call.function.name === 'write_file' && result.success && call.function.arguments?.path) {
|
||||
const { addWrittenFile } = await import('./hooks.js');
|
||||
@@ -2310,13 +2069,6 @@ async function handleExecuting(
|
||||
ctx.allToolRecords.push(record);
|
||||
// R88: 为工具结果添加元数据提示(token 估算)
|
||||
const formattedResult = addResultMetadata(formatToolResultForModel(record.name, record.result!));
|
||||
// R104: 检测重复工具结果(仅记录日志,不修改结果内容)
|
||||
if (record.status === 'success') {
|
||||
const dedup = isDuplicateToolResult(record.name, formattedResult);
|
||||
if (dedup.duplicate) {
|
||||
logInfo(`R104: 检测到重复工具结果: ${record.name},已记录`);
|
||||
}
|
||||
}
|
||||
// R92: 工具结果格式标准化 — 添加统一头信息(工具名、状态、耗时、结果大小)
|
||||
const resultDuration = Date.now() - record.timestamp;
|
||||
const resultSize = formattedResult.length;
|
||||
@@ -2333,15 +2085,6 @@ async function handleExecuting(
|
||||
if (cacheKey) setToolCache(cacheKey, { result: record.result!, timestamp: Date.now() });
|
||||
// 记录度量
|
||||
recordToolCall(record.name, record.status, Date.now() - record.timestamp);
|
||||
// ── 通用工具结果核验 — 所有写类工具执行后验证 ──
|
||||
// P3 #13 修复:收集核验警告到 ctx.verifyWarnings,供 Completion Gate 使用
|
||||
if (record.status === 'success') {
|
||||
const warning = await verifyToolResult(record.name, record.arguments, record.result!);
|
||||
if (warning) {
|
||||
ctx.verifyWarnings.push(warning);
|
||||
logWarn(warning);
|
||||
}
|
||||
}
|
||||
// ── post_tool Hook ──
|
||||
executeHooks('post_tool', ctx, { toolName: record.name, toolArgs: record.arguments, toolResult: record.result! }).catch(err => logWarn('post_tool hook 异常', String(err)));
|
||||
// P0 修复: 使用参数匹配而非仅工具名匹配,避免同批次同名工具的错误关联
|
||||
@@ -2695,35 +2438,9 @@ async function handleReflecting(
|
||||
}
|
||||
} catch { /* Hook 异常不影响主流程 */ }
|
||||
|
||||
try {
|
||||
const gateResult = await runCompletionGate(ctx);
|
||||
if (!gateResult.passed) {
|
||||
recordCompletionGate(false);
|
||||
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 {
|
||||
recordCompletionGate(true);
|
||||
}
|
||||
// R90: 记录完成门控评分到日志
|
||||
if (gateResult.score < 100) {
|
||||
logInfo(`R90: Completion Gate 评分: ${gateResult.score}/100`);
|
||||
}
|
||||
} catch { /* Gate 异常不影响主流程 */ }
|
||||
// Completion Gate 已删除 — 所有检查项(notThinking/toolResultReview/contextEfficiency/planModeCompletion)均已删除
|
||||
// AI 应基于工具结果和上下文自行判断是否需要继续或完成
|
||||
// Plan Mode 下 AI 可通过 formatPlanStatus() 看到剩余步骤,应自行决定是否继续
|
||||
|
||||
logInfo('模型停止工具调用 → ReAct Agent Loop 结束');
|
||||
// ── 保存 Plan Mode 最终进度到状态,供下一轮 Loop 注入历史 ──
|
||||
@@ -2881,8 +2598,6 @@ export async function runAgentLoop(
|
||||
startTime: Date.now(),
|
||||
lastLoopStats: undefined,
|
||||
emergencyCompressCount: 0,
|
||||
completionGateFailCount: 0,
|
||||
verifyWarnings: [],
|
||||
compressedThisCycle: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ interface SessionMetrics {
|
||||
toolCalls: Array<{ name: string; status: string; duration?: number }>;
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
completionGatePassed: boolean;
|
||||
errorPatterns: string[];
|
||||
}
|
||||
|
||||
@@ -55,7 +54,6 @@ export function startSessionMetrics(sessionId: string, model: string): void {
|
||||
toolCalls: [],
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
completionGatePassed: false,
|
||||
errorPatterns: [],
|
||||
};
|
||||
logDebug(`Agent Metrics: 开始采集会话 ${sessionId}`);
|
||||
@@ -82,12 +80,6 @@ export function recordToolCall(name: string, status: string, durationMs?: number
|
||||
}
|
||||
}
|
||||
|
||||
/** 完成门控结果 */
|
||||
export function recordCompletionGate(passed: boolean): void {
|
||||
if (!currentMetrics) return;
|
||||
currentMetrics.completionGatePassed = passed;
|
||||
}
|
||||
|
||||
/** 结束会话度量 */
|
||||
export function endSessionMetrics(): SessionMetrics | null {
|
||||
if (!currentMetrics) return null;
|
||||
@@ -124,7 +116,6 @@ function persistMetricsHistory(): void {
|
||||
iterations: m.totalIterations,
|
||||
toolCallCount: m.toolCalls.length,
|
||||
toolSuccessRate: getToolSuccessRate(m),
|
||||
completionGatePassed: m.completionGatePassed,
|
||||
errorPatterns: m.errorPatterns,
|
||||
inputTokens: m.totalInputTokens,
|
||||
outputTokens: m.totalOutputTokens,
|
||||
@@ -152,7 +143,6 @@ export function loadMetricsHistory(): void {
|
||||
toolCalls: [],
|
||||
totalInputTokens: item.inputTokens || 0,
|
||||
totalOutputTokens: item.outputTokens || 0,
|
||||
completionGatePassed: item.completionGatePassed || false,
|
||||
errorPatterns: item.errorPatterns || [],
|
||||
});
|
||||
}
|
||||
@@ -192,7 +182,6 @@ export function aggregateMetrics(): AgentMetrics {
|
||||
totalSessions: 0,
|
||||
avgIterationsPerTask: 0,
|
||||
toolSuccessRate: 0,
|
||||
avgCompletionScore: 0,
|
||||
frequentErrors: [],
|
||||
tokenEfficiency: 0,
|
||||
collectedAt: Date.now(),
|
||||
@@ -221,7 +210,6 @@ export function aggregateMetrics(): AgentMetrics {
|
||||
totalSessions: history.length,
|
||||
avgIterationsPerTask: Math.round(totalIterations / history.length),
|
||||
toolSuccessRate: allToolCalls.length > 0 ? successCalls / allToolCalls.length : 0,
|
||||
avgCompletionScore: history.filter(m => m.completionGatePassed).length / history.length,
|
||||
frequentErrors,
|
||||
tokenEfficiency: totalInputTokens > 0 ? totalOutputTokens / totalInputTokens : 0,
|
||||
collectedAt: Date.now(),
|
||||
@@ -330,7 +318,6 @@ export function formatMetricsReport(metrics: AgentMetrics): string {
|
||||
`总会话数: ${metrics.totalSessions}`,
|
||||
`平均迭代/任务: ${metrics.avgIterationsPerTask}`,
|
||||
`工具成功率: ${formatPercent(metrics.toolSuccessRate)}`,
|
||||
`完成门控通过率: ${formatPercent(metrics.avgCompletionScore)}`,
|
||||
`Token 效率: ${formatPercent(metrics.tokenEfficiency)}`,
|
||||
`高频错误: ${metrics.frequentErrors.length > 0 ? metrics.frequentErrors.map(e => `${e.pattern}(${e.count}次)`).join(', ') : '无'}`,
|
||||
].join('\n');
|
||||
@@ -347,7 +334,6 @@ export function exportMetricsJSON(): string {
|
||||
sessions_total: metrics.totalSessions,
|
||||
avg_iterations_per_task: metrics.avgIterationsPerTask,
|
||||
tool_success_rate: parseFloat(metrics.toolSuccessRate.toFixed(4)),
|
||||
completion_gate_pass_rate: parseFloat(metrics.avgCompletionScore.toFixed(4)),
|
||||
token_efficiency: parseFloat(metrics.tokenEfficiency.toFixed(4)),
|
||||
top_errors: metrics.frequentErrors.slice(0, 5),
|
||||
improvement_suggestions: generateImprovementSuggestions().slice(0, 3).map(s => ({
|
||||
@@ -373,9 +359,6 @@ export function exportMetricsPrometheus(): string {
|
||||
lines.push('# HELP metona_tool_success_rate Tool call success rate (0-1)');
|
||||
lines.push('# TYPE metona_tool_success_rate gauge');
|
||||
lines.push(`metona_tool_success_rate ${metrics.toolSuccessRate.toFixed(4)}`);
|
||||
lines.push('# HELP metona_completion_gate_pass_rate Completion gate pass rate (0-1)');
|
||||
lines.push('# TYPE metona_completion_gate_pass_rate gauge');
|
||||
lines.push(`metona_completion_gate_pass_rate ${metrics.avgCompletionScore.toFixed(4)}`);
|
||||
lines.push('# HELP metona_token_efficiency Token output/input efficiency ratio');
|
||||
lines.push('# TYPE metona_token_efficiency gauge');
|
||||
lines.push(`metona_token_efficiency ${metrics.tokenEfficiency.toFixed(4)}`);
|
||||
|
||||
@@ -97,227 +97,10 @@ export function detectConsecutiveIdentical(minCount: number): { detected: boolea
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R56: 目标对齐验证
|
||||
// R56 已删除:目标对齐验证 — 关键词匹配粗糙,反复注入干扰 AI 判断
|
||||
// R63 已删除:工具调用速率限制 — 剥夺 AI 试错空间,误伤密集型任务
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 用户原始目标(在 handleInit 时设置) */
|
||||
let _userGoal: string = '';
|
||||
|
||||
/** 设置用户原始目标 */
|
||||
export function setUserGoal(goal: string): void {
|
||||
_userGoal = goal.slice(0, 500); // 限制长度防止占用过多内存
|
||||
}
|
||||
|
||||
/** 获取用户原始目标 */
|
||||
export function getUserGoal(): string {
|
||||
return _userGoal;
|
||||
}
|
||||
|
||||
/** 重置用户目标 */
|
||||
export function resetUserGoal(): void {
|
||||
_userGoal = '';
|
||||
}
|
||||
|
||||
/** 目标对齐检测结果 */
|
||||
export interface GoalAlignmentResult {
|
||||
aligned: boolean;
|
||||
reason: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* R56: 检测 Agent 是否偏离用户原始目标
|
||||
*
|
||||
* 检测策略:
|
||||
* 1. 提取用户目标中的关键动词和名词
|
||||
* 2. 检查最近 5 轮的工具调用是否与目标关键词相关
|
||||
* 3. 如果连续多轮工具调用都与目标无关,判定为偏离
|
||||
*
|
||||
* @param recentToolNames 最近 N 轮的工具调用名称列表
|
||||
* @param recentContent 最近 N 轮的模型输出内容摘要
|
||||
* @param loopCount 当前循环计数
|
||||
*/
|
||||
export function checkGoalAlignment(
|
||||
recentToolNames: string[],
|
||||
recentContent: string,
|
||||
loopCount: number,
|
||||
): GoalAlignmentResult {
|
||||
if (!_userGoal || _userGoal.length < 10) {
|
||||
return { aligned: true, reason: '目标太短,跳过对齐检测' };
|
||||
}
|
||||
|
||||
// 仅在循环数 >= 6 时开始检测(前几轮可能是探索阶段)
|
||||
if (loopCount < 6) {
|
||||
return { aligned: true, reason: '前期探索阶段,跳过检测' };
|
||||
}
|
||||
|
||||
const goalLower = _userGoal.toLowerCase();
|
||||
|
||||
// 提取目标关键词
|
||||
const goalKeywords = extractGoalKeywords(_userGoal);
|
||||
|
||||
// 检查最近内容是否包含目标关键词
|
||||
const contentLower = recentContent.toLowerCase();
|
||||
const contentMatches = goalKeywords.filter(kw => contentLower.includes(kw.toLowerCase()));
|
||||
|
||||
// 检查最近的工具调用是否与目标相关
|
||||
// 每种工具与特定目标类型的关联度
|
||||
const toolGoalRelevance = assessToolGoalRelevance(recentToolNames, goalLower);
|
||||
|
||||
// 如果最近内容中包含目标关键词,认为是对齐的
|
||||
if (contentMatches.length >= 1) {
|
||||
return { aligned: true, reason: `内容中包含目标关键词: ${contentMatches.join(', ')}` };
|
||||
}
|
||||
|
||||
// 如果工具调用与目标高度相关,认为是对齐的
|
||||
if (toolGoalRelevance.relevant) {
|
||||
return { aligned: true, reason: toolGoalRelevance.reason };
|
||||
}
|
||||
|
||||
// 偏离判定:内容无关键词 + 工具不相关 + 已过探索阶段
|
||||
return {
|
||||
aligned: false,
|
||||
reason: `最近 ${recentToolNames.length} 轮工具调用 (${recentToolNames.slice(-3).join(', ')}) 与用户目标"${_userGoal.slice(0, 60)}..."可能不相关`,
|
||||
suggestion: `请回顾用户原始任务: "${_userGoal.slice(0, 100)}"。如果当前操作是在为任务做准备,请继续;如果已经偏离,请调整方向。`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 从用户目标中提取关键词 */
|
||||
function extractGoalKeywords(goal: string): string[] {
|
||||
const keywords: string[] = [];
|
||||
|
||||
// 中文关键词
|
||||
const cnPatterns = [
|
||||
/搜索|查找|查一下|搜一下|检索|查询/g,
|
||||
/写入|创建|生成|保存|输出/g,
|
||||
/运行|执行|编译|构建/g,
|
||||
/读取|查看|打开/g,
|
||||
/修改|编辑|更新|替换/g,
|
||||
/删除|移除|清理/g,
|
||||
/下载|安装/g,
|
||||
/分析|研究|评估|对比/g,
|
||||
/测试|验证|检查/g,
|
||||
/部署|发布/g,
|
||||
];
|
||||
|
||||
// 英文关键词
|
||||
const enPatterns = [
|
||||
/\b(search|find|lookup)\b/gi,
|
||||
/\b(write|create|generate|save)\b/gi,
|
||||
/\b(run|execute|build|compile)\b/gi,
|
||||
/\b(read|view|open)\b/gi,
|
||||
/\b(edit|modify|update|replace)\b/gi,
|
||||
/\b(delete|remove|clean)\b/gi,
|
||||
/\b(download|install)\b/gi,
|
||||
/\b(analyze|research|evaluate|compare)\b/gi,
|
||||
/\b(test|verify|check)\b/gi,
|
||||
/\b(deploy|publish)\b/gi,
|
||||
];
|
||||
|
||||
for (const p of [...cnPatterns, ...enPatterns]) {
|
||||
const matches = goal.match(p);
|
||||
if (matches) keywords.push(...matches);
|
||||
}
|
||||
|
||||
// 提取路径/文件名(如果目标中包含)
|
||||
const pathMatches = goal.match(/[\w-]+\.(ts|js|py|json|md|txt|html|css|go|rs|java|cpp|c)/gi);
|
||||
if (pathMatches) keywords.push(...pathMatches);
|
||||
|
||||
// 提取引号中的内容
|
||||
const quotedMatches = goal.match(/[""']([^""']{3,30})[""']/g);
|
||||
if (quotedMatches) {
|
||||
for (const q of quotedMatches) {
|
||||
keywords.push(q.replace(/[""']/g, ''));
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(keywords)];
|
||||
}
|
||||
|
||||
/** 评估工具调用与用户目标的相关性 */
|
||||
function assessToolGoalRelevance(toolNames: string[], goalLower: string): { relevant: boolean; reason: string } {
|
||||
if (toolNames.length === 0) return { relevant: true, reason: '无工具调用,跳过' };
|
||||
|
||||
// 文件操作类目标
|
||||
const isFileTask = /文件|目录|file|directory|path|路径/.test(goalLower);
|
||||
// 搜索类目标
|
||||
const isSearchTask = /搜索|查找|查|search|find|grep/.test(goalLower);
|
||||
// 命令执行类目标
|
||||
const isCommandTask = /运行|执行|命令|终端|run|execute|command|shell/.test(goalLower);
|
||||
// 网络类目标
|
||||
const isWebTask = /网页|网站|url|web|fetch|搜索|search/.test(goalLower);
|
||||
// Git 类目标
|
||||
const isGitTask = /git|提交|推送|分支|commit|push|pull/.test(goalLower);
|
||||
// 浏览器类目标
|
||||
const isBrowserTask = /浏览器|网页|截图|browser|screenshot/.test(goalLower);
|
||||
|
||||
const recentTools = toolNames.slice(-5);
|
||||
|
||||
if (isFileTask && recentTools.some(t => /read_file|write_file|edit_file|list_directory|search_files|tree/.test(t))) {
|
||||
return { relevant: true, reason: '文件操作与文件类目标相关' };
|
||||
}
|
||||
if (isSearchTask && recentTools.some(t => /search_files|web_search|grep/.test(t))) {
|
||||
return { relevant: true, reason: '搜索操作与搜索类目标相关' };
|
||||
}
|
||||
if (isCommandTask && recentTools.some(t => /run_command/.test(t))) {
|
||||
return { relevant: true, reason: '命令执行与命令类目标相关' };
|
||||
}
|
||||
if (isWebTask && recentTools.some(t => /web_search|web_fetch|browser_open/.test(t))) {
|
||||
return { relevant: true, reason: '网络操作与网络类目标相关' };
|
||||
}
|
||||
if (isGitTask && recentTools.some(t => /git/.test(t))) {
|
||||
return { relevant: true, reason: 'Git 操作与 Git 类目标相关' };
|
||||
}
|
||||
if (isBrowserTask && recentTools.some(t => /browser_/.test(t))) {
|
||||
return { relevant: true, reason: '浏览器操作与浏览器类目标相关' };
|
||||
}
|
||||
|
||||
// memory 和 plan_track 始终认为相关
|
||||
if (recentTools.some(t => /memory|plan_track/.test(t))) {
|
||||
return { relevant: true, reason: '记忆/计划操作始终相关' };
|
||||
}
|
||||
|
||||
return { relevant: false, reason: '最近工具调用与目标类型不匹配' };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R63: 工具调用速率限制 — 防止快速连续调用同一工具浪费资源
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const _toolCallTimestamps = new Map<string, number[]>();
|
||||
const RATE_LIMIT_WINDOW = 60_000; // 60 秒窗口
|
||||
const RATE_LIMIT_DEFAULT = 20; // 默认每分钟最多 20 次
|
||||
|
||||
/** R63: 工具特定的速率限制配置 */
|
||||
const TOOL_RATE_LIMITS: Record<string, number> = {
|
||||
web_search: 10, // 每分钟最多 10 次搜索
|
||||
web_fetch: 8, // 每分钟最多 8 次抓取
|
||||
run_command: 15, // 每分钟最多 15 次命令
|
||||
browser_open: 8, // 每分钟最多 8 次浏览器打开
|
||||
download_file: 5, // 每分钟最多 5 次下载
|
||||
};
|
||||
|
||||
/** R63: 检查工具调用是否超过速率限制 */
|
||||
export function checkRateLimit(toolName: string): { allowed: boolean; retryAfterMs: number } {
|
||||
const limit = TOOL_RATE_LIMITS[toolName] ?? RATE_LIMIT_DEFAULT;
|
||||
const now = Date.now();
|
||||
const timestamps = _toolCallTimestamps.get(toolName) || [];
|
||||
|
||||
// 清理过期时间戳
|
||||
const valid = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW);
|
||||
|
||||
if (valid.length >= limit) {
|
||||
// 计算最早过期时间
|
||||
const oldest = valid[0];
|
||||
const retryAfterMs = RATE_LIMIT_WINDOW - (now - oldest);
|
||||
return { allowed: false, retryAfterMs: Math.max(1000, retryAfterMs) };
|
||||
}
|
||||
|
||||
valid.push(now);
|
||||
_toolCallTimestamps.set(toolName, valid);
|
||||
return { allowed: true, retryAfterMs: 0 };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R66-R67: 错误分类系统 — 区分瞬态/永久错误,指导重试策略
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -451,7 +234,12 @@ export function validatePathSandbox(
|
||||
}
|
||||
|
||||
// 检查路径遍历攻击
|
||||
const relativePath = normalized.toLowerCase().replace(normalizedWs.toLowerCase(), '').replace(/^[\\/]/, '');
|
||||
// P0 修复:用前缀锚定 replace,避免工作空间路径是另一路径子串时误判
|
||||
const wsLower = normalizedWs.toLowerCase();
|
||||
const normLower = normalized.toLowerCase();
|
||||
const relativePath = normLower.startsWith(wsLower)
|
||||
? normLower.slice(wsLower.length).replace(/^[\\/]/, '')
|
||||
: normLower;
|
||||
if (relativePath.includes('..')) {
|
||||
// 允许在工作空间内部的相对路径引用
|
||||
const segments = relativePath.split(/[\\/]/);
|
||||
@@ -485,45 +273,9 @@ function isAbsolute(p: string): boolean {
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R87: 工具熔断器 — 连续失败后自动禁用工具,防止雪崩
|
||||
// R87 已删除:工具熔断器 — 剥夺 AI 试错空间,连续失败可能是参数调试过程
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 工具失败计数器 */
|
||||
const _toolFailureCount = new Map<string, number>();
|
||||
const CIRCUIT_BREAKER_THRESHOLD = 5; // 连续失败 5 次后熔断
|
||||
const CIRCUIT_BREAKER_COOLDOWN = 60_000; // 熔断冷却 60 秒
|
||||
const _circuitBreakerTripped = new Map<string, number>(); // toolName → trip timestamp
|
||||
|
||||
/** R87: 记录工具失败 */
|
||||
export function recordToolFailure(toolName: string): void {
|
||||
const count = (_toolFailureCount.get(toolName) || 0) + 1;
|
||||
_toolFailureCount.set(toolName, count);
|
||||
if (count >= CIRCUIT_BREAKER_THRESHOLD) {
|
||||
_circuitBreakerTripped.set(toolName, Date.now());
|
||||
logWarn(`R87: 熔断器触发 — ${toolName} 连续失败 ${count} 次,禁用 60 秒`);
|
||||
}
|
||||
}
|
||||
|
||||
/** R87: 记录工具成功(重置失败计数) */
|
||||
export function recordToolSuccess(toolName: string): void {
|
||||
_toolFailureCount.delete(toolName);
|
||||
_circuitBreakerTripped.delete(toolName);
|
||||
}
|
||||
|
||||
/** R87: 检查工具是否被熔断 */
|
||||
export function isToolCircuitBroken(toolName: string): { broken: boolean; remainingMs: number } {
|
||||
const tripTime = _circuitBreakerTripped.get(toolName);
|
||||
if (!tripTime) return { broken: false, remainingMs: 0 };
|
||||
const elapsed = Date.now() - tripTime;
|
||||
if (elapsed >= CIRCUIT_BREAKER_COOLDOWN) {
|
||||
// 冷却期过了,半开状态:允许调用但保持失败计数
|
||||
_circuitBreakerTripped.delete(toolName);
|
||||
_toolFailureCount.set(toolName, CIRCUIT_BREAKER_THRESHOLD - 1); // 只给一次机会
|
||||
return { broken: false, remainingMs: 0 };
|
||||
}
|
||||
return { broken: true, remainingMs: CIRCUIT_BREAKER_COOLDOWN - elapsed };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R88: 工具结果元数据 — 为模型提供结果大小的上下文提示
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -621,56 +373,9 @@ export function recordErrorPattern(toolName: string, errorMsg: string): string |
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R104: 工具结果去重 — 相似结果检测,减少上下文中的冗余
|
||||
// R104 已删除:工具结果去重 — 误伤轮询类任务(如反复 run_command 检查构建状态)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface ToolResultFingerprint {
|
||||
toolName: string;
|
||||
contentHash: string;
|
||||
timestamp: number;
|
||||
resultLength: number;
|
||||
}
|
||||
|
||||
const _resultFingerprints: ToolResultFingerprint[] = [];
|
||||
const MAX_FINGERPRINTS = 50;
|
||||
|
||||
/** R104: 计算内容的简化指纹(用于相似度检测) */
|
||||
function computeResultFingerprint(content: string): string {
|
||||
// 取内容的前200字符 + 后100字符作为指纹
|
||||
const head = content.slice(0, 200);
|
||||
const tail = content.slice(-100);
|
||||
let hash = 5381;
|
||||
const combined = head + tail;
|
||||
for (let i = 0; i < combined.length; i++) {
|
||||
hash = ((hash << 5) + hash + combined.charCodeAt(i)) & 0x7fffffff;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
/** R104: 检查工具结果是否与最近的结果高度相似 */
|
||||
export function isDuplicateToolResult(toolName: string, resultContent: string): { duplicate: boolean; similarTo?: string } {
|
||||
const hash = computeResultFingerprint(resultContent);
|
||||
const now = Date.now();
|
||||
const DEDUP_WINDOW = 60_000; // 60 秒内的结果视为候选
|
||||
|
||||
// 查找相同工具+相同指纹的结果
|
||||
for (const fp of _resultFingerprints) {
|
||||
if (fp.toolName === toolName && fp.contentHash === hash && (now - fp.timestamp) < DEDUP_WINDOW) {
|
||||
return { duplicate: true, similarTo: `${fp.timestamp}` };
|
||||
}
|
||||
}
|
||||
|
||||
// 记录当前结果
|
||||
_resultFingerprints.push({
|
||||
toolName, contentHash: hash, timestamp: now, resultLength: resultContent.length,
|
||||
});
|
||||
if (_resultFingerprints.length > MAX_FINGERPRINTS) {
|
||||
_resultFingerprints.shift();
|
||||
}
|
||||
|
||||
return { duplicate: false };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R109: 工具参数消毒 — 防止通过工具参数注入恶意内容
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -718,55 +423,25 @@ export interface AgentDiagnostics {
|
||||
timestamp: number;
|
||||
toolResultStoreSize: number;
|
||||
toolCallHistoryLength: number;
|
||||
rateLimitActiveTools: string[];
|
||||
circuitBreakerStatus: Array<{ tool: string; broken: boolean; failures: number }>;
|
||||
errorPatternCount: number;
|
||||
topErrorPatterns: Array<{ tool: string; signature: string; count: number }>;
|
||||
resultFingerprintCount: number;
|
||||
duplicateResultCount: number;
|
||||
userGoal: string;
|
||||
}
|
||||
|
||||
/** R112: 收集 Agent 诊断信息 */
|
||||
export function collectDiagnostics(): AgentDiagnostics {
|
||||
const rateLimitActive: string[] = [];
|
||||
const now = Date.now();
|
||||
for (const [tool, timestamps] of _toolCallTimestamps) {
|
||||
const valid = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW);
|
||||
if (valid.length > 0) {
|
||||
rateLimitActive.push(`${tool}(${valid.length}/${TOOL_RATE_LIMITS[tool] ?? RATE_LIMIT_DEFAULT})`);
|
||||
}
|
||||
}
|
||||
|
||||
const circuitBreakerStatus = Array.from(_toolFailureCount.entries()).map(([tool, count]) => ({
|
||||
tool,
|
||||
broken: _circuitBreakerTripped.has(tool),
|
||||
failures: count,
|
||||
}));
|
||||
|
||||
const topErrorPatterns = Array.from(_errorPatterns.values())
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5)
|
||||
.map(e => ({ tool: e.toolName, signature: e.errorSignature, count: e.count }));
|
||||
|
||||
const duplicateCount = _resultFingerprints.filter(fp => {
|
||||
// 同一工具同一指纹出现多次
|
||||
return _resultFingerprints.some(other =>
|
||||
other !== fp && other.toolName === fp.toolName && other.contentHash === fp.contentHash
|
||||
);
|
||||
}).length;
|
||||
|
||||
return {
|
||||
timestamp: now,
|
||||
toolResultStoreSize: _toolResultStore.size,
|
||||
toolCallHistoryLength: _toolCallHistory.length,
|
||||
rateLimitActiveTools: rateLimitActive,
|
||||
circuitBreakerStatus,
|
||||
errorPatternCount: _errorPatterns.size,
|
||||
topErrorPatterns,
|
||||
resultFingerprintCount: _resultFingerprints.length,
|
||||
duplicateResultCount: duplicateCount,
|
||||
userGoal: _userGoal.slice(0, 80),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -777,18 +452,11 @@ export function formatDiagnosticsReport(diag: AgentDiagnostics): string {
|
||||
`${'─'.repeat(50)}`,
|
||||
`Tool Result Store: ${diag.toolResultStoreSize} entries`,
|
||||
`Tool Call History: ${diag.toolCallHistoryLength} entries`,
|
||||
`Rate Limit Active: ${diag.rateLimitActiveTools.length > 0 ? diag.rateLimitActiveTools.join(', ') : 'none'}`,
|
||||
`Circuit Breakers:`,
|
||||
`Error Patterns: ${diag.errorPatternCount}`,
|
||||
];
|
||||
for (const cb of diag.circuitBreakerStatus) {
|
||||
lines.push(` ${cb.broken ? '🔴' : '🟢'} ${cb.tool}: ${cb.failures} failures${cb.broken ? ' (BROKEN)' : ''}`);
|
||||
}
|
||||
lines.push(`Error Patterns: ${diag.errorPatternCount}`);
|
||||
for (const ep of diag.topErrorPatterns) {
|
||||
lines.push(` ⚠️ ${ep.tool}: "${ep.signature}" (${ep.count}x)`);
|
||||
}
|
||||
lines.push(`Result Fingerprints: ${diag.resultFingerprintCount} (duplicates: ${diag.duplicateResultCount})`);
|
||||
lines.push(`User Goal: "${diag.userGoal}"`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -1007,14 +675,8 @@ export function checkArchivedReferences(messages: Array<{ content?: string }>):
|
||||
export function resetAllSafetyState(): void {
|
||||
_toolResultStore.clear();
|
||||
_toolCallHistory.length = 0;
|
||||
_userGoal = '';
|
||||
_toolCallTimestamps.clear();
|
||||
_toolFailureCount.clear();
|
||||
_circuitBreakerTripped.clear();
|
||||
// R97: 清理错误模式
|
||||
_errorPatterns.clear();
|
||||
// R104: 清理结果指纹
|
||||
_resultFingerprints.length = 0;
|
||||
// R118: 清理性能分析数据
|
||||
_loopTimings.length = 0;
|
||||
logInfo('Agent Safety: 所有安全状态已重置');
|
||||
@@ -1027,12 +689,7 @@ export function resetAllSafetyState(): void {
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -1041,12 +698,7 @@ 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],
|
||||
};
|
||||
}
|
||||
@@ -1057,17 +709,8 @@ export function restoreSafetyState(snapshot: SafetyStateSnapshot): void {
|
||||
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);
|
||||
}
|
||||
@@ -1499,86 +1142,9 @@ export function autoTuneMemorySearch(
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R119: 工具执行队列优先级 — 按重要性排序工具执行
|
||||
// R119 已删除:工具优先级排序 — 强制重排可能打乱 AI 设计的执行顺序
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export type ToolPriority = 'critical' | 'high' | 'normal' | 'low';
|
||||
|
||||
const TOOL_PRIORITY_MAP: Record<string, ToolPriority> = {
|
||||
// 关键:用户直接请求的操作
|
||||
write_file: 'critical',
|
||||
edit_file: 'critical',
|
||||
delete_file: 'high',
|
||||
|
||||
// 高:信息获取类
|
||||
read_file: 'high',
|
||||
list_directory: 'high',
|
||||
search_files: 'high',
|
||||
|
||||
// 普通:辅助工具
|
||||
run_command: 'normal',
|
||||
web_search: 'normal',
|
||||
web_fetch: 'normal',
|
||||
git: 'normal',
|
||||
|
||||
// 低:非关键
|
||||
memory: 'low',
|
||||
datetime: 'low',
|
||||
calculator: 'low',
|
||||
random: 'low',
|
||||
uuid: 'low',
|
||||
};
|
||||
|
||||
/** R119: 获取工具优先级 */
|
||||
export function getToolPriority(toolName: string): ToolPriority {
|
||||
return TOOL_PRIORITY_MAP[toolName] || 'normal';
|
||||
}
|
||||
|
||||
/** R119: 按优先级排序工具调用 */
|
||||
export function sortToolsByPriority<T extends { name: string }>(
|
||||
tools: T[]
|
||||
): T[] {
|
||||
const priorityOrder: Record<ToolPriority, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
normal: 2,
|
||||
low: 3,
|
||||
};
|
||||
|
||||
return [...tools].sort((a, b) => {
|
||||
const pa = priorityOrder[getToolPriority(a.name)];
|
||||
const pb = priorityOrder[getToolPriority(b.name)];
|
||||
return pa - pb;
|
||||
});
|
||||
}
|
||||
|
||||
/** R119: 按优先级分批(同优先级可并行) */
|
||||
export function batchToolsByPriority<T extends { name: string }>(
|
||||
tools: T[]
|
||||
): T[][] {
|
||||
const batches: T[][] = [];
|
||||
const sorted = sortToolsByPriority(tools);
|
||||
|
||||
let currentBatch: T[] = [];
|
||||
let currentPriority: ToolPriority | null = null;
|
||||
|
||||
for (const tool of sorted) {
|
||||
const priority = getToolPriority(tool.name);
|
||||
if (currentPriority === null) {
|
||||
currentPriority = priority;
|
||||
}
|
||||
if (priority !== currentPriority) {
|
||||
if (currentBatch.length > 0) batches.push(currentBatch);
|
||||
currentBatch = [];
|
||||
currentPriority = priority;
|
||||
}
|
||||
currentBatch.push(tool);
|
||||
}
|
||||
|
||||
if (currentBatch.length > 0) batches.push(currentBatch);
|
||||
return batches;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R124: 压缩上下文中工具引用解析 — 恢复被压缩的工具结果引用
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
/**
|
||||
* Completion Gate — 完成门控模块
|
||||
* Harness Engineering: 在 Agent 输出最终答案前进行结构化检查
|
||||
*
|
||||
* 设计理念:
|
||||
* - 计算性反馈优先(规则驱动、毫秒级、100% 可靠)
|
||||
* - 推理性反馈补充(AI 判断、秒级、非确定)
|
||||
* - 所有检查项可独立开关
|
||||
*/
|
||||
|
||||
import { logWarn, logInfo } from './log-service.js';
|
||||
import { estimateTokens } from './context-manager.js';
|
||||
import type { LoopContext, CompletionCheck } from '../types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 内置检查项
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 工具结果审查:是否存在未处理的工具错误 */
|
||||
const toolResultReviewCheck: CompletionCheck = {
|
||||
name: 'toolResultReview',
|
||||
description: '检查是否存在未处理的工具执行错误',
|
||||
check: async (ctx: LoopContext) => {
|
||||
const errorRecords = ctx.allToolRecords.filter(r => r.status === 'error');
|
||||
if (errorRecords.length === 0) return { passed: true, reason: '' };
|
||||
|
||||
// 检查这些错误是否在最近的上下文中被讨论过
|
||||
const recentContent = ctx.messages.slice(-5).map(m => m.content || '').join(' ');
|
||||
const unresolvedErrors = errorRecords.filter(r => {
|
||||
const errorName = r.name;
|
||||
return !recentContent.includes(errorName + ' 失败')
|
||||
&& !recentContent.includes(errorName + ' error')
|
||||
&& !recentContent.includes(errorName + ' 错误');
|
||||
});
|
||||
|
||||
if (unresolvedErrors.length > 0) {
|
||||
return {
|
||||
passed: false,
|
||||
reason: `存在 ${unresolvedErrors.length} 个未处理的工具错误: ${unresolvedErrors.map(r => r.name).join(', ')}。请说明这些错误的影响或提供替代方案。`
|
||||
};
|
||||
}
|
||||
return { passed: true, reason: '' };
|
||||
},
|
||||
};
|
||||
|
||||
/** 思考阶段检测:回复不应仍在"思考中"状态 */
|
||||
const notThinkingCheck: CompletionCheck = {
|
||||
name: 'notThinking',
|
||||
description: '检查回复是否已从思考阶段过渡到结论阶段',
|
||||
check: async (ctx: LoopContext) => {
|
||||
// 仅检查短回复(<200字)——长回复几乎肯定是完整回答
|
||||
if (ctx.content.length > 200) return { passed: true, reason: '' };
|
||||
|
||||
const contentLower = ctx.content.slice(0, 100).toLowerCase();
|
||||
const thinkingMarkers = ['让我看看', '观察一下', '先检查一下', '我需要先', '正在分析', '让我再'];
|
||||
const continuationSignals = ctx.content.endsWith('...') || ctx.content.endsWith('等等') || ctx.content.endsWith('…');
|
||||
const conclusionMarkers = [
|
||||
/Final\s*Answer/i, /最终答案/, /最终回答/, /总结/, /任务完成/, /以上就是/,
|
||||
/以上[是为]/, /我的能力/, /可以帮你/, /能够/, /我是/, /以下[是为]/,
|
||||
];
|
||||
|
||||
const hasConclusion = conclusionMarkers.some(p => p.test(ctx.content));
|
||||
if (hasConclusion) return { passed: true, reason: '' };
|
||||
|
||||
const thinkingCount = thinkingMarkers.filter(m => contentLower.includes(m)).length;
|
||||
const isClearlyThinking = thinkingCount >= 2 || (thinkingCount >= 1 && continuationSignals);
|
||||
|
||||
if (isClearlyThinking) {
|
||||
return {
|
||||
passed: false,
|
||||
reason: '回复看起来仍在思考阶段(短回复 + 思考动词 + 无结论标志),请基于已有结果给出明确的结论。'
|
||||
};
|
||||
}
|
||||
return { passed: true, reason: '' };
|
||||
},
|
||||
};
|
||||
|
||||
/** 上下文效率检查:是否存在过度调用工具 */
|
||||
const contextEfficiencyCheck: CompletionCheck = {
|
||||
name: 'contextEfficiency',
|
||||
description: '检查工具调用效率是否合理(避免过度调用)',
|
||||
check: async (ctx: LoopContext) => {
|
||||
const toolMessages = ctx.messages.filter(m => m.role === 'tool');
|
||||
const nonToolMessages = ctx.messages.filter(m => m.role !== 'tool' && m.role !== 'system');
|
||||
|
||||
if (toolMessages.length > nonToolMessages.length * 3 && ctx.loopCount > 5) {
|
||||
return {
|
||||
passed: false,
|
||||
reason: `工具调用过多(${toolMessages.length} 次 vs ${nonToolMessages.length} 次非工具消息),可能存在重复或低效的工具调用。请检查是否可以直接基于已有结果给出回答。`
|
||||
};
|
||||
}
|
||||
return { passed: true, reason: '' };
|
||||
},
|
||||
};
|
||||
|
||||
/** R83: Plan Mode 完成检查 — 验证所有计划步骤是否已标记完成 */
|
||||
const planModeCompletionCheck: CompletionCheck = {
|
||||
name: 'planModeCompletion',
|
||||
description: 'Plan Mode 下检查所有计划步骤是否已完成',
|
||||
check: async (ctx: LoopContext) => {
|
||||
if (ctx.mode !== 'plan') return { passed: true, reason: '' };
|
||||
// 从 state 获取 Plan Tracker
|
||||
try {
|
||||
const { getPlanTracker } = await import('./tool-registry.js');
|
||||
const tracker = getPlanTracker();
|
||||
if (!tracker.active || tracker.steps.length === 0) return { passed: true, reason: '' };
|
||||
if (tracker.done < tracker.total) {
|
||||
const remaining = tracker.total - tracker.done;
|
||||
const undoneSteps = tracker.steps.filter(s => !s.done).map(s => s.label).slice(0, 3);
|
||||
return {
|
||||
passed: false,
|
||||
reason: `Plan Mode: 还有 ${remaining} 步未完成(${undoneSteps.join('、')}${remaining > 3 ? '...' : ''})。请继续执行剩余步骤,或说明为什么这些步骤无法完成。`
|
||||
};
|
||||
}
|
||||
return { passed: true, reason: '' };
|
||||
} catch {
|
||||
return { passed: true, reason: '' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 检查项注册与管理
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 默认启用的检查项 */
|
||||
const DEFAULT_ENABLED_CHECKS = new Set([
|
||||
'toolResultReview',
|
||||
'notThinking',
|
||||
'planModeCompletion',
|
||||
]);
|
||||
|
||||
/** 注册表 */
|
||||
const checkRegistry = new Map<string, CompletionCheck>([
|
||||
['toolResultReview', toolResultReviewCheck],
|
||||
['notThinking', notThinkingCheck],
|
||||
['contextEfficiency', contextEfficiencyCheck],
|
||||
['planModeCompletion', planModeCompletionCheck],
|
||||
]);
|
||||
|
||||
/** 已启用的检查项 */
|
||||
const enabledChecks = new Set(DEFAULT_ENABLED_CHECKS);
|
||||
|
||||
/** 注册自定义检查项 */
|
||||
export function registerCompletionCheck(check: CompletionCheck): void {
|
||||
checkRegistry.set(check.name, check);
|
||||
logInfo(`Completion Gate: 注册检查项 "${check.name}"`);
|
||||
}
|
||||
|
||||
/** 移除检查项 */
|
||||
export function unregisterCompletionCheck(name: string): void {
|
||||
checkRegistry.delete(name);
|
||||
enabledChecks.delete(name);
|
||||
}
|
||||
|
||||
/** 启用/禁用检查项 */
|
||||
export function setCheckEnabled(name: string, enabled: boolean): void {
|
||||
if (enabled) {
|
||||
if (checkRegistry.has(name)) enabledChecks.add(name);
|
||||
} else {
|
||||
enabledChecks.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取所有已注册检查项 */
|
||||
export function getRegisteredChecks(): CompletionCheck[] {
|
||||
return Array.from(checkRegistry.values());
|
||||
}
|
||||
|
||||
/** 获取已启用的检查项 */
|
||||
export function getEnabledChecks(): CompletionCheck[] {
|
||||
return getRegisteredChecks().filter(c => enabledChecks.has(c.name));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 执行门控
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface GateResult {
|
||||
passed: boolean;
|
||||
reason: string;
|
||||
/** R90: 各项检查的详细结果 */
|
||||
details: Array<{ name: string; passed: boolean; reason: string; durationMs: number }>;
|
||||
/** R90/R127: 完成门控评分(0-100) */
|
||||
score: number;
|
||||
/** R90/R127: 评分明细 */
|
||||
scoreBreakdown: Array<{ check: string; passed: boolean; points: number }>;
|
||||
}
|
||||
|
||||
/** R127: 各检查项的权重分配 */
|
||||
const CHECK_WEIGHTS: Record<string, number> = {
|
||||
toolResultReview: 25,
|
||||
notThinking: 20,
|
||||
contextEfficiency: 15,
|
||||
planModeCompletion: 10,
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行完成门控检查
|
||||
* R90: 返回评分结果,评分基于各检查项的权重
|
||||
*/
|
||||
export async function runCompletionGate(ctx: LoopContext): Promise<GateResult> {
|
||||
const checks = getEnabledChecks();
|
||||
if (checks.length === 0) {
|
||||
return { passed: true, reason: '', details: [], score: 100, scoreBreakdown: [] };
|
||||
}
|
||||
|
||||
const details: GateResult['details'] = [];
|
||||
const scoreBreakdown: GateResult['scoreBreakdown'] = [];
|
||||
let totalScore = 0;
|
||||
let maxScore = 0;
|
||||
|
||||
for (const check of checks) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await check.check(ctx);
|
||||
const durationMs = Date.now() - start;
|
||||
const weight = CHECK_WEIGHTS[check.name] ?? 10;
|
||||
maxScore += weight;
|
||||
|
||||
details.push({
|
||||
name: check.name,
|
||||
passed: result.passed,
|
||||
reason: result.reason,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
scoreBreakdown.push({
|
||||
check: check.name,
|
||||
passed: result.passed,
|
||||
points: result.passed ? weight : 0,
|
||||
});
|
||||
|
||||
if (result.passed) {
|
||||
totalScore += weight;
|
||||
} else {
|
||||
logWarn(`Completion Gate: "${check.name}" — ${result.reason}`);
|
||||
return {
|
||||
passed: false,
|
||||
reason: result.reason,
|
||||
details,
|
||||
score: Math.round((totalScore / Math.max(1, maxScore)) * 100),
|
||||
scoreBreakdown,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
// 检查项本身抛异常:容错不阻塞流程,但语义上标记为未通过且不得分
|
||||
const weight = CHECK_WEIGHTS[check.name] ?? 10;
|
||||
maxScore += weight;
|
||||
const errMsg = `检查异常: ${(err as Error).message}`;
|
||||
logWarn(`Completion Gate: "${check.name}" 检查项抛出异常 — ${errMsg}`);
|
||||
details.push({
|
||||
name: check.name,
|
||||
passed: false,
|
||||
reason: errMsg,
|
||||
durationMs: Date.now() - start,
|
||||
});
|
||||
scoreBreakdown.push({
|
||||
check: check.name,
|
||||
passed: false,
|
||||
points: 0,
|
||||
});
|
||||
// 注意:此处不 return,继续执行后续检查项(容错设计)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
passed: true,
|
||||
reason: '',
|
||||
details,
|
||||
score: Math.round((totalScore / Math.max(1, maxScore)) * 100),
|
||||
scoreBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* R127: 生成门控报告(供日志/调试使用)
|
||||
*/
|
||||
export function formatGateReport(result: GateResult): string {
|
||||
const status = result.passed ? '✅ 通过' : '❌ 未通过';
|
||||
let report = `Completion Gate ${status} (评分: ${result.score}/100)`;
|
||||
if (!result.passed) {
|
||||
report += ` — ${result.reason}`;
|
||||
}
|
||||
report += `\n${'─'.repeat(40)}`;
|
||||
for (const d of result.details) {
|
||||
const icon = d.passed ? '✅' : '❌';
|
||||
report += `\n${icon} ${d.name}: ${d.reason || '通过'} (${d.durationMs}ms)`;
|
||||
}
|
||||
// R127: 评分明细
|
||||
if (result.scoreBreakdown.length > 0) {
|
||||
report += `\n${'─'.repeat(40)}\n评分明细:`;
|
||||
for (const s of result.scoreBreakdown) {
|
||||
report += `\n ${s.passed ? '✅' : '❌'} ${s.check}: ${s.points} 分`;
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
@@ -295,19 +295,12 @@ export function scoreMessageImportance(msg: OllamaMessage): number {
|
||||
/完成|done|✓|success|成功|结果|result/i,
|
||||
/项目|project|工作空间|workspace|git|repo|仓库/i,
|
||||
];
|
||||
// R10: 修复低价值模式误报 — 移除单字“好”(匹配几乎所有中文文本)
|
||||
// 仅匹配明确的短语回复,且仅对短消息(<100字)生效
|
||||
const lowValuePatterns = [/^(好的|明白|ok|知道了|嗯|哦|好[的呀吧]|收到)$/i,
|
||||
/^(继续|请继续|帮帮我|可以吗|行吗|好的谢谢)$/i,
|
||||
/^(谢谢|感谢|不客气|多谢|thanks?)$/i,
|
||||
];
|
||||
// 低价值关键词黑名单已删除 — 误伤边界情况(如"好的,我发现了一个 bug")
|
||||
// AI 应自行判断消息价值,长度加分机制已足够区分短回复
|
||||
|
||||
for (const p of highValuePatterns) {
|
||||
if (p.test(content)) { score += 1; break; }
|
||||
}
|
||||
for (const p of lowValuePatterns) {
|
||||
if (p.test(content) && content.length < 100) { score -= 2; break; }
|
||||
}
|
||||
|
||||
// 工具调用 → 高价值
|
||||
if (msg.tool_calls?.length) score += 2;
|
||||
@@ -850,8 +843,9 @@ function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): O
|
||||
for (let i = 0; i < messages.length; i += batchSize) {
|
||||
const batch = messages.slice(i, i + batchSize);
|
||||
const summary = createQuickSummary(batch);
|
||||
// 使用 user role 而非 system role — 摘要是对话历史的延续,用 system 会与系统指令语义混淆
|
||||
summaries.push({
|
||||
role: 'system',
|
||||
role: 'user',
|
||||
content: `【更早的对话摘要(第 ${Math.floor(i / batchSize) + 1} 部分)】\n${summary}`,
|
||||
compressed: true
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { OllamaAPI } from '../api/ollama.js';
|
||||
import { TOOL_DEFINITIONS } from './tool-registry.js';
|
||||
import { getEnabledToolDefinitions } from './tool-registry.js';
|
||||
import { logInfo, logWarn, logError } from './log-service.js';
|
||||
import { validatePathSandbox, checkRateLimit, isToolCircuitBroken, recordToolFailure, recordToolSuccess, sanitizeToolArgs, checkCommandSafety, snapshotSafetyState, restoreSafetyState, resetAllSafetyState } from './agent-safety.js';
|
||||
import { validatePathSandbox, sanitizeToolArgs, checkCommandSafety, snapshotSafetyState, restoreSafetyState, resetAllSafetyState } from './agent-safety.js';
|
||||
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
|
||||
import type { ToolResult, ToolCall, ToolDefinition } from '../types.js';
|
||||
|
||||
@@ -176,18 +176,7 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
|
||||
// 工具执行前再次检查中止信号
|
||||
if (subAgentAC.signal.aborted) break;
|
||||
|
||||
// R89: 子 Agent 熔断器检查 — 连续失败后自动禁用工具
|
||||
const circuitBreaker = isToolCircuitBroken(tc.name);
|
||||
if (circuitBreaker.broken) {
|
||||
const waitSec = Math.ceil(circuitBreaker.remainingMs / 1000);
|
||||
logWarn(`R89: 子 Agent 熔断器拦截: ${tc.name},冷却中(还需 ${waitSec}s)`);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: `工具「${tc.name}」因连续失败已被暂时禁用,请等待 ${waitSec} 秒后重试。` })}\n<<<TOOL_RESULT_END>>>`,
|
||||
tool_name: tc.name
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// R89/R82 已删除:子 Agent 熔断器 + 速率限制 — 剥夺 AI 试错空间
|
||||
|
||||
// R109: 子 Agent 参数消毒
|
||||
tc.arguments = sanitizeToolArgs(tc.name, tc.arguments);
|
||||
@@ -209,19 +198,6 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
|
||||
}
|
||||
}
|
||||
|
||||
// R82: 子 Agent 速率限制 — 防止子代理快速连续调用同一工具
|
||||
const rateLimit = checkRateLimit(tc.name);
|
||||
if (!rateLimit.allowed) {
|
||||
const waitSec = Math.ceil(rateLimit.retryAfterMs / 1000);
|
||||
logWarn(`R82: 子 Agent 速率限制: ${tc.name},等待 ${waitSec}s`);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: `调用频率过高,等待 ${waitSec}s` })}\n<<<TOOL_RESULT_END>>>`,
|
||||
tool_name: tc.name
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// R81: 子 Agent 路径沙箱 — 确保文件操作不超出工作空间
|
||||
const SUB_FILE_TOOLS = new Set(['read_file', 'list_directory', 'search_files', 'web_fetch']);
|
||||
if (SUB_FILE_TOOLS.has(tc.name)) {
|
||||
@@ -246,12 +222,6 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
|
||||
try {
|
||||
const { executeTool } = await import('./tool-registry.js');
|
||||
const result = await executeTool(tc.name, tc.arguments);
|
||||
// R89: 记录工具成功/失败到熔断器
|
||||
if (result.success) {
|
||||
recordToolSuccess(tc.name);
|
||||
} else {
|
||||
recordToolFailure(tc.name);
|
||||
}
|
||||
const resultStr = formatResult(tc.name, result);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
|
||||
@@ -893,7 +893,10 @@ function hasPathTraversal(path: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** R28: 命令注入检测 — 检查命令中是否包含危险的 shell 注入模式 */
|
||||
/** R28: 危险命令关键词 — 命令替换中包含这些词才拦截 */
|
||||
const DANGEROUS_CMD_KEYWORDS = 'rm|del|format|mkfs|dd|fdisk|shred|umount|chmod\\s+777|chown|shutdown|reboot|halt';
|
||||
|
||||
/** R28: 命令注入检测 — 仅拦截命令替换中包含危险命令的情况,避免误伤 $(date) 等合法用法 */
|
||||
function hasCommandInjection(command: string): boolean {
|
||||
if (!command) return false;
|
||||
const dangerousPatterns = [
|
||||
@@ -901,8 +904,9 @@ function hasCommandInjection(command: string): boolean {
|
||||
/\|\s*(rm|del|format|mkfs)\s/i, // 管道到删除操作
|
||||
/&&\s*(rm|del|format|mkfs)\s/i, // AND链中的删除操作
|
||||
/\$\{.*IFS.*\}/i, // IFS 变量注入
|
||||
/\$\([^)]*\)/, // 命令替换 $(...)
|
||||
/`[^`]*`/, // 反引号命令替换
|
||||
// 仅拦截命令替换中包含危险命令的情况,放行 $(date) 等合法用法
|
||||
new RegExp(`\\$\\([^)]*\\b(?:${DANGEROUS_CMD_KEYWORDS})\\b[^)]*\\)`, 'i'),
|
||||
new RegExp('`[^`]*\\b(?:' + DANGEROUS_CMD_KEYWORDS + ')\\b[^`]*`', 'i'),
|
||||
/\x00/, // null 字节注入
|
||||
];
|
||||
return dangerousPatterns.some(p => p.test(command));
|
||||
|
||||
Vendored
-12
@@ -413,10 +413,6 @@ export interface LoopContext {
|
||||
};
|
||||
/** R8 紧急压缩重试计数(防止溢出→压缩→重试无限循环) */
|
||||
emergencyCompressCount: number;
|
||||
/** Completion Gate 连续失败次数(达到上限后不再重试) */
|
||||
completionGateFailCount: number;
|
||||
/** 工具结果核验警告列表(供 Completion Gate 使用) */
|
||||
verifyWarnings: string[];
|
||||
/** 上下文压缩标记 — 防止同一轮重复触发压缩 */
|
||||
compressedThisCycle: boolean;
|
||||
}
|
||||
@@ -463,19 +459,11 @@ export interface HookData {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 完成门控检查项 */
|
||||
export interface CompletionCheck {
|
||||
name: string;
|
||||
description: string;
|
||||
check: (ctx: LoopContext) => Promise<{ passed: boolean; reason: string }>;
|
||||
}
|
||||
|
||||
/** Agent 度量指标 */
|
||||
export interface AgentMetrics {
|
||||
totalSessions: number;
|
||||
avgIterationsPerTask: number;
|
||||
toolSuccessRate: number;
|
||||
avgCompletionScore: number;
|
||||
frequentErrors: Array<{ pattern: string; count: number }>;
|
||||
tokenEfficiency: number;
|
||||
collectedAt: number;
|
||||
|
||||
Reference in New Issue
Block a user