feat: 升级至 v0.2.1 — 流式渲染修复、安全增强、工具自动执行
流式渲染修复: - runId 机制防止 abort 后旧流事件污染新 run - run lock 防止并发 run 污染引擎状态 - abort race 提前退出工具执行等待 - TERMINATED 状态通过 stateChange 发射 - tool_call_delta 流式参数拼接 + pending 占位替换 - 首轮卡片创建路径统一,traceStep 按 ID 精确匹配 - compressed 事件转发为 toast 通知 安全增强: - ConfirmationHook 支持持久化自动执行(跨会话) - 设置面板新增自动执行工具管理 UI - SandboxManager 双重安全校验 fail-closed - 审计日志链式哈希防篡改 - PromptInjectionDefender 中文注入标记清理 - scanCode 28 模式 + base64/$() 检测 - validatePath realpathSync 防符号链接逃逸 - code-search 使用 execFile 防命令注入 新增工具: - file_editor、code_search、task_manager、diff_viewer 其他: - Agent Loop 加 PARSING/REFLECTING 状态 + 指数退避重试 - MemoryManager TF-IDF 语义检索 - run_command Windows 中文编码修复(chcp 65001) - 版本号 0.2.0 → 0.2.1
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Memory Consolidator — 会话结束记忆固化器
|
||||
*
|
||||
* 每次 Agent 完成一轮对话后,调用 LLM 判断本次对话中哪些内容
|
||||
* 值得持久化到工作空间根目录的 MEMORY.md。
|
||||
*
|
||||
* 工作流程:
|
||||
* 1. 收集本次对话的 user message + assistant final answer + tool calls 摘要
|
||||
* 2. 读取当前 MEMORY.md 内容作为参考(避免重复)
|
||||
* 3. 调用 LLM,要求其返回 JSON 数组 [{section, entry}]
|
||||
* 4. 解析响应,调用 WorkspaceService.appendMemory 追加
|
||||
*
|
||||
* 安全约束:
|
||||
* - 仅追加新条目,不修改已有内容
|
||||
* - section 限定为预定义的 5 个分区
|
||||
* - 单次最多追加 5 条记忆,避免 LLM 过度提取
|
||||
* - LLM 调用失败时静默降级(不影响主流程)
|
||||
*
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第六章
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import log from 'electron-log';
|
||||
import type { IMetonaProviderAdapter } from '../types/metona-adapter';
|
||||
import type { MetonaRequest, MetonaMessage } from '../types';
|
||||
import type { WorkspaceService } from '../../services/workspace.service';
|
||||
import type { IterationStep } from '../agent-loop/types';
|
||||
|
||||
/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */
|
||||
const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const;
|
||||
type AllowedSection = typeof ALLOWED_SECTIONS[number];
|
||||
|
||||
/** 单次固化最多追加的条目数 */
|
||||
const MAX_ENTRIES_PER_CONSOLIDATION = 5;
|
||||
|
||||
/** 单条记忆最大长度 */
|
||||
const MAX_ENTRY_LENGTH = 500;
|
||||
|
||||
export interface ConsolidationResult {
|
||||
appended: number;
|
||||
entries: Array<{ section: string; entry: string }>;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export class MemoryConsolidator {
|
||||
constructor(
|
||||
private adapter: IMetonaProviderAdapter,
|
||||
private workspaceService: WorkspaceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 热切换 Adapter(配置变更时由 main.ts 调用)
|
||||
*/
|
||||
setAdapter(adapter: IMetonaProviderAdapter): void {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 固化本次对话的重要记忆到 MEMORY.md
|
||||
*
|
||||
* @param userMessage 用户原始消息
|
||||
* @param assistantAnswer Agent 最终回答
|
||||
* @param iterations Agent Loop 迭代步骤(用于提取工具调用摘要)
|
||||
* @returns 固化结果
|
||||
*/
|
||||
async consolidate(
|
||||
userMessage: string,
|
||||
assistantAnswer: string,
|
||||
iterations: IterationStep[],
|
||||
): Promise<ConsolidationResult> {
|
||||
try {
|
||||
// 1. 构建对话摘要
|
||||
const conversationDigest = this.buildConversationDigest(userMessage, assistantAnswer, iterations);
|
||||
if (!conversationDigest) {
|
||||
return { appended: 0, entries: [], skipped: 0 };
|
||||
}
|
||||
|
||||
// 2. 读取当前 MEMORY.md 内容(供 LLM 去重)
|
||||
const currentMemory = this.workspaceService.getFiles().memory;
|
||||
const memoryDigest = this.truncateMemoryForPrompt(currentMemory);
|
||||
|
||||
// 3. 调用 LLM 提取需要持久化的记忆
|
||||
const llmResponse = await this.callLLMForExtraction(conversationDigest, memoryDigest);
|
||||
if (!llmResponse) {
|
||||
return { appended: 0, entries: [], skipped: 0 };
|
||||
}
|
||||
|
||||
// 4. 解析 JSON 响应
|
||||
const extracted = this.parseExtractionResponse(llmResponse);
|
||||
if (extracted.length === 0) {
|
||||
log.debug('[MemoryConsolidator] No memories worth persisting');
|
||||
return { appended: 0, entries: [], skipped: 0 };
|
||||
}
|
||||
|
||||
// 5. 过滤 + 截断 + 追加到 MEMORY.md
|
||||
const validEntries: Array<{ section: string; entry: string }> = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (const item of extracted.slice(0, MAX_ENTRIES_PER_CONSOLIDATION)) {
|
||||
if (!this.isValidEntry(item)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const truncatedEntry = item.entry.slice(0, MAX_ENTRY_LENGTH);
|
||||
try {
|
||||
this.workspaceService.appendMemory(item.section, truncatedEntry);
|
||||
validEntries.push({ section: item.section, entry: truncatedEntry });
|
||||
} catch (err) {
|
||||
log.warn(`[MemoryConsolidator] Failed to append to section "${item.section}":`, err);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
if (validEntries.length > 0) {
|
||||
log.info(`[MemoryConsolidator] Persisted ${validEntries.length} memories to MEMORY.md (skipped: ${skipped})`);
|
||||
}
|
||||
|
||||
return { appended: validEntries.length, entries: validEntries, skipped };
|
||||
} catch (error) {
|
||||
log.error('[MemoryConsolidator] Consolidation failed:', error);
|
||||
return { appended: 0, entries: [], skipped: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建对话摘要(供 LLM 分析)
|
||||
*/
|
||||
private buildConversationDigest(
|
||||
userMessage: string,
|
||||
assistantAnswer: string,
|
||||
iterations: IterationStep[],
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// 用户消息
|
||||
parts.push(`[USER] ${this.truncate(userMessage, 2000)}`);
|
||||
|
||||
// 工具调用摘要(如果有)
|
||||
const toolSummaries: string[] = [];
|
||||
for (const iter of iterations) {
|
||||
if (!iter.toolCalls?.length) continue;
|
||||
for (let i = 0; i < iter.toolCalls.length; i++) {
|
||||
const tc = iter.toolCalls[i];
|
||||
const result = iter.toolResults?.find((r) => r.toolCallId === tc.id);
|
||||
const status = result?.success ? 'ok' : 'error';
|
||||
const resultPreview = result?.result
|
||||
? this.truncate(JSON.stringify(result.result), 200)
|
||||
: result?.error ?? '';
|
||||
toolSummaries.push(` - ${tc.name}(${this.truncate(JSON.stringify(tc.args), 100)}) [${status}]${resultPreview ? ': ' + resultPreview : ''}`);
|
||||
}
|
||||
}
|
||||
if (toolSummaries.length > 0) {
|
||||
parts.push(`[TOOLS]\n${toolSummaries.join('\n')}`);
|
||||
}
|
||||
|
||||
// Agent 最终回答
|
||||
parts.push(`[ASSISTANT] ${this.truncate(assistantAnswer, 2000)}`);
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断 MEMORY.md 内容用于 prompt(避免过长)
|
||||
*/
|
||||
private truncateMemoryForPrompt(memory: string): string {
|
||||
if (!memory) return '(empty)';
|
||||
// 截取前 3000 字符,保留分区结构概览
|
||||
if (memory.length <= 3000) return memory;
|
||||
return memory.slice(0, 3000) + '\n... (truncated)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 LLM 提取需要持久化的记忆
|
||||
*/
|
||||
private async callLLMForExtraction(
|
||||
conversationDigest: string,
|
||||
currentMemory: string,
|
||||
): Promise<string | null> {
|
||||
const sectionsList = ALLOWED_SECTIONS.map((s) => `"${s}"`).join(', ');
|
||||
|
||||
const request: MetonaRequest = {
|
||||
meta: {
|
||||
sessionId: 'memory-consolidation',
|
||||
iteration: 0,
|
||||
requestId: `mc_${nanoid(12)}`,
|
||||
timestamp: Date.now(),
|
||||
agentVersion: '1.0.0',
|
||||
},
|
||||
systemPrompt: {
|
||||
roleDefinition: 'You are a memory curator for an AI agent. Your job is to decide what information from the current conversation is worth persisting to the agent\'s long-term memory file (MEMORY.md) for future sessions.',
|
||||
outputConstraints: [
|
||||
'Analyze the conversation below and extract ONLY information that meets ALL of these criteria:',
|
||||
'1. Long-term value: will be useful in future conversations (not transient task state)',
|
||||
'2. Not already present in the current MEMORY.md (avoid duplicates)',
|
||||
'3. Concrete and actionable (not vague observations)',
|
||||
'',
|
||||
'Good candidates: user preferences, project facts, important decisions, pending todos, known issues.',
|
||||
'Bad candidates: temporary tool results, trivial conversation, already-known facts.',
|
||||
'',
|
||||
`Output a JSON array. Each element: {"section": one of ${sectionsList}, "entry": concise description (max 200 chars, in the conversation language)}`,
|
||||
'If nothing is worth persisting, output an empty array: []',
|
||||
'Output ONLY the JSON array, no markdown fences, no explanation.',
|
||||
].join('\n'),
|
||||
safetyGuidelines: 'Do not persist sensitive data (passwords, API keys, tokens). Do not persist user personal information beyond what is necessary for the agent to function.',
|
||||
},
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `## Current MEMORY.md content:\n\n${currentMemory}\n\n## Current conversation:\n\n${conversationDigest}\n\n## Task:\nExtract information worth persisting. Output JSON array only.`,
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
params: {
|
||||
maxTokens: 1024,
|
||||
temperature: 0.0,
|
||||
stream: false,
|
||||
thinkingEnabled: false,
|
||||
thinkingEffort: 'low',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.adapter.chat(request);
|
||||
return response.content.trim();
|
||||
} catch (error) {
|
||||
log.warn('[MemoryConsolidator] LLM call failed:', (error as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 LLM 的提取响应
|
||||
*/
|
||||
private parseExtractionResponse(raw: string): Array<{ section: string; entry: string }> {
|
||||
if (!raw) return [];
|
||||
|
||||
// 尝试直接解析 JSON
|
||||
let cleaned = raw.trim();
|
||||
|
||||
// 移除可能的 markdown 代码围栏
|
||||
if (cleaned.startsWith('```')) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed
|
||||
.filter((item): item is { section: string; entry: string } =>
|
||||
typeof item === 'object' && item !== null &&
|
||||
typeof item.section === 'string' && typeof item.entry === 'string',
|
||||
)
|
||||
.map((item) => ({
|
||||
section: item.section.trim(),
|
||||
entry: item.entry.trim(),
|
||||
}))
|
||||
.filter((item) => item.section && item.entry);
|
||||
} catch {
|
||||
log.warn('[MemoryConsolidator] Failed to parse LLM response as JSON:', cleaned.slice(0, 200));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验条目是否合法(section 在允许列表内)
|
||||
*/
|
||||
private isValidEntry(item: { section: string; entry: string }): boolean {
|
||||
return (ALLOWED_SECTIONS as readonly string[]).includes(item.section) && item.entry.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断字符串
|
||||
*/
|
||||
private truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + '...';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user