feat: v5.1.0 - 记忆replace/remove、Skill渐进式加载、安全扫描、预算可配置、工具截断配置
This commit is contained in:
@@ -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 = '';
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}%` }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: '关闭浏览器'
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user