@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
1,391 lines (1,197 loc) • 108 kB
text/typescript
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