UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

902 lines (796 loc) 30.3 kB
import { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; import { z } from 'zod'; import { Prompt } from '../../ai/manager'; import { ElectronMCPClient } from '../../ai/mcp/electron-client'; import { MCPOutputFormatter } from '../formatters/MCPOutputFormatter'; import { KubernetesTool, KubernetesToolContext } from './kubernetes'; import { AVAILABLE_TOOLS, getToolByName } from './registry'; import { ToolBase, ToolResponse } from './ToolBase'; export class ToolManager { private tools: ToolBase[] = []; private toolHandlers: Map<string, ToolBase> = new Map(); private mcpTools: (DynamicTool | DynamicStructuredTool)[] = []; private mcpToolsInitialized: boolean = false; private mcpInitializationPromise: Promise<void> | null = null; private boundModel: BaseChatModel | null = null; private providerId: string | null = null; private mcpFormatter: MCPOutputFormatter | null = null; private mcpClient: ElectronMCPClient; constructor({ kubernetesContext, enabledToolIds, }: { kubernetesContext?: KubernetesToolContext; enabledToolIds?: string[]; } = {}) { console.debug('🔧 ToolManager: Initializing with enabledToolIds:', enabledToolIds); if (kubernetesContext) { this.configureKubernetesContext(kubernetesContext); } this.mcpClient = new ElectronMCPClient(); this.initializeTools(enabledToolIds); this.mcpInitializationPromise = this.initializeMCPTools(); } /** * Initialize only enabled tools from the registry */ private initializeTools(enabledToolIds?: string[]): void { // Initialize regular tools first for (const ToolClass of AVAILABLE_TOOLS) { const tempTool = new ToolClass(); if (enabledToolIds && !enabledToolIds.includes(tempTool.config.name)) { continue; // Skip tools not enabled } try { this.addTool(tempTool); } catch (error) { console.error(`Failed to load tool ${ToolClass.name}:`, error); } } // MCP tools are initialized via mcpInitializationPromise in the constructor } /** * Initialize MCP tools from Electron main process */ private async initializeMCPTools(): Promise<void> { try { console.debug('Initializing MCP tools from Electron...'); if (!this.mcpClient.isAvailable()) { this.mcpToolsInitialized = true; return; } // First check the server config to know which servers are actually configured. // This is the user's source of truth — getToolsConfig() may return stale/cached // tools from servers that have been removed but not yet fully disconnected. const serverConfigResponse = await this.mcpClient.getConfig(); const configuredServerNames = new Set<string>(); if (serverConfigResponse.success && serverConfigResponse.config?.servers) { for (const server of serverConfigResponse.config.servers as any[]) { if (server.enabled !== false && server.name) { configuredServerNames.add(server.name); } } } // If no servers are configured (or all disabled), clear MCP tools immediately // regardless of what getToolsConfig() might return (stale cache) if (configuredServerNames.size === 0) { this.mcpTools = []; this.mcpToolsInitialized = true; return; } // Get tools configuration (discovered tools from connected servers) const toolsConfigResponse = await this.mcpClient.getToolsConfig(); if ( !toolsConfigResponse.success || !toolsConfigResponse.config || Object.keys(toolsConfigResponse.config).length === 0 ) { this.mcpTools = []; // Ensure MCP tools are cleared this.mcpToolsInitialized = true; return; } // Create tools from configuration, but only from servers that are actually configured const mcpToolsData: any[] = []; Object.entries(toolsConfigResponse.config).forEach( ([serverName, serverTools]: [string, any]) => { // Skip tools from servers that are no longer in the user's config if (!configuredServerNames.has(serverName)) { return; } Object.entries(serverTools).forEach(([toolName, toolConfig]: [string, any]) => { const fullToolName = `${serverName}__${toolName}`; mcpToolsData.push({ name: fullToolName, description: toolConfig.description || `Tool: ${toolName} from ${serverName} server`, inputSchema: toolConfig.inputSchema || {}, // Use the actual schema from MCP config server: serverName, enabled: toolConfig.enabled !== false, }); }); } ); // Filter MCP tools using the new configuration system const filteredMcpTools: typeof mcpToolsData = []; for (const toolData of mcpToolsData) { // MCP tools are controlled by their own configuration system, not legacy enabledToolIds // Check if tool is enabled in MCP configuration if (!toolData.enabled) { continue; } // Then check the new MCP-specific configuration const isEnabled = await this.mcpClient.isToolEnabled(toolData.name); if (!isEnabled) { continue; } filteredMcpTools.push(toolData); } if (filteredMcpTools.length > 0) { // Convert MCP tools to LangChain DynamicStructuredTool format this.mcpTools = filteredMcpTools.map( toolData => new DynamicStructuredTool({ name: toolData.name, description: this.buildMCPToolDescription(toolData), schema: toolData.inputSchema && Object.keys(toolData.inputSchema).length > 0 ? toolData.inputSchema : z.object({}), func: async (args: any) => { try { // Handle argument mapping for MCP tools // LangChain may wrap args in different formats, need to handle properly const mappedArgs = this.mapMCPToolArguments(args, toolData.inputSchema); // Execute MCP tool through Electron API const result = await window.desktopApi?.mcp.executeTool( toolData.name, mappedArgs ); // Extract actual result from MCP response const actualResult = result?.result || result; // Ensure we return a string response const response = typeof actualResult === 'string' ? actualResult : JSON.stringify(actualResult); return response; } catch (error) { console.error(`Error executing MCP tool ${toolData.name}:`, error); throw error; } }, }) ); this.mcpToolsInitialized = true; // If we have a bound model, rebind with the new MCP tools if (this.boundModel && this.providerId) { this.boundModel = this.bindToModel(this.boundModel, this.providerId); } } else { this.mcpToolsInitialized = true; } } catch (error) { this.mcpToolsInitialized = true; // Continue without MCP tools - this is not a fatal error } } /** * Build a rich description for MCP tools that includes parameter info. * Since DynamicTool doesn't support 'schema', we embed parameter details in the description. */ private buildMCPToolDescription(toolData: any): string { let desc = toolData.description || `MCP tool: ${toolData.name}`; const schema = toolData.inputSchema; if (schema?.properties && Object.keys(schema.properties).length > 0) { const params = Object.entries(schema.properties) .map(([name, prop]: [string, any]) => { const required = schema.required?.includes(name) ? ' (required)' : ''; return ` - ${name}${required}: ${prop.description || prop.type || 'any'}`; }) .join('\n'); desc += `\n\nParameters:\n${params}`; } return desc; } /** * Map LangChain tool arguments to MCP tool expected format * Handles common argument wrapping patterns and schema mismatches */ private mapMCPToolArguments(args: any, inputSchema?: any): any { if (!inputSchema) { // If no schema, return args as-is return args; } const schemaProps = inputSchema?.properties; // Handle tools that expect no parameters if (!schemaProps || Object.keys(schemaProps).length === 0) { // Return empty object for tools that expect no parameters return {}; } // Handle empty/null/undefined args if ( !args || args === '' || args === '""' || (typeof args === 'object' && Object.keys(args).length === 0) ) { // Check if the tool has required parameters const requiredProps = inputSchema?.required || []; if (requiredProps.length === 0) { // Tool has no required parameters, safe to return empty object return {}; } else { // Tool has required parameters but got empty args - create default structure return this.createDefaultParameterStructure(inputSchema); } } // First, check if args is properly structured for the schema if (args && typeof args === 'object') { // Remove any non-schema fields like 'input' that shouldn't be there const schemaPropertyNames = Object.keys(schemaProps); const cleanArgs: any = {}; // Copy only fields that exist in the schema for (const [key, value] of Object.entries(args)) { if (schemaPropertyNames.includes(key)) { cleanArgs[key] = value; } } // If we found valid schema fields, use the cleaned args if (Object.keys(cleanArgs).length > 0) { return this.filterMCPArguments(cleanArgs, inputSchema); } // If no valid schema fields found, check for 'input' wrapper if ('input' in args && !schemaProps.input) { const inputValue = args.input; // Handle empty input if (!inputValue || inputValue === '' || inputValue === '""') { const requiredProps = inputSchema?.required || []; if (requiredProps.length === 0) { return {}; } } // If the input is a primitive value and the schema has only one property if ( schemaPropertyNames.length === 1 && (typeof inputValue === 'string' || typeof inputValue === 'number' || typeof inputValue === 'boolean') ) { // Map the primitive value to the single expected property return { [schemaPropertyNames[0]]: inputValue }; } // If the input is an object, try to unwrap it if (typeof inputValue === 'object' && inputValue !== null) { return this.filterMCPArguments(inputValue, inputSchema); } // For primitive values with multiple schema properties, try common mappings if (typeof inputValue === 'string') { // Try common parameter names if (schemaProps.query) { return { query: inputValue }; } if (schemaProps.path) { return { path: inputValue }; } if (schemaProps.directory) { return { directory: inputValue }; } if (schemaProps.file) { return { file: inputValue }; } if (schemaProps.name) { return { name: inputValue }; } // If no common mapping found, map to first required property, then first property const requiredProps = inputSchema?.required || []; const targetProp = requiredProps.length > 0 ? requiredProps[0] : schemaPropertyNames[0]; if (targetProp) { return { [targetProp]: inputValue }; } } // Return the unwrapped input and let the tool handle validation return inputValue; } } // If args structure matches or is already properly formatted, filter and return return this.filterMCPArguments(args, inputSchema); } /** * Filter MCP arguments to only include required fields and fields with actual values */ private filterMCPArguments(args: any, inputSchema?: any): any { if (!inputSchema || !args || typeof args !== 'object') { return args; } const schemaProps = inputSchema?.properties; const requiredProps = inputSchema?.required || []; if (!schemaProps) { return args; } const filteredArgs: any = {}; // Always include all required properties, even if they have empty/default values for (const requiredProp of requiredProps) { if (requiredProp in args) { filteredArgs[requiredProp] = args[requiredProp]; } else { // If required property is missing, add a default value based on schema const propSchema = schemaProps[requiredProp]; if (propSchema) { if (propSchema.type === 'string') { filteredArgs[requiredProp] = propSchema.default || ''; } else if (propSchema.type === 'number' || propSchema.type === 'integer') { filteredArgs[requiredProp] = propSchema.default || 0; } else if (propSchema.type === 'boolean') { filteredArgs[requiredProp] = propSchema.default || false; } else if (propSchema.type === 'array') { filteredArgs[requiredProp] = propSchema.default || []; } else if (propSchema.type === 'object') { filteredArgs[requiredProp] = propSchema.default || {}; } else { filteredArgs[requiredProp] = propSchema.default || {}; } } } } // Include optional properties only if they have actual values for (const [key, value] of Object.entries(args)) { // Skip if already included as required if (requiredProps.includes(key)) { continue; } // Skip if property is not in schema if (!(key in schemaProps)) { continue; } // Include only if value is meaningful (not empty string, null, undefined, or empty object/array) if (this.hasActualValue(value)) { filteredArgs[key] = value; } } return filteredArgs; } /** * Check if a value is meaningful (not empty/null/undefined) */ private hasActualValue(value: any): boolean { // Null, undefined, or empty string are not actual values if (value === null || value === undefined || value === '') { return false; } // Empty arrays are not actual values if (Array.isArray(value)) { return value.length > 0; } // Empty objects are not actual values if (typeof value === 'object') { return Object.keys(value).length > 0; } // Numbers (including 0), booleans, and non-empty strings are actual values return true; } /** * Create default parameter structure based on schema * For required parameters that are missing, provide appropriate defaults */ private createDefaultParameterStructure(inputSchema: any): any { if (!inputSchema || !inputSchema.properties) { return {}; } const defaultParams: any = {}; const requiredProps = inputSchema.required || []; const schemaProps = inputSchema.properties; for (const requiredProp of requiredProps) { if (requiredProp in schemaProps) { const propSchema = schemaProps[requiredProp]; // Create appropriate default values based on type if (propSchema.type === 'string') { defaultParams[requiredProp] = propSchema.default || ''; } else if (propSchema.type === 'number' || propSchema.type === 'integer') { defaultParams[requiredProp] = propSchema.default || 0; } else if (propSchema.type === 'boolean') { defaultParams[requiredProp] = propSchema.default || false; } else if (propSchema.type === 'array') { defaultParams[requiredProp] = propSchema.default || []; } else if (propSchema.type === 'object') { defaultParams[requiredProp] = propSchema.default || {}; } else { // For unknown types, try to use the default or provide an empty object defaultParams[requiredProp] = propSchema.default || {}; } } } return defaultParams; } /** * Configure external dependencies for tools that need them */ configureKubernetesContext(context: KubernetesToolContext): void { // Only configure if the tool is enabled and present if (!this.hasTool('kubernetes_api_request')) { console.warn( 'AI Assistant: KubernetesTool is disabled or not present, skipping context configuration' ); return; } const kubeTool = getToolByName('kubernetes_api_request', this.tools) as KubernetesTool; if (kubeTool) { // Only set context and log if the tool context has actually changed if (!kubeTool.hasContext() || kubeTool.isContextDifferent(context)) { kubeTool.setContext(context); } } else { console.warn('KubernetesTool not found, cannot configure context'); } } /** * Add a tool to the manager (internal method) */ private addTool(tool: ToolBase): void { // Check for duplicate tool names if (this.toolHandlers.has(tool.config.name)) { console.warn(`Tool with name '${tool.config.name}' already exists, skipping`); return; } this.tools.push(tool); this.toolHandlers.set(tool.config.name, tool); } /** * Refresh MCP tools by re-fetching configuration from the Electron backend. * Call this when MCP configuration changes (servers added/removed/reset). */ async refreshMCPTools(): Promise<void> { this.mcpToolsInitialized = false; this.mcpTools = []; await this.initializeMCPTools(); // Rebind model if we have one, to update tool bindings if (this.boundModel && this.providerId) { await this.bindToModelAsync(this.boundModel, this.providerId); } } /** * Get all configured tools as LangChain tools (including MCP tools) */ getLangChainTools() { const regularTools = this.tools.map(tool => tool.createLangChainTool()); return [...regularTools, ...this.mcpTools]; } /** * Get all MCP tools as LangChain tools */ getMCPTools() { return this.mcpTools; } /** * Check if a specific tool is configured (including MCP tools) */ hasTool(toolName: string): boolean { // Check regular tools first if (this.toolHandlers.has(toolName)) { return true; } // Check MCP tools return this.mcpTools.some(tool => tool.name === toolName); } /** * Execute a tool by name with the given arguments */ async executeTool( toolName: string, args: Record<string, any>, toolCallId?: string, pendingPrompt?: Prompt ): Promise<ToolResponse> { if (!this.hasTool(toolName)) { return { content: JSON.stringify({ error: true, message: `Tool '${toolName}' is disabled or not available.`, }), shouldAddToHistory: true, shouldProcessFollowUp: false, metadata: { error: 'tool_disabled', toolName }, }; } // Check if it's a regular tool first const regularTool = this.toolHandlers.get(toolName); if (regularTool) { return await regularTool.handler(args, toolCallId, pendingPrompt); } // Check if it's an MCP tool const mcpTool = this.mcpTools.find(tool => tool.name === toolName); if (mcpTool) { try { const rawResult = await mcpTool.invoke(args); // Check if the raw result indicates an error const isError = this.detectMCPError(rawResult); // Format the MCP output using AI if formatter is available let formattedContent = rawResult; if (this.mcpFormatter) { try { const formatted = await this.mcpFormatter.formatMCPOutput(rawResult, toolName); formattedContent = JSON.stringify({ formatted: true, mcpOutput: formatted, raw: rawResult, isError: isError || formatted.type === 'error', originalArgs: args, }); } catch (formatError) { console.warn(`Failed to format MCP output for ${toolName}:`, formatError); // Fall back to simple formatting const simpleFormatted = this.mcpFormatter.formatSimple(rawResult, toolName); formattedContent = JSON.stringify({ formatted: true, mcpOutput: simpleFormatted, raw: rawResult, isError: isError || simpleFormatted.type === 'error', originalArgs: args, }); } } else { // No formatter available, but still detect errors if (isError) { formattedContent = JSON.stringify({ error: true, message: this.extractErrorMessage(rawResult), toolName, raw: rawResult, originalArgs: args, }); } } return { content: formattedContent, shouldAddToHistory: true, shouldProcessFollowUp: false, metadata: { toolName, source: 'mcp', formatted: !!this.mcpFormatter, isError: isError || (this.mcpFormatter && JSON.parse(formattedContent).mcpOutput?.type === 'error'), originalArgs: args, }, }; } catch (error) { console.error(`Error executing MCP tool ${toolName}:`, error); // Format execution errors properly if formatter is available let errorContent: string; if (this.mcpFormatter) { const errorFormatted = { type: 'error' as const, title: `Tool Execution Failed: ${toolName}`, summary: 'The MCP tool failed to execute due to an internal error.', data: { message: 'Tool execution failed', details: error instanceof Error ? error.message : 'Unknown execution error', suggestions: [ 'Check if the tool is properly configured and accessible', 'Verify the input parameters match the tool requirements', 'Try again in a few moments as this may be a temporary issue', ], }, insights: ['Tool execution errors may indicate configuration or connectivity issues'], warnings: ['This tool is currently unavailable'], actionable_items: ['Review tool configuration and try again'], metadata: { toolName, responseSize: 0, processingTime: 0, dataPoints: 0, }, }; errorContent = JSON.stringify({ formatted: true, mcpOutput: errorFormatted, raw: error instanceof Error ? error.message : 'Unknown error', isError: true, originalArgs: args, }); } else { errorContent = JSON.stringify({ error: true, message: `Error executing MCP tool: ${ error instanceof Error ? error.message : 'Unknown error' }`, toolName, originalArgs: args, }); } return { content: errorContent, shouldAddToHistory: true, shouldProcessFollowUp: false, metadata: { error: 'mcp_execution_error', toolName, isError: true }, }; } } throw new Error(`Tool ${toolName} not found`); } /** * Bind all tools to a LangChain model */ bindToModel(model: BaseChatModel, providerId: string): BaseChatModel { try { // Store for potential rebinding when MCP tools are loaded this.providerId = providerId; // Initialize MCP formatter with the model if (!this.mcpFormatter) { this.mcpFormatter = new MCPOutputFormatter(model); } const langChainTools = this.getLangChainTools(); if (langChainTools.length === 0) { console.warn('No tools configured for binding'); this.boundModel = model; return model; } // @todo: need to fix return type of this bindToModel method. // @ts-ignore return model.bindTools(langChainTools); } catch (error) { console.error(`Error binding tools to ${providerId} model:`, error); this.boundModel = model; return model; } } /** * Bind all tools to a LangChain model, waiting for MCP tools to initialize first */ async bindToModelAsync(model: BaseChatModel, providerId: string): Promise<BaseChatModel> { // Wait for MCP tools to initialize before binding await this.waitForMCPToolsInitialization(); return this.bindToModel(model, providerId); } /** * Wait for MCP tools to be initialized */ async waitForMCPToolsInitialization(): Promise<void> { if (this.mcpInitializationPromise) { await this.mcpInitializationPromise; } } /** * Check if MCP tools are initialized */ areMCPToolsInitialized(): boolean { return this.mcpToolsInitialized; } /** * Get list of all configured tool names (including MCP tools) */ getToolNames(): string[] { const regularToolNames = this.tools.map(tool => tool.config.name); const mcpToolNames = this.mcpTools.map(tool => tool.name); return [...regularToolNames, ...mcpToolNames]; } /** * Get MCP client instance for configuration management */ getMCPClient(): ElectronMCPClient { return this.mcpClient; } /** * Enable or disable an MCP tool */ async setMCPToolEnabled(toolName: string, enabled: boolean): Promise<boolean> { if (!this.mcpClient.isAvailable()) { return false; } const { serverName, toolName: actualToolName } = this.mcpClient.parseToolName(toolName); const result = await this.mcpClient.setToolEnabled(serverName, actualToolName, enabled); if (result) { // Reinitialize MCP tools to reflect the change this.mcpToolsInitialized = false; this.mcpInitializationPromise = this.initializeMCPTools(); await this.mcpInitializationPromise; // If we have a bound model, rebind with the updated tools if (this.boundModel && this.providerId) { this.boundModel = this.bindToModel(this.boundModel, this.providerId); } } return result; } /** * Check if an MCP tool is enabled */ async isMCPToolEnabled(toolName: string): Promise<boolean> { if (!this.mcpClient.isAvailable()) { return true; } return await this.mcpClient.isToolEnabled(toolName); } /** * Get MCP tool statistics */ async getMCPToolStats(toolName: string): Promise<any | null> { if (!this.mcpClient.isAvailable()) { return null; } const { serverName, toolName: actualToolName } = this.mcpClient.parseToolName(toolName); return await this.mcpClient.getToolStats(serverName, actualToolName); } /** * Get MCP tools configuration */ async getMCPToolsConfig(): Promise<{ success: boolean; config?: any; error?: string }> { if (!this.mcpClient.isAvailable()) { return { success: false, error: 'MCP client not available - not running in Electron environment', }; } return await this.mcpClient.getToolsConfig(); } /** * Update MCP tools configuration */ async updateMCPToolsConfig(config: any): Promise<boolean> { if (!this.mcpClient.isAvailable()) { return false; } const result = await this.mcpClient.updateToolsConfig(config); if (result) { // Reinitialize MCP tools to reflect the changes this.mcpToolsInitialized = false; this.mcpInitializationPromise = this.initializeMCPTools(); await this.mcpInitializationPromise; // If we have a bound model, rebind with the updated tools if (this.boundModel && this.providerId) { this.boundModel = this.bindToModel(this.boundModel, this.providerId); } } return result; } /** * Detect if an MCP tool result indicates an error */ private detectMCPError(result: string): boolean { try { const parsed = JSON.parse(result); // Check for explicit error indicators if (parsed.success === false || parsed.error === true) { return true; } // Check for error messages if (parsed.error || parsed.message?.toLowerCase().includes('error')) { return true; } // Check for schema mismatch or other common error patterns if (typeof parsed.error === 'string' && parsed.error.length > 0) { return true; } return false; } catch { // If not JSON, check for common error patterns in the string const lowerResult = result.toLowerCase(); return ( lowerResult.includes('error') || lowerResult.includes('failed') || lowerResult.includes('exception') || lowerResult.includes('invalid') || lowerResult.includes('schema mismatch') ); } } /** * Extract error message from MCP tool result */ private extractErrorMessage(result: string): string { try { const parsed = JSON.parse(result); // Try various fields that might contain the error message if (parsed.error && typeof parsed.error === 'string') { return parsed.error; } if (parsed.message) { return parsed.message; } if (parsed.details) { return parsed.details; } return 'Tool execution failed with an unspecified error'; } catch { // Not JSON, return the raw result or a cleaned version return result.length > 200 ? result.substring(0, 200) + '...' : result; } } }