/** * Tool Registry — 工具注册表 * * 管理所有可用工具(内置 + MCP),提供查找、注册、注销功能。 * 提供 per-tool 超时强制和结果大小限制,防止卡死和上下文溢出。 */ import type { MetonaToolDef, MetonaToolCall, MetonaToolResult, } from '../types'; import type { IMetonaTool, ToolRegistryEntry, ToolExecutionContext } from '../types/metona-tool'; /** 工具返回值最大字符数(约 50KB),超过则截断 */ const MAX_RESULT_CHARS = 50_000; export class ToolRegistry { private tools = new Map(); /** 注册内置工具 */ registerBuiltin(tool: IMetonaTool): void { this.tools.set(tool.definition.name, { tool, source: 'builtin', enabled: true, }); } /** 注册 MCP 工具 */ registerMCP(serverName: string, tool: IMetonaTool): void { this.tools.set(tool.definition.name, { tool, source: 'mcp', serverName, enabled: true, }); } /** 注销 MCP Server 提供的所有工具 */ unregisterMCPTools(serverName: string): void { for (const [name, entry] of this.tools) { if (entry.source === 'mcp' && entry.serverName === serverName) { this.tools.delete(name); } } } /** 获取工具 */ get(name: string): IMetonaTool | undefined { const entry = this.tools.get(name); if (!entry?.enabled) return undefined; return entry.tool; } /** 列出所有已启用工具的定义 */ listTools(): MetonaToolDef[] { return Array.from(this.tools.values()) .filter((e) => e.enabled) .map((e) => e.tool.definition); } /** 列出所有工具定义(含已禁用的,供设置 UI 使用) */ listAllTools(): Array { return Array.from(this.tools.values()).map((e) => ({ ...e.tool.definition, enabled: e.enabled, })); } /** 设置工具启用/禁用状态(供 IPC tools:toggle 调用) */ setToolEnabled(name: string, enabled: boolean): void { const entry = this.tools.get(name); if (entry) { entry.enabled = enabled; } } /** 执行工具(带超时强制和结果大小限制) */ async execute( toolCall: MetonaToolCall, context: ToolExecutionContext, ): Promise { const tool = this.get(toolCall.name); if (!tool) { return { toolCallId: toolCall.id, toolName: toolCall.name, result: null, success: false, error: `Unknown tool: ${toolCall.name}`, durationMs: 0, timestamp: Date.now(), }; } const startTs = Date.now(); const timeoutMs = tool.definition.timeoutMs; // M-15 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积 // 工具正常完成时未触发的 timer 会持续占用事件循环 timeoutMs 毫秒 let timer: ReturnType | undefined; try { // 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop const result = await Promise.race([ tool.execute(toolCall.args, context), new Promise((_, reject) => { timer = setTimeout( () => reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)), timeoutMs, ); }), ]); // 结果大小限制 — 防止过大返回值耗尽 LLM 上下文窗口 const safeResult = this.truncateResult(result); return { toolCallId: toolCall.id, toolName: toolCall.name, result: safeResult, success: true, durationMs: Date.now() - startTs, timestamp: Date.now(), }; } catch (error) { return { toolCallId: toolCall.id, toolName: toolCall.name, result: null, success: false, error: (error as Error).message, durationMs: Date.now() - startTs, timestamp: Date.now(), }; } finally { // M-15 修复: 无论工具成功或失败,清理 timeout timer if (timer) clearTimeout(timer); } } /** * 截断过大的工具返回值,防止 LLM 上下文溢出 * * v0.3.1 修复 FAIL-1: view_image 返回的 dataUrl 需完整传输给多模态 LLM, * 截断会导致 base64 损坏、图片无法显示。对含 dataUrl 字段的结果跳过截断。 * view_image 已在工具内部限制文件大小 5MB,base64 后约 6.7MB, * 多模态 LLM 能处理此量级数据。 * * v0.3.1 修复 WARN-2: dataUrl 检测前置到 stringify 之前, * 避免对 5MB+ 的图片对象做无意义的 JSON.stringify(约 6.7MB 字符串)。 */ private truncateResult(result: unknown): unknown { // 先检测 dataUrl 白名单:图片类结果跳过截断(避免 base64 损坏 + 避免无意义的序列化) if (typeof result === 'object' && result !== null && 'dataUrl' in result) { return result; } const str = typeof result === 'string' ? result : JSON.stringify(result); if (str.length <= MAX_RESULT_CHARS) return result; return { _truncated: true, _original_size: str.length, _preview: str.slice(0, MAX_RESULT_CHARS), _message: `Result truncated: original ${str.length} chars exceeds limit ${MAX_RESULT_CHARS}`, }; } /** 获取工具数量 */ get size(): number { return Array.from(this.tools.values()).filter((e) => e.enabled).length; } }