UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

1,391 lines (1,197 loc) 108 kB
import { ChatAnthropic } from '@langchain/anthropic'; import { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { AIMessage, BaseMessage, ChatMessage, HumanMessage, SystemMessage, } from '@langchain/core/messages'; import { StringOutputParser } from '@langchain/core/output_parsers'; import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts'; import { RunnablePassthrough, RunnableSequence } from '@langchain/core/runnables'; import { ChatDeepSeek } from '@langchain/deepseek'; import { ChatGoogleGenerativeAI } from '@langchain/google-genai'; import { ChatMistralAI } from '@langchain/mistralai'; import { ChatOllama } from '@langchain/ollama'; import { AzureChatOpenAI, ChatOpenAI } from '@langchain/openai'; import sanitizeHtml from 'sanitize-html'; import AIManager, { Prompt } from '../ai/manager'; import { basePrompt } from '../ai/prompts'; import { MCPArgumentProcessor, UserContext } from '../components/mcpOutput/MCPArgumentProcessor'; import { inlineToolApprovalManager } from '../utils/InlineToolApprovalManager'; import { ToolCall } from '../utils/ToolApprovalManager'; import { isBuiltInTool } from '../utils/ToolConfigManager'; import { apiErrorPromptTemplate, toolFailurePromptTemplate } from './PromptTemplates'; import { KubernetesToolContext, ToolManager } from './tools'; import { RecommendedTool, ToolOrchestrator } from './tools/ToolOrchestrator'; export default class LangChainManager extends AIManager { private model: BaseChatModel; private boundModel: BaseChatModel | null = null; private providerId: string; private toolManager: ToolManager; private currentAbortController: AbortController | null = null; private promptTemplate: ChatPromptTemplate; private outputParser: StringOutputParser; private useDirectToolCalling: boolean = false; // Response cache for common queries (in-memory) private responseCache: Map<string, { response: Prompt; timestamp: number }> = new Map(); private readonly CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes private readonly MAX_CACHE_SIZE = 30; // Maximum cached responses constructor(providerId: string, config: Record<string, any>, enabledTools?: string[]) { super(); this.providerId = providerId; const enabledToolIds = enabledTools ?? []; console.log( 'AI Assistant: Initializing with enabled tools:', enabledToolIds || 'all tools enabled' ); this.toolManager = new ToolManager({ enabledToolIds }); // Only enabled tools this.model = this.createModel(providerId, config); // Initialize prompt template and output parser this.promptTemplate = this.createPromptTemplate(); this.outputParser = new StringOutputParser(); // Set up event listeners for inline tool confirmations this.setupToolConfirmationListeners(); } // Set up event listeners for tool confirmation events private setupToolConfirmationListeners() { inlineToolApprovalManager.on('request-confirmation', (data: any) => { // Add the tool confirmation message to chat history this.addToolConfirmationMessage('', data.toolConfirmation); }); inlineToolApprovalManager.on('update-confirmation', (data: any) => { // Update the specific tool confirmation message with new state (e.g., loading) this.updateToolConfirmationMessage(data.requestId, data.toolConfirmation); }); } // Helper method to extract text content from different response formats private extractTextContent(content: any): string { if (typeof content === 'string') { return content; } // Handle Gemini's array format: [{ type: 'text', text: '...' }, ...] if (Array.isArray(content)) { return content .filter(item => item && typeof item === 'object' && item.type === 'text') .map(item => item.text || '') .join(''); } // Handle object format with text property if (content && typeof content === 'object') { if (content.text) { return content.text; } if (content.content) { return this.extractTextContent(content.content); } } // Fallback: try to stringify try { return String(content || ''); } catch (error) { console.warn('Error extracting text content:', error); return ''; } } // Method to abort current request abort() { if (this.currentAbortController) { this.currentAbortController.abort(); this.currentAbortController = null; } } /** * Streaming version of userSend for better perceived performance * Yields content as it's generated by the model */ async *userSendStream(message: string): AsyncGenerator<string, Prompt, undefined> { const userPrompt: Prompt = { role: 'user', content: message }; this.history.push(userPrompt); // Check cache first const cacheKey = this.getCacheKey(message); const cached = this.responseCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) { // Cache hit - yield entire cached response at once yield cached.response.content; this.history.push(cached.response); return cached.response; } // Create abort controller for this request this.currentAbortController = new AbortController(); try { const modelToUse = this.boundModel || this.model; // Prepare messages const messages = [ new SystemMessage(this.createSystemPrompt()), ...this.prepareChatHistory(), new HumanMessage(message), ]; // Stream the response const stream = await modelToUse.stream(messages, { signal: this.currentAbortController?.signal, }); let fullContent = ''; let toolCalls: any[] = []; for await (const chunk of stream) { const content = this.extractTextContent(chunk.content); if (content) { fullContent += content; yield content; } // Collect tool calls if present (for providers that stream them) if (chunk.tool_calls && chunk.tool_calls.length > 0) { toolCalls = chunk.tool_calls; } } this.currentAbortController = null; // Create the complete response const assistantPrompt: Prompt = { role: 'assistant', content: fullContent, toolCalls: toolCalls.length > 0 ? toolCalls.map(tc => ({ type: 'function', id: tc.id, function: { name: tc.name, arguments: JSON.stringify(tc.args || {}), }, })) : undefined, }; // If there are tool calls, handle them with streaming if (toolCalls.length > 0) { this.history.push(assistantPrompt); // Execute tool calls (this is fast - 7-14ms) await this.handleToolCallsForStreaming(toolCalls, assistantPrompt); // Stream the follow-up response after tool execution for await (const chunk of this.processToolResponsesStream()) { yield chunk; } // Return the final response (already added to history by processToolResponsesStream) return this.history[this.history.length - 1]; } this.history.push(assistantPrompt); // Cache non-tool responses if (!assistantPrompt.toolCalls || assistantPrompt.toolCalls.length === 0) { this.responseCache.set(cacheKey, { response: { ...assistantPrompt }, timestamp: Date.now(), }); if (this.responseCache.size % 5 === 0) { this.cleanResponseCache(); } // Clear progress steps for non-tool responses } return assistantPrompt; } catch (error) { this.currentAbortController = null; throw error; } } // Create a reusable prompt template private createPromptTemplate(): ChatPromptTemplate { return ChatPromptTemplate.fromMessages([ ['system', '{systemPrompt}'], new MessagesPlaceholder('chatHistory'), ['human', '{input}'], ]); } // Create a simple chain for basic responses private createBasicChain() { const modelToUse = this.boundModel || this.model; return this.promptTemplate.pipe(modelToUse).pipe(this.outputParser); } /** * Extract the base URL from an Azure OpenAI endpoint. * Users may paste the full API URL (e.g., https://xxx.openai.azure.com/openai/v1/chat/completions) * but the SDK expects only the base URL (e.g., https://xxx.openai.azure.com). */ private extractAzureBaseUrl(endpoint: string): string { try { const url = new URL(endpoint); // Return only the origin (protocol + host), stripping any path return url.origin; } catch { // If URL parsing fails, fall back to stripping trailing slashes return endpoint.replace(/\/+$/, ''); } } private createModel(providerId: string, config: Record<string, any>): BaseChatModel { const sanitizeString = (value: unknown): string => typeof value === 'string' ? value.trim() : ''; const sanitizedConfig = { ...config, apiKey: sanitizeString(config.apiKey), endpoint: sanitizeString(config.endpoint), baseUrl: sanitizeString(config.baseUrl), deploymentName: sanitizeString(config.deploymentName), model: sanitizeString(config.model), }; try { switch (providerId) { case 'openai': if (!sanitizedConfig.apiKey) { throw new Error('API key is required for OpenAI'); } return new ChatOpenAI({ apiKey: sanitizedConfig.apiKey, model: sanitizedConfig.model, verbose: true, }); case 'azure': if ( !sanitizedConfig.apiKey || !sanitizedConfig.endpoint || !sanitizedConfig.deploymentName ) { throw new Error('Incomplete Azure OpenAI configuration'); } return new AzureChatOpenAI({ // Extract only the base URL (protocol + host), stripping any path // e.g. "https://xxx.openai.azure.com/openai/v1/chat/completions" → "https://xxx.openai.azure.com" azureOpenAIEndpoint: this.extractAzureBaseUrl(sanitizedConfig.endpoint), azureOpenAIApiKey: sanitizedConfig.apiKey, azureOpenAIApiDeploymentName: sanitizedConfig.deploymentName, azureOpenAIApiVersion: '2025-04-01-preview', model: sanitizedConfig.model, verbose: true, }); case 'anthropic': if (!sanitizedConfig.apiKey) { throw new Error('API key is required for Anthropic'); } return new ChatAnthropic({ apiKey: sanitizedConfig.apiKey, model: sanitizedConfig.model, verbose: true, }); case 'mistral': if (!sanitizedConfig.apiKey) { throw new Error('API key is required for Mistral AI'); } return new ChatMistralAI({ apiKey: sanitizedConfig.apiKey, model: sanitizedConfig.model, verbose: true, }); case 'gemini': { if (!sanitizedConfig.apiKey) { throw new Error('API key is required for Google Gemini'); } return new ChatGoogleGenerativeAI({ apiKey: sanitizedConfig.apiKey, model: sanitizedConfig.model, verbose: true, }); } case 'deepseek': { if (!sanitizedConfig.apiKey) { throw new Error('API key is required for DeepSeek'); } return new ChatDeepSeek({ apiKey: sanitizedConfig.apiKey, model: sanitizedConfig.model, verbose: true, }); } case 'vllm': { if (!sanitizedConfig.baseUrl) { throw new Error('Base URL is required for vLLM'); } if (!sanitizedConfig.model) { throw new Error('Model is required for vLLM'); } return new ChatOpenAI({ apiKey: sanitizedConfig.apiKey || 'sk-noop', model: sanitizedConfig.model, verbose: true, configuration: { baseURL: (url => (url.endsWith('/v1') ? url : `${url}/v1`))( sanitizedConfig.baseUrl.replace(/\/+$/, '') ), }, }); } case 'local': { if (!sanitizedConfig.baseUrl) { throw new Error('Base URL is required for local models'); } const headers: Record<string, string> = {}; if (sanitizedConfig.apiKey) { headers['Authorization'] = `Bearer ${sanitizedConfig.apiKey}`; } return new ChatOllama({ baseUrl: sanitizedConfig.baseUrl, model: sanitizedConfig.model, verbose: true, headers: Object.keys(headers).length ? headers : undefined, }); } default: throw new Error(`Unsupported provider: ${providerId}`); } } catch (error) { console.error(`Error creating model for provider ${providerId}:`, error); throw error; } } async configureTools(tools: any[], kubernetesContext: KubernetesToolContext): Promise<void> { await this.toolManager.waitForMCPToolsInitialization(); // Configure the Kubernetes context for the KubernetesTool this.toolManager.configureKubernetesContext(kubernetesContext); // Get all tools (including MCP tools) const allTools = this.toolManager.getLangChainTools(); // Bind all tools to the model for compatible providers (OpenAI, Azure, etc.) // Use the async version to ensure MCP tools are properly included this.boundModel = await this.toolManager.bindToModelAsync(this.model, this.providerId); // Enable direct tool calling for better performance if (allTools.length > 0 && this.canUseDirectToolCalling()) { this.useDirectToolCalling = true; } } /** * Check if the current provider can use direct tool calling */ private canUseDirectToolCalling(): boolean { // All major providers support direct tool calling return ['openai', 'azure', 'anthropic', 'mistral', 'gemini', 'vllm'].includes(this.providerId); } /** * Build user context from current conversation and state */ private buildUserContext(): UserContext { // Get the most recent user message const recentUserMessages = this.history.filter(prompt => prompt.role === 'user').slice(-3); // Last 3 user messages for context const userMessage = recentUserMessages.length > 0 ? recentUserMessages[recentUserMessages.length - 1].content : ''; // Build conversation history const conversationHistory = this.history .slice(-10) // Last 10 messages .map(prompt => ({ role: prompt.role, content: prompt.content, })); // Get recent tool results const lastToolResults: Record<string, any> = {}; const recentToolResponses = this.history.filter(prompt => prompt.role === 'tool').slice(-5); // Last 5 tool responses recentToolResponses.forEach(response => { if (response.name) { try { const parsed = JSON.parse(response.content); lastToolResults[response.name] = parsed; } catch { lastToolResults[response.name] = response.content; } } }); return { userMessage, conversationHistory, lastToolResults, timeContext: new Date(), }; } /** * Get description for a tool (for approval dialog) */ private getToolDescription(toolName: string, isMCPTool: boolean): string { if (isMCPTool) { // MCP tool descriptions can be more specific based on tool name if (toolName.includes('trace') || toolName.includes('profile')) { return 'Traces system calls and processes for debugging'; } else if (toolName.includes('network') || toolName.includes('socket')) { return 'Monitors network connections and traffic'; } else if (toolName.includes('top') || toolName.includes('process')) { return 'Shows running processes and resource usage'; } else if (toolName.includes('exec') || toolName.includes('run')) { return 'Executes commands in containers'; } else { return `Inspektor Gadget debugging tool: ${toolName}`; } } else { // Regular Kubernetes tools if (toolName.includes('kubernetes')) { return 'Executes Kubernetes API operations'; } return `Kubernetes management tool: ${toolName}`; } } /** * Add a tool confirmation message to the history */ public addToolConfirmationMessage( content: string, toolConfirmation: any, updateHistoryCallback?: () => void ): void { const confirmationPrompt: Prompt = { role: 'assistant', content: content, toolConfirmation: toolConfirmation, isDisplayOnly: true, // Don't send to LLM requestId: toolConfirmation.requestId, // Add requestId for tracking }; this.history.push(confirmationPrompt); // Call the update callback if provided to trigger UI re-render if (updateHistoryCallback) { updateHistoryCallback(); } } public updateToolConfirmationMessage(requestId: string, updatedToolConfirmation: any): void { // Find the message with matching requestId const messageIndex = this.history.findIndex( prompt => prompt.requestId === requestId && prompt.toolConfirmation ); if (messageIndex !== -1) { // Update the tool confirmation in the existing message this.history[messageIndex] = { ...this.history[messageIndex], toolConfirmation: updatedToolConfirmation, }; // Use the inline tool approval manager to emit update event inlineToolApprovalManager.emit('message-updated', { requestId, updatedToolConfirmation }); } else { console.warn('⚠️ LangChainManager: Could not find tool confirmation message to update'); } } /** * Refresh MCP tools when configuration changes. * Re-fetches tools from the Electron backend and rebinds the model. */ public async refreshMCPTools(): Promise<void> { await this.toolManager.refreshMCPTools(); // Rebind the model to update tool bindings if (this.model) { this.boundModel = await this.toolManager.bindToModelAsync(this.model, this.providerId); } } /** * Clear the most recent tool confirmation message from history * Called after tool execution completes to hide the loading dialog */ public clearToolConfirmation(): void { // Find the most recent tool confirmation message (from the end) for (let i = this.history.length - 1; i >= 0; i--) { if (this.history[i].toolConfirmation) { // Remove this message from history this.history.splice(i, 1); return; } } } // Helper method to prepare chat history for prompt template private prepareChatHistory(): BaseMessage[] { // Filter out system messages and display-only messages to avoid conflicts with the system message in the prompt template const filteredHistory = this.history.filter( prompt => prompt.role !== 'system' && !prompt.isDisplayOnly ); return this.convertPromptsToMessages(filteredHistory); } // Helper method to create system prompt with context private createSystemPrompt(): string { const availableTools = this.toolManager.getToolNames(); const hasKubernetesTool = availableTools.includes('kubernetes_api_request'); let systemPromptContent; if (!hasKubernetesTool) { // Modified prompt when Kubernetes tools are disabled systemPromptContent = `You are an AI assistant for the Headlamp Kubernetes UI. You help users understand and manage their Kubernetes resources through a web interface. IMPORTANT: Kubernetes API access tools are currently DISABLED in your settings. CRITICAL LIMITATIONS: - You CANNOT access live cluster data (pods, deployments, services, etc.) - You CANNOT fetch current resource information from the cluster - You CANNOT retrieve logs, events, or real-time status information - DO NOT promise to fetch, retrieve, or access any live cluster data WHAT YOU CAN DO: - Provide general Kubernetes guidance and explanations - Generate YAML examples for resource creation - Explain Kubernetes concepts and best practices - Help troubleshoot based on information the user provides - Direct users to enable tools if they need live data access WHEN USERS ASK FOR LIVE DATA: - Clearly explain that you cannot access live cluster information - Inform them that Kubernetes API tools are disabled - Provide instructions to enable tools in AI Assistant settings - Offer to help with general guidance instead YAML FORMATTING: When providing Kubernetes YAML examples, use this format: ## [Resource Type] Example: Brief explanation of the resource. \`\`\`yaml apiVersion: [version] kind: [kind] metadata: name: [name] namespace: default spec: # Configuration here \`\`\` Note: The YAML you provide will be displayed in a preview editor with an "Edit" button that allows users to modify the configuration before applying it to their cluster. RESPONSES: - Format responses in markdown - Be honest about limitations - Always suggest enabling tools for live data access - Provide helpful general guidance when possible - If asked non-Kubernetes questions, politely redirect and include a light Kubernetes joke`; } else { // Original prompt when tools are available systemPromptContent = basePrompt; } // Add MCP tool guidance if we have MCP tools available const mcpTools = this.toolManager.getMCPTools(); if (mcpTools.length > 0) { systemPromptContent += ` MCP TOOLS AVAILABLE: You have access to the following MCP (Model Context Protocol) tools: ${mcpTools.map(tool => `- ${tool.name}: ${tool.description || 'No description'}`).join('\n')} CRITICAL - WHEN TO USE MCP TOOLS: - For ANY user question that matches an available MCP tool → USE IT immediately - Don't overthink - if there's a tool for it, use it! - Examples: * User asks about time → Use time-related tools (get_current_time, convert_time, etc.) * User asks to search → Use search tools * User asks about GitHub → Use GitHub tools * User asks for debugging/monitoring → Use debugging tools TOOL USAGE GUIDANCE: PARAMETER HANDLING: - When calling MCP tools, read the tool schema carefully and provide the required parameters - Extract parameters from the user's request (e.g., timezone, location, dates, names, etc.) - Use context-aware defaults when parameters aren't specified - If a parameter is unclear, make a reasonable assumption or use the tool's default RESPONSE FORMATTING: When MCP tools return data: 1. **Present results clearly** - Format the response in an easy-to-read way 2. **Add context** - Explain what the results mean, don't just show raw data 3. **Be concise** - Summarize when appropriate, don't overwhelm with details 4. **Use appropriate formatting** - Tables for structured data, lists for items, code blocks for technical output Examples of good MCP tool responses: - Time query → "The current time is 3:45 PM EST (8:45 PM UTC)" - Search query → "Found 5 results: [formatted list with key details]" - Data query → "Here are the top 3 results: [table or list with relevant information]" - Monitoring query → "Current status: [key metrics and insights]" ALWAYS interpret results meaningfully - don't just show raw JSON or data dumps.`; } if (this.currentContext) { systemPromptContent += `\n\nCURRENT CONTEXT:\n${this.currentContext}`; } return systemPromptContent; } // Helper method to create system prompt specifically for tool response processing private createToolResponseSystemPrompt(): string { const baseSystemPrompt = this.createSystemPrompt(); // Add specific instructions for tool response processing const toolResponseInstructions = ` IMPORTANT: You have just received tool execution results. Your task is to: 1. ANALYZE the tool results and provide a clear, helpful response to the user 2. SUMMARIZE the information in a user-friendly way 3. DO NOT call additional tools unless the user explicitly requests more actions 4. FOCUS on explaining what the tools found or accomplished 5. If the tool results show data (like file listings, directories, etc.), present them in a clear, formatted way The user is waiting for you to explain what the tools discovered. Provide a direct, informative response based on the tool results.`; return baseSystemPrompt + toolResponseInstructions; } private convertPromptsToMessages(prompts: Prompt[]): BaseMessage[] { return prompts.map(prompt => { switch (prompt.role) { case 'system': return new SystemMessage(prompt.content); case 'user': return new HumanMessage(prompt.content); case 'assistant': return new AIMessage({ content: prompt.content, additional_kwargs: {}, }); case 'tool': return new AIMessage(`Tool Response (${prompt.toolCallId}): ${prompt.content}`); default: return new ChatMessage(prompt.content, prompt.role); } }); } /** * Generate cache key for a message (simple hash) */ private getCacheKey(message: string): string { // Create a deterministic hash from message + recent context const contextStr = this.history .slice(-3) // Include last 3 messages for context .map(p => `${p.role}:${p.content?.substring(0, 100)}`) .join('|'); const fullStr = `${contextStr}|${message}`; let hash = 0; for (let i = 0; i < fullStr.length; i++) { const char = fullStr.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32-bit integer } return `msg_${hash}_${message.length}`; } /** * Clean expired cache entries */ private cleanResponseCache(): void { const now = Date.now(); const expiredKeys: string[] = []; for (const [key, value] of this.responseCache.entries()) { if (now - value.timestamp > this.CACHE_TTL_MS) { expiredKeys.push(key); } } expiredKeys.forEach(key => this.responseCache.delete(key)); // If cache is still too large, remove oldest entries if (this.responseCache.size > this.MAX_CACHE_SIZE) { const entries = Array.from(this.responseCache.entries()).sort( (a, b) => a[1].timestamp - b[1].timestamp ); const toRemove = entries.slice(0, this.responseCache.size - this.MAX_CACHE_SIZE); toRemove.forEach(([key]) => this.responseCache.delete(key)); } } /** * ============================================================================= * HISTORY MANAGEMENT PATTERN (NEW - Simplified) * ============================================================================= * * PROBLEM: History was being pushed in 28+ different places, causing: * - Confusion about who manages history * - Easy to forget to push or push twice * - Hard to trace history state * * NEW PATTERN: * 1. Only userSend() and userSendStream() manage history (main entry points) * 2. All internal methods return Prompt WITHOUT pushing to history * 3. Use addToHistory() helper for explicit history updates * * MIGRATION GUIDE: * - OLD: method() { const p = {...}; this.history.push(p); return p; } * - NEW: method() { return {...}; } // userSend will handle history * * EXCEPTIONS (when to push directly): * - Tool confirmation messages (temporary UI state) * - Error messages that need immediate display * - Multi-step operations where intermediate state matters * * ============================================================================= */ /** * Centralized method to add message to history * Use this instead of direct this.history.push() */ private addToHistory(prompt: Prompt): Prompt { this.history.push(prompt); return prompt; } /** * Helper to create and add user message to history */ private addUserMessage(content: string): Prompt { return this.addToHistory({ role: 'user', content }); } /** * Helper to create and add assistant message to history */ private addAssistantMessage(content: string, additional?: Partial<Prompt>): Prompt { return this.addToHistory({ role: 'assistant', content, ...additional }); } async userSend(message: string): Promise<Prompt> { // Clear previous progress steps const userPrompt: Prompt = { role: 'user', content: message }; this.history.push(userPrompt); // Check cache first for non-tool-dependent queries const cacheKey = this.getCacheKey(message); const cached = this.responseCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) { // Cache hit - return cached response this.history.push(cached.response); return cached.response; } // Create abort controller for this request this.currentAbortController = new AbortController(); try { // FIRST: Try to orchestrate multiple relevant tools before making LLM call // This enables multi-tool execution for comprehensive responses const recommendedTools = await this.orchestrateToolsForRequest(message); if (recommendedTools && recommendedTools.length > 0) { // Execute multiple tools together for a comprehensive response return await this.handleMultipleToolExecution(message, recommendedTools); } // FALLBACK: Use direct tool calling if enabled if (this.useDirectToolCalling) { return await this.handleDirectToolCallingRequest(message); } const modelToUse = this.boundModel || this.model; // For local models, use simplified approach if (this.providerId === 'local') { return await this.handleLocalModelRequest(message, modelToUse); } // Use chain-based approach for other models const response = await this.handleChainBasedRequest(message, modelToUse); // Cache successful non-tool responses if (!response.toolCalls || response.toolCalls.length === 0) { this.responseCache.set(cacheKey, { response: { ...response }, timestamp: Date.now(), }); // Clean cache periodically if (this.responseCache.size % 5 === 0) { this.cleanResponseCache(); } } return response; } catch (error) { return this.handleUserSendError(error); } } // Handle requests using direct tool calling (single LLM call) private async handleDirectToolCallingRequest(message: string): Promise<Prompt> { try { const modelToUse = this.boundModel || this.model; // Prepare input for the model with tools const chainInput = { systemPrompt: this.createSystemPrompt(), chatHistory: this.prepareChatHistory(), input: message, }; // Convert chain input to messages const messages = [ new SystemMessage(chainInput.systemPrompt), ...chainInput.chatHistory, new HumanMessage(chainInput.input), ]; // Single LLM call with tool capabilities const response = await modelToUse.invoke(messages, { signal: this.currentAbortController?.signal, }); this.currentAbortController = null; // Handle tool calls if present if (response.tool_calls?.length) { return await this.handleToolCalls(response); } else { // Handle regular response const assistantPrompt: Prompt = { role: 'assistant', content: this.extractTextContent(response.content), }; this.history.push(assistantPrompt); // Clear progress steps for non-tool responses return assistantPrompt; } } catch (error) { console.error('Error in direct tool calling request:', error); // If direct tool calling fails, fall back to regular approach this.useDirectToolCalling = false; const modelToUse = this.boundModel || this.model; return await this.handleChainBasedRequest(message, modelToUse); } } // Handle requests for local models (simplified) private async handleLocalModelRequest(message: string, model: BaseChatModel): Promise<Prompt> { const systemMessage = new SystemMessage(this.createSystemPrompt()); const userMessage = new HumanMessage(message); const messages = [systemMessage, userMessage]; const response = await model.invoke(messages, { signal: this.currentAbortController.signal, }); this.currentAbortController = null; const assistantPrompt: Prompt = { role: 'assistant', content: this.extractTextContent(response.content), }; this.history.push(assistantPrompt); // Clear progress steps for local model responses return assistantPrompt; } // Handle requests using chain-based approach private async handleChainBasedRequest(message: string, model: BaseChatModel): Promise<Prompt> { // Prepare input for the chain const chainInput = { systemPrompt: this.createSystemPrompt(), chatHistory: this.prepareChatHistory(), input: message, }; // For models with tools, use direct invocation to handle tool calls if (this.boundModel) { return await this.handleToolEnabledRequest(chainInput, model); } // For simple requests without tools, use the chain const chain = this.createBasicChain(); const response = await chain.invoke(chainInput, { signal: this.currentAbortController.signal, }); this.currentAbortController = null; const assistantPrompt: Prompt = { role: 'assistant', content: this.extractTextContent(response), }; this.history.push(assistantPrompt); // Clear progress steps for chain-based responses return assistantPrompt; } // Handle requests for models with tools enabled private async handleToolEnabledRequest(chainInput: any, model: BaseChatModel): Promise<Prompt> { // Convert chain input to messages for tool-enabled models const messages = [ new SystemMessage(chainInput.systemPrompt), ...chainInput.chatHistory, new HumanMessage(chainInput.input), ]; // IMPORTANT: Use the boundModel (which has tools) instead of the original model const modelToUse = this.boundModel || model; const response = await modelToUse.invoke(messages, { signal: this.currentAbortController.signal, }); this.currentAbortController = null; // Handle tool calls if present if (response.tool_calls?.length) { return await this.handleToolCalls(response); } // Handle regular response const assistantPrompt: Prompt = { role: 'assistant', content: this.extractTextContent(response.content), }; this.history.push(assistantPrompt); // Clear progress steps after generating response without tool calls return assistantPrompt; } /** * Analyze user request to determine ALL relevant tools that should be executed together. * Only triggers when MCP tools are available (built-in K8s tool uses direct tool calling). * This avoids an extra LLM call for every message when only K8s tool is present. */ private async orchestrateToolsForRequest(userMessage: string): Promise<RecommendedTool[] | null> { try { // Only orchestrate when MCP tools are available // The built-in K8s tool works fine with direct tool calling and doesn't need orchestration const mcpTools = this.toolManager.getMCPTools(); if (mcpTools.length === 0) { return null; } // Quick pre-check: skip orchestration for very short or clearly conversational messages // to avoid an expensive LLM call on every message const trimmedMessage = userMessage.trim().toLowerCase(); if (trimmedMessage.length < 10) { return null; } const conversationalPatterns = [ /^(hi|hello|hey|thanks|thank you|ok|okay|yes|no|sure|great|cool|bye|goodbye)\b/i, /^(what can you do|who are you|help me)\b/i, ]; if (conversationalPatterns.some(p => p.test(trimmedMessage))) { return null; } const enabledToolIds = this.toolManager.getToolNames(); if (enabledToolIds.length === 0) { return null; } // IMPORTANT: Only pass MCP tools to the orchestrator, NOT built-in tools. // Built-in tools like kubernetes_api_request work much better with the LLM's // native tool calling where the model generates proper URLs with actual values. // The orchestrator generates template URLs like /api/v1/namespaces/{namespace} // which don't work as real API requests. const availableTools = mcpTools.map(tool => ({ name: tool.name, description: tool.description || '', })); // If no MCP tools to orchestrate, skip if (availableTools.length === 0) { return null; } // Use ToolOrchestrator to analyze and recommend tools const recommendation = await ToolOrchestrator.analyzeAndRecommendTools( userMessage, availableTools, this.boundModel || this.model, this.history.slice(-10), // Pass last 10 messages for context this.currentAbortController?.signal ); // Only use orchestration when multiple tools are recommended. // Single tool recommendations should use the normal direct tool calling flow // which produces better arguments (especially for kubernetes_api_request). if (recommendation.shouldExecuteAll && recommendation.tools.length >= 2) { return recommendation.tools; } // Single or zero tools — let the normal LLM tool-calling flow handle it return null; } catch (error) { return null; } } /** * Execute multiple tools together based on orchestration recommendation * Requests approval before executing each batch of tools * Collects results and provides a comprehensive response */ private async handleMultipleToolExecution( userMessage: string, recommendedTools: RecommendedTool[] ): Promise<Prompt> { try { // Prepare tools with enhanced arguments (using same pattern as regular tool execution) const toolsForApproval = await Promise.all( recommendedTools.map(async tool => { const isMCPTool = !isBuiltInTool(tool.name); let processedArguments = tool.arguments || {}; // Use AI to enhance arguments for MCP tools (same as regular flow) if (isMCPTool) { try { const toolSchema = await MCPArgumentProcessor.getToolSchema(tool.name); if (toolSchema) { // Build user context from current conversation const userContext = this.buildUserContext(); // Store original arguments for comparison const originalArguments = { ...processedArguments }; // Use AI to intelligently prepare arguments processedArguments = await this.enhanceArgumentsWithAI( tool.name, toolSchema, userContext, processedArguments ); // Mark which fields were enhanced by LLM for UI display processedArguments._llmEnhanced = { enhanced: true, originalArgs: originalArguments, enhancedFields: this.identifyEnhancedFields( originalArguments, processedArguments ), }; } } catch (error) { console.warn(`Failed to enhance arguments for ${tool.name}:`, error); // Fall back to original arguments } } return { id: `orchestrated-${tool.name}-${Date.now()}`, name: tool.name, description: tool.description, arguments: processedArguments, type: isMCPTool ? 'mcp' : 'regular', priority: tool.priority, reason: tool.reason, }; }) ); const approvedToolIds: string[] = []; // Separate built-in tools from MCP tools (same pattern as handleToolCalls) const builtInToolsForApproval = toolsForApproval.filter(tool => isBuiltInTool(tool.name)); const mcpToolsForApproval = toolsForApproval.filter(tool => !isBuiltInTool(tool.name)); // Auto-approve all built-in tools (no user interaction needed) approvedToolIds.push(...builtInToolsForApproval.map(tool => tool.id)); // Only request approval for MCP tools if (mcpToolsForApproval.length > 0) { try { const approvedMCPToolIds = await inlineToolApprovalManager.requestApproval( mcpToolsForApproval, this ); approvedToolIds.push(...approvedMCPToolIds); } catch (approvalError) { // If user denied MCP tools but built-in tools were approved, continue with built-in only if (builtInToolsForApproval.length === 0) { const denialPrompt: Prompt = { role: 'assistant', content: "I understand. I won't execute those tools. Feel free to ask me something else.", }; this.history.push(denialPrompt); return denialPrompt; } // Otherwise continue with only built-in tools } } // Filter approved tools and get their processed arguments // Match by checking if the approved ID contains the tool name as a suffix const approvedTools = recommendedTools.filter(tool => { const expectedIdPrefix = `orchestrated-${tool.name}-`; return approvedToolIds.some(id => id === tool.name || id.startsWith(expectedIdPrefix)); }); // Group tools by execution strategy (parallel vs sequential) const { parallel, sequential } = ToolOrchestrator.groupToolsByExecutionStrategy(approvedTools); // Execute parallel tools first const toolResults: Record<string, any> = {}; const toolExecutionIds: Record<string, string> = {}; if (parallel.length > 0) { const parallelPromises = parallel.map(async tool => { const approvalData = toolsForApproval.find(t => t.name === tool.name); const toolCallId = approvalData?.id || `orchestrated-${tool.name}-${Date.now()}`; toolExecutionIds[tool.name] = toolCallId; try { const result = await this.toolManager.executeTool( tool.name, approvalData?.arguments || tool.arguments || {} ); toolResults[tool.name] = result; return result; } catch (error) { toolResults[tool.name] = { error: true, message: `Failed to execute ${tool.name}: ${error?.message || 'Unknown error'}`, }; } }); try { await Promise.all(parallelPromises); } catch (error) { console.error('Error executing parallel tools:', error); // Continue with sequential tools even if some parallel tools fail } } // Execute sequential tools one by one for (const tool of sequential) { const approvalData = toolsForApproval.find(t => t.name === tool.name); const toolCallId = approvalData?.id || `orchestrated-${tool.name}-${Date.now()}`; toolExecutionIds[tool.name] = toolCallId; try { const result = await this.toolManager.executeTool( tool.name, approvalData?.arguments || tool.arguments || {} ); toolResults[tool.name] = result; } catch (error) { toolResults[tool.name] = { error: true, message: `Failed to execute ${tool.name}: ${error?.message || 'Unknown error'}`, }; } } // DO NOT add tool results to history - we'll let the LLM response handle rendering // This prevents duplicate JSON rendering in the UI // The results are kept in memory for the response generation below // Use LLM to generate a comprehensive response based on tool results // Pass the FULL tool results with all data for the LLM to analyze const response = await this.generateResponseFromToolResults(userMessage, toolResults); // Clear the tool confirmation message from history after execution completes // This ensures the "Executing tools..." loading dialog is hidden this.clearToolConfirmation(); return response; } catch (error) { // Fall back to regular LLM response const errorPrompt: Prompt = { role: 'assistant', content: `I encountered an error coordinating multiple tools: ${ error?.message || 'Unknown error' }. Please try your request again or ask a simpler question.`, error: true, }; this.history.push(errorPrompt); return errorPrompt; } } /** * Execute a single tool and return its result */ private async executeSingleTool(tool: RecommendedTool): Promise<any> { try { // Create a tool call object compatible with existing tool execution logic const toolCall = { id: `tool-${tool.name}-${Date.now()}`, function: { name: tool.name, arguments: JSON.stringify(tool.arguments || {}), }, }; // Use the existing tool manager to execute const toolResponse = await this.toolManager.executeTool( tool.name, tool.arguments || {}, toolCall.id, { role: 'assistant', content: '' } // Placeholder prompt ); return { success: true, toolName: tool.name, data: JSON.parse(toolResponse.content), }; } catch (error) { return { success: false, toolName: tool.name, error: true, message: error?.message || 'Unknown error occurred', }; } } /** * Aggregate results from multiple tools into a structured format */ private aggregateToolResults(results: Record<string, any>): string { let aggregation = '## Tool Execution Results\n\n'; for (const [toolName, result] of Object.entries(results)) { aggregation += `### ${toolName}\n`; if (result.error) { aggregation += `**Error**: ${result.message}\n\n`; } else if (result.success) { aggregation += `**Status**: Successfully executed\n`; aggregation += `**Data**:\n\`\`\`json\n${JSON.stringify(result.data, null, 2)}\n\`\`\`\n\n`; } else { aggregation += `**Result**:\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\`\n\n`; } } return aggregation; } /** * Generate a comprehensive response based on aggregated tool results * Now accepts full tool results object to provide complete context to LLM */ private async generateResponseFromToolResults( userMessage: string, toolResults: Record<string, any> ): Promise<Prompt> { try { // Use the UNBOUND model to force text output, not more tool calls const model = this.model; // Format tool results with full data for LLM analysis let formattedResults = '## Tool Execution Results\n\n'; for (const [toolName, result] of Object.entries(toolResults)) { formattedResults += `### ${toolName}\n`; if (result.error || result.isError) { formattedResults += `**Status**: ❌ Error\n`; formattedResults += `**Error Message**: ${result.message || 'Unknown error'}\n\n`; } else { formattedResults += `**Status**: ✅ Success\n`; // Include the raw data (this is what was missing before!) if (result.data) { formattedResults += `**Data**:\n`; formattedResults += '```json\n'; formattedResults += JSON.stringify(result.data, null, 2); formattedResults += '\n```\n\n'; } else if (result.content) { // Handle case where tool returns content directly formattedResults += `**Data**:\n${result.content}\n\n`; } else { formattedResults += `**Result**:\n`; formattedResults += '```json\n'; formattedResults += JSON.stringify(result, null, 2); formattedResults += '\n```\n\n'; } } } const systemPrompt = `You are an AI assistant analyzing tool results and providing helpful responses. Based on tool data AND the user's original request, generate a comprehensive response. CRITICAL GUIDELINES: 1. If user asked to LEARN/UNDERSTAND a concept, START with educational explanation before showing data 2. If user asked "teach me about X", explain the concept clearly with examples 3. Analyze and discuss ACTUAL data from tools - reference specific values 4. Synthesize information to provide a complete picture 5. Use formatting (lists, sections) for readability 6. Explain what data means and why it matters 7. Provide actionable next steps 8. NEVER just show raw data tables - always add context and explanation Examples: - "teach me about ingress" → Explain what ingress is, how it works, THEN show their resources with context - "show me pods" → Present pod data with status summary and insights - "what is a deployment" → Explain deployment concept (don't need tool data for this)`; const userPrompt = `Original user request: "${userMessage}" Tool Results: ${formattedResults} Please