P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
242 lines
9.1 KiB
TypeScript
242 lines
9.1 KiB
TypeScript
/**
|
||
* Agent Engine Manager — 每会话独立引擎管理器(P2-10)
|
||
*
|
||
* 解决原"全局单引擎"的两个缺陷:
|
||
* 1. 全局串行锁:原 AgentLoopEngine.currentRunPromise 使所有会话共享一把锁,
|
||
* 上一会话未结束时新会话消息需排队等待(最长卡 120s 工具超时)。
|
||
* 现在每个会话持有独立引擎实例,多会话可并行运行。
|
||
* 2. adapter abort 信号互踩:原所有引擎/SubAgent 共享一个 adapter 实例,
|
||
* setAbortSignal 单槽位导致并发时中断信号错乱。
|
||
* 现在创建引擎时通过 adapter 工厂为每个引擎生成独立 adapter 实例
|
||
* (adapter 是无状态的配置包装,实例化成本可忽略)。
|
||
*
|
||
* 引擎生命周期:
|
||
* - 按需创建(首次 sendMessage 时),事件统一转发到 manager(附加 sessionId)
|
||
* - LRU 淘汰:缓存超过 30 个引擎时,淘汰最旧的非运行中引擎
|
||
*
|
||
* @see electron/harness/agent-loop/engine.ts — 引擎实现
|
||
*/
|
||
|
||
import { EventEmitter } from 'events';
|
||
import { AgentLoopEngine } from '../harness/agent-loop';
|
||
import type { AgentLoopConfig } from '../harness/agent-loop/types';
|
||
import type { IMetonaProviderAdapter, MetonaToolDef } from '../harness/types';
|
||
import type { ToolRegistry } from '../harness/tools/registry';
|
||
import type { PreToolHook } from '../harness/hooks/pre-tool';
|
||
import type { PostToolHook } from '../harness/hooks/post-tool';
|
||
import log from 'electron-log';
|
||
|
||
/** 引擎缓存上限(超过后淘汰最旧的非运行中引擎) */
|
||
const MAX_ENGINES = 30;
|
||
|
||
export class AgentEngineManager extends EventEmitter {
|
||
private engines = new Map<string, AgentLoopEngine>();
|
||
/** 运行中的会话(stateChange INIT 添加 / TERMINATED 移除),用于 LRU 淘汰保护 */
|
||
private running = new Set<string>();
|
||
/** 主 adapter(供 MemoryConsolidator 等共享组件使用) */
|
||
private primaryAdapter: IMetonaProviderAdapter;
|
||
private fallbackAdapter: IMetonaProviderAdapter | null = null;
|
||
private baseConfig: Partial<AgentLoopConfig>;
|
||
private workspacePath = '';
|
||
|
||
constructor(
|
||
private opts: {
|
||
/** adapter 工厂(每次调用返回新实例;闭包内读取最新配置) */
|
||
buildAdapter: () => IMetonaProviderAdapter;
|
||
baseConfig: Partial<AgentLoopConfig>;
|
||
toolRegistry?: ToolRegistry;
|
||
preToolHooks?: PreToolHook[];
|
||
postToolHooks?: PostToolHook[];
|
||
},
|
||
) {
|
||
super();
|
||
this.primaryAdapter = opts.buildAdapter();
|
||
this.baseConfig = { ...opts.baseConfig };
|
||
}
|
||
|
||
/** 主 adapter(供 consolidator / orchestrator 等共享使用) */
|
||
getAdapter(): IMetonaProviderAdapter {
|
||
return this.primaryAdapter;
|
||
}
|
||
|
||
/** 创建独立 adapter 实例(每引擎/SubAgent 独享,避免 abort 信号互踩) */
|
||
createAdapter(): IMetonaProviderAdapter {
|
||
return this.opts.buildAdapter();
|
||
}
|
||
|
||
getFallbackAdapter(): IMetonaProviderAdapter | null {
|
||
return this.fallbackAdapter;
|
||
}
|
||
|
||
/** 设置故障转移 Provider(同步到所有引擎) */
|
||
setFallbackAdapter(adapter: IMetonaProviderAdapter | null): void {
|
||
this.fallbackAdapter = adapter;
|
||
for (const engine of this.engines.values()) {
|
||
engine.setFallbackAdapter(adapter);
|
||
}
|
||
}
|
||
|
||
getWorkspacePath(): string {
|
||
return this.workspacePath;
|
||
}
|
||
|
||
setWorkspacePath(path: string): void {
|
||
this.workspacePath = path;
|
||
for (const engine of this.engines.values()) {
|
||
engine.setWorkspacePath(path);
|
||
}
|
||
}
|
||
|
||
/** 同步工具列表到所有引擎(工具开关变更 / MCP 注册完成时) */
|
||
setToolsAll(tools: MetonaToolDef[]): void {
|
||
for (const engine of this.engines.values()) {
|
||
engine.setTools(tools);
|
||
}
|
||
}
|
||
|
||
/** 热更新所有引擎配置(设置变更时) */
|
||
updateConfigAll(partial: Partial<AgentLoopConfig>): void {
|
||
this.baseConfig = { ...this.baseConfig, ...partial };
|
||
for (const engine of this.engines.values()) {
|
||
engine.updateConfig(partial);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重建所有 adapter(LLM 配置变更时由 reloadAdapter 调用)
|
||
* 工厂闭包读取最新配置,重建 primary + 各引擎独立实例。
|
||
*/
|
||
refreshAdapters(): void {
|
||
this.primaryAdapter = this.opts.buildAdapter();
|
||
for (const engine of this.engines.values()) {
|
||
engine.setAdapter(this.createAdapter());
|
||
engine.setFallbackAdapter(this.fallbackAdapter);
|
||
}
|
||
}
|
||
|
||
/** 获取(或创建)会话引擎 */
|
||
getEngine(sessionId: string): AgentLoopEngine {
|
||
let engine = this.engines.get(sessionId);
|
||
if (!engine) {
|
||
engine = this.createEngine(sessionId);
|
||
this.engines.set(sessionId, engine);
|
||
this.evictIdleEngines();
|
||
}
|
||
return engine;
|
||
}
|
||
|
||
/**
|
||
* v0.7.4 P1-3: 会话当前是否有 run 进行中。
|
||
* 供 agent:sendMessage 做同会话并发防重入(第二个 invoke 在第一个 run 未结束时
|
||
* 直接拒绝,而非让同一引擎并行 runStream 或排队 30s 后强制 abort)。
|
||
*/
|
||
isRunning(sessionId: string): boolean {
|
||
return this.running.has(sessionId);
|
||
}
|
||
|
||
/**
|
||
* v0.7.4 P3-5: 会话删除/归档时显式销毁引擎(内存收口)。
|
||
* 旧实现引擎只靠 LRU 上限 30 淘汰,会话删除后引擎与 adapter 实例仍驻留内存。
|
||
* 调用方在 sessions:delete 时联动调用;正在运行中的会话由调用方先 abort。
|
||
*/
|
||
disposeEngine(sessionId: string): void {
|
||
const engine = this.engines.get(sessionId);
|
||
if (!engine) return;
|
||
engine.destroy();
|
||
this.engines.delete(sessionId);
|
||
this.running.delete(sessionId);
|
||
log.debug(`[EngineManager] disposed engine for session ${sessionId}`);
|
||
}
|
||
|
||
/** 中断指定会话引擎 */
|
||
abort(sessionId: string): void {
|
||
this.engines.get(sessionId)?.abort();
|
||
}
|
||
|
||
/** 等待指定会话当前 run 结束 */
|
||
async waitForAbort(sessionId: string, timeoutMs = 5_000): Promise<boolean> {
|
||
return this.engines.get(sessionId)?.waitForAbort(timeoutMs) ?? true;
|
||
}
|
||
|
||
/** 当前引擎数量(测试/诊断用) */
|
||
get size(): number {
|
||
return this.engines.size;
|
||
}
|
||
|
||
// ===== 私有方法 =====
|
||
|
||
private createEngine(sessionId: string): AgentLoopEngine {
|
||
const engine = new AgentLoopEngine(
|
||
{
|
||
...this.baseConfig,
|
||
contextWindow: this.primaryAdapter.getContextWindow(),
|
||
},
|
||
this.createAdapter(),
|
||
this.opts.toolRegistry,
|
||
this.opts.preToolHooks ?? [],
|
||
this.opts.postToolHooks ?? [],
|
||
);
|
||
engine.setFallbackAdapter(this.fallbackAdapter);
|
||
if (this.workspacePath) engine.setWorkspacePath(this.workspacePath);
|
||
// v0.5.2 关键修复: 新建引擎从 registry 拉取当前启用工具。
|
||
// setToolsAll 只同步"已存在"的引擎 — 引擎是懒创建的(首次 sendMessage 时 getEngine),
|
||
// 启动期的 setToolsAll 调用时 engines Map 为空,全是 no-op。
|
||
// 此前缺此调用 → 新引擎 this.tools=[] → LLM 请求不带 tools →
|
||
// 模型无法发起 tool_call(症状:模型口头说要调工具,实际不调,凭历史记忆瞎编)。
|
||
// v0.4.0 P2-10 引入每会话引擎时遗留的回归,v0.5.2 修复。
|
||
if (this.opts.toolRegistry) {
|
||
engine.setTools(this.opts.toolRegistry.listTools());
|
||
}
|
||
this.forwardEngineEvents(engine, sessionId);
|
||
log.debug(
|
||
`[EngineManager] engine created for session ${sessionId} (total: ${this.engines.size + 1})`,
|
||
);
|
||
return engine;
|
||
}
|
||
|
||
/** 将引擎事件转发到 manager(统一附加 sessionId,供常驻监听器消费) */
|
||
private forwardEngineEvents(engine: AgentLoopEngine, sessionId: string): void {
|
||
engine.on('streamEvent', (event) => {
|
||
this.emit('streamEvent', { ...event, sessionId: event.sessionId || sessionId });
|
||
});
|
||
engine.on('stateChange', (data) => {
|
||
const payload = { ...data, sessionId: data.sessionId || sessionId };
|
||
// 维护运行中集合(LRU 淘汰保护)
|
||
if (payload.state === 'INIT' || payload.current === 'INIT') this.running.add(sessionId);
|
||
if (payload.state === 'TERMINATED' || payload.current === 'TERMINATED')
|
||
this.running.delete(sessionId);
|
||
this.emit('stateChange', payload);
|
||
});
|
||
engine.on('complete', (data) => {
|
||
this.running.delete(sessionId);
|
||
this.emit('complete', { ...data, sessionId: data.sessionId || sessionId });
|
||
});
|
||
engine.on('compressed', (data) => {
|
||
this.emit('compressed', { ...data, sessionId });
|
||
});
|
||
engine.on('deadLoop', (data) => {
|
||
this.emit('deadLoop', { ...data, sessionId: data.sessionId || sessionId });
|
||
});
|
||
engine.on('providerSwitched', (data) => {
|
||
this.emit('providerSwitched', { ...data, sessionId: data.sessionId || sessionId });
|
||
});
|
||
engine.on('aborted', () => {
|
||
this.emit('aborted', { sessionId });
|
||
});
|
||
}
|
||
|
||
/** LRU 淘汰:超过上限时删除最旧的非运行中引擎 */
|
||
private evictIdleEngines(): void {
|
||
if (this.engines.size <= MAX_ENGINES) return;
|
||
let toEvict = this.engines.size - MAX_ENGINES;
|
||
for (const [sessionId, engine] of this.engines) {
|
||
if (toEvict <= 0) break;
|
||
if (this.running.has(sessionId)) continue;
|
||
engine.destroy();
|
||
this.engines.delete(sessionId);
|
||
log.debug(`[EngineManager] evicted idle engine for session ${sessionId}`);
|
||
toEvict--;
|
||
}
|
||
}
|
||
}
|