feat: v5.1.0 - 记忆replace/remove、Skill渐进式加载、安全扫描、预算可配置、工具截断配置

This commit is contained in:
thzxx
2026-04-18 12:09:27 +08:00
parent a3a523e2a8
commit 51a84bd0ad
11 changed files with 355 additions and 24 deletions
+33 -8
View File
@@ -28,16 +28,30 @@ import type {
TraceEntry
} from '../types.js';
const MAX_LOOPS = 15;
const MAX_LOOP_TIME = 600000; // 10 分钟总超时
const TOOL_EXEC_TIMEOUT = 30000; // 工具执行超时 30 秒
const STREAM_TIMEOUT = 120000; // 单次流式调用超时 2 分钟
const MAX_RETRIES = 2; // 工具错误自动重试次数
/** 每个工具返回给模型的最大字符数 */
const TOOL_MAX_RESULT_SIZE: Record<string, number> = {
web_fetch: 20000, // 网页内容通常较长
web_search: 3000, // 搜索结果已精简
read_file: 15000, // 文件内容
read_multiple_files: 10000,
list_directory: 5000,
search_files: 5000,
run_command: 10000, // 命令输出
git: 5000,
session_read: 15000,
browser_extract: 10000,
browser_evaluate: 8000,
};
/** v4.1: 工具并行执行 — 依赖检测用 */
const TOOLS_WITH_DATA_DEPS = new Set(['web_fetch', 'edit_file', 'append_file', 'move_file', 'delete_file']);
const AGENT_SYSTEM_PROMPT = `你是一个具备工具调用能力的 AI 助手。你有 34 个工具可以调用,包括文件操作、联网搜索、网页抓取、命令执行、浏览器控制、记忆管理、MCP 工具等。
const AGENT_SYSTEM_PROMPT = `你是一个具备工具调用能力的 AI 助手。你有 38 个工具可以调用,包括文件操作、联网搜索、网页抓取、命令执行、浏览器控制、记忆管理、MCP 工具等。
## 核心规则
@@ -73,7 +87,9 @@ const TOOL_USAGE_GUIDE = `【工具链规则(强制执行)】
7. 不要重复调用:已经成功调用过的工具+参数组合不要再调用。
8. 记忆工具:可以用 memory_search 搜索过去记忆,用 memory_add 添加重要信息。
9. 会话工具:可以用 session_list 列出历史会话,用 session_read 读取会话内容。
10. 并行调用:当多个工具调用互相独立时,可以在同一次回复中同时调用它们。`;
10. 并行调用:当多个工具调用互相独立时,可以在同一次回复中同时调用它们。
11. 管理记忆:可以用 memory_replace 更新已有记忆(子串匹配 old_text),用 memory_remove 删除不再需要的记忆。
12. 查看技能:可以用 skill_list 列出所有可用技能,用 skill_view 查看特定技能的详细工具链。`;
/** 工具名白名单:用于文本解析兜底时过滤非法工具名 */
const VALID_TOOL_NAMES = new Set([
@@ -81,7 +97,8 @@ const VALID_TOOL_NAMES = new Set([
'delete_file', 'run_command', 'move_file', 'copy_file', 'web_fetch', 'web_search',
'append_file', 'edit_file', 'get_file_info', 'tree', 'download_file', 'diff_files',
'replace_in_files', 'read_multiple_files', 'git', 'compress',
'memory_search', 'memory_add', 'session_list', 'session_read'
'memory_search', 'memory_add', 'memory_replace', 'memory_remove', 'session_list', 'session_read',
'skill_list', 'skill_view'
]);
/**
@@ -234,7 +251,8 @@ function formatToolResultForModel(toolName: string, result: ToolResult): string
case 'web_fetch': {
let content = (result.content as string) || '';
// 截断过长内容,避免撑爆上下文
if (content.length > 15000) content = content.slice(0, 15000) + '\n... (已截断)';
const webFetchMax = TOOL_MAX_RESULT_SIZE['web_fetch'] || 20000;
if (content.length > webFetchMax) content = content.slice(0, webFetchMax) + '\n... (已截断)';
return JSON.stringify({ success: true, url: result.url, content });
}
@@ -315,7 +333,11 @@ function formatToolResultForModel(toolName: string, result: ToolResult): string
k === 'status' || k === 'length' || k === 'isDirectory') continue;
clean[k] = v;
}
return JSON.stringify(clean);
let json = JSON.stringify(clean);
// 按工具配置截断
const maxLen = TOOL_MAX_RESULT_SIZE[toolName] || 15000;
if (json.length > maxLen) json = json.slice(0, maxLen) + '\n... (已截断)';
return json;
}
}
}
@@ -466,6 +488,9 @@ export async function runAgentLoop(
logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, tokens≈${estimateTokens(messages.map(m => m.content || '').join(''))}`);
// 迭代预算:从 state 读取,默认 15
const maxLoops = state.get<number>('maxTurns', 15);
let loopCount = 0;
const allToolRecords: ToolCallRecord[] = [];
const loopStartTime = Date.now();
@@ -481,7 +506,7 @@ export async function runAgentLoop(
return { eval_count: totalEvalCount || undefined, total_duration: totalDuration };
};
while (loopCount < MAX_LOOPS) {
while (loopCount < maxLoops) {
loopCount++;
// 全局超时检查
@@ -498,7 +523,7 @@ export async function runAgentLoop(
return;
}
logAgentLoop(loopCount, MAX_LOOPS);
logAgentLoop(loopCount, maxLoops);
let thinking = '';
content = '';
+113
View File
@@ -248,6 +248,13 @@ export async function addMemory(data: {
}): Promise<MemoryEntry> {
const db = state.get<ChatDB | null>(KEYS.DB);
// 安全扫描:检测 prompt injection 和敏感信息
const securityCheck = scanMemorySecurity(data.content);
if (!securityCheck.safe) {
logWarn('记忆安全扫描拦截', securityCheck.reason);
throw new Error(`记忆内容被安全规则拦截: ${securityCheck.reason}`);
}
// 检查重复(内容相似度 > 80% 则跳过)
const existing = memoryCache.find(e => {
if (e.type !== data.type) return false;
@@ -293,6 +300,112 @@ export async function addMemory(data: {
return entry;
}
// ── 安全扫描 ──
/** 记忆内容安全扫描 */
function scanMemorySecurity(content: string): { safe: boolean; reason: string } {
// Prompt injection 模式
const injectionPatterns = [
/ignore\s+(all\s+)?previous/i,
/forget\s+(all\s+)?instructions/i,
/you\s+are\s+now\s+a/i,
/new\s+system\s*prompt/i,
/override\s+(your|the)\s+/i,
/disregard\s+(all|any|previous)/i,
];
for (const p of injectionPatterns) {
if (p.test(content)) return { safe: false, reason: '疑似 prompt injection 攻击' };
}
// 敏感信息模式
const secretPatterns = [
/-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/,
/sk-[a-zA-Z0-9]{20,}/,
/ghp_[a-zA-Z0-9]{36}/,
/AKIA[A-Z0-9]{16}/,
/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/, // 信用卡号
];
for (const p of secretPatterns) {
if (p.test(content)) return { safe: false, reason: '疑似包含敏感信息(密钥/密码/信用卡号)' };
}
// 不可见字符
if (/[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/.test(content)) {
return { safe: false, reason: '包含不可见 Unicode 字符' };
}
return { safe: true, reason: '' };
}
// ── 记忆替换(根据 old_text 子串匹配)──
export async function replaceMemoryByContent(
target: 'memory' | 'user',
oldText: string,
newContent: string
): Promise<{ success: boolean; message: string }> {
if (!oldText || oldText.length < 2) {
return { success: false, message: 'old_text 不能为空且至少 2 个字符' };
}
if (!newContent || newContent.length < 2) {
return { success: false, message: 'new_content 不能为空且至少 2 个字符' };
}
// 安全扫描
const securityCheck = scanMemorySecurity(newContent);
if (!securityCheck.safe) {
return { success: false, message: `新内容被安全规则拦截: ${securityCheck.reason}` };
}
// 匹配
const matches = memoryCache.filter(e => {
if (target === 'user') {
return (e.type === 'preference' || e.type === 'fact') && e.content.includes(oldText);
}
return e.content.includes(oldText);
});
if (matches.length === 0) {
return { success: false, message: `未找到包含 "${oldText.slice(0, 50)}" 的记忆` };
}
if (matches.length > 1) {
return { success: false, message: `匹配到 ${matches.length} 条记忆,请使用更精确的 old_text` };
}
await updateMemory(matches[0].id, { content: newContent.trim() });
logMemory('替换记忆', `${matches[0].id}: ${oldText.slice(0, 30)}${newContent.slice(0, 30)}`);
return { success: true, message: `已替换记忆: ${matches[0].id}` };
}
// ── 记忆删除(根据 old_text 子串匹配)──
export async function removeMemoryByContent(
target: 'memory' | 'user',
oldText: string
): Promise<{ success: boolean; message: string }> {
if (!oldText || oldText.length < 2) {
return { success: false, message: 'old_text 不能为空且至少 2 个字符' };
}
const matches = memoryCache.filter(e => {
if (target === 'user') {
return (e.type === 'preference' || e.type === 'fact') && e.content.includes(oldText);
}
return e.content.includes(oldText);
});
if (matches.length === 0) {
return { success: false, message: `未找到包含 "${oldText.slice(0, 50)}" 的记忆` };
}
if (matches.length > 1) {
return { success: false, message: `匹配到 ${matches.length} 条记忆,请使用更精确的 old_text` };
}
await deleteMemory(matches[0].id);
logMemory('删除记忆', `${matches[0].id}: ${oldText.slice(0, 50)}`);
return { success: true, message: `已删除记忆: ${matches[0].id}` };
}
// ── 记忆更新 ──
export async function updateMemory(id: string, updates: Partial<MemoryEntry>): Promise<void> {
+53
View File
@@ -268,3 +268,56 @@ function generateSkillDescription(chain: ToolChainStep[], sessionTitle: string):
const steps = chain.map((s, i) => `${i + 1}. ${s.description}`).join('');
return `从会话「${sessionTitle}」中提取。步骤:${steps}`;
}
// ── 渐进式技能加载 ──
/** 列出所有技能(渐进式 Level 0:只返回名称、描述、成功率) */
export async function listSkills(): Promise<Array<{
name: string;
description: string;
success_rate: string;
chain_preview: string;
}>> {
const bridge = (window as any).metonaDesktop;
if (!bridge?.db?.getAllSkills) return [];
const allSkills: Skill[] = await bridge.db.getAllSkills();
return allSkills.map(skill => {
const total = skill.success_count + skill.fail_count;
const rate = total > 0 ? Math.round(skill.success_count / total * 100) : 100;
let chainPreview = '';
try {
const chain: ToolChainStep[] = JSON.parse(skill.tool_chain);
chainPreview = chain.map(s => s.name).join(' → ');
} catch { /* */ }
return {
name: skill.name,
description: skill.description,
success_rate: `${rate}%`,
chain_preview: chainPreview
};
});
}
/** 查看技能详情(渐进式 Level 1:返回完整工具链和参数提示) */
export async function viewSkill(skillName: string): Promise<{
success: boolean;
skill?: Skill & { chain: ToolChainStep[]; success_rate: string };
error?: string;
}> {
const bridge = (window as any).metonaDesktop;
if (!bridge?.db?.getAllSkills) return { success: false, error: '数据库不可用' };
const allSkills: Skill[] = await bridge.db.getAllSkills();
const skill = allSkills.find(s =>
s.name.toLowerCase() === skillName.toLowerCase() ||
s.name.toLowerCase().includes(skillName.toLowerCase())
);
if (!skill) return { success: false, error: `未找到技能: ${skillName}` };
let chain: ToolChainStep[] = [];
try { chain = JSON.parse(skill.tool_chain); } catch { /* */ }
const total = skill.success_count + skill.fail_count;
const rate = total > 0 ? Math.round(skill.success_count / total * 100) : 100;
return {
success: true,
skill: { ...skill, chain, success_rate: `${rate}%` }
};
}
+92 -3
View File
@@ -388,6 +388,37 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
}
}
},
{
type: 'function',
function: {
name: 'memory_replace',
description: 'Replace an existing memory entry with updated content. Uses substring matching to find the entry to replace.',
parameters: {
type: 'object',
required: ['target', 'old_text', 'new_content'],
properties: {
target: { type: 'string', enum: ['memory', 'user'], description: 'Target store: memory (agent notes) or user (user profile).' },
old_text: { type: 'string', description: 'Unique substring that identifies the entry to replace.' },
new_content: { type: 'string', description: 'The new content to replace the old entry with.' }
}
}
}
},
{
type: 'function',
function: {
name: 'memory_remove',
description: 'Remove a memory entry. Uses substring matching to find the entry to remove.',
parameters: {
type: 'object',
required: ['target', 'old_text'],
properties: {
target: { type: 'string', enum: ['memory', 'user'], description: 'Target store: memory (agent notes) or user (user profile).' },
old_text: { type: 'string', description: 'Unique substring that identifies the entry to remove.' }
}
}
}
},
{
type: 'function',
function: {
@@ -417,6 +448,31 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
}
}
},
{
type: 'function',
function: {
name: 'skill_list',
description: 'List all available auto-generated skills. Shows name, description, and success rate. Use skill_view to see full details of a specific skill.',
parameters: {
type: 'object',
properties: {}
}
}
},
{
type: 'function',
function: {
name: 'skill_view',
description: 'View full details of a specific skill including its tool chain, parameter hints, and usage statistics.',
parameters: {
type: 'object',
required: ['name'],
properties: {
name: { type: 'string', description: 'The skill name (or partial name) to view.' }
}
}
}
},
// ══════════════════════════════════════════════
// v4.3 新增工具:子代理委派
// ══════════════════════════════════════════════
@@ -562,7 +618,8 @@ let enabledTools: Set<string> = new Set([
'move_file', 'copy_file', 'web_fetch', 'web_search', 'append_file', 'edit_file',
'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files',
'read_multiple_files', 'git', 'compress',
'memory_search', 'memory_add', 'session_list', 'session_read', 'spawn_task',
'memory_search', 'memory_add', 'memory_replace', 'memory_remove',
'session_list', 'session_read', 'skill_list', 'skill_view', 'spawn_task',
'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract',
'browser_click', 'browser_type', 'browser_scroll', 'browser_close'
]);
@@ -637,6 +694,23 @@ export async function executeTool(toolName: string, args: Record<string, unknown
logToolResult('memory_add', true, `${type}: ${content.slice(0, 50)}`);
return { success: true, id: entry.id, type: entry.type, content: entry.content };
}
if (toolName === 'memory_replace') {
const { replaceMemoryByContent } = await import('./memory-manager.js');
const target = String(args.target || 'memory') as 'memory' | 'user';
const oldText = String(args.old_text || '');
const newContent = String(args.new_content || '');
const result = await replaceMemoryByContent(target, oldText, newContent);
logToolResult('memory_replace', result.success, result.message);
return result;
}
if (toolName === 'memory_remove') {
const { removeMemoryByContent } = await import('./memory-manager.js');
const target = String(args.target || 'memory') as 'memory' | 'user';
const oldText = String(args.old_text || '');
const result = await removeMemoryByContent(target, oldText);
logToolResult('memory_remove', result.success, result.message);
return result;
}
if (toolName === 'session_list') {
const bridge = window.metonaDesktop;
if (!bridge?.db) return { success: false, error: '桌面 API 不可用' };
@@ -688,6 +762,19 @@ export async function executeTool(toolName: string, args: Record<string, unknown
logToolResult('spawn_task', result.success, result.success ? `完成, ${(result as any).loops}` : result.error);
return result;
}
if (toolName === 'skill_list') {
const { listSkills } = await import('./skill-manager.js');
const skills = await listSkills();
logToolResult('skill_list', true, `${skills.length} 个技能`);
return { success: true, skills, total: skills.length };
}
if (toolName === 'skill_view') {
const { viewSkill } = await import('./skill-manager.js');
const name = String(args.name || '');
const result = await viewSkill(name);
logToolResult('skill_view', result.success, result.success ? result.skill?.name : result.error);
return result;
}
// run_command 走 workspace IPC
if (toolName === 'run_command') {
@@ -731,7 +818,8 @@ export function getToolIcon(name: string): string {
edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍',
memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖',
memory_search: '🧠', memory_add: '💾', memory_replace: '🔄', memory_remove: '🗑️',
session_list: '📋', session_read: '📖', skill_list: '📚', skill_view: '📖', spawn_task: '🤖',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
};
@@ -747,7 +835,8 @@ export function formatToolName(name: string): string {
get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆',
session_list: '会话列表', session_read: '读取会话', skill_list: '技能列表', skill_view: '技能详情', spawn_task: '子代理委派',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
};