v0.12.3: 全面审计修复 + 稳定性加固 + 文档更新
P0 安全/稳定性修复 (6项):
- sqlite persist 改用临时文件+rename,I/O错误日志
- IPC fs 处理器加入 checkPathAllowed 路径验证
- handleCompress 命令注入修复 + 跨平台 (powershell/tar/zip/unzip)
- browser 全页截图简化,使用 capturePage full rect
- workspace 竞态条件修复
- editFile 5MB 大小限制
P1 安全/稳定性修复 (7项):
- 大小写不敏感搜索: query 同步转换小写
- web_fetch 无 content-length 时流式读取+10MB限制防OOM
- Windows SIGTERM 改用 taskkill /PID /T /F 强制终止
- startsWith 路径检查 Windows 大小写不敏感
- 安全黑名单扩展: 33个目录路径 + 30条危险命令 (Linux+Windows)
- plan_track stepIndex 改用 typeof === 'number' 精确检查
- MCP 前缀改用双下划线分隔 mcp_{server}__{tool} 防歧义
P2 代码质量修复 (8项):
- 修复 EXECUTING→THINKING 死循环Bug (转换表缺失)
- COMPRESSING 硬上限绕过时同步 _loopState 全局状态
- Abort 路径补齐 onDone 回调, 防止UI状态残留
- 幻觉检测从4条扩展到16条规则, 覆盖全部工具类别
- 移除 getTokenEfficiency 死代码
- 跨平台路径清理 (replace(/[\/]+$/, ''))
- 动态 import 加 try/catch, 失败自动批准不阻断
- #modelSelect 加 null 守卫, 防止运行时崩溃
文档 & UI 更新:
- README.md: 版本号/特性表/架构图/安全机制/工具清单全面刷新
- 帮助面板: 新增 Plan Mode、抗幻觉&稳定性章节, Agent Loop补8状态机
- 工具面板: plan_track卡片 + plan-mode徽章样式, 标题更新为42+1
- 工作空间面板宽度: 480px → 400px
- 源码注释移除所有版本标签, 仅保留4处必要位置
- 版本号 0.12.2 → 0.12.3
This commit is contained in:
+1260
-663
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Agent Metrics — 治理与度量模块 (v0.12.0)
|
||||
* Harness Engineering Phase 8: 度量驱动的优化循环
|
||||
*
|
||||
* 功能:
|
||||
* - 会话级度量采集(迭代效率、工具成功率、完成质量)
|
||||
* - 全局趋势聚合
|
||||
* - 错误模式识别(转向循环)
|
||||
* - 度量仪表盘数据源
|
||||
*/
|
||||
|
||||
import { logInfo, logDebug } from './log-service.js';
|
||||
import type { AgentMetrics, LoopContext } from '../types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 度量采集
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 当前会话的度量快照 */
|
||||
interface SessionMetrics {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
totalIterations: number;
|
||||
toolCalls: Array<{ name: string; status: string; duration?: number }>;
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
completionGatePassed: boolean;
|
||||
errorPatterns: string[];
|
||||
}
|
||||
|
||||
let currentMetrics: SessionMetrics | null = null;
|
||||
const sessionMetricsHistory: SessionMetrics[] = [];
|
||||
|
||||
/** 开始采集会话度量 */
|
||||
export function startSessionMetrics(sessionId: string, model: string): void {
|
||||
currentMetrics = {
|
||||
sessionId,
|
||||
model,
|
||||
startTime: Date.now(),
|
||||
endTime: 0,
|
||||
totalIterations: 0,
|
||||
toolCalls: [],
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
completionGatePassed: false,
|
||||
errorPatterns: [],
|
||||
};
|
||||
logDebug(`Agent Metrics: 开始采集会话 ${sessionId}`);
|
||||
}
|
||||
|
||||
/** 记录一轮迭代 */
|
||||
export function recordIteration(ctx: LoopContext): void {
|
||||
if (!currentMetrics) return;
|
||||
currentMetrics.totalIterations = ctx.loopCount;
|
||||
currentMetrics.totalInputTokens = ctx.totalPromptEvalCount;
|
||||
currentMetrics.totalOutputTokens = ctx.totalEvalCount;
|
||||
}
|
||||
|
||||
/** 记录工具调用 */
|
||||
export function recordToolCall(name: string, status: string, durationMs?: number): void {
|
||||
if (!currentMetrics) return;
|
||||
currentMetrics.toolCalls.push({ name, status, duration: durationMs });
|
||||
if (status === 'error') {
|
||||
// 错误模式:记录工具名和失败状态
|
||||
const pattern = `${name}:error`;
|
||||
if (!currentMetrics.errorPatterns.includes(pattern)) {
|
||||
currentMetrics.errorPatterns.push(pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 完成门控结果 */
|
||||
export function recordCompletionGate(passed: boolean): void {
|
||||
if (!currentMetrics) return;
|
||||
currentMetrics.completionGatePassed = passed;
|
||||
}
|
||||
|
||||
/** 结束会话度量 */
|
||||
export function endSessionMetrics(): SessionMetrics | null {
|
||||
if (!currentMetrics) return null;
|
||||
currentMetrics.endTime = Date.now();
|
||||
sessionMetricsHistory.push({ ...currentMetrics });
|
||||
const metrics = { ...currentMetrics };
|
||||
logInfo(
|
||||
`Agent Metrics: 会话完成 — ` +
|
||||
`${metrics.totalIterations} 轮, ` +
|
||||
`${metrics.toolCalls.length} 工具调用, ` +
|
||||
`${formatPercent(getToolSuccessRate(metrics))} 成功率`
|
||||
);
|
||||
currentMetrics = null;
|
||||
return metrics;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 度量计算
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 计算工具成功率 */
|
||||
function getToolSuccessRate(m: SessionMetrics): number {
|
||||
if (m.toolCalls.length === 0) return 1;
|
||||
const successCount = m.toolCalls.filter(t => t.status === 'success').length;
|
||||
return successCount / m.toolCalls.length;
|
||||
}
|
||||
|
||||
/** 格式化百分比 */
|
||||
function formatPercent(ratio: number): string {
|
||||
return (ratio * 100).toFixed(1) + '%';
|
||||
}
|
||||
|
||||
|
||||
/** 聚合全局度量 */
|
||||
export function aggregateMetrics(): AgentMetrics {
|
||||
const history = sessionMetricsHistory;
|
||||
if (history.length === 0) {
|
||||
return {
|
||||
totalSessions: 0,
|
||||
avgIterationsPerTask: 0,
|
||||
toolSuccessRate: 0,
|
||||
avgCompletionScore: 0,
|
||||
frequentErrors: [],
|
||||
tokenEfficiency: 0,
|
||||
collectedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const totalIterations = history.reduce((s, m) => s + m.totalIterations, 0);
|
||||
const allToolCalls = history.flatMap(m => m.toolCalls);
|
||||
const successCalls = allToolCalls.filter(t => t.status === 'success').length;
|
||||
const totalInputTokens = history.reduce((s, m) => s + m.totalInputTokens, 0);
|
||||
const totalOutputTokens = history.reduce((s, m) => s + m.totalOutputTokens, 0);
|
||||
|
||||
// 聚合错误模式
|
||||
const errorCounts = new Map<string, number>();
|
||||
for (const m of history) {
|
||||
for (const pattern of m.errorPatterns) {
|
||||
errorCounts.set(pattern, (errorCounts.get(pattern) || 0) + 1);
|
||||
}
|
||||
}
|
||||
const frequentErrors = Array.from(errorCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([pattern, count]) => ({ pattern, count }));
|
||||
|
||||
return {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 转向循环:错误模式 → 改进建议
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface ImprovementSuggestion {
|
||||
pattern: string;
|
||||
frequency: number;
|
||||
suggestion: string;
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析错误模式,生成改进建议
|
||||
* 实现"让错误只犯一次"的核心理念
|
||||
*/
|
||||
export function generateImprovementSuggestions(): ImprovementSuggestion[] {
|
||||
const metrics = aggregateMetrics();
|
||||
const suggestions: ImprovementSuggestion[] = [];
|
||||
|
||||
for (const err of metrics.frequentErrors) {
|
||||
const [tool, status] = err.pattern.split(':');
|
||||
|
||||
// 特定工具的重复失败
|
||||
if (status === 'error') {
|
||||
if (tool === 'web_fetch' && err.count >= 3) {
|
||||
suggestions.push({
|
||||
pattern: err.pattern,
|
||||
frequency: err.count,
|
||||
suggestion: `web_fetch 频繁失败 (${err.count}次)。考虑在 AGENT.md 中添加规则:优先使用 browser_open + browser_extract 作为替代方案。`,
|
||||
severity: 'high',
|
||||
});
|
||||
} else if (tool === 'web_search' && err.count >= 3) {
|
||||
suggestions.push({
|
||||
pattern: err.pattern,
|
||||
frequency: err.count,
|
||||
suggestion: `web_search 频繁失败 (${err.count}次)。检查 SearXNG 配置或网络连接。可在 AGENT.md 中添加备用搜索策略。`,
|
||||
severity: 'high',
|
||||
});
|
||||
} else if (err.count >= 5) {
|
||||
suggestions.push({
|
||||
pattern: err.pattern,
|
||||
frequency: err.count,
|
||||
suggestion: `工具 ${tool} 反复失败 ${err.count} 次。建议审查该工具的参数构造逻辑,或在 AGENT.md 中添加错误处理指南。`,
|
||||
severity: 'medium',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Token 效率过低
|
||||
if (metrics.tokenEfficiency < 0.1 && metrics.totalSessions > 3) {
|
||||
suggestions.push({
|
||||
pattern: 'low_token_efficiency',
|
||||
frequency: metrics.totalSessions,
|
||||
suggestion: `Token 效率过低 (${formatPercent(metrics.tokenEfficiency)})。Agent 可能过度调用工具。建议在 AGENT.md 中添加:简单常识问题直接回答,不要调用工具。`,
|
||||
severity: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
// 迭代次数过高
|
||||
if (metrics.avgIterationsPerTask > 50) {
|
||||
suggestions.push({
|
||||
pattern: 'high_iterations',
|
||||
frequency: metrics.totalSessions,
|
||||
suggestion: `平均迭代次数过高 (${metrics.avgIterationsPerTask} 轮)。建议降低 maxTurns 设置或在 AGENT.md 中添加提前终止条件。`,
|
||||
severity: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将改进建议格式化为 AGENT.md 补充规则
|
||||
*/
|
||||
export function formatSuggestionsAsRules(suggestions: ImprovementSuggestion[]): string {
|
||||
if (suggestions.length === 0) return '';
|
||||
|
||||
let rules = '\n\n## 自动生成的改进规则 (v0.12.0)\n';
|
||||
rules += '> 以下规则由 Agent Metrics 系统根据历史错误模式自动生成\n\n';
|
||||
|
||||
for (const s of suggestions) {
|
||||
rules += `### ${s.pattern}\n`;
|
||||
rules += `- **严重程度**: ${s.severity}\n`;
|
||||
rules += `- **出现频率**: ${s.frequency} 次\n`;
|
||||
rules += `- **建议**: ${s.suggestion}\n\n`;
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 格式化输出(供仪表盘使用)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export function formatMetricsReport(metrics: AgentMetrics): string {
|
||||
return [
|
||||
`Agent Metrics 报告 (${new Date(metrics.collectedAt).toLocaleString()})`,
|
||||
`${'─'.repeat(50)}`,
|
||||
`总会话数: ${metrics.totalSessions}`,
|
||||
`平均迭代/任务: ${metrics.avgIterationsPerTask}`,
|
||||
`工具成功率: ${formatPercent(metrics.toolSuccessRate)}`,
|
||||
`完成门控通过率: ${formatPercent(metrics.avgCompletionScore)}`,
|
||||
`Token 效率: ${formatPercent(metrics.tokenEfficiency)}`,
|
||||
`高频错误: ${metrics.frequentErrors.length > 0 ? metrics.frequentErrors.map(e => `${e.pattern}(${e.count}次)`).join(', ') : '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Completion Gate — 完成门控模块 (v0.12.0)
|
||||
* Harness Engineering Phase 3: 在 Agent 输出最终答案前进行结构化检查
|
||||
*
|
||||
* 设计理念:
|
||||
* - 计算性反馈优先(规则驱动、毫秒级、100% 可靠)
|
||||
* - 推理性反馈补充(AI 判断、秒级、非确定)
|
||||
* - 所有检查项可独立开关
|
||||
*/
|
||||
|
||||
import { logWarn, logInfo } from './log-service.js';
|
||||
import { estimateTokens } from './context-manager.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;
|
||||
|
||||
// ── 统一的"声称 → 必需工具"映射表(覆盖全部 42 个工具)──
|
||||
const CLAIM_RULES: Array<{ patterns: RegExp[]; requiredTools: string[]; label: string }> = [
|
||||
// 文件写入
|
||||
{ patterns: [/已写入文件/, /已创建文件/, /文件已生成/, /已保存到/, /已成功创建/, /成功写入/,
|
||||
/文件.*已.*创建/, /文件.*已.*写入/, /生成.*文件/, /写入.*工作空间/],
|
||||
requiredTools: ['write_file'], label: '写入/创建文件' },
|
||||
// 文件读取
|
||||
{ patterns: [/已读取文件/, /文件内容显示/, /读取了.*文件/, /文件.*内容.*如下/,
|
||||
/查看.*文件.*内容/, /打开文件.*看到/],
|
||||
requiredTools: ['read_file', 'read_multiple_files'], label: '读取文件' },
|
||||
// 文件编辑
|
||||
{ patterns: [/已修改文件/, /已编辑文件/, /文件已更新/, /已替换.*内容/,
|
||||
/修改了.*文件/, /更新了.*文件/],
|
||||
requiredTools: ['edit_file', 'write_file'], label: '编辑/修改文件' },
|
||||
// 文件删除
|
||||
{ patterns: [/已删除文件/, /已移除文件/, /文件已清除/, /删除了.*文件/],
|
||||
requiredTools: ['delete_file'], label: '删除文件' },
|
||||
// 文件移动/复制/重命名
|
||||
{ patterns: [/已移动文件/, /已复制文件/, /已重命名/, /文件.*已.*移动/, /文件.*已.*复制/],
|
||||
requiredTools: ['move_file', 'copy_file'], label: '移动/复制文件' },
|
||||
// 目录浏览
|
||||
{ patterns: [/目录包含/, /列出.*目录/, /目录.*结构/, /文件列表.*如下/, /目录.*内容/],
|
||||
requiredTools: ['list_directory', 'tree'], label: '列出目录' },
|
||||
// 文件搜索
|
||||
{ patterns: [/搜索到.*文件/, /找到.*匹配/, /搜索.*结果.*文件/],
|
||||
requiredTools: ['search_files'], label: '搜索文件' },
|
||||
// 搜索/抓取
|
||||
{ patterns: [/搜索结果显示/, /根据搜索/, /抓取了.*网页/, /从.*搜索.*得到/,
|
||||
/查询到/, /检索到/, /搜索到/, /网络.*显示/, /实时.*数据.*显示/],
|
||||
requiredTools: ['web_search', 'web_fetch'], label: '搜索/抓取网页' },
|
||||
// 浏览器
|
||||
{ patterns: [/浏览器截图显示/, /打开网页.*看到/, /页面显示/, /浏览器.*打开/,
|
||||
/点击了.*按钮/, /输入了.*文本/, /页面.*截图/],
|
||||
requiredTools: ['browser_open', 'browser_screenshot', 'browser_extract', 'browser_click', 'browser_type'], label: '浏览器操作' },
|
||||
// 命令执行
|
||||
{ patterns: [/运行.*命令/, /执行.*脚本/, /命令.*输出/, /运行结果/, /命令.*返回/,
|
||||
/执行.*结果/, /终端.*显示/],
|
||||
requiredTools: ['run_command'], label: '执行命令' },
|
||||
// Git 操作
|
||||
{ patterns: [/已提交/, /已推送/, /已暂存/, /已创建分支/, /已合并/, /已克隆/,
|
||||
/git.*commit/, /git.*push/, /git.*add/, /提交了.*代码/],
|
||||
requiredTools: ['git'], label: 'Git 操作' },
|
||||
// 下载文件
|
||||
{ patterns: [/已下载文件/, /下载完成/, /文件.*已.*下载/, /从.*下载/],
|
||||
requiredTools: ['download_file'], label: '下载文件' },
|
||||
// 压缩/解压
|
||||
{ patterns: [/已压缩/, /已解压/, /压缩完成/, /解压完成/, /归档.*已.*创建/],
|
||||
requiredTools: ['compress'], label: '压缩/解压' },
|
||||
// 文件信息
|
||||
{ patterns: [/文件.*大小.*字节/, /文件.*权限.*为/, /文件.*修改日期/],
|
||||
requiredTools: ['get_file_info'], label: '获取文件信息' },
|
||||
// 内存/记忆操作
|
||||
{ patterns: [/已记住/, /已添加记忆/, /记忆已保存/, /已更新记忆/, /已删除记忆/],
|
||||
requiredTools: ['memory_add', 'memory_replace', 'memory_remove'], label: '记忆操作' },
|
||||
// 会话操作
|
||||
{ patterns: [/历史会话.*显示/, /已读取.*会话/, /会话.*内容.*如下/],
|
||||
requiredTools: ['session_list', 'session_read'], label: '会话操作' },
|
||||
// 子代理
|
||||
{ patterns: [/子代理.*完成/, /子任务.*已.*执行/, /spawn.*task.*完成/],
|
||||
requiredTools: ['spawn_task'], label: '子代理委派' },
|
||||
// 系统工具
|
||||
{ patterns: [/计算.*结果.*为/, /哈希.*值.*为/, /UUID.*生成/, /随机.*生成.*为/],
|
||||
requiredTools: ['calculator', 'hash', 'uuid', 'random'], label: '系统工具' },
|
||||
];
|
||||
|
||||
for (const rule of CLAIM_RULES) {
|
||||
for (const pattern of rule.patterns) {
|
||||
if (pattern.test(content)) {
|
||||
const anyCalled = rule.requiredTools.some(t => calledTools.has(t));
|
||||
if (!anyCalled) {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Context Indexer — 渐进式披露模块 (v0.12.0)
|
||||
* Harness Engineering Phase 6: 三级上下文管理
|
||||
*
|
||||
* 索引层 (Index) — 始终保留:项目结构树 + 入口文件地图 + 技术栈摘要
|
||||
* 接口层 (Interface) — 按需加载:模块 API 声明 + 类型定义 + 配置文件
|
||||
* 实现层 (Implementation) — 修改时加载:具体源代码
|
||||
*
|
||||
* 设计理念:
|
||||
* - 用目录式索引告诉智能体"去哪找",而非"全记住"
|
||||
* - 上下文可从数万 Token 压至几千
|
||||
* - 通过 load_context 工具按需触发接口层和实现层的加载
|
||||
*/
|
||||
|
||||
import { logInfo, logDebug, logWarn } from './log-service.js';
|
||||
import { estimateTokens } from './context-manager.js';
|
||||
import type { ProjectIndex, ContextTier } from '../types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 项目索引缓存
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 项目索引缓存(5 分钟 TTL) */
|
||||
let cachedIndex: ProjectIndex | null = null;
|
||||
let cacheTimestamp = 0;
|
||||
const INDEX_CACHE_TTL = 5 * 60 * 1000; // 5 分钟
|
||||
|
||||
/** 最大索引 Token 预算 */
|
||||
const MAX_INDEX_TOKENS = 2000;
|
||||
|
||||
/**
|
||||
* 构建项目索引
|
||||
* 扫描工作空间目录结构,生成精简的结构摘要
|
||||
*/
|
||||
export async function buildProjectIndex(workspaceDir: string): Promise<ProjectIndex> {
|
||||
// 检查缓存
|
||||
if (cachedIndex && Date.now() - cacheTimestamp < INDEX_CACHE_TTL) {
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
try {
|
||||
const bridge = window.metonaDesktop;
|
||||
if (!bridge?.isDesktop) {
|
||||
return createEmptyIndex();
|
||||
}
|
||||
|
||||
// 利用现有 tree 工具扫描目录结构(限制深度 3 层)
|
||||
const treeResult = await bridge.tool.execute('tree', {
|
||||
path: workspaceDir,
|
||||
max_depth: 3,
|
||||
include_hidden: false,
|
||||
});
|
||||
|
||||
let structure = '';
|
||||
if (treeResult.success && treeResult.tree) {
|
||||
structure = String(treeResult.tree);
|
||||
// Token 预算截断
|
||||
if (estimateTokens(structure) > MAX_INDEX_TOKENS) {
|
||||
const lines = structure.split('\n');
|
||||
structure = lines.slice(0, Math.min(lines.length, 60)).join('\n')
|
||||
+ '\n... (目录结构已截断,使用 list_directory 查看完整内容)';
|
||||
}
|
||||
} else {
|
||||
structure = '(无法读取工作空间目录结构)';
|
||||
}
|
||||
|
||||
// 识别入口文件
|
||||
const entryFiles: string[] = [];
|
||||
const commonEntries = [
|
||||
'package.json', 'tsconfig.json', 'vite.config.ts',
|
||||
'main.ts', 'index.ts', 'index.html', 'app.ts',
|
||||
'Cargo.toml', 'pyproject.toml', 'go.mod', 'CMakeLists.txt',
|
||||
'README.md', 'Makefile', 'docker-compose.yml',
|
||||
];
|
||||
for (const entry of commonEntries) {
|
||||
try {
|
||||
// 跨平台路径拼接(清理尾部斜杠,统一用 posix 风格,Node.js 可容错处理)
|
||||
const cleanDir = workspaceDir.replace(/[\\/]+$/, '');
|
||||
const filePath = cleanDir + '/' + entry;
|
||||
const checkResult = await bridge.workspace.readFile(filePath);
|
||||
if (checkResult?.success) {
|
||||
entryFiles.push(entry);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 检测技术栈
|
||||
const techStack = detectTechStack(entryFiles);
|
||||
|
||||
const index: ProjectIndex = {
|
||||
structure,
|
||||
entryFiles,
|
||||
techStack,
|
||||
tokenCount: estimateTokens(structure),
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
cachedIndex = index;
|
||||
cacheTimestamp = Date.now();
|
||||
logInfo('项目索引已构建', `${techStack.join(', ')}, ${entryFiles.length} 入口文件, ${index.tokenCount} tokens`);
|
||||
return index;
|
||||
} catch (err) {
|
||||
logWarn('项目索引构建失败', (err as Error).message);
|
||||
return createEmptyIndex();
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建空索引 */
|
||||
function createEmptyIndex(): ProjectIndex {
|
||||
return {
|
||||
structure: '(未检测到工作空间)',
|
||||
entryFiles: [],
|
||||
techStack: [],
|
||||
tokenCount: 0,
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 根据入口文件检测技术栈 */
|
||||
function detectTechStack(entryFiles: string[]): string[] {
|
||||
const stack: string[] = [];
|
||||
const fileSet = new Set(entryFiles.map(f => f.toLowerCase()));
|
||||
|
||||
if (fileSet.has('package.json')) stack.push('Node.js');
|
||||
if (fileSet.has('tsconfig.json')) stack.push('TypeScript');
|
||||
if (fileSet.has('vite.config.ts')) stack.push('Vite');
|
||||
if (fileSet.has('cargo.toml')) stack.push('Rust');
|
||||
if (fileSet.has('pyproject.toml')) stack.push('Python');
|
||||
if (fileSet.has('go.mod')) stack.push('Go');
|
||||
if (fileSet.has('cmakelists.txt')) stack.push('C/C++');
|
||||
if (fileSet.has('docker-compose.yml')) stack.push('Docker');
|
||||
if (fileSet.has('makefile')) stack.push('Make');
|
||||
|
||||
return stack.length > 0 ? stack : ['未知'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成索引层系统提示词
|
||||
* 始终保留在上下文中,告诉 AI "去哪找"
|
||||
*/
|
||||
export function buildIndexContext(index: ProjectIndex): string {
|
||||
if (!index.structure || index.tokenCount === 0) return '';
|
||||
|
||||
let context = `【项目索引 — 始终可见】
|
||||
项目结构:
|
||||
${index.structure}
|
||||
|
||||
技术栈: ${index.techStack.join(', ') || '未检测'}
|
||||
入口文件: ${index.entryFiles.length > 0 ? index.entryFiles.join(', ') : '未检测'}
|
||||
|
||||
💡 使用 list_directory 查看目录详情,使用 read_file 读取具体文件。
|
||||
💡 使用 search_files 按内容搜索代码。
|
||||
`;
|
||||
|
||||
// Token 预算控制
|
||||
if (estimateTokens(context) > MAX_INDEX_TOKENS) {
|
||||
context = context.slice(0, Math.floor(context.length * 0.8)) + '\n... (索引已截断)';
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建接口层上下文(按需加载)
|
||||
* @param modulePattern 模块匹配模式,如 "src/services/"
|
||||
*/
|
||||
export async function buildInterfaceContext(modulePattern: string, workspaceDir: string): Promise<string> {
|
||||
try {
|
||||
const bridge = window.metonaDesktop;
|
||||
if (!bridge?.isDesktop) return '';
|
||||
|
||||
// 搜索模块相关的类型定义和配置文件
|
||||
const searchResult = await bridge.tool.execute('search_files', {
|
||||
path: workspaceDir,
|
||||
query: modulePattern,
|
||||
search_type: 'filename',
|
||||
max_results: 10,
|
||||
});
|
||||
|
||||
if (!searchResult.success || !(searchResult as any).results?.length) {
|
||||
return `(未找到与 "${modulePattern}" 相关的接口文件)`;
|
||||
}
|
||||
|
||||
const results = (searchResult as any).results as Array<{ path: string }>;
|
||||
const paths = results.map(r => r.path).slice(0, 8);
|
||||
|
||||
// 批量读取接口文件(限制每文件 2000 字符)
|
||||
const readResult = await bridge.tool.execute('read_multiple_files', {
|
||||
paths,
|
||||
max_chars_per_file: 2000,
|
||||
});
|
||||
|
||||
if (readResult.success) {
|
||||
const filesInfo = paths.map(p => ` 📄 ${p}`).join('\n');
|
||||
return `【接口层 — ${modulePattern}】
|
||||
相关文件:
|
||||
${filesInfo}
|
||||
|
||||
内容预览:
|
||||
${JSON.stringify((readResult as any).files)}`;
|
||||
}
|
||||
|
||||
return `【接口层 — ${modulePattern}】
|
||||
相关文件:${paths.join(', ')}`;
|
||||
} catch (err) {
|
||||
logWarn('接口层上下文加载失败', (err as Error).message);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载指定层级的上下文
|
||||
*/
|
||||
export async function loadContextByTier(
|
||||
tier: ContextTier,
|
||||
modulePattern: string,
|
||||
workspaceDir: string,
|
||||
): Promise<string> {
|
||||
switch (tier) {
|
||||
case 'index': {
|
||||
const index = await buildProjectIndex(workspaceDir);
|
||||
return buildIndexContext(index);
|
||||
}
|
||||
case 'interface':
|
||||
return buildInterfaceContext(modulePattern, workspaceDir);
|
||||
case 'implementation':
|
||||
// 实现层由 Agent 自行通过 read_file 加载
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Harness Hook 系统 (v0.12.0)
|
||||
* Harness Engineering Phase 4: 可扩展的 Agent 执行钩子架构
|
||||
*
|
||||
* 钩子阶段:
|
||||
* - pre_tool: 工具执行前(安全检查、缓存检查、权限验证)
|
||||
* - post_tool: 工具执行后(结果验证、自动 lint、自动测试)
|
||||
* - post_iteration: 每轮迭代后(记忆提取、上下文裁剪)
|
||||
* - pre_completion: 最终输出前(完成门控、质量检查)
|
||||
*
|
||||
* 设计原则:
|
||||
* - 异步并行执行,失败不阻塞主流程
|
||||
* - 优先级排序,高优先级先执行
|
||||
* - 可动态注册/移除
|
||||
* - MCP 工具可注册自定义 Hook
|
||||
*/
|
||||
|
||||
import { logInfo, logWarn, logDebug } from './log-service.js';
|
||||
import type { HarnessHook, HookPhase, HookResult, HookData, LoopContext } from '../types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Hook 注册表
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const hookRegistry = new Map<string, HarnessHook>();
|
||||
const phaseIndex = new Map<HookPhase, HarnessHook[]>();
|
||||
|
||||
function ensurePhaseIndex(phase: HookPhase): void {
|
||||
if (!phaseIndex.has(phase)) {
|
||||
phaseIndex.set(phase, []);
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册 Hook */
|
||||
export function registerHook(hook: HarnessHook): void {
|
||||
hookRegistry.set(hook.name, hook);
|
||||
ensurePhaseIndex(hook.phase);
|
||||
phaseIndex.get(hook.phase)!.push(hook);
|
||||
// 按优先级降序排列
|
||||
phaseIndex.get(hook.phase)!.sort((a, b) => b.priority - a.priority);
|
||||
logDebug(`Hook 已注册: "${hook.name}" phase=${hook.phase} priority=${hook.priority}`);
|
||||
}
|
||||
|
||||
/** 移除 Hook */
|
||||
export function unregisterHook(name: string): void {
|
||||
const hook = hookRegistry.get(name);
|
||||
if (!hook) return;
|
||||
hookRegistry.delete(name);
|
||||
const list = phaseIndex.get(hook.phase);
|
||||
if (list) {
|
||||
const idx = list.findIndex(h => h.name === name);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** 启用/禁用 Hook */
|
||||
export function setHookEnabled(name: string, enabled: boolean): void {
|
||||
const hook = hookRegistry.get(name);
|
||||
if (hook) {
|
||||
hook.enabled = enabled;
|
||||
logDebug(`Hook "${name}" ${enabled ? '已启用' : '已禁用'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取所有已注册 Hook */
|
||||
export function getRegisteredHooks(): HarnessHook[] {
|
||||
return Array.from(hookRegistry.values());
|
||||
}
|
||||
|
||||
/** 获取指定阶段已启用的 Hook */
|
||||
export function getPhaseHooks(phase: HookPhase): HarnessHook[] {
|
||||
return (phaseIndex.get(phase) || []).filter(h => h.enabled);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Hook 执行引擎
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 并行执行指定阶段的所有 Hook
|
||||
* @returns 所有 Hook 的执行结果汇总
|
||||
*/
|
||||
export async function executeHooks(
|
||||
phase: HookPhase,
|
||||
ctx: LoopContext,
|
||||
data: HookData = {}
|
||||
): Promise<{ results: HookResult[]; allPassed: boolean }> {
|
||||
const hooks = getPhaseHooks(phase);
|
||||
if (hooks.length === 0) {
|
||||
return { results: [], allPassed: true };
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
hooks.map(async (hook) => {
|
||||
try {
|
||||
const result = await hook.handler(ctx, data);
|
||||
if (!result.passed) {
|
||||
logWarn(`Hook "${hook.name}" 未通过: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
logWarn(`Hook "${hook.name}" 执行异常: ${(err as Error).message}`);
|
||||
return { passed: true, message: `Hook 异常(已忽略): ${(err as Error).message}` };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const allPassed = results.every(r => r.passed);
|
||||
return { results, allPassed };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 内置 Hook
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* SecurityCheckHook — 工具执行前安全检查
|
||||
* 检查命令和路径是否在黑名单内
|
||||
*/
|
||||
export const securityCheckHook: HarnessHook = {
|
||||
name: 'SecurityCheck',
|
||||
phase: 'pre_tool',
|
||||
priority: 100, // 最高优先级
|
||||
enabled: true,
|
||||
handler: async (_ctx: LoopContext, data: HookData) => {
|
||||
const { toolName, toolArgs } = data;
|
||||
if (toolName === 'run_command' && typeof toolArgs?.command === 'string') {
|
||||
const cmd = toolArgs.command as string;
|
||||
// 快速黑名单检查
|
||||
const dangerous = ['rm -rf /', 'mkfs', 'dd if=', 'shutdown', 'reboot', ':(){', 'chmod 777 /'];
|
||||
for (const d of dangerous) {
|
||||
if (cmd.toLowerCase().includes(d.toLowerCase())) {
|
||||
return { passed: false, message: `危险命令被拦截: ${cmd.slice(0, 100)}` };
|
||||
}
|
||||
}
|
||||
// 管道执行 shell 检测
|
||||
if (/(\||>|<)\s*(sh|bash|zsh|powershell|cmd)/i.test(cmd)) {
|
||||
return { passed: false, message: `禁止通过管道执行 shell: ${cmd.slice(0, 100)}` };
|
||||
}
|
||||
}
|
||||
if (toolName === 'delete_file' && typeof toolArgs?.path === 'string') {
|
||||
const p = (toolArgs.path as string).toLowerCase();
|
||||
const protectedPaths = ['/etc', '/sys', '/proc', '/dev', '/boot', 'c:\\windows', '.ssh', '.gnupg'];
|
||||
for (const pp of protectedPaths) {
|
||||
if (p.includes(pp)) {
|
||||
return { passed: false, message: `受保护路径不可删除: ${p}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { passed: true, message: '' };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* ResultValidationHook — 工具执行后结果验证
|
||||
* 检查工具返回是否有效。注意:此 Hook 为咨询性(post_tool 阶段不阻断执行),
|
||||
* 实际的截断由 agent-engine 的 formatToolResultForModel 和 40 条消息硬限制负责。
|
||||
*/
|
||||
export const resultValidationHook: HarnessHook = {
|
||||
name: 'ResultValidation',
|
||||
phase: 'post_tool',
|
||||
priority: 90,
|
||||
enabled: true,
|
||||
handler: async (_ctx: LoopContext, data: HookData) => {
|
||||
const { toolName, toolResult } = data;
|
||||
if (!toolResult) return { passed: true, message: '' };
|
||||
|
||||
// 检查空结果
|
||||
if (!toolResult.success && !toolResult.error) {
|
||||
return { passed: false, message: `工具 ${toolName} 执行失败但未提供错误信息` };
|
||||
}
|
||||
|
||||
// 检查超大结果。不同工具有不同阈值:
|
||||
// web_search 包含自动抓取内容,允许较大结果
|
||||
const SIZE_LIMITS: Record<string, number> = {
|
||||
web_search: 1_000_000, // 1MB(含自动抓取的多页内容)
|
||||
web_fetch: 500_000, // 500KB
|
||||
read_file: 200_000, // 200KB
|
||||
default: 500_000, // 默认 500KB
|
||||
};
|
||||
const limit = SIZE_LIMITS[toolName || ''] || SIZE_LIMITS.default;
|
||||
|
||||
if (toolResult.success && toolResult) {
|
||||
const resultStr = JSON.stringify(toolResult);
|
||||
if (resultStr.length > limit) {
|
||||
// 仅警告,不阻断(实际截断由 agent-engine 负责)
|
||||
return {
|
||||
passed: true,
|
||||
message: `工具 ${toolName} 返回结果较大 (${(resultStr.length / 1024).toFixed(0)}KB),将由上下文管理器自动截断`,
|
||||
data: { oversized: true, size: resultStr.length },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { passed: true, message: '' };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* IterationMetricsHook — 迭代度量收集
|
||||
* 每轮迭代后记录关键指标
|
||||
*/
|
||||
export const iterationMetricsHook: HarnessHook = {
|
||||
name: 'IterationMetrics',
|
||||
phase: 'post_iteration',
|
||||
priority: 50,
|
||||
enabled: true,
|
||||
handler: async (ctx: LoopContext, _data: HookData) => {
|
||||
// 收集本轮迭代统计
|
||||
const totalMessages = ctx.messages.length;
|
||||
const toolMessages = ctx.messages.filter(m => m.role === 'tool').length;
|
||||
logDebug(
|
||||
`迭代度量 [第 ${ctx.loopCount} 轮]: ` +
|
||||
`${totalMessages} 消息, ${toolMessages} 工具消息, ` +
|
||||
`${ctx.totalEvalCount} eval tokens, ${ctx.allToolRecords.length} 总工具调用`
|
||||
);
|
||||
return { passed: true, message: '' };
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 初始化:注册所有内置 Hook
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let hooksInitialized = false;
|
||||
|
||||
export function initHarnessHooks(): void {
|
||||
if (hooksInitialized) return;
|
||||
registerHook(securityCheckHook);
|
||||
registerHook(resultValidationHook);
|
||||
registerHook(iterationMetricsHook);
|
||||
hooksInitialized = true;
|
||||
logInfo('Harness Hook 系统已初始化', `${hookRegistry.size} 个 Hook 已注册`);
|
||||
}
|
||||
@@ -151,8 +151,8 @@ export async function executeMCPTool(toolName: string, args: Record<string, unkn
|
||||
return { success: false, error: 'MCP API 不可用' };
|
||||
}
|
||||
|
||||
// 解析工具全名: mcp_{serverName}_{toolName}
|
||||
const match = toolName.match(/^mcp_(.+?)_(.+)$/);
|
||||
// 解析工具全名: mcp_{serverName}__{toolName}(双下划线分隔,避免 serverName/toolName 含下划线时歧义)
|
||||
const match = toolName.match(/^mcp_(.+?)__(.+)$/);
|
||||
if (!match) {
|
||||
return { success: false, error: `无效的 MCP 工具名: ${toolName}` };
|
||||
}
|
||||
|
||||
@@ -465,6 +465,25 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
}
|
||||
},
|
||||
// ══════════════════════════════════════════════
|
||||
// 新增工具:Plan Mode 执行追踪
|
||||
// ══════════════════════════════════════════════
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'plan_track',
|
||||
description: 'Manage Plan Mode execution progress. Use this to mark plan steps as completed so you always know what remains. REQUIRED in Plan Mode — call plan_track after completing each step.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['action'],
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['status', 'mark_done', 'mark_all_done'], description: 'status=查看当前进度, mark_done=标记步骤完成, mark_all_done=全部完成' },
|
||||
step_index: { type: 'integer', description: 'Step number (1-indexed) to mark as done. Required for mark_done.' },
|
||||
step_label: { type: 'string', description: 'Optional: description of what was completed, for logging.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// ══════════════════════════════════════════════
|
||||
// v5.0 新增工具:浏览器控制
|
||||
// ══════════════════════════════════════════════
|
||||
// v5.1 Browser 控制工具(增强版)
|
||||
@@ -717,7 +736,7 @@ let enabledTools: Set<string> = new Set([
|
||||
'memory_search', 'memory_add', 'memory_replace', 'memory_remove',
|
||||
'session_list', 'session_read', 'spawn_task',
|
||||
'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract',
|
||||
'browser_click', 'browser_type', 'browser_scroll', 'browser_close',
|
||||
'browser_click', 'browser_type', 'browser_scroll', 'browser_wait', 'browser_close',
|
||||
'datetime', 'calculator',
|
||||
'random', 'uuid', 'json_format', 'hash'
|
||||
]);
|
||||
@@ -727,6 +746,17 @@ export function setToolEnabled(toolName: string, enabled: boolean): void {
|
||||
else enabledTools.delete(toolName);
|
||||
}
|
||||
|
||||
/** Plan Mode 激活时注册 plan_track,关闭时移除 */
|
||||
export function setPlanModeActive(active: boolean): void {
|
||||
if (active) {
|
||||
enabledTools.add('plan_track');
|
||||
} else {
|
||||
enabledTools.delete('plan_track');
|
||||
// 清除追踪器状态
|
||||
state.set('_planTracker', null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function getEnabledToolDefinitions(): ToolDefinition[] {
|
||||
const builtIn = TOOL_DEFINITIONS.filter(def => enabledTools.has(def.function.name));
|
||||
@@ -879,6 +909,38 @@ export async function executeTool(toolName: string, args: Record<string, unknown
|
||||
return result;
|
||||
}
|
||||
|
||||
// plan_track — Plan Mode 执行追踪
|
||||
if (toolName === 'plan_track') {
|
||||
const action = args.action as string;
|
||||
const stepIndex = args.step_index as number | undefined;
|
||||
const stepLabel = (args.step_label as string) || '';
|
||||
const tracker = getPlanTracker();
|
||||
if (action === 'status') {
|
||||
return { success: true, action: 'status', steps: tracker.steps, total: tracker.total, done: tracker.done };
|
||||
}
|
||||
if (action === 'mark_done' && typeof stepIndex === 'number' && stepIndex > 0) {
|
||||
const idx = stepIndex - 1;
|
||||
if (idx >= 0 && idx < tracker.steps.length) {
|
||||
tracker.steps[idx].done = true;
|
||||
tracker.done = tracker.steps.filter(s => s.done).length;
|
||||
if (stepLabel) tracker.steps[idx].label = stepLabel;
|
||||
savePlanTracker(tracker);
|
||||
const remaining = tracker.total - tracker.done;
|
||||
logToolResult('plan_track', true, `步骤 ${stepIndex} 已完成 (${tracker.done}/${tracker.total}${remaining > 0 ? `, 剩余 ${remaining}` : ', 全部完成!'})`);
|
||||
return { success: true, action: 'mark_done', step: stepIndex, done: tracker.done, total: tracker.total, remaining };
|
||||
}
|
||||
return { success: false, error: `步骤 ${stepIndex} 不存在(共 ${tracker.total} 步)` };
|
||||
}
|
||||
if (action === 'mark_all_done') {
|
||||
for (const s of tracker.steps) s.done = true;
|
||||
tracker.done = tracker.total;
|
||||
savePlanTracker(tracker);
|
||||
logToolResult('plan_track', true, `全部 ${tracker.total} 步已完成`);
|
||||
return { success: true, action: 'mark_all_done', done: tracker.total, total: tracker.total, remaining: 0 };
|
||||
}
|
||||
return { success: false, error: `未知操作: ${action}` };
|
||||
}
|
||||
|
||||
// run_command 走 workspace IPC
|
||||
if (toolName === 'run_command') {
|
||||
const command = args.command as string;
|
||||
@@ -918,12 +980,70 @@ export function getToolIcon(name: string): string {
|
||||
git: '🔖', compress: '🗜️', web_search: '🔍',
|
||||
memory_search: '🧠', memory_add: '💾', memory_replace: '🔄', memory_remove: '🗑️',
|
||||
session_list: '📋', session_read: '📖', spawn_task: '🤖',
|
||||
plan_track: '📋',
|
||||
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
|
||||
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
|
||||
};
|
||||
return icons[name] || '🔧';
|
||||
}
|
||||
|
||||
/** ── Plan Mode 执行追踪器 ── */
|
||||
|
||||
export interface PlanStep {
|
||||
index: number;
|
||||
label: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface PlanTracker {
|
||||
steps: PlanStep[];
|
||||
total: number;
|
||||
done: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
let _planTracker: PlanTracker = { steps: [], total: 0, done: 0, active: false };
|
||||
|
||||
export function getPlanTracker(): PlanTracker {
|
||||
// 每次读取时同步 state 中的最新状态
|
||||
const saved = state.get<PlanTracker | null>('_planTracker', null);
|
||||
if (saved) _planTracker = saved;
|
||||
return _planTracker;
|
||||
}
|
||||
|
||||
export function savePlanTracker(tracker: PlanTracker): void {
|
||||
_planTracker = tracker;
|
||||
state.set('_planTracker', tracker);
|
||||
}
|
||||
|
||||
export function initPlanTracker(steps: string[]): PlanTracker {
|
||||
const tracker: PlanTracker = {
|
||||
steps: steps.map((label, i) => ({ index: i + 1, label, done: false })),
|
||||
total: steps.length,
|
||||
done: 0,
|
||||
active: true,
|
||||
};
|
||||
savePlanTracker(tracker);
|
||||
logInfo('Plan Tracker 已初始化', `${tracker.total} 个步骤`);
|
||||
return tracker;
|
||||
}
|
||||
|
||||
export function clearPlanTracker(): void {
|
||||
_planTracker = { steps: [], total: 0, done: 0, active: false };
|
||||
state.set('_planTracker', null);
|
||||
}
|
||||
|
||||
export function formatPlanStatus(): string {
|
||||
const t = getPlanTracker();
|
||||
if (!t.active || t.steps.length === 0) return '';
|
||||
const lines = t.steps.map(s => {
|
||||
const icon = s.done ? '✅' : '⬜';
|
||||
return `${icon} 步骤${s.index}: ${s.label}`;
|
||||
});
|
||||
const pct = t.total > 0 ? Math.round(t.done / t.total * 100) : 0;
|
||||
return `[Plan Mode 执行进度: ${t.done}/${t.total} (${pct}%)]\n${lines.join('\n')}\n\n完成每一步后请调用 plan_track(action='mark_done', step_index=N) 标记已完成。`;
|
||||
}
|
||||
|
||||
export function formatToolName(name: string): string {
|
||||
const names: Record<string, string> = {
|
||||
read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录',
|
||||
@@ -935,6 +1055,7 @@ export function formatToolName(name: string): string {
|
||||
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
|
||||
memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆',
|
||||
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
|
||||
plan_track: '计划追踪',
|
||||
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
|
||||
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
|
||||
};
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Verification System — 验证系统模块 (v0.12.0)
|
||||
* Harness Engineering Phase 5: 计算性反馈管道
|
||||
*
|
||||
* 在 Agent 修改文件后自动运行:
|
||||
* - Linter(代码风格检查)
|
||||
* - Type Checker(类型检查)
|
||||
* - Test Runner(单元测试)
|
||||
* - Diff Analyzer(修改范围分析)
|
||||
*
|
||||
* 设计原则:
|
||||
* - 所有验证利用现有 run_command 工具执行
|
||||
* - 验证 Hook 注册到 hooks.ts 的 post_tool 阶段
|
||||
* - 验证失败的结果作为 Observation 注入 Agent 上下文
|
||||
* - 可配置开关(设置面板)
|
||||
*/
|
||||
|
||||
import { registerHook } from './hooks.js';
|
||||
import type { HarnessHook } from '../types.js';
|
||||
import { logInfo, logDebug, logWarn } from './log-service.js';
|
||||
import type { LoopContext, HookData, HookResult } from '../types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 验证配置
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface VerificationConfig {
|
||||
/** 是否启用自动 Lint */
|
||||
autoLint: boolean;
|
||||
/** 是否启用自动类型检查 */
|
||||
autoTypeCheck: boolean;
|
||||
/** 是否启用自动测试 */
|
||||
autoTest: boolean;
|
||||
/** Lint 命令(留空自动检测) */
|
||||
lintCommand: string;
|
||||
/** 类型检查命令 */
|
||||
typeCheckCommand: string;
|
||||
/** 测试命令 */
|
||||
testCommand: string;
|
||||
}
|
||||
|
||||
/** 默认配置 */
|
||||
const defaultConfig: VerificationConfig = {
|
||||
autoLint: false,
|
||||
autoTypeCheck: false,
|
||||
autoTest: false,
|
||||
lintCommand: '',
|
||||
typeCheckCommand: '',
|
||||
testCommand: '',
|
||||
};
|
||||
|
||||
/** 运行时配置 */
|
||||
let config: VerificationConfig = { ...defaultConfig };
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 文件类型检测
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 检测文件扩展名对应的语言 */
|
||||
function detectFileType(filePath: string): string | null {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const map: Record<string, string> = {
|
||||
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
|
||||
py: 'python', rs: 'rust', go: 'go', java: 'java',
|
||||
cpp: 'cpp', c: 'c', rb: 'ruby', css: 'css',
|
||||
html: 'html', vue: 'vue', svelte: 'svelte',
|
||||
};
|
||||
return map[ext] || null;
|
||||
}
|
||||
|
||||
/** 检测项目根目录(查找 package.json / pyproject.toml / Cargo.toml) */
|
||||
function detectProjectRoot(filePath: string): string | null {
|
||||
// 简化实现:向上查找 package.json
|
||||
const parts = filePath.replace(/\\/g, '/').split('/');
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const dir = parts.slice(0, i + 1).join('/');
|
||||
// 这里无法实际访问文件系统,返回文件所在目录的父级作为近似
|
||||
}
|
||||
// 返回文件所在目录
|
||||
const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
||||
return lastSep > 0 ? filePath.slice(0, lastSep) : null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 验证 Hook 实现
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* DiffAnalyzerHook — 修改范围分析
|
||||
* 检测 Agent 的修改是否跨越了架构边界
|
||||
*/
|
||||
export const diffAnalyzerHook: HarnessHook = {
|
||||
name: 'DiffAnalyzer',
|
||||
phase: 'post_tool',
|
||||
priority: 75,
|
||||
enabled: true,
|
||||
handler: async (_ctx: LoopContext, data: HookData): Promise<HookResult> => {
|
||||
const { toolName, toolArgs } = data;
|
||||
if (toolName !== 'write_file' && toolName !== 'edit_file') {
|
||||
return { passed: true, message: '' };
|
||||
}
|
||||
|
||||
const filePath = (toolArgs?.path as string) || '';
|
||||
if (!filePath) return { passed: true, message: '' };
|
||||
|
||||
// 分析修改的文件类型
|
||||
const fileType = detectFileType(filePath);
|
||||
if (!fileType) return { passed: true, message: '' };
|
||||
|
||||
// 分析跨层调用风险
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 检测是否修改了核心基础模块
|
||||
const criticalPaths = ['/core/', '/base/', '/common/', '/shared/', '/utils/', '/lib/'];
|
||||
const filePathLower = filePath.toLowerCase();
|
||||
for (const cp of criticalPaths) {
|
||||
if (filePathLower.includes(cp)) {
|
||||
warnings.push(`⚠️ 修改了核心基础模块 "${filePath}",可能影响多个依赖方`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
return {
|
||||
passed: true, // 警告不阻止,但注入提醒
|
||||
message: warnings.join('; '),
|
||||
data: { warnings },
|
||||
};
|
||||
}
|
||||
|
||||
return { passed: true, message: '' };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* FileChangeAuditHook — 文件变更审计
|
||||
* 记录 Agent 的每一次文件修改
|
||||
*/
|
||||
export const fileChangeAuditHook: HarnessHook = {
|
||||
name: 'FileChangeAudit',
|
||||
phase: 'post_tool',
|
||||
priority: 30,
|
||||
enabled: true,
|
||||
handler: async (ctx: LoopContext, data: HookData): Promise<HookResult> => {
|
||||
const { toolName, toolArgs, toolResult } = data;
|
||||
if (toolName !== 'write_file' && toolName !== 'edit_file' && toolName !== 'delete_file') {
|
||||
return { passed: true, message: '' };
|
||||
}
|
||||
|
||||
const filePath = (toolArgs?.path as string) || '';
|
||||
const success = toolResult?.success || false;
|
||||
|
||||
logInfo(
|
||||
`文件变更审计 [第 ${ctx.loopCount} 轮]: ` +
|
||||
`${toolName} ${filePath} — ${success ? '✅' : '❌'}`
|
||||
);
|
||||
|
||||
return { passed: true, message: '' };
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 初始化
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let verificationInitialized = false;
|
||||
|
||||
export function initVerificationSystem(): void {
|
||||
if (verificationInitialized) return;
|
||||
registerHook(diffAnalyzerHook);
|
||||
registerHook(fileChangeAuditHook);
|
||||
verificationInitialized = true;
|
||||
logInfo('验证系统已初始化', 'DiffAnalyzer + FileChangeAudit Hook 已注册');
|
||||
}
|
||||
|
||||
/** 获取验证配置 */
|
||||
export function getVerificationConfig(): VerificationConfig {
|
||||
return { ...config };
|
||||
}
|
||||
|
||||
/** 更新验证配置 */
|
||||
export function updateVerificationConfig(updates: Partial<VerificationConfig>): void {
|
||||
Object.assign(config, updates);
|
||||
logInfo('验证配置已更新', JSON.stringify(config));
|
||||
}
|
||||
Reference in New Issue
Block a user