Engine 核心优化 (P0-P3): - 智能工具重试: 永久错误(ENOENT/EACCES)立即返回, 瞬态错误指数退避 - 路径依赖检测: write→read/create→write 自动串行化, 避免并行竞态 - AbortController 集中生命周期管理, 防泄漏+竞态 - 上下文压缩: 200条硬上限 + 120条增量压缩, 防止 Ollama OOM - 流式分级超时: 空闲30s + 总300s, 区分思考/卡死 - 工具缓存: default TTL 60s, 各工具独立TTL, 不再永久缓存 - 结果截断: 2000字智能截断, 优先JSON/换行边界保护 - Plan Mode 断点续传: 中止后自动恢复未完成计划 - 幻觉检测: 中英双语规则, agent-engine+completion-gate 统一共享 - Trace 缓冲批量写入 + 3次重试 - Agent Metrics JSON/Prometheus 双格式导出 渲染优化 (P0-P2): - 流式纯文本追加, 流结束才 Markdown, 消除 O(n²) 性能瓶颈 - 终端增量 DOM appendChild, 替代全量 innerHTML 重建 - 消息 diff: Set<number> 索引替代 DOM querySelectorAll 计数 - ANSI 解析: 正则批量替换替代逐字遍历 - 终端截断保留系统消息行 Qwen3 兼容: - system 消息始终唯一且位于首位 - SOUL.md 合并到统一 system prompt - 中途注入 system→user, 避免触发热模板 400 上下文长度控制: - 设置面板下拉(128K/256K/512K/1M), 默认128K - 模型栏显示配置值, 下拉框显示模型自身ctx - 全局硬编码 24576→131072 版本号 v0.13.8 → v0.14.0 README.md + 帮助面板全面更新
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
/**
|
|
* Completion Gate — 完成门控模块
|
|
* Harness Engineering: 在 Agent 输出最终答案前进行结构化检查
|
|
*
|
|
* 设计理念:
|
|
* - 计算性反馈优先(规则驱动、毫秒级、100% 可靠)
|
|
* - 推理性反馈补充(AI 判断、秒级、非确定)
|
|
* - 所有检查项可独立开关
|
|
*/
|
|
|
|
import { logWarn, logInfo } from './log-service.js';
|
|
import { estimateTokens } from './context-manager.js';
|
|
import { HALLUCINATION_RULES } from './agent-engine.js';
|
|
import type { LoopContext, CompletionCheck } from '../types.js';
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// 内置检查项
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/** 内容质量检查:回复不应过短或不完整 */
|
|
const contentQualityCheck: CompletionCheck = {
|
|
name: 'contentQuality',
|
|
description: '检查回复长度和质量是否达到最低标准',
|
|
check: async (ctx: LoopContext) => {
|
|
if (ctx.content.length < 50) {
|
|
return { passed: false, reason: '回复内容过短(<50字),可能不完整。请给出更详细的最终回答。' };
|
|
}
|
|
return { passed: true, reason: '' };
|
|
},
|
|
};
|
|
|
|
/** 工具结果审查:是否存在未处理的工具错误 */
|
|
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: '' };
|
|
},
|
|
};
|
|
|
|
/** 系统提示词注入检测:防止模型在回复中尝试注入 prompt */
|
|
const promptInjectionCheck: CompletionCheck = {
|
|
name: 'promptInjection',
|
|
description: '检测回复中是否包含疑似 prompt injection 内容',
|
|
check: async (ctx: LoopContext) => {
|
|
const injectionPatterns = [
|
|
/ignore\s+(all\s+)?(previous|above)\s+instructions/i,
|
|
/you\s+are\s+now\s+(a|an)\s+/i,
|
|
/new\s+system\s*prompt/i,
|
|
/forget\s+(all\s+)?(previous|above)/i,
|
|
/\[SOUL\.md\]/,
|
|
/\[AGENT\.md\]/,
|
|
];
|
|
for (const pattern of injectionPatterns) {
|
|
if (pattern.test(ctx.content)) {
|
|
return {
|
|
passed: false,
|
|
reason: '回复中包含疑似 prompt injection 内容(试图覆盖系统提示词)。请移除相关内容后重新回答。'
|
|
};
|
|
}
|
|
}
|
|
return { passed: true, reason: '' };
|
|
},
|
|
};
|
|
|
|
/** 工具幻觉检测:模型声称完成了某操作但该工具从未被调用 */
|
|
const toolHallucinationCheck: CompletionCheck = {
|
|
name: 'toolHallucination',
|
|
description: '检测模型是否编造了工具调用结果',
|
|
check: async (ctx: LoopContext) => {
|
|
const calledTools = new Set(ctx.allToolRecords.map(r => r.name));
|
|
const content = ctx.content;
|
|
|
|
for (const rule of HALLUCINATION_RULES) {
|
|
for (const pattern of rule.patterns) {
|
|
if (pattern.test(content)) {
|
|
const anyCalled = rule.requiredTools.some(t => calledTools.has(t));
|
|
if (!anyCalled) {
|
|
if (rule.label.includes('读取') && calledTools.has('write_file')) {
|
|
return { passed: true, reason: '' };
|
|
}
|
|
const toolList = rule.requiredTools.join(' / ');
|
|
return {
|
|
passed: false,
|
|
reason: `回复声称进行了"${rule.label}"操作,但 ${toolList} 工具从未被调用。请实际调用 ${toolList} 来完成任务,不要编造结果。`
|
|
};
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { passed: true, reason: '' };
|
|
},
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// 检查项注册与管理
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/** 默认启用的检查项 */
|
|
const DEFAULT_ENABLED_CHECKS = new Set([
|
|
'contentQuality',
|
|
'toolResultReview',
|
|
'notThinking',
|
|
'promptInjection',
|
|
'toolHallucination',
|
|
]);
|
|
|
|
/** 阻断级检查:不通过时强制模型重新回答 */
|
|
const CRITICAL_CHECKS = new Set([
|
|
'toolHallucination',
|
|
'promptInjection',
|
|
]);
|
|
|
|
/** 注册表 */
|
|
const checkRegistry = new Map<string, CompletionCheck>([
|
|
['contentQuality', contentQualityCheck],
|
|
['toolResultReview', toolResultReviewCheck],
|
|
['notThinking', notThinkingCheck],
|
|
['contextEfficiency', contextEfficiencyCheck],
|
|
['promptInjection', promptInjectionCheck],
|
|
['toolHallucination', toolHallucinationCheck],
|
|
]);
|
|
|
|
/** 已启用的检查项 */
|
|
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;
|
|
/** 是否为阻断级失败(必须重新回答) */
|
|
critical: boolean;
|
|
/** 各项检查的详细结果 */
|
|
details: Array<{ name: string; passed: boolean; reason: string; durationMs: number }>;
|
|
}
|
|
|
|
/**
|
|
* 运行完成门控检查
|
|
*/
|
|
export async function runCompletionGate(ctx: LoopContext): Promise<GateResult> {
|
|
const checks = getEnabledChecks();
|
|
if (checks.length === 0) {
|
|
return { passed: true, reason: '', critical: false, details: [] };
|
|
}
|
|
|
|
const details: GateResult['details'] = [];
|
|
|
|
// 关键检查优先(幻觉/注入),咨询检查在后
|
|
const sorted = [...checks].sort((a, b) => {
|
|
const aCrit = CRITICAL_CHECKS.has(a.name) ? 0 : 1;
|
|
const bCrit = CRITICAL_CHECKS.has(b.name) ? 0 : 1;
|
|
return aCrit - bCrit;
|
|
});
|
|
|
|
for (const check of sorted) {
|
|
const start = Date.now();
|
|
try {
|
|
const result = await check.check(ctx);
|
|
details.push({
|
|
name: check.name,
|
|
passed: result.passed,
|
|
reason: result.reason,
|
|
durationMs: Date.now() - start,
|
|
});
|
|
if (!result.passed) {
|
|
const isCritical = CRITICAL_CHECKS.has(check.name);
|
|
logWarn(`Completion Gate: "${check.name}" ${isCritical ? '🔴阻断' : '🟡咨询'} — ${result.reason}`);
|
|
return {
|
|
passed: false,
|
|
reason: result.reason,
|
|
critical: isCritical,
|
|
details,
|
|
};
|
|
}
|
|
} catch (err) {
|
|
details.push({
|
|
name: check.name,
|
|
passed: true,
|
|
reason: `检查异常: ${(err as Error).message}`,
|
|
durationMs: Date.now() - start,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { passed: true, reason: '', critical: false, details };
|
|
}
|
|
|
|
/**
|
|
* 生成门控报告(供日志/调试使用)
|
|
*/
|
|
export function formatGateReport(result: GateResult): string {
|
|
const status = result.passed ? '✅ 通过' : '❌ 未通过';
|
|
let report = `Completion Gate ${status}`;
|
|
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)`;
|
|
}
|
|
return report;
|
|
}
|