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:
紫影233
2026-07-16 16:44:04 +08:00
parent a4b82b45bc
commit 80581deb37
13 changed files with 46 additions and 1124 deletions
+13 -298
View File
@@ -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,
};