feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。
P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层
P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}
P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)
Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。
验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
This commit is contained in:
+58
-103
@@ -29,6 +29,8 @@ import { buildUserContextPrefix, withUserContextPrefix } from '../harness/prompt
|
||||
// v0.7.3 P1-5: 记忆固化触发决策(纯函数)
|
||||
import { shouldConsolidate } from '../harness/memory/consolidation-policy';
|
||||
import log from 'electron-log';
|
||||
// v0.8.1 P0-4: 主进程 toast 文案双语(ui.locale 驱动)
|
||||
import { mt } from '../utils/main-locale';
|
||||
|
||||
/** 构建 "时区名 (UTC±N)" 标签(注入用户上下文前置块;失败回退 UTC) */
|
||||
function buildTimezoneLabel(): string {
|
||||
@@ -67,34 +69,14 @@ interface IterationTrace {
|
||||
finishReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.0 P1-1a: 单会话的流式回放缓冲 —— 渲染层切走会话期间,事件管道仍照常
|
||||
* 投递(按会话隔离被渲染层准入拒绝),此缓冲按序保存 streamEvent + stateChange
|
||||
* 双通道事件;用户切回会话时经 agent:getReplayState 拉取并灌入渲染层事件总线,
|
||||
* 实现"后台会话运行内容不丢失"。
|
||||
*
|
||||
* 有界设计:单会话上限 MAX_REPLAY_EVENTS 条 / MAX_REPLAY_BYTES 字节,溢出丢弃
|
||||
* 最旧并置 truncated 标记;TERMINATED 后保留(供"完成后切回"看到最终内容),
|
||||
* 下一次 run 启动(INIT stateChange)时清空重建。
|
||||
*/
|
||||
interface ReplayEntry {
|
||||
channel: 'streamEvent' | 'stateChange';
|
||||
payload: unknown;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
interface ReplayBuffer {
|
||||
events: ReplayEntry[];
|
||||
bytes: number;
|
||||
truncated: boolean;
|
||||
/** 最近一次观察到的 runId(供渲染层回放后对齐 run 守卫) */
|
||||
runId?: string;
|
||||
terminated: boolean;
|
||||
}
|
||||
|
||||
const MAX_REPLAY_EVENTS = 2000;
|
||||
const MAX_REPLAY_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_REPLAY_SESSIONS = 50;
|
||||
// v0.8.1 P0-3: 回放缓冲抽为独立模块(replay-buffer.ts)—— 会话删除/彻底删除
|
||||
// 的终态路径可显式清除缓冲,杜绝已删会话最多 4MB/会话的内存滞留
|
||||
import {
|
||||
appendReplay,
|
||||
resetReplayBuffer,
|
||||
markReplayTerminated,
|
||||
getReplayBufferData,
|
||||
} from './replay-buffer';
|
||||
|
||||
export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
const {
|
||||
@@ -118,54 +100,9 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// ===== 常驻事件管道:text_delta 按会话节流(F8) =====
|
||||
const throttleStates = new Map<string, ThrottleState>();
|
||||
const iterationTraces = new Map<string, IterationTrace>();
|
||||
// v0.8.0 P1-1a: 每会话流式回放缓冲(后台会话内容恢复)
|
||||
const replayBuffers = new Map<string, ReplayBuffer>();
|
||||
|
||||
/** 追加一条事件到会话回放缓冲(有界:条数/字节双上限,溢出丢最旧) */
|
||||
const appendReplay = (sessionId: string, entry: ReplayEntry): void => {
|
||||
let buf = replayBuffers.get(sessionId);
|
||||
if (!buf) {
|
||||
// 会话数上限保护:超出后淘汰最早的缓冲(Map 迭代序 = 插入序)
|
||||
if (replayBuffers.size >= MAX_REPLAY_SESSIONS) {
|
||||
const oldest = replayBuffers.keys().next().value as string | undefined;
|
||||
if (oldest !== undefined) replayBuffers.delete(oldest);
|
||||
}
|
||||
buf = { events: [], bytes: 0, truncated: false, terminated: false };
|
||||
replayBuffers.set(sessionId, buf);
|
||||
}
|
||||
// run 启动(INIT)后旧 run 缓冲作废 —— 由 stateChange 监听器在 INIT 时清空,
|
||||
// 此处仅在 terminated 缓冲上遇到新内容时兜底重置(正常路径 INIT 先行)
|
||||
if (buf.terminated) {
|
||||
replayBuffers.delete(sessionId);
|
||||
buf = { events: [], bytes: 0, truncated: false, terminated: false };
|
||||
replayBuffers.set(sessionId, buf);
|
||||
}
|
||||
let size = 0;
|
||||
try {
|
||||
size = JSON.stringify(entry.payload).length;
|
||||
} catch {
|
||||
size = 256; // 序列化失败按保守值计入
|
||||
}
|
||||
buf.events.push(entry);
|
||||
buf.bytes += size;
|
||||
while (
|
||||
buf.events.length > MAX_REPLAY_EVENTS ||
|
||||
(buf.bytes > MAX_REPLAY_BYTES && buf.events.length > 1)
|
||||
) {
|
||||
const dropped = buf.events.shift();
|
||||
buf.truncated = true;
|
||||
if (dropped) {
|
||||
try {
|
||||
buf.bytes -= JSON.stringify(dropped.payload).length;
|
||||
} catch {
|
||||
buf.bytes -= 256;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 记录 runId(首个携带者)
|
||||
const payload = entry.payload as { runId?: string } | null;
|
||||
if (!buf.runId && payload?.runId) buf.runId = payload.runId;
|
||||
};
|
||||
// v0.8.1 review: MEMORY.md 体积告警去重(每会话一次)
|
||||
const memoryOversizeWarned = new Set<string>();
|
||||
|
||||
// v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方)
|
||||
// v0.7.4 P3-5: LRU 化 —— 旧实现每会话一条永不清除,长期运行后无界增长。
|
||||
@@ -340,12 +277,11 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
if (sessionId) {
|
||||
const stateRaw = data.state ?? data.current ?? '';
|
||||
if (stateRaw === 'INIT') {
|
||||
replayBuffers.delete(sessionId);
|
||||
resetReplayBuffer(sessionId);
|
||||
} else {
|
||||
appendReplay(sessionId, { channel: 'stateChange', payload: data, ts: Date.now() });
|
||||
if (stateRaw === 'TERMINATED') {
|
||||
const buf = replayBuffers.get(sessionId);
|
||||
if (buf) buf.terminated = true;
|
||||
markReplayTerminated(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,7 +370,11 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// toast 通知用户压缩已发生
|
||||
broadcast('toast:show', {
|
||||
type: 'info',
|
||||
message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens})`,
|
||||
message: mt('agent.toast.compressed', {
|
||||
original: data.originalTokens ?? '?',
|
||||
compressed: data.compressedTokens ?? '?',
|
||||
saved: savedTokens,
|
||||
}),
|
||||
});
|
||||
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
|
||||
broadcast('agent:streamEvent', {
|
||||
@@ -454,7 +394,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
log.warn(`[AGENT] Dead loop detected at iteration ${data.iteration ?? '?'}`);
|
||||
broadcast('toast:show', {
|
||||
type: 'warning',
|
||||
message: `检测到死循环(第 ${data.iteration ?? '?'} 轮):连续3轮重复相同工具调用,已自动终止`,
|
||||
message: mt('agent.toast.deadLoop', { iteration: data.iteration ?? '?' }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -470,7 +410,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
});
|
||||
broadcast('toast:show', {
|
||||
type: 'warning',
|
||||
message: `Provider 故障转移: ${data.from ?? '?'} → ${data.to ?? '?'}(主 Provider 请求失败)`,
|
||||
message: mt('agent.toast.providerSwitched', { from: data.from ?? '?', to: data.to ?? '?' }),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -498,7 +438,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
|
||||
sendErrorEvent('无效的会话 ID', sessionId ?? '');
|
||||
sendErrorEvent(mt('agent.error.invalidSessionId'), sessionId ?? '');
|
||||
return { success: false, error: 'Invalid sessionId' };
|
||||
}
|
||||
if (
|
||||
@@ -507,7 +447,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
typeof userMessage.content !== 'string'
|
||||
) {
|
||||
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
|
||||
sendErrorEvent('无效的消息格式', sessionId);
|
||||
sendErrorEvent(mt('agent.error.invalidMessage'), sessionId);
|
||||
return { success: false, error: 'Invalid message format' };
|
||||
}
|
||||
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
|
||||
@@ -517,7 +457,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// 排队 30s 后强制 abort 旧 run,用户看到"上一次操作未完成")。此处直接拒绝并
|
||||
// 广播明确错误事件,前端 isStreaming 正常收尾。
|
||||
if (agentEngineManager.isRunning(sessionId)) {
|
||||
const busyMsg = '该会话正在执行任务,请等待完成或先中断后再发送';
|
||||
const busyMsg = mt('agent.error.sessionBusy');
|
||||
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} is already running`);
|
||||
sendErrorEvent(busyMsg, sessionId);
|
||||
return { success: false, error: busyMsg };
|
||||
@@ -529,7 +469,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// 此处提前校验并走统一的 sendErrorEvent + stopRecording 收尾路径。
|
||||
const sessionExists = sessionService.getSession(sessionId) != null;
|
||||
if (!sessionExists) {
|
||||
const missingMsg = '会话不存在或已被删除,请刷新后重试';
|
||||
const missingMsg = mt('agent.error.sessionMissing');
|
||||
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} not found`);
|
||||
sendErrorEvent(missingMsg, sessionId);
|
||||
await sessionRecorder.stopRecording(sessionId, {
|
||||
@@ -543,8 +483,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
|
||||
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
|
||||
if (!ctx.reloadAdapter()) {
|
||||
const errorMsg =
|
||||
'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)';
|
||||
const errorMsg = mt('agent.error.adapterLoadFailed');
|
||||
log.error('[AGENT]', errorMsg);
|
||||
sendErrorEvent(errorMsg, sessionId);
|
||||
await sessionRecorder.stopRecording(sessionId, {
|
||||
@@ -593,8 +532,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
if (contextBuilder.isUsingFallbackRole()) {
|
||||
broadcast('toast:show', {
|
||||
type: 'info',
|
||||
message:
|
||||
'未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
|
||||
message: mt('agent.soul.fallbackToast'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -603,7 +541,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// 每条消息都改变 system 字节 → 跨 run 缓存全 miss。现随首条 user 消息注入
|
||||
// (LLM 语义等价),system prompt 保持跨 run 字节级稳定。
|
||||
try {
|
||||
const memories = memoryManager.search(userMessage.content, {
|
||||
// v0.8.1: search 升级 async(向量混合检索)
|
||||
const memories = await memoryManager.search(userMessage.content, {
|
||||
topK: 5,
|
||||
minImportance: 0.3,
|
||||
});
|
||||
@@ -850,7 +789,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
);
|
||||
broadcast('toast:show', {
|
||||
type: 'info',
|
||||
message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`,
|
||||
message: mt('agent.toast.consolidated', { count: result.appended }),
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -861,6 +800,18 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
log.debug(`[AGENT] Memory consolidation skipped (${decision.reason})`);
|
||||
}
|
||||
|
||||
// v0.8.1 review 修复: MEMORY.md 体积告警移出固化分支 —— 每次成功 run 后
|
||||
// 检查(此前仅固化触发时检查,跳过固化的大文件永不告警);每会话只告警
|
||||
// 一次,避免连续消息刷屏。
|
||||
const memorySize = (workspaceService.getFiles().memory ?? '').length;
|
||||
if (memorySize > 51_200 && !memoryOversizeWarned.has(sessionId)) {
|
||||
memoryOversizeWarned.add(sessionId);
|
||||
broadcast('toast:show', {
|
||||
type: 'warning',
|
||||
message: mt('agent.toast.memoryOversize'),
|
||||
});
|
||||
}
|
||||
|
||||
// v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默)
|
||||
if (output.terminationReason === 'completed') {
|
||||
titleGenerator
|
||||
@@ -955,7 +906,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
}
|
||||
const balance = await adapter.getBalance();
|
||||
if (!balance) {
|
||||
return { success: false, error: '余额查询失败(API Key 无效或网络错误)' };
|
||||
return { success: false, error: mt('llm.balance.queryFailed') };
|
||||
}
|
||||
return { success: true, data: balance };
|
||||
} catch (error) {
|
||||
@@ -975,18 +926,18 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
const model = configService.get<string>('llm.model') ?? '';
|
||||
const apiKey = configService.get<string>('llm.apiKey') || '';
|
||||
if (!provider || !model) {
|
||||
return { success: false, error: 'LLM 未配置(Provider/Model 为空),无法获取模型列表' };
|
||||
return { success: false, error: mt('llm.listModels.notConfigured') };
|
||||
}
|
||||
if (!apiKey && provider !== 'ollama') {
|
||||
return { success: false, error: 'API Key 未配置,无法获取模型列表' };
|
||||
return { success: false, error: mt('llm.listModels.noApiKey') };
|
||||
}
|
||||
// 列表基于当前已保存配置 —— 先幂等重载 adapter(配置签名未变时为 no-op)
|
||||
if (!ctx.reloadAdapter()) {
|
||||
return { success: false, error: 'LLM 配置校验失败,请先在设置中修正配置' };
|
||||
return { success: false, error: mt('llm.listModels.configInvalid') };
|
||||
}
|
||||
const adapter = agentEngineManager.getAdapter();
|
||||
if (!adapter.listModels) {
|
||||
return { success: false, error: '当前 Provider 不支持模型列表查询' };
|
||||
return { success: false, error: mt('llm.listModels.unsupported') };
|
||||
}
|
||||
const models = await adapter.listModels();
|
||||
return { success: true, data: models };
|
||||
@@ -1014,10 +965,10 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
}
|
||||
const adapter = agentEngineManager.getAdapter();
|
||||
if (!(adapter instanceof OllamaAdapter)) {
|
||||
return { success: false, error: '仅 Ollama Provider 支持模型下载' };
|
||||
return { success: false, error: mt('llm.pull.ollamaOnly') };
|
||||
}
|
||||
if (ollamaPullController) {
|
||||
return { success: false, error: '已有模型下载任务进行中,请先取消' };
|
||||
return { success: false, error: mt('llm.pull.inProgress') };
|
||||
}
|
||||
const controller = new AbortController();
|
||||
ollamaPullController = controller;
|
||||
@@ -1045,7 +996,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
|
||||
ipcMain.handle('llm:ollamaPullCancel', async () => {
|
||||
if (!ollamaPullController) {
|
||||
return { success: false, error: '没有进行中的下载任务' };
|
||||
return { success: false, error: mt('llm.pull.none') };
|
||||
}
|
||||
ollamaPullController.abort();
|
||||
return { success: true };
|
||||
@@ -1071,6 +1022,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// 旧实现只覆盖 taskCompleted/taskError 路径,被 abort 的 SubAgent 残留 Map 条目
|
||||
for (const taskId of abortedTaskIds) {
|
||||
confirmationHook.forgetSession(taskId);
|
||||
// v0.8.1 P0-2: 被中止 SubAgent 的工作记忆一并清理
|
||||
memoryManager.clearWorkingMemory(taskId);
|
||||
subTraces.delete(taskId);
|
||||
subMeta.delete(taskId);
|
||||
// 中止的 SubAgent 录制文件也收尾(TRACE 完整性:标记为中断终止)
|
||||
@@ -1104,14 +1057,14 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
if (typeof sessionId !== 'string' || !sessionId) {
|
||||
return { success: false, error: 'Invalid sessionId' };
|
||||
}
|
||||
const buf = replayBuffers.get(sessionId);
|
||||
const data = getReplayBufferData(sessionId);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
isRunning: agentEngineManager.isRunning(sessionId),
|
||||
runId: buf?.runId ?? null,
|
||||
truncated: buf?.truncated ?? false,
|
||||
events: buf?.events ?? [],
|
||||
runId: data.runId,
|
||||
truncated: data.truncated,
|
||||
events: data.events,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1151,6 +1104,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
subMeta.delete(taskId);
|
||||
// v0.7.3 P2-3: SubAgent 终态 —— 决策记忆随任务终结清理(防长期运行泄漏)
|
||||
confirmationHook.forgetSession(taskId);
|
||||
// v0.8.1 P0-2: SubAgent 终态联动清理工作记忆(taskId 作为 sessionId 写入)
|
||||
memoryManager.clearWorkingMemory(taskId);
|
||||
};
|
||||
|
||||
orchestrator.on(
|
||||
|
||||
Reference in New Issue
Block a user