Files
metona-ai-desktop/electron/services/agent-engine-manager.service.ts
T
thzxx e4d693c1c7
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
CI / 产物编译验证 (push) Successful in 10m6s
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m42s
fix: v0.5.2 紧急修复 — 工具调用完全失效(引擎懒创建不带工具)+ DeepSeek 余额解析错误
P0 工具调用失效(用户实测反馈:Agnes/DeepSeek 均无法调用工具):
- 根因:AgentEngineManager.createEngine 未调用 setTools。引擎是懒创建的
  (首次 sendMessage 时 getEngine),启动期的 setToolsAll 调用时 engines
  Map 为空(全是 no-op)→ 新引擎 this.tools=[] → LLM 请求不带 tools →
  模型无法发起 tool_call。症状与用户反馈完全吻合:模型口头说要调工具
  (模仿历史消息中的工具调用模式),实际不调,凭记忆瞎编结果。
- 引入点:v0.4.0 P2-10 每会话引擎重构(v0.3.x 全局单引擎时代 setTools
  直接作用于唯一引擎,无此问题)。
- 修复:createEngine 从 toolRegistry 拉取当前启用工具(registry 是启用
  状态的唯一事实源,MCP 后注册/工具开关场景均一致)。

P1 DeepSeek 余额显示错误(用户实测反馈:显示的不是真实余额):
- 根因:DeepSeek 官方 /user/balance 实际返回 balance_infos 数组格式,
  此前按扁平字段解析(data.total_balance)→ 恒为 undefined → 界面恒显示 0。
- 修复:优先解析 balance_infos[0],回退扁平格式(网关兼容);URL 规范化
  (剥离尾斜杠与 /v1 前缀 — 余额端点在根路径,chat 端点两种写法都合法)。

测试(215 → 224 用例):
- 新增 AgentEngineManager 回归测试 ×4:懒创建引擎的 LLM 请求必须携带
  registry 工具定义(本次事故的直接拦截测试)/ 禁用工具不出现 / setToolsAll
  热更新 / 无 registry 时行为不回归
- 新增 DeepSeek 余额解析测试 ×5:官方数组格式 / 扁平回退 / URL 规范化 /
  非 2xx / 网络异常
- 补强引擎链路测试:mock adapter 记录请求并断言 tools 契约 — 此前 mock
  无条件吐 tool_call 事件,掩盖了"请求未携带工具定义"的缺陷(复检盲区
  的直接教训:mock 必须断言请求契约,否则测试是道具)

验证: lint 0 / typecheck 双工程 0 / test:electron 224 全过 / build 成功
2026-08-21 22:11:11 +08:00

219 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}
}
/**
* 重建所有 adapterLLM 配置变更时由 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;
}
/** 中断指定会话引擎 */
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--;
}
}
}