UNPKG

converse-mcp-server

Version:

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

496 lines (443 loc) 16.5 kB
/** * Roundtable Mode Engine * * Turn-based multi-model round-table. Models respond SEQUENTIALLY in the order * given; each model sees the full running transcript (prior laps + earlier turns * in the current lap) and builds on it. One tool call runs exactly one lap (one * turn per model); the caller drives more laps by passing back the continuation_id. * * This is the sequential counterpart to the parallel engine. It is an execution * core: it resolves the turn plan, runs the lap loop, and RETURNS the lap data * (turns, transcript, counts). Persistence, export, and MCP-response construction * live in the unified chat tool's shared shell. * * CRITICAL provider constraint: SDK providers (codex, claude, copilot) reduce the * message array to ONLY the last `user` message. Therefore each turn's entire * context (prior-lap transcript + lap prompt + same-lap turns + framing) is packed * into a SINGLE self-contained final user message ("turn packet"). Do not spread * turn context across multiple messages. */ import { debugLog } from '../../utils/console.js'; import { acquireProviderStream } from './streamShared.js'; import { getDefaultModelForProvider, getProviderUnavailableMessage, getAvailableProviders, resolveModelSpec, } from '../../utils/modelRouting.js'; /** * Render a stored transcript (from prior laps or a prior chat/consensus thread) * into labeled text that can be embedded in the next turn's packet. Stored state * pairs user (prompt) and assistant (response) messages; we re-render those as * readable context so last-user-only SDK providers still see the history (and so * a provider does not mistake prior multi-speaker transcript for its own previous * output). Also used by the parallel engine's shell to pack prior history into a * resumed chat/consensus turn. * @param {Array} storedMessages - Stored messages from a prior conversation state * @returns {string} Labeled prior-transcript text ('' for a new conversation) */ export function renderStoredTranscriptToText(storedMessages = []) { if (!Array.isArray(storedMessages) || storedMessages.length === 0) { return ''; } const blocks = []; let lapNumber = 0; let pendingPrompt = null; const toText = (content) => { if (typeof content === 'string') { return content; } if (Array.isArray(content)) { // Complex content array (files/images + text) — extract text parts only return content .filter((part) => part && part.type === 'text' && part.text) .map((part) => part.text) .join('\n'); } return ''; }; for (const message of storedMessages) { if (!message || message.role === 'system') { continue; } if (message.role === 'user') { pendingPrompt = toText(message.content); } else if (message.role === 'assistant') { lapNumber += 1; const promptText = pendingPrompt ? `${pendingPrompt}\n\n` : ''; const assistantText = toText(message.content); blocks.push( `## Earlier in this round-table (lap ${lapNumber}):\n${promptText}${assistantText}`, ); pendingPrompt = null; } } return blocks.join('\n\n'); } /** * Build the per-turn framing text for the model at position `i`. * @param {object} params * @returns {string} Framing text appended to the turn packet */ function buildFramingText({ i, models }) { const total = models.length; const selfModel = models[i]; const prevModel = i > 0 ? models[i - 1] : null; const nextModel = i < total - 1 ? models[i + 1] : null; const order = models.join(', '); const prevText = prevModel || 'no one (you open the round)'; const nextText = nextModel || 'no one (you close this round)'; const handoffText = nextModel ? `Your response will be passed to the next participant (${nextModel}).` : 'Your response will be returned to the user, as you are the last participant this round.'; const lines = [ `You are participant "${selfModel}" in a multi-model round-table conversation.`, `Participants, in speaking order: ${order}.`, `You are speaking in position ${i + 1} of ${total}, after ${prevText}, before ${nextText}.`, 'The original topic/prompt for this round is shown above, followed by any responses already given this round.', 'Respond to the whole conversation so far — build on, challenge, or refine what others have said; do not merely repeat them.', handoffText, ]; return lines.join('\n'); } /** * Build the single self-contained turn packet TEXT for the model at position `i`. * Order: prior-transcript section, lap prompt, same-lap turns, framing. * This is the LAST user message — the only thing last-user-only SDK providers see. * @param {object} params * @returns {string} Turn packet text */ function buildTurnPacket({ priorTranscriptText, prompt, sameLapTurns, i, models }) { const parts = []; if (priorTranscriptText && priorTranscriptText.trim()) { parts.push(priorTranscriptText.trim()); } parts.push(`Original topic for this round:\n${prompt}`); // Same-lap turns from models 0..i-1 (omitted for the opener, i=0) if (i > 0 && sameLapTurns.length > 0) { const turnBlocks = sameLapTurns.map((turn) => { if (turn.status === 'success') { return `### ${turn.model} said:\n${turn.response}`; } return `### ${turn.model} did not respond (error: ${turn.error})`; }); parts.push(turnBlocks.join('\n\n')); } parts.push(buildFramingText({ i, models })); return parts.join('\n\n'); } /** * Format the full lap transcript for storage/display. * @param {Array} lapTurns - Turns from the current lap * @returns {string} Formatted transcript */ export function formatLapTranscript(lapTurns) { let content = ''; let successful = 0; lapTurns.forEach((turn, index) => { if (turn.status === 'success') { successful += 1; content += `### ${turn.model} (turn ${index + 1}):\n${turn.response}\n\n---\n\n`; } else { content += `### ${turn.model} (turn ${index + 1}, did not respond):\nError: ${turn.error}\n\n---\n\n`; } }); content += `\n**Summary:** Conversation lap completed with ${successful}/${lapTurns.length} successful turns.`; return content; } /** * Resolve the ordered model list into a turn plan. Unlike the parallel engine, * unknown or unavailable models are NOT dropped — they are recorded with a * preFailReason so they keep their position in the order (and produce a failed * turn). * @param {Array<string>} models - Ordered model list * @param {object} providers - Provider instances * @param {object} config - Configuration * @param {boolean} hasImages - Whether the request includes images * @returns {Array<object>} Ordered turn plan entries */ export function resolveTurnPlan(models, providers, config, hasImages = false) { // Single "auto" expands to the first available provider's default model only // (a single-model round-table is valid). Multiple explicit models resolve per-entry. let modelsToProcess = models; if (models.length === 1 && String(models[0]).toLowerCase() === 'auto') { const [firstAvailable] = getAvailableProviders(providers, config, { hasImages, limit: 1, }); // If a provider is available, use its default model. Otherwise keep "auto" // so it resolves to a turn that fails cleanly (all-fail laps must complete). modelsToProcess = firstAvailable ? [getDefaultModelForProvider(firstAvailable)] : ['auto']; } return modelsToProcess.map((modelName) => { if (!modelName || typeof modelName !== 'string') { return { model: modelName || 'unknown', provider: null, providerInstance: null, resolvedModel: null, preFailReason: 'Invalid model specification', }; } const { providerName, provider, resolvedModel, status, options } = resolveModelSpec(modelName, providers, config); if (status === 'not_found') { return { model: modelName, provider: providerName, providerInstance: null, resolvedModel, preFailReason: `Provider not found: ${providerName}`, }; } if (status === 'unavailable') { return { model: modelName, provider: providerName, providerInstance: null, resolvedModel, preFailReason: getProviderUnavailableMessage(providerName), }; } return { model: modelName, provider: providerName, providerInstance: provider, resolvedModel, resolveOptions: options, preFailReason: null, }; }); } /** * Build the final user message content for a turn. Files/images are attached to * THIS message so multimodal providers see them, with the packet text appended. * @returns {string|Array} User message content */ function buildTurnUserContent(packetText, contextMessage) { if (contextMessage && contextMessage.content) { return [...contextMessage.content, { type: 'text', text: packetText }]; } return packetText; } /** * Execute a single turn. Streams (updating job progress) when a job context is * present; otherwise performs a plain invoke. Cancellation propagates by throwing * so the lap aborts rather than demoting to a failed turn. * @returns {Promise<object>} Turn result { model, provider, status, response|error } */ async function executeTurn( plan, messages, options, context, streamNormalizer, turnIndex, ) { // `options.signal` is the active signal for both sync (request signal) and // async (job context signal) paths. const activeSignal = options.signal; try { if (activeSignal?.aborted) { throw new Error('Roundtable execution was cancelled'); } // Synchronous path (no job context): plain invoke. if (!context) { const response = await plan.providerInstance.invoke(messages, options); return { model: plan.model, provider: plan.provider, status: 'success', response: response.content, metadata: response.metadata || {}, }; } // Async path: stream and surface progress. const { stream, response: acquiredResponse } = await acquireProviderStream( plan.providerInstance, messages, options, ); let response = acquiredResponse; if (stream) { const normalizedStream = streamNormalizer.normalize(plan.provider, stream, { provider: plan.provider, model: options.model, requestId: `${context.jobId}-turn-${turnIndex}`, }); let accumulatedContent = ''; let finalUsage = null; let finalMetadata = {}; for await (const event of normalizedStream) { if (context.signal?.aborted) { throw new Error('Roundtable execution was cancelled'); } switch (event.type) { case 'delta': accumulatedContent += event.data.textDelta; break; case 'usage': finalUsage = event.data.usage; break; case 'end': accumulatedContent = event.data.content || accumulatedContent; finalUsage = event.data.usage || finalUsage; finalMetadata = event.data.metadata || finalMetadata; break; case 'error': throw new Error(`Streaming error: ${event.data.error.message}`); } } response = { content: accumulatedContent, metadata: { ...finalMetadata, usage: finalUsage, streaming: true }, }; } if (!stream && !response) { response = await plan.providerInstance.invoke(messages, options); } return { model: plan.model, provider: plan.provider, status: 'success', response: response.content, metadata: response.metadata || {}, }; } catch (error) { // Cancellation must abort the whole lap, not be demoted to a failed turn. // Rethrow so it propagates to the caller (sync shell or job runner). if (activeSignal?.aborted || error.name === 'AbortError') { throw error; } return { model: plan.model, provider: plan.provider, status: 'failed', error: error.message, }; } } /** * Run one roundtable lap: one turn per model, in order, each seeing the full * running transcript. Returns the lap data for the shell to persist/format. * * @param {object} params * @param {Array<string>} params.models - Ordered model list * @param {string} params.prompt - Lap prompt * @param {Array} params.priorHistory - Loaded stored messages (may include a leading system msg) * @param {object|null} params.contextMessage - Files/images context message * @param {string} params.systemPrompt - System prompt for the roundtable mode * @param {object} params.providers - Provider instances * @param {object} params.config - Configuration * @param {AbortSignal} [params.signal] - Sync-path abort signal * @param {string} params.reasoning_effort - Reasoning depth * @param {boolean} params.hasImages - Whether the request includes images * @param {object|null} [params.context] - Job context (async) or null (sync) * @param {object} [params.providerStreamNormalizer] - Stream normalizer (async) * @returns {Promise<object>} { lapTurns, transcript, turnsSuccessful, turnsFailed, lapUserMessage } */ export async function runRoundtableLap({ models, prompt, priorHistory, contextMessage, systemPrompt, providers, config, signal, reasoning_effort, hasImages, context = null, providerStreamNormalizer, }) { const priorTranscriptText = renderStoredTranscriptToText(priorHistory); const turnPlan = resolveTurnPlan(models, providers, config, hasImages); const activeSignal = context ? context.signal : signal; if (context) { await context.updateJob({ models_list: models.join(', '), roundtable_progress: `0/${turnPlan.length}`, total_turns: turnPlan.length, completed_turns: 0, }); } const lapTurns = []; for (let i = 0; i < turnPlan.length; i++) { if (activeSignal?.aborted) { throw new Error('Roundtable execution was cancelled'); } const plan = turnPlan[i]; if (plan.preFailReason) { lapTurns.push({ model: plan.model, provider: plan.provider, status: 'failed', error: plan.preFailReason, position: i, }); } else { const packetText = buildTurnPacket({ priorTranscriptText, prompt, sameLapTurns: lapTurns, i, models, }); const finalUserContent = buildTurnUserContent(packetText, contextMessage); const messages = [ { role: 'system', content: systemPrompt }, { role: 'user', content: finalUserContent }, ]; const turnResult = await executeTurn( plan, messages, { reasoning_effort, signal: activeSignal, config, model: plan.resolvedModel, // Web search opt-in from an OpenRouter `:online` decoration; only ever // set for OpenRouter turns. ...(plan.resolveOptions?.web_search && { web_search: true }), }, context, providerStreamNormalizer, i, ); lapTurns.push({ ...turnResult, position: i }); } if (context) { // Report per-turn progress with the running transcript. Use flat keys (not // a `progress` object) — asyncJobStore.update() treats the reserved // `progress` key as a numeric 0..1 value. Numeric overall progress is // supplied separately as a fraction. await context.updateJob({ roundtable_progress: `${i + 1}/${turnPlan.length}`, accumulated_content: formatLapTranscript(lapTurns), progress: (i + 1) / turnPlan.length, total_turns: turnPlan.length, completed_turns: i + 1, current_model: plan.model, }); } } const turnsSuccessful = lapTurns.filter((t) => t.status === 'success').length; const turnsFailed = lapTurns.length - turnsSuccessful; const transcript = formatLapTranscript(lapTurns); const lapUserMessage = { role: 'user', content: buildTurnUserContent(prompt, contextMessage), }; debugLog( `[Roundtable] Lap completed: ${turnsSuccessful}/${lapTurns.length} turns succeeded`, ); return { lapTurns, transcript, turnsSuccessful, turnsFailed, lapUserMessage, }; }