CI / verify (push) Successful in 1m53s
- P0: 发送路径用户消息重复注入根治(history-builder 纯模块 + 单测,连带修复 maxCount 截断保留最旧消息缺陷);/undo、/retry 消息删除差量落库(新增 db:getMessageIds/deleteMessages 四层通道);/compress 摘要 role:user + 可折叠卡片渲染 + 旧 system 行读取归一化 - P1: memory search 默认 limit=8;工具缓存键/去重改稳定序列化;run_command 超时联动主进程杀子进程;记忆访问统计写回纳入写入锁;搜索自动抓取单页限幅 8k;Token 趋势采样移出 calculateContextStats 并记录裁剪后值;空白 assistant 幽灵消息跳过入库(新迭代/中止两路径) - P2: 新增 tool-security(18 用例,平台自适应)与 history-builder(11 用例)测试;帮助/README/DEVELOPMENT 文案与代码事实对齐;vendor 失效 sourcemap 与 .npmrc 弃用配置清理;备份导入携带 attachments 修复 - 版本号升级 0.17.2(5 文件白名单);typecheck 零错误 / 301 测试通过 / 构建通过
741 lines
29 KiB
TypeScript
741 lines
29 KiB
TypeScript
/**
|
||
* Agent Safety — Agent 安全防护与行为治理模块
|
||
*
|
||
* 从 agent-engine.ts 提取的 R51-R56 功能:
|
||
* - R51: 工具结果离线存储
|
||
* - R52: 状态震荡检测
|
||
* - R54: 增强死循环检测
|
||
* - R56: 目标对齐验证
|
||
*
|
||
* 设计原则:
|
||
* - 纯函数/可测试,不依赖 OllamaAPI
|
||
* - 模块级状态隔离,通过 reset* 函数清理
|
||
*/
|
||
|
||
import type { OllamaMessage } from '../types.js';
|
||
import { logInfo } from './log-service.js';
|
||
import { stableStringify } from '../utils/utils.js';
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R51: 工具结果离线存储
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/** 会话级缓存,完整结果按 ID 存储,上下文仅保留精简引用 */
|
||
const _toolResultStore = new Map<string, { toolName: string; fullContent: string; timestamp: number }>();
|
||
const TOOL_STORE_MAX = 200;
|
||
|
||
/** 存储完整工具结果并返回引用 ID */
|
||
export function storeToolResult(toolName: string, fullContent: string): string {
|
||
const refId = `toolref_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||
_toolResultStore.set(refId, { toolName, fullContent, timestamp: Date.now() });
|
||
// LRU 淘汰
|
||
if (_toolResultStore.size > TOOL_STORE_MAX) {
|
||
const oldestKey = _toolResultStore.keys().next().value;
|
||
if (oldestKey) _toolResultStore.delete(oldestKey);
|
||
}
|
||
return refId;
|
||
}
|
||
|
||
/** 将旧工具结果消息精简为引用格式 */
|
||
export function compactOldToolResult(msg: OllamaMessage): OllamaMessage {
|
||
if (msg.role !== 'tool' || !msg.content) return msg;
|
||
if (msg.content.startsWith('[工具结果已归档]')) return msg;
|
||
if (msg.content.length > 1500) {
|
||
const refId = storeToolResult(msg.tool_name || 'unknown', msg.content);
|
||
const summary = msg.content.slice(0, 500);
|
||
const totalLen = msg.content.length;
|
||
return {
|
||
...msg,
|
||
content: `${summary}\n\n[工具结果已归档 ref=${refId} 完整内容 ${totalLen} 字符已离线存储,如需查看完整结果请重新调用工具]`,
|
||
};
|
||
}
|
||
return msg;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 状态震荡 / 死循环检测(历史功能,检测逻辑已被移除;保留调用历史
|
||
// 数组以维持快照/恢复 API 的兼容性)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
const _toolCallHistory: string[] = [];
|
||
|
||
/** 记录工具调用到历史序列(供快照/恢复) */
|
||
export function recordToolCallHistory(toolName: string, args: Record<string, unknown>): void {
|
||
const key = `${toolName}:${stableStringify(args).slice(0, 100)}`;
|
||
_toolCallHistory.push(key);
|
||
if (_toolCallHistory.length > 8) _toolCallHistory.shift();
|
||
}
|
||
|
||
// R56/R63 已删除:目标对齐验证 + 速率限制
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R66-R67: 错误分类系统 — 区分瞬态/永久错误,指导重试策略
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
export type ErrorClass = 'transient' | 'permanent' | 'security' | 'unknown';
|
||
|
||
export interface ClassifiedError {
|
||
class: ErrorClass;
|
||
shouldRetry: boolean;
|
||
maxRetries: number;
|
||
backoffMs: number;
|
||
userMessage: string;
|
||
}
|
||
|
||
/** R66: 瞬态错误模式 — 可重试 */
|
||
const TRANSIENT_PATTERNS = [
|
||
/timeout|timed?\s*out/i,
|
||
/ECONNRESET|ECONNREFUSED|ETIMEDOUT/i,
|
||
/socket\s*hang\s*up/i,
|
||
/network|connection\s*(refused|reset|closed)/i,
|
||
/503|502|500/i,
|
||
/rate\s*limit|429/i,
|
||
/EAGAIN|EBUSY/i,
|
||
/temporarily\s*unavailable/i,
|
||
/超时|连接.*失败|网络.*错误/,
|
||
];
|
||
|
||
/** R66: 永久错误模式 — 不可重试 */
|
||
const PERMANENT_PATTERNS = [
|
||
/ENOENT|no\s*such\s*file/i,
|
||
/EACCES|permission\s*denied/i,
|
||
/ENOTDIR|EISDIR/i,
|
||
/invalid\s*(path|argument|parameter)/i,
|
||
/syntax\s*error/i,
|
||
/JSON.*parse/i,
|
||
/not\s*found/i,
|
||
/文件不存在|路径.*错误|参数.*无效/,
|
||
];
|
||
|
||
/** R66: 安全错误模式 — 不可重试,需用户介入 */
|
||
const SECURITY_PATTERNS = [
|
||
/安全警告|security/i,
|
||
/injection|注入/i,
|
||
/path\s*traversal|路径遍历/i,
|
||
/file:\/\/|内网/i,
|
||
];
|
||
|
||
/** R66: 分类错误并给出重试建议 */
|
||
export function classifyError(error: string): ClassifiedError {
|
||
const errLower = error.toLowerCase();
|
||
|
||
// 安全错误
|
||
for (const p of SECURITY_PATTERNS) {
|
||
if (p.test(error)) {
|
||
return {
|
||
class: 'security',
|
||
shouldRetry: false,
|
||
maxRetries: 0,
|
||
backoffMs: 0,
|
||
userMessage: `安全拦截: ${error.slice(0, 100)}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
// 永久错误
|
||
for (const p of PERMANENT_PATTERNS) {
|
||
if (p.test(error)) {
|
||
return {
|
||
class: 'permanent',
|
||
shouldRetry: false,
|
||
maxRetries: 0,
|
||
backoffMs: 0,
|
||
userMessage: `操作失败(不可重试): ${error.slice(0, 100)}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
// 瞬态错误
|
||
for (const p of TRANSIENT_PATTERNS) {
|
||
if (p.test(error)) {
|
||
return {
|
||
class: 'transient',
|
||
shouldRetry: true,
|
||
maxRetries: 2,
|
||
backoffMs: 1000,
|
||
userMessage: `临时错误(可重试): ${error.slice(0, 100)}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
// 未知错误 — 允许 1 次重试
|
||
return {
|
||
class: 'unknown',
|
||
shouldRetry: true,
|
||
maxRetries: 1,
|
||
backoffMs: 500,
|
||
userMessage: error.slice(0, 100),
|
||
};
|
||
}
|
||
|
||
/** R67: 计算指数退避延迟 */
|
||
export function calculateBackoff(attempt: number, baseMs: number): number {
|
||
return Math.min(baseMs * Math.pow(2, attempt), 10_000); // 上限 10 秒
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R76: 路径沙箱验证 — 确保文件操作不超出工作空间边界
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/** R76: 验证路径是否在工作空间范围内 */
|
||
export function validatePathSandbox(
|
||
path: string,
|
||
workspaceDir: string,
|
||
): { valid: boolean; reason?: string; normalizedPath?: string } {
|
||
if (!path) return { valid: false, reason: '路径为空' };
|
||
if (!workspaceDir) return { valid: true, normalizedPath: path }; // 无工作空间时放行
|
||
|
||
try {
|
||
// 标准化路径(处理 .. 和 . )
|
||
const normalized = normalizePath(path);
|
||
const normalizedWs = normalizePath(workspaceDir);
|
||
|
||
// 绝对路径检查
|
||
if (isAbsolute(normalized)) {
|
||
if (!normalized.toLowerCase().startsWith(normalizedWs.toLowerCase())) {
|
||
return {
|
||
valid: false,
|
||
reason: `路径 "${path}" 不在工作空间 "${workspaceDir}" 范围内`,
|
||
};
|
||
}
|
||
}
|
||
|
||
// 检查路径遍历攻击
|
||
// 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(/[\\/]/);
|
||
let depth = 0;
|
||
for (const seg of segments) {
|
||
if (seg === '..') depth--;
|
||
else if (seg && seg !== '.') depth++;
|
||
if (depth < 0) {
|
||
return {
|
||
valid: false,
|
||
reason: `路径遍历检测: "${path}" 试图超出工作空间边界`,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
return { valid: true, normalizedPath: normalized };
|
||
} catch {
|
||
return { valid: false, reason: `路径解析失败: ${path}` };
|
||
}
|
||
}
|
||
|
||
/** R76: 路径标准化辅助函数 */
|
||
function normalizePath(p: string): string {
|
||
return p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '');
|
||
}
|
||
|
||
/** R76: 判断是否为绝对路径 */
|
||
function isAbsolute(p: string): boolean {
|
||
return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('/');
|
||
}
|
||
|
||
// R87 已删除:工具熔断器
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R88: 工具结果元数据 — 为模型提供结果大小的上下文提示
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/** R88: 估算字符串的 token 数(轻量级,用于工具结果元数据) */
|
||
export function estimateResultTokens(text: string): number {
|
||
if (!text) return 0;
|
||
let cn = 0, other = 0;
|
||
for (const ch of text) {
|
||
if (/[\u4e00-\u9fff]/.test(ch)) cn++;
|
||
else other++;
|
||
}
|
||
return Math.ceil(cn / 1.5 + other / 4);
|
||
}
|
||
|
||
/** R88: 为工具结果添加元数据提示,帮助模型理解结果规模 */
|
||
export function addResultMetadata(formattedResult: string): string {
|
||
const tokens = estimateResultTokens(formattedResult);
|
||
if (tokens > 500) {
|
||
return formattedResult + `\n[元数据: ~${tokens} tokens]`;
|
||
}
|
||
return formattedResult;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R97: 错误模式学习 — 从重复错误中学习并生成改进建议
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
export interface ErrorPatternEntry {
|
||
toolName: string;
|
||
errorSignature: string;
|
||
count: number;
|
||
firstSeen: number;
|
||
lastSeen: number;
|
||
suggestedFix?: string;
|
||
}
|
||
|
||
const _errorPatterns = new Map<string, ErrorPatternEntry>();
|
||
const MAX_ERROR_PATTERNS = 50;
|
||
|
||
/** R97: 已知的错误→建议映射 */
|
||
const ERROR_FIX_MAP: Array<{ pattern: RegExp; fix: string }> = [
|
||
{ pattern: /ENOENT|no such file/i, fix: '检查文件路径是否正确,使用 list_directory 确认文件存在' },
|
||
{ pattern: /EACCES|permission denied/i, fix: '检查文件权限,或尝试使用不同路径' },
|
||
{ pattern: /timeout|timed out/i, fix: '尝试拆分任务为更小的步骤,或增加超时时间' },
|
||
{ pattern: /ECONNRESET|ECONNREFUSED/i, fix: '检查网络连接或目标服务是否可用' },
|
||
{ pattern: /rate limit|429/i, fix: '降低调用频率,添加延迟重试' },
|
||
{ pattern: /JSON.*parse|syntax error/i, fix: '验证输入数据的 JSON 格式是否正确' },
|
||
{ pattern: /tool.*not.*found|unknown tool/i, fix: '检查工具名拼写,使用正确的工具名' },
|
||
{ pattern: /invalid.*argument|参数.*无效/i, fix: '检查工具参数是否符合要求' },
|
||
];
|
||
|
||
/** R97: 记录错误模式并返回改进建议 */
|
||
export function recordErrorPattern(toolName: string, errorMsg: string): string | undefined {
|
||
// 提取错误签名(移除具体路径/数值等细节)
|
||
const signature = errorMsg
|
||
.replace(/['"]/g, '')
|
||
.replace(/\/[\w./\\-]+/g, '<path>')
|
||
.replace(/\d+/g, 'N')
|
||
.slice(0, 100);
|
||
|
||
const key = `${toolName}::${signature}`;
|
||
const now = Date.now();
|
||
const existing = _errorPatterns.get(key);
|
||
|
||
if (existing) {
|
||
existing.count++;
|
||
existing.lastSeen = now;
|
||
} else {
|
||
// LRU 淘汰
|
||
if (_errorPatterns.size >= MAX_ERROR_PATTERNS) {
|
||
const oldestKey = _errorPatterns.keys().next().value;
|
||
if (oldestKey) _errorPatterns.delete(oldestKey);
|
||
}
|
||
// 查找建议
|
||
let suggestedFix: string | undefined;
|
||
for (const { pattern, fix } of ERROR_FIX_MAP) {
|
||
if (pattern.test(errorMsg)) {
|
||
suggestedFix = fix;
|
||
break;
|
||
}
|
||
}
|
||
_errorPatterns.set(key, {
|
||
toolName, errorSignature: signature, count: 1,
|
||
firstSeen: now, lastSeen: now, suggestedFix,
|
||
});
|
||
}
|
||
|
||
// 如果同一错误出现 2+ 次,返回建议
|
||
const entry = _errorPatterns.get(key);
|
||
if (entry && entry.count >= 2 && entry.suggestedFix) {
|
||
return `[错误模式提示] "${toolName}" 已 ${entry.count} 次出现类似错误。建议: ${entry.suggestedFix}`;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
// R104 已删除:工具结果去重
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R109 已移除:工具参数消毒(sanitizeToolArgs)
|
||
// 该实现会污染 write_file 的 content 等数据型参数(把合法文本当作注入
|
||
// 模式替换掉),安全收益不抵数据破坏风险;注入防御由以下机制承担:
|
||
// - 主进程 checkPathAllowed / checkCommandAllowed / checkPublicHttpUrl
|
||
// - 系统提示词的数据边界标记(REFERENCE_DATA / TOOL_RESULT 信封)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R112: 诊断系统 — 收集 Agent 运行状态用于调试和优化
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
export interface AgentDiagnostics {
|
||
timestamp: number;
|
||
toolResultStoreSize: number;
|
||
toolCallHistoryLength: number;
|
||
errorPatternCount: number;
|
||
topErrorPatterns: Array<{ tool: string; signature: string; count: number }>;
|
||
}
|
||
|
||
/** R112: 收集 Agent 诊断信息 */
|
||
export function collectDiagnostics(): AgentDiagnostics {
|
||
const now = Date.now();
|
||
|
||
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 }));
|
||
|
||
return {
|
||
timestamp: now,
|
||
toolResultStoreSize: _toolResultStore.size,
|
||
toolCallHistoryLength: _toolCallHistory.length,
|
||
errorPatternCount: _errorPatterns.size,
|
||
topErrorPatterns,
|
||
};
|
||
}
|
||
|
||
/** R112: 格式化诊断报告(用于日志输出) */
|
||
export function formatDiagnosticsReport(diag: AgentDiagnostics): string {
|
||
const lines: string[] = [
|
||
`Agent Diagnostics Report (${new Date(diag.timestamp).toLocaleTimeString()})`,
|
||
`${'─'.repeat(50)}`,
|
||
`Tool Result Store: ${diag.toolResultStoreSize} entries`,
|
||
`Tool Call History: ${diag.toolCallHistoryLength} entries`,
|
||
`Error Patterns: ${diag.errorPatternCount}`,
|
||
];
|
||
for (const ep of diag.topErrorPatterns) {
|
||
lines.push(` ⚠️ ${ep.tool}: "${ep.signature}" (${ep.count}x)`);
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R113: 命令白名单增强 — 对 run_command 进行更精细的安全控制
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/** R113: 高风险命令模式 — 需要用户确认 */
|
||
const HIGH_RISK_COMMAND_PATTERNS = [
|
||
{ pattern: /\brm\s+-rf?\b/i, risk: 'high', reason: '递归删除文件' },
|
||
{ pattern: /\bformat\b/i, risk: 'high', reason: '格式化磁盘' },
|
||
{ pattern: /\bmkfs\b/i, risk: 'high', reason: '创建文件系统' },
|
||
{ pattern: /\bdd\s+if=/i, risk: 'high', reason: '磁盘镜像写入' },
|
||
{ pattern: /\bchmod\s+777\b/i, risk: 'medium', reason: '设置全权限' },
|
||
{ pattern: /\bchown\b/i, risk: 'medium', reason: '修改文件所有者' },
|
||
{ pattern: /\bkill\s+-9\b/i, risk: 'medium', reason: '强制终止进程' },
|
||
{ pattern: /\biptables\b/i, risk: 'high', reason: '修改防火墙规则' },
|
||
{ pattern: /\breg(?:edit|svc)?\s+/i, risk: 'high', reason: '修改注册表' },
|
||
{ pattern: /\bnetsh\b/i, risk: 'medium', reason: '修改网络配置' },
|
||
{ pattern: /\bgit\s+push\s+--force\b/i, risk: 'medium', reason: '强制推送(覆盖远程)' },
|
||
{ pattern: /\bnpm\s+publish\b/i, risk: 'medium', reason: '发布 npm 包' },
|
||
];
|
||
|
||
/** R113: 禁止的命令模式 — 不可执行 */
|
||
const FORBIDDEN_COMMAND_PATTERNS = [
|
||
{ pattern: /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/i, reason: 'fork 炸弹' },
|
||
{ pattern: /\brm\s+-rf\s+\/\s*$/i, reason: '删除根目录' },
|
||
{ pattern: /\bmkfs\.\w+\s+\/dev\//i, reason: '格式化系统设备' },
|
||
{ pattern: /\bshutdown\b|\breboot\b|\bhalt\b/i, reason: '系统关机/重启' },
|
||
{ pattern: /\b>\s*\/dev\/sda/i, reason: '直接写入磁盘设备' },
|
||
];
|
||
|
||
/** R113: 检查命令安全性,返回风险评估结果 */
|
||
export function checkCommandSafety(command: string): { safe: boolean; riskLevel: 'none' | 'medium' | 'high' | 'forbidden'; reason?: string } {
|
||
// 检查禁止的命令
|
||
for (const { pattern, reason } of FORBIDDEN_COMMAND_PATTERNS) {
|
||
if (pattern.test(command)) {
|
||
return { safe: false, riskLevel: 'forbidden', reason: `禁止执行: ${reason}` };
|
||
}
|
||
}
|
||
|
||
// 检查高风险命令
|
||
for (const { pattern, risk, reason } of HIGH_RISK_COMMAND_PATTERNS) {
|
||
if (pattern.test(command)) {
|
||
return {
|
||
safe: risk !== 'high', // high risk 需要 confirmation(但不是完全禁止)
|
||
riskLevel: risk as 'medium' | 'high',
|
||
reason: `${reason}(风险等级: ${risk})`,
|
||
};
|
||
}
|
||
}
|
||
|
||
return { safe: true, riskLevel: 'none' };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R95: 工具结果按类型智能截断 — 不同工具类型采用不同截断策略
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/** R95: 工具类型 → 截断策略映射 */
|
||
const TOOL_TRUNCATE_STRATEGY: Record<string, {
|
||
/** 优先保留的部分 */
|
||
preserve: 'head' | 'tail' | 'both' | 'json_keys';
|
||
/** 默认最大长度 */
|
||
maxLen: number;
|
||
/** JSON keys 保留策略(仅当 preserve=json_keys 时生效) */
|
||
keepKeys?: string[];
|
||
}> = {
|
||
// 命令输出:保留尾部(最终结果在尾部)
|
||
run_command: { preserve: 'both', maxLen: 4000 },
|
||
// 搜索结果:保留头部(最相关的结果在头部)
|
||
search_files: { preserve: 'head', maxLen: 3000 },
|
||
web_search: { preserve: 'head', maxLen: 3000 },
|
||
// 文件读取:保留头部(文件开头通常更重要)
|
||
read_file: { preserve: 'head', maxLen: 6000 },
|
||
// 目录列表:保留头部
|
||
list_directory: { preserve: 'head', maxLen: 2000 },
|
||
tree: { preserve: 'head', maxLen: 3000 },
|
||
// 网页抓取:保留头部(标题和摘要)
|
||
web_fetch: { preserve: 'head', maxLen: 5000 },
|
||
// Git 输出:保留尾部
|
||
git: { preserve: 'tail', maxLen: 3000 },
|
||
// 默认:两端保留
|
||
_default: { preserve: 'both', maxLen: 4000 },
|
||
};
|
||
|
||
/**
|
||
* R95: 按工具类型智能截断工具结果
|
||
* 不同工具类型的输出有不同的结构特征,截断策略应适配
|
||
*/
|
||
export function smartTruncateByToolType(
|
||
toolName: string,
|
||
content: string,
|
||
maxLen?: number,
|
||
): string {
|
||
const strategy = TOOL_TRUNCATE_STRATEGY[toolName] || TOOL_TRUNCATE_STRATEGY._default;
|
||
const limit = maxLen ?? strategy.maxLen;
|
||
|
||
if (content.length <= limit) return content;
|
||
|
||
const truncateMsg = (originalLen: number, truncatedLen: number) =>
|
||
`\n... [R95截断: ${originalLen - truncatedLen} 字符已省略]`;
|
||
|
||
switch (strategy.preserve) {
|
||
case 'head':
|
||
return content.slice(0, limit) + truncateMsg(content.length, limit);
|
||
|
||
case 'tail':
|
||
return truncateMsg(content.length, limit) + content.slice(-limit);
|
||
|
||
case 'both': {
|
||
// 保留前 60% + 后 40%
|
||
const headLen = Math.floor(limit * 0.6);
|
||
const tailLen = limit - headLen;
|
||
return (
|
||
content.slice(0, headLen) +
|
||
truncateMsg(content.length, limit) +
|
||
content.slice(-tailLen)
|
||
);
|
||
}
|
||
|
||
case 'json_keys': {
|
||
// 尝试解析 JSON 并保留指定 keys
|
||
try {
|
||
const parsed = JSON.parse(content);
|
||
const kept: Record<string, unknown> = {};
|
||
if (strategy.keepKeys) {
|
||
for (const key of strategy.keepKeys) {
|
||
if (key in parsed) kept[key] = parsed[key];
|
||
}
|
||
}
|
||
let result = JSON.stringify(kept);
|
||
if (result.length > limit) {
|
||
// 如果保留 keys 后仍然过长,截断
|
||
return result.slice(0, limit) + truncateMsg(result.length, limit);
|
||
}
|
||
return result;
|
||
} catch {
|
||
// JSON 解析失败,回退到 both 策略
|
||
const headLen = Math.floor(limit * 0.6);
|
||
const tailLen = limit - headLen;
|
||
return (
|
||
content.slice(0, headLen) +
|
||
truncateMsg(content.length, limit) +
|
||
content.slice(-tailLen)
|
||
);
|
||
}
|
||
}
|
||
|
||
default:
|
||
return content.slice(0, limit) + truncateMsg(content.length, limit);
|
||
}
|
||
}
|
||
|
||
// R99 已移除:工具结果引用解析(retrieveToolResult / checkArchivedReferences)
|
||
// 归档结果暂无工具可取回;如需查看完整结果,模型按归档提示重新调用原工具
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 统一重置(新会话开始时调用)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
export function resetAllSafetyState(): void {
|
||
_toolResultStore.clear();
|
||
_toolCallHistory.length = 0;
|
||
// R97: 清理错误模式
|
||
_errorPatterns.clear();
|
||
// R118: 清理性能分析数据
|
||
_loopTimings.length = 0;
|
||
logInfo('Agent Safety: 所有安全状态已重置');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// P1 #3: 安全状态快照/恢复 — 供 Sub-Agent 隔离使用
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
export interface SafetyStateSnapshot {
|
||
toolResultStore: Map<string, { toolName: string; fullContent: string; timestamp: number }>;
|
||
toolCallHistory: string[];
|
||
errorPatterns: Map<string, ErrorPatternEntry>;
|
||
loopTimings: LoopTiming[];
|
||
}
|
||
|
||
/** P1 #3: 快照当前安全状态(供 Sub-Agent 在隔离环境中使用) */
|
||
export function snapshotSafetyState(): SafetyStateSnapshot {
|
||
return {
|
||
toolResultStore: new Map(_toolResultStore),
|
||
toolCallHistory: [..._toolCallHistory],
|
||
errorPatterns: new Map(_errorPatterns),
|
||
loopTimings: [..._loopTimings],
|
||
};
|
||
}
|
||
|
||
/** P1 #3: 从快照恢复安全状态(Sub-Agent 执行完毕后恢复主 Agent 状态) */
|
||
export function restoreSafetyState(snapshot: SafetyStateSnapshot): void {
|
||
_toolResultStore.clear();
|
||
snapshot.toolResultStore.forEach((v, k) => _toolResultStore.set(k, v));
|
||
_toolCallHistory.length = 0;
|
||
_toolCallHistory.push(...snapshot.toolCallHistory);
|
||
_errorPatterns.clear();
|
||
snapshot.errorPatterns.forEach((v, k) => _errorPatterns.set(k, v));
|
||
_loopTimings.length = 0;
|
||
_loopTimings.push(...snapshot.loopTimings);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R116: 错误恢复建议增强 — 提供上下文化的错误恢复建议
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
interface ErrorRecoverySuggestion {
|
||
toolName: string;
|
||
error: string;
|
||
suggestions: string[];
|
||
severity: 'low' | 'medium' | 'high';
|
||
}
|
||
|
||
const _errorRecoveryRules: Array<{
|
||
pattern: RegExp;
|
||
suggestions: string[];
|
||
severity: ErrorRecoverySuggestion['severity'];
|
||
}> = [
|
||
{
|
||
pattern: /ENOENT|no such file|文件不存在|not found/i,
|
||
suggestions: [
|
||
'检查文件路径是否正确(注意大小写和路径分隔符)',
|
||
'使用 list_dir 确认文件是否存在',
|
||
'如果文件需要创建,先使用 write_file 创建',
|
||
],
|
||
severity: 'low',
|
||
},
|
||
{
|
||
pattern: /EACCES|permission denied|权限/i,
|
||
suggestions: [
|
||
'检查文件权限设置',
|
||
'尝试使用不同的路径(工作空间目录内)',
|
||
'联系管理员获取必要权限',
|
||
],
|
||
severity: 'medium',
|
||
},
|
||
{
|
||
pattern: /timeout|超时|timed?\s*out/i,
|
||
suggestions: [
|
||
'将任务拆分为更小的步骤',
|
||
'检查网络连接是否正常',
|
||
'增加超时时间或重试',
|
||
],
|
||
severity: 'medium',
|
||
},
|
||
{
|
||
pattern: /syntax error|语法错误|parse error/i,
|
||
suggestions: [
|
||
'检查代码语法是否正确',
|
||
'使用 JSON 验证工具检查格式',
|
||
'查看错误行号定位问题',
|
||
],
|
||
severity: 'high',
|
||
},
|
||
{
|
||
pattern: /connection refused|ECONNREFUSED|连接.*拒绝/i,
|
||
suggestions: [
|
||
'确认目标服务是否正在运行',
|
||
'检查端口号和地址是否正确',
|
||
'检查防火墙设置',
|
||
],
|
||
severity: 'medium',
|
||
},
|
||
{
|
||
pattern: /rate limit|429|频率/i,
|
||
suggestions: [
|
||
'降低请求频率,添加延迟',
|
||
'等待一段时间后重试',
|
||
'考虑使用缓存减少请求次数',
|
||
],
|
||
severity: 'low',
|
||
},
|
||
{
|
||
pattern: /out of memory|内存不足|OOM/i,
|
||
suggestions: [
|
||
'减少处理的数据量(分批处理)',
|
||
'检查是否有内存泄漏',
|
||
'增加系统可用内存',
|
||
],
|
||
severity: 'high',
|
||
},
|
||
];
|
||
|
||
/** R116: 获取上下文化的错误恢复建议 */
|
||
export function getErrorRecoverySuggestions(toolName: string, error: string): ErrorRecoverySuggestion {
|
||
const suggestions: string[] = [];
|
||
let severity: ErrorRecoverySuggestion['severity'] = 'low';
|
||
|
||
for (const rule of _errorRecoveryRules) {
|
||
if (rule.pattern.test(error)) {
|
||
suggestions.push(...rule.suggestions);
|
||
if (rule.severity === 'high') severity = 'high';
|
||
else if (rule.severity === 'medium' && severity !== 'high') severity = 'medium';
|
||
}
|
||
}
|
||
|
||
// 工具特定的建议
|
||
if (toolName === 'run_command' && /command not found|命令未找到/i.test(error)) {
|
||
suggestions.push('检查命令是否已安装(使用 which/where 确认)');
|
||
suggestions.push('确认命令名称拼写正确');
|
||
}
|
||
if (toolName === 'write_file' && /disk full|磁盘已满/i.test(error)) {
|
||
suggestions.push('清理磁盘空间后重试');
|
||
suggestions.push('尝试写入到其他磁盘');
|
||
}
|
||
if (toolName === 'web_fetch' && /SSL|certificate/i.test(error)) {
|
||
suggestions.push('检查 URL 是否使用 HTTPS');
|
||
suggestions.push('确认 SSL 证书是否有效');
|
||
}
|
||
|
||
// 如果没有匹配到任何规则,提供通用建议
|
||
if (suggestions.length === 0) {
|
||
suggestions.push('检查参数是否正确');
|
||
suggestions.push('尝试简化操作步骤');
|
||
suggestions.push('查看完整错误信息以获取更多线索');
|
||
}
|
||
|
||
return { toolName, error, suggestions, severity };
|
||
}
|
||
|
||
/** R116: 格式化错误恢复建议为可读字符串 */
|
||
export function formatErrorRecovery(suggestion: ErrorRecoverySuggestion): string {
|
||
const severityIcon = suggestion.severity === 'high' ? '🔴' : suggestion.severity === 'medium' ? '🟡' : '🟢';
|
||
const lines = [
|
||
`${severityIcon} 错误恢复建议 (${suggestion.toolName}):`,
|
||
`错误: ${suggestion.error.slice(0, 200)}`,
|
||
'建议:',
|
||
];
|
||
for (let i = 0; i < suggestion.suggestions.length; i++) {
|
||
lines.push(` ${i + 1}. ${suggestion.suggestions[i]}`);
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R118: 循环计时数据(供安全状态快照/恢复使用;报告生成已移除)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
export interface LoopTiming {
|
||
loop: number;
|
||
phase: string;
|
||
durationMs: number;
|
||
timestamp: number;
|
||
}
|
||
|
||
const _loopTimings: LoopTiming[] = [];
|
||
|