UNPKG

converse-mcp-server

Version:

Converse MCP Server - Converse with other LLMs with chat and consensus tools

569 lines (501 loc) 19.9 kB
/** * Consensus Tool * * Multi-provider parallel execution with response aggregation. * Calls all available providers simultaneously and aggregates responses. */ import { createToolResponse, createToolError } from './index.js'; import { processUnifiedContext, createFileContext } from '../utils/contextProcessor.js'; import { generateContinuationId, addMessageToHistory } from '../continuationStore.js'; import { debugLog, debugError } from '../utils/console.js'; import { createLogger } from '../utils/logger.js'; import { CONSENSUS_PROMPT } from '../systemPrompts.js'; import { applyTokenLimit, getTokenLimit } from '../utils/tokenLimiter.js'; import { validateAllPaths } from '../utils/fileValidator.js'; const logger = createLogger('consensus'); /** * Consensus tool implementation * @param {object} args - Tool arguments * @param {object} dependencies - Injected dependencies (config, providers, continuationStore) * @returns {object} MCP tool response */ export async function consensusTool(args, dependencies) { try { const { config, providers, continuationStore, contextProcessor } = dependencies; // Validate required arguments if (!args.prompt || typeof args.prompt !== 'string') { return createToolError('Prompt is required and must be a string'); } if (!args.models || !Array.isArray(args.models) || args.models.length === 0) { return createToolError('Models array is required and must contain at least one model'); } // Extract and validate arguments const { prompt, models, files = [], images = [], continuation_id, enable_cross_feedback = true, cross_feedback_prompt, temperature = 0.2, reasoning_effort = 'medium', use_websearch = false } = args; let conversationHistory = []; let continuationId = continuation_id; // Load existing conversation if continuation_id provided if (continuationId) { try { const existingState = await continuationStore.get(continuationId); if (existingState) { conversationHistory = existingState.messages || []; } else { // Invalid continuation ID - start fresh continuationId = generateContinuationId(); } } catch (error) { logger.error('Error loading conversation', { error }); // Continue with fresh conversation on error continuationId = generateContinuationId(); } } else { // Generate new continuation ID for new conversation continuationId = generateContinuationId(); } // Validate file paths before processing if (files.length > 0 || images.length > 0) { const validation = await validateAllPaths({ files, images }); if (!validation.valid) { logger.error('File validation failed', { errors: validation.errors }); return validation.errorResponse; } } // Process context (files and images) let contextMessage = null; if (files.length > 0 || images.length > 0) { try { const contextRequest = { files: Array.isArray(files) ? files : [], images: Array.isArray(images) ? images : [] }; const contextResult = await contextProcessor.processUnifiedContext(contextRequest); // Create context message from files and images const allProcessedFiles = [...contextResult.files, ...contextResult.images]; if (allProcessedFiles.length > 0) { contextMessage = createFileContext(allProcessedFiles, { includeMetadata: true, includeErrors: true }); } } catch (error) { logger.error('Error processing context', { error }); // Continue without context if processing fails } } // Build message array for providers const messages = []; // Add system prompt messages.push({ role: 'system', content: CONSENSUS_PROMPT }); // Add conversation history messages.push(...conversationHistory); // Add user prompt with context const userMessage = { role: 'user', content: prompt // default to simple string content }; // If we have context (files/images), create complex content array if (contextMessage && contextMessage.content) { // Create complex content array userMessage.content = [ ...contextMessage.content, // Include all file/image parts { type: 'text', text: prompt } // Add the user prompt as text ]; } messages.push(userMessage); // Resolve model specifications to provider calls const providerCalls = []; const failedModels = []; for (const modelSpec of models) { if (!modelSpec.model || typeof modelSpec.model !== 'string') { failedModels.push({ model: modelSpec.model || 'unknown', error: 'Invalid model specification', status: 'failed' }); continue; } const modelName = modelSpec.model; const providerName = mapModelToProvider(modelName, providers); const resolvedModelName = resolveAutoModel(modelName, providerName); const provider = providers[providerName]; if (!provider) { failedModels.push({ model: modelName, provider: providerName, error: `Provider not found: ${providerName}`, status: 'failed' }); continue; } if (!provider.isAvailable(config)) { failedModels.push({ model: modelName, provider: providerName, error: `Provider ${providerName} not available (check API key)`, status: 'failed' }); continue; } providerCalls.push({ model: modelName, // Keep original model name for display provider: providerName, providerInstance: provider, options: { temperature, reasoning_effort, use_websearch, config, ...modelSpec, // Allow model-specific overrides model: resolvedModelName // Use resolved model name for API call (must be after spread) } }); } if (providerCalls.length === 0) { return createToolError( `No valid providers available for the specified models. Failed models: ${failedModels.map(f => f.model).join(', ')}` ); } // Phase 1: Initial parallel provider calls logger.debug('Calling providers in parallel', { data: { providerCount: providerCalls.length } }); const initialResults = await Promise.allSettled( providerCalls.map(async (call) => { try { const response = await call.providerInstance.invoke(messages, call.options); return { model: call.model, provider: call.provider, status: 'success', response: response.content, metadata: response.metadata || {} }; } catch (error) { return { model: call.model, provider: call.provider, status: 'failed', error: error.message, metadata: {} }; } }) ); // Process initial results const initialPhase = { successful: [], failed: [] }; initialResults.forEach((result, index) => { if (result.status === 'fulfilled') { if (result.value.status === 'success') { initialPhase.successful.push(result.value); } else { initialPhase.failed.push(result.value); } } else { initialPhase.failed.push({ model: providerCalls[index].model, provider: providerCalls[index].provider, status: 'failed', error: result.reason.message || 'Unknown error', metadata: {} }); } }); // Add pre-failed models to failed list initialPhase.failed.push(...failedModels); let refinedPhase = null; // Phase 2: Cross-feedback (if enabled and we have multiple successful responses) if (enable_cross_feedback && initialPhase.successful.length > 1) { logger.debug('Running cross-feedback phase', { data: { responseCount: initialPhase.successful.length } }); // Create cross-feedback prompt const feedbackPrompt = cross_feedback_prompt || `Based on the other AI responses below, please refine your answer to the original question. Consider different perspectives and provide your final response: Original Question: ${prompt} Other AI Responses: ${initialPhase.successful.map((r, i) => `${i + 1}. ${r.model}: ${r.response}`).join('\n\n')} Please provide your refined response:`; // Build feedback messages - need to add the assistant's initial response first const feedbackMessages = [...messages]; // Run refinement calls in parallel const refinementResults = await Promise.allSettled( initialPhase.successful.map(async (initialResult) => { try { const call = providerCalls.find(c => c.model === initialResult.model); // Build model-specific feedback messages with the assistant's initial response const modelFeedbackMessages = [...messages]; // Add the assistant's initial response modelFeedbackMessages.push({ role: 'assistant', content: initialResult.response }); // Now add the feedback prompt modelFeedbackMessages.push({ role: 'user', content: feedbackPrompt }); const response = await call.providerInstance.invoke(modelFeedbackMessages, call.options); return { ...initialResult, refined_response: response.content, refined_metadata: response.metadata || {}, initial_response: initialResult.response, status: 'success' }; } catch (error) { return { ...initialResult, refined_response: null, refined_error: error.message, initial_response: initialResult.response, status: 'partial' // Had initial success but refinement failed }; } }) ); // Process refinement results refinedPhase = []; refinementResults.forEach((result) => { if (result.status === 'fulfilled') { refinedPhase.push(result.value); } else { // This shouldn't happen with our error handling, but just in case const originalResult = result.value || {}; refinedPhase.push({ ...originalResult, refined_response: null, refined_error: 'Refinement phase failed unexpectedly', status: 'partial' }); } }); } // Save conversation state try { const consensusMessage = { role: 'assistant', content: `Consensus completed with ${initialPhase.successful.length} successful responses` + (refinedPhase ? ` and ${refinedPhase.filter(r => r.status === 'success').length} refined responses` : '') }; const conversationState = { messages: [...messages, consensusMessage], type: 'consensus', lastUpdated: Date.now(), consensusData: { modelsRequested: models.length, providersSuccessful: initialPhase.successful.length, providersFailed: initialPhase.failed.length, crossFeedbackEnabled: enable_cross_feedback } }; await continuationStore.set(continuationId, conversationState); } catch (error) { logger.error('Error saving consensus conversation', { error }); // Continue even if save fails } // Build result object keeping backward compatibility but removing rawResponse const result = { status: 'consensus_complete', models_consulted: models.length, successful_initial_responses: initialPhase.successful.length, failed_responses: initialPhase.failed.length, refined_responses: refinedPhase ? refinedPhase.filter(r => r.status === 'success').length : 0, phases: { initial: initialPhase.successful, ...(refinedPhase !== null && { refined: refinedPhase }), failed: initialPhase.failed }, continuation: { id: continuationId, messageCount: messages.length + 1 }, settings: { enable_cross_feedback, temperature, models_requested: models.map(m => m.model) } }; // Apply token limiting to the final response const tokenLimit = getTokenLimit(config); const resultStr = JSON.stringify(result, null, 2); const limitedResult = applyTokenLimit(resultStr, tokenLimit); // Parse the limited result back to object format to preserve structure let finalResult; try { finalResult = JSON.parse(limitedResult.content); } catch (e) { // Fallback if parsing fails - return original result finalResult = result; } return createToolResponse(finalResult); } catch (error) { logger.error('Consensus tool error', { error }); return createToolError('Consensus tool failed', error); } } /** * Map model name to provider name (same as chat tool) * @param {string} model - Model name * @returns {string} Provider name */ /** * Resolve "auto" model to default model for the provider */ function resolveAutoModel(model, providerName) { if (model.toLowerCase() !== 'auto') { return model; } const defaults = { 'openai': 'o3', 'xai': 'grok-4-0709', 'google': 'gemini-2.5-pro', 'anthropic': 'claude-sonnet-4-20250514', 'mistral': 'magistral-medium-2506', 'deepseek': 'deepseek-reasoner', 'openrouter': 'qwen/qwen3-coder' }; return defaults[providerName] || 'o3'; } function mapModelToProvider(model, providers) { const modelLower = model.toLowerCase(); // Handle "auto" - default to OpenAI if (modelLower === 'auto') { return 'openai'; } // Check OpenRouter-specific patterns first if (modelLower === 'openrouter auto' || modelLower === 'auto router' || modelLower === 'auto-router' || modelLower === 'openrouter-auto') { return 'openrouter'; } // If model contains "/", check if native provider supports it if (modelLower.includes('/')) { // Check each provider to see if they have this exact model for (const [providerName, provider] of Object.entries(providers)) { if (provider && provider.getModelConfig) { const modelConfig = provider.getModelConfig(model); if (modelConfig && !modelConfig.isDynamic && !modelConfig.needsApiUpdate) { // Model exists in this provider's static list return providerName; } } } // No native provider has this model, route to OpenRouter return 'openrouter'; } // For non-slash models, use keyword matching as before // OpenAI models if (modelLower.includes('gpt') || modelLower.includes('o1') || modelLower.includes('o3') || modelLower.includes('o4')) { return 'openai'; } // XAI models if (modelLower.includes('grok')) { return 'xai'; } // Google models if (modelLower.includes('gemini') || modelLower.includes('flash') || modelLower.includes('pro') || modelLower === 'google') { return 'google'; } // Anthropic models if (modelLower.includes('claude') || modelLower.includes('opus') || modelLower.includes('sonnet') || modelLower.includes('haiku')) { return 'anthropic'; } // Mistral models if (modelLower.includes('mistral') || modelLower.includes('magistral')) { return 'mistral'; } // DeepSeek models if (modelLower.includes('deepseek') || modelLower === 'reasoner' || modelLower === 'r1' || modelLower === 'chat') { return 'deepseek'; } // OpenRouter models (specific model patterns) if (modelLower.includes('qwen') || modelLower.includes('kimi') || modelLower.includes('moonshot') || modelLower === 'k2') { return 'openrouter'; } // Default fallback return 'openai'; } // Tool metadata consensusTool.description = 'PARALLEL CONSENSUS WITH CROSS-MODEL FEEDBACK - Gathers perspectives from multiple AI models simultaneously. Models provide initial responses, then optionally refine based on others\' insights. Returns both phases in a single call. Handles partial failures gracefully. For: complex decisions, architectural choices, technical evaluations.'; consensusTool.inputSchema = { type: 'object', properties: { prompt: { type: 'string', description: 'The problem or proposal to gather consensus on. Include context and specific questions. Example: "Should we use microservices or monolith architecture for our e-commerce platform with 100k users?"', }, models: { type: 'array', items: { type: 'object', properties: { model: { type: 'string' }, }, required: ['model'], }, description: 'List of models to consult. Example: [{"model": "o3"}, {"model": "gemini-2.5-pro"}, {"model": "grok-4-0709"}]', }, files: { type: 'array', items: { type: 'string' }, description: 'File paths for additional context (absolute or relative paths). Example: ["C:\\Users\\username\\project\\architecture.md", "./requirements.txt"]', }, images: { type: 'array', items: { type: 'string' }, description: 'Image paths for visual context (absolute or relative paths, or base64). Example: ["C:\\Users\\username\\current_architecture.png", "./user_flow.jpg"]', }, continuation_id: { type: 'string', description: 'Thread continuation ID for multi-turn conversations. Example: "consensus_1703123456789_xyz789"', }, enable_cross_feedback: { type: 'boolean', description: 'Enable refinement phase where models see others\' responses and can improve their answers. Example: true (recommended), false (faster single-phase only). Default: true', default: true, }, cross_feedback_prompt: { type: 'string', description: 'Custom prompt for refinement phase. Example: "Focus on scalability trade-offs in your refinement" or leave empty for default cross-feedback prompt', }, temperature: { type: 'number', description: 'Response randomness (0.0-1.0). Examples: 0.1 (very focused), 0.2 (analytical - default), 0.5 (balanced). Default: 0.2', minimum: 0.0, maximum: 1.0, default: 0.2, }, reasoning_effort: { type: 'string', enum: ['minimal', 'low', 'medium', 'high', 'max'], description: 'Reasoning depth for thinking models. Examples: "low" (light analysis), "medium" (balanced), "high" (complex analysis). Default: "medium"', default: 'medium' }, use_websearch: { type: 'boolean', description: 'Enable web search for current information. Only works with models that support web search (OpenAI, XAI, Google). Example: true for recent developments or up to date documentation. Default: false', default: false }, }, required: ['prompt', 'models'], };