converse-mcp-server 2.29.2 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.env.example +6 -3
  2. package/README.md +96 -94
  3. package/docs/API.md +703 -1562
  4. package/docs/ARCHITECTURE.md +13 -11
  5. package/docs/EXAMPLES.md +241 -667
  6. package/docs/PROVIDERS.md +104 -79
  7. package/package.json +1 -1
  8. package/src/async/asyncJobStore.js +2 -2
  9. package/src/async/jobRunner.js +6 -1
  10. package/src/async/providerStreamNormalizer.js +59 -1
  11. package/src/config.js +4 -3
  12. package/src/prompts/helpPrompt.js +43 -61
  13. package/src/providers/anthropic.js +0 -31
  14. package/src/providers/claude.js +0 -14
  15. package/src/providers/codex.js +15 -21
  16. package/src/providers/copilot.js +36 -202
  17. package/src/providers/deepseek.js +73 -53
  18. package/src/providers/gemini-cli.js +0 -3
  19. package/src/providers/google.js +10 -27
  20. package/src/providers/interface.js +0 -3
  21. package/src/providers/mistral.js +169 -63
  22. package/src/providers/openai-compatible.js +113 -28
  23. package/src/providers/openai.js +14 -66
  24. package/src/providers/openrouter-discovery.js +308 -0
  25. package/src/providers/openrouter.js +452 -282
  26. package/src/providers/xai.js +298 -280
  27. package/src/services/summarizationService.js +4 -14
  28. package/src/systemPrompts.js +19 -2
  29. package/src/tools/cancelJob.js +7 -1
  30. package/src/tools/chat.js +957 -995
  31. package/src/tools/checkStatus.js +1 -0
  32. package/src/tools/index.js +2 -6
  33. package/src/tools/modes/parallel.js +488 -0
  34. package/src/tools/modes/roundtable.js +495 -0
  35. package/src/tools/modes/streamShared.js +31 -0
  36. package/src/utils/contextProcessor.js +1 -38
  37. package/src/utils/conversationExporter.js +6 -26
  38. package/src/utils/formatStatus.js +46 -19
  39. package/src/utils/modelRouting.js +207 -4
  40. package/src/providers/openrouter-endpoints-client.js +0 -220
  41. package/src/tools/consensus.js +0 -1813
  42. package/src/tools/conversation.js +0 -1233
@@ -1,1813 +0,0 @@
1
- /**
2
- * Consensus Tool
3
- *
4
- * Multi-provider parallel execution with response aggregation.
5
- * Calls all available providers simultaneously and aggregates responses.
6
- */
7
-
8
- import {
9
- createToolResponse,
10
- createToolError,
11
- formatFailureDetails,
12
- } from './index.js';
13
- import {
14
- processUnifiedContext,
15
- createFileContext,
16
- } from '../utils/contextProcessor.js';
17
- import {
18
- generateContinuationId,
19
- addMessageToHistory,
20
- isValidContinuationId,
21
- } from '../continuationStore.js';
22
- import { isSafeIdSegment } from '../utils/idValidation.js';
23
- import { debugLog, debugError } from '../utils/console.js';
24
- import { createLogger } from '../utils/logger.js';
25
- import { CONSENSUS_PROMPT } from '../systemPrompts.js';
26
- import { applyTokenLimit, getTokenLimit } from '../utils/tokenLimiter.js';
27
- import { validateAllPaths } from '../utils/fileValidator.js';
28
- import { SummarizationService } from '../services/summarizationService.js';
29
- import { exportConversation } from '../utils/conversationExporter.js';
30
- import {
31
- providerSupportsImages,
32
- getProviderUnavailableMessage,
33
- } from '../utils/modelRouting.js';
34
-
35
- const logger = createLogger('consensus');
36
-
37
- /**
38
- * Consensus tool implementation
39
- * @param {object} args - Tool arguments
40
- * @param {object} dependencies - Injected dependencies (config, providers, continuationStore)
41
- * @returns {object} MCP tool response
42
- */
43
- export async function consensusTool(args, dependencies) {
44
- try {
45
- const {
46
- config,
47
- providers,
48
- continuationStore,
49
- contextProcessor,
50
- jobRunner,
51
- providerStreamNormalizer,
52
- signal,
53
- } = dependencies;
54
-
55
- // Validate required arguments
56
- if (!args.prompt || typeof args.prompt !== 'string') {
57
- return createToolError('Prompt is required and must be a string');
58
- }
59
-
60
- if (
61
- !args.models ||
62
- !Array.isArray(args.models) ||
63
- args.models.length === 0
64
- ) {
65
- return createToolError(
66
- 'Models array is required and must contain at least one model',
67
- );
68
- }
69
-
70
- // Extract and validate arguments
71
- const {
72
- prompt,
73
- models,
74
- files = [],
75
- images = [],
76
- continuation_id,
77
- enable_cross_feedback = true,
78
- cross_feedback_prompt,
79
- temperature = 0.2,
80
- reasoning_effort = 'medium',
81
- use_websearch = false,
82
- async = false,
83
- export: shouldExport = false,
84
- } = args;
85
-
86
- // Handle async execution mode
87
- if (async) {
88
- // Validate async dependencies are available
89
- if (!jobRunner || !providerStreamNormalizer) {
90
- return createToolError(
91
- 'Async execution not available - missing async dependencies',
92
- );
93
- }
94
-
95
- // Validate custom continuation ID for async safety (used as filesystem path segment)
96
- if (continuation_id && !isSafeIdSegment(continuation_id)) {
97
- return createToolError(
98
- `Invalid continuation_id for async mode: "${continuation_id}". Async IDs must contain only letters, numbers, hyphens, and underscores (max 128 chars).`,
99
- );
100
- }
101
-
102
- // Generate continuation ID for background execution result
103
- const bgContinuationId = continuation_id || generateContinuationId();
104
-
105
- // Determine if this is a custom ID (non-standard format AND not found in store)
106
- let isCustomId = false;
107
- if (continuation_id && !isValidContinuationId(continuation_id)) {
108
- try {
109
- const existing = await continuationStore.get(continuation_id);
110
- isCustomId = !existing;
111
- } catch {
112
- isCustomId = true;
113
- }
114
- }
115
-
116
- // Create models list for status display
117
- const modelsList = args.models.join(', ');
118
-
119
- // Generate title early for initial response
120
- const summarizationService = new SummarizationService(providers, config);
121
- let title = null;
122
- try {
123
- title = await summarizationService.generateTitle(prompt);
124
- debugLog(
125
- `Consensus: Generated title for initial response - "${title}"`,
126
- );
127
- } catch (error) {
128
- debugError(
129
- 'Consensus: Failed to generate title for initial response',
130
- error,
131
- );
132
- title = prompt.substring(0, 50);
133
- }
134
-
135
- try {
136
- // Submit background job
137
- const jobId = await jobRunner.submit(
138
- {
139
- tool: 'consensus',
140
- sessionId: bgContinuationId, // Use continuation_id as sessionId for consistency
141
- options: {
142
- ...args,
143
- jobId: bgContinuationId, // Use continuation ID as job ID
144
- models_list: modelsList, // Add models list for status display
145
- title, // Pass the generated title
146
- },
147
- },
148
- async (context) => {
149
- // Execute consensus in background using stream normalizer
150
- return await executeConsensusWithStreaming(
151
- args,
152
- {
153
- ...dependencies,
154
- continuationId: bgContinuationId,
155
- isCustomId,
156
- title, // Pass title to execution context
157
- },
158
- context,
159
- );
160
- },
161
- );
162
-
163
- // Format initial response like check_status output
164
- const startTime = new Date()
165
- .toLocaleString('en-GB', {
166
- day: '2-digit',
167
- month: '2-digit',
168
- year: 'numeric',
169
- hour: '2-digit',
170
- minute: '2-digit',
171
- second: '2-digit',
172
- hour12: false,
173
- })
174
- .replace(',', '');
175
-
176
- const statusLine = `⏳ SUBMITTED | CONSENSUS | ${bgContinuationId} | 1/1 | Started: ${startTime} | "${title || 'Processing...'}" | ${modelsList}`;
177
-
178
- // Return formatted response with status line and continuation_id
179
- return createToolResponse({
180
- content: `${statusLine}\ncontinuation_id: ${bgContinuationId}`,
181
- continuation: {
182
- id: bgContinuationId, // Use continuation_id as the primary ID
183
- status: 'processing',
184
- ...(isCustomId && { custom_id: true }),
185
- },
186
- async_execution: true,
187
- });
188
- } catch (error) {
189
- logger.error('Failed to submit async consensus job', { error });
190
- return createToolError(`Async execution failed: ${error.message}`);
191
- }
192
- }
193
-
194
- let conversationHistory = [];
195
- let continuationId = continuation_id;
196
- let isCustomId = false;
197
-
198
- // Load existing conversation if continuation_id provided
199
- if (continuationId) {
200
- try {
201
- const existingState =
202
- await dependencies.continuationStore.get(continuationId);
203
- if (existingState) {
204
- conversationHistory = existingState.messages || [];
205
- } else {
206
- // Preserve user-provided ID and start fresh conversation
207
- isCustomId = !isValidContinuationId(continuationId);
208
- }
209
- } catch (error) {
210
- logger.error('Error loading conversation', { error });
211
- // Preserve user-provided ID on error
212
- isCustomId = !isValidContinuationId(continuationId);
213
- }
214
- } else {
215
- // Generate new continuation ID for new conversation
216
- continuationId = generateContinuationId();
217
- }
218
-
219
- // Validate file paths before processing
220
- if (files.length > 0 || images.length > 0) {
221
- const validation = await validateAllPaths(
222
- {
223
- files,
224
- images,
225
- },
226
- { clientCwd: config.server?.client_cwd },
227
- );
228
- if (!validation.valid) {
229
- logger.error('File validation failed', { errors: validation.errors });
230
- return validation.errorResponse;
231
- }
232
- }
233
-
234
- // Process context (files and images)
235
- let contextMessage = null;
236
- if (files.length > 0 || images.length > 0) {
237
- try {
238
- const contextRequest = {
239
- files: Array.isArray(files) ? files : [],
240
- images: Array.isArray(images) ? images : [],
241
- };
242
-
243
- const contextResult = await contextProcessor.processUnifiedContext(
244
- contextRequest,
245
- {
246
- enforceSecurityCheck: false, // Allow files from any location
247
- skipSecurityCheck: true, // Legacy flag for backward compatibility
248
- clientCwd: config.server?.client_cwd, // Use auto-detected client working directory
249
- },
250
- );
251
-
252
- // Create context message from files and images
253
- const allProcessedFiles = [
254
- ...contextResult.files,
255
- ...contextResult.images,
256
- ];
257
- if (allProcessedFiles.length > 0) {
258
- contextMessage = createFileContext(allProcessedFiles, {
259
- includeMetadata: true,
260
- includeErrors: true,
261
- });
262
- }
263
- } catch (error) {
264
- logger.error('Error processing context', { error });
265
- // Continue without context if processing fails
266
- }
267
- }
268
-
269
- // Build message array for providers
270
- const messages = [];
271
-
272
- // Add system prompt
273
- messages.push({
274
- role: 'system',
275
- content: CONSENSUS_PROMPT,
276
- });
277
-
278
- // Add conversation history
279
- messages.push(...conversationHistory);
280
-
281
- // Add user prompt with context
282
- const userMessage = {
283
- role: 'user',
284
- content: prompt, // default to simple string content
285
- };
286
-
287
- // If we have context (files/images), create complex content array
288
- if (contextMessage && contextMessage.content) {
289
- // Create complex content array
290
- userMessage.content = [
291
- ...contextMessage.content, // Include all file/image parts
292
- { type: 'text', text: prompt }, // Add the user prompt as text
293
- ];
294
- }
295
-
296
- messages.push(userMessage);
297
-
298
- // Resolve model specifications to provider calls
299
- const providerCalls = [];
300
- const failedModels = [];
301
-
302
- // Special handling for single "auto" model - expand to first 3 available providers
303
- let modelsToProcess = models;
304
- if (models.length === 1 && models[0].toLowerCase() === 'auto') {
305
- // Find first 3 available providers
306
- // Prioritize subscription-based providers (codex, gemini-cli, claude, copilot) over API-key providers
307
- const availableProviders = [];
308
- const providerOrder = [
309
- 'codex',
310
- 'gemini-cli',
311
- 'claude',
312
- 'copilot',
313
- 'openai',
314
- 'google',
315
- 'xai',
316
- 'anthropic',
317
- 'mistral',
318
- 'deepseek',
319
- 'openrouter',
320
- ];
321
-
322
- const requestHasImages = Array.isArray(images) && images.length > 0;
323
-
324
- for (const providerName of providerOrder) {
325
- if (availableProviders.length >= 3) break;
326
- const provider = providers[providerName];
327
- if (provider && provider.isAvailable(config)) {
328
- // Skip text-only providers when the request includes images.
329
- if (
330
- requestHasImages &&
331
- !providerSupportsImages(provider, providerName)
332
- ) {
333
- continue;
334
- }
335
- availableProviders.push(providerName);
336
- }
337
- }
338
-
339
- if (availableProviders.length === 0) {
340
- return createToolError(
341
- 'No providers available. Please configure at least one API key.',
342
- );
343
- }
344
-
345
- // Create model names for each available provider with their default model
346
- modelsToProcess = availableProviders.map((providerName) =>
347
- getDefaultModelForProvider(providerName),
348
- );
349
-
350
- logger.debug('Auto-expanded to providers', {
351
- data: {
352
- providers: availableProviders,
353
- models: modelsToProcess,
354
- },
355
- });
356
- }
357
-
358
- for (const modelName of modelsToProcess) {
359
- if (!modelName || typeof modelName !== 'string') {
360
- failedModels.push({
361
- model: modelName || 'unknown',
362
- error: 'Invalid model specification',
363
- status: 'failed',
364
- });
365
- continue;
366
- }
367
- const providerName = mapModelToProvider(modelName, providers);
368
- const resolvedModelName = resolveAutoModel(modelName, providerName);
369
- const provider = providers[providerName];
370
-
371
- if (!provider) {
372
- failedModels.push({
373
- model: modelName,
374
- provider: providerName,
375
- error: `Provider not found: ${providerName}`,
376
- status: 'failed',
377
- });
378
- continue;
379
- }
380
-
381
- if (!provider.isAvailable(config)) {
382
- failedModels.push({
383
- model: modelName,
384
- provider: providerName,
385
- error: getProviderUnavailableMessage(providerName),
386
- status: 'failed',
387
- });
388
- continue;
389
- }
390
-
391
- providerCalls.push({
392
- model: modelName, // Keep original model name for display
393
- provider: providerName,
394
- providerInstance: provider,
395
- options: {
396
- temperature,
397
- reasoning_effort,
398
- use_websearch,
399
- signal,
400
- config,
401
- model: resolvedModelName, // Use resolved model name for API call
402
- },
403
- });
404
- }
405
-
406
- if (providerCalls.length === 0) {
407
- return createToolError(
408
- `No valid providers available for the specified models. Failed models: ${failedModels.map((f) => f.model).join(', ')}`,
409
- );
410
- }
411
-
412
- // Phase 1: Initial parallel provider calls
413
- logger.debug('Calling providers in parallel', {
414
- data: { providerCount: providerCalls.length },
415
- });
416
- const consensusStartTime = Date.now();
417
- const initialResults = await Promise.allSettled(
418
- providerCalls.map(async (call) => {
419
- try {
420
- const response = await call.providerInstance.invoke(
421
- messages,
422
- call.options,
423
- );
424
- return {
425
- model: call.model,
426
- provider: call.provider,
427
- status: 'success',
428
- response: response.content,
429
- metadata: response.metadata || {},
430
- };
431
- } catch (error) {
432
- return {
433
- model: call.model,
434
- provider: call.provider,
435
- status: 'failed',
436
- error: error.message,
437
- metadata: {},
438
- };
439
- }
440
- }),
441
- );
442
-
443
- // Process initial results
444
- const initialPhase = {
445
- successful: [],
446
- failed: [],
447
- };
448
-
449
- initialResults.forEach((result, index) => {
450
- if (result.status === 'fulfilled') {
451
- if (result.value.status === 'success') {
452
- initialPhase.successful.push(result.value);
453
- } else {
454
- initialPhase.failed.push(result.value);
455
- }
456
- } else {
457
- initialPhase.failed.push({
458
- model: providerCalls[index].model,
459
- provider: providerCalls[index].provider,
460
- status: 'failed',
461
- error: result.reason.message || 'Unknown error',
462
- metadata: {},
463
- });
464
- }
465
- });
466
-
467
- // Add pre-failed models to failed list
468
- initialPhase.failed.push(...failedModels);
469
-
470
- let refinedPhase = null;
471
-
472
- // Phase 2: Cross-feedback (if enabled and we have multiple successful responses)
473
- // Skip if signal is already aborted after Phase 1
474
- if (enable_cross_feedback && initialPhase.successful.length > 1 && !signal?.aborted) {
475
- logger.debug('Running cross-feedback phase', {
476
- data: { responseCount: initialPhase.successful.length },
477
- });
478
-
479
- // Create cross-feedback prompt
480
- const feedbackPrompt =
481
- cross_feedback_prompt ||
482
- `Based on the other AI responses below, please refine your answer to the original question. Consider different perspectives and provide your final response:
483
-
484
- Original Question: ${prompt}
485
-
486
- Other AI Responses:
487
- ${initialPhase.successful.map((r, i) => `${i + 1}. ${r.model}: ${r.response}`).join('\n\n')}
488
-
489
- Please provide your refined response:`;
490
-
491
- // Build feedback messages - need to add the assistant's initial response first
492
- const feedbackMessages = [...messages];
493
-
494
- // Run refinement calls in parallel
495
- const refinementResults = await Promise.allSettled(
496
- initialPhase.successful.map(async (initialResult) => {
497
- try {
498
- const call = providerCalls.find(
499
- (c) => c.model === initialResult.model,
500
- );
501
-
502
- // Build model-specific feedback messages with the assistant's initial response
503
- const modelFeedbackMessages = [...messages];
504
- // Add the assistant's initial response
505
- modelFeedbackMessages.push({
506
- role: 'assistant',
507
- content: initialResult.response,
508
- });
509
- // Now add the feedback prompt
510
- modelFeedbackMessages.push({
511
- role: 'user',
512
- content: feedbackPrompt,
513
- });
514
-
515
- const response = await call.providerInstance.invoke(
516
- modelFeedbackMessages,
517
- call.options,
518
- );
519
-
520
- return {
521
- ...initialResult,
522
- refined_response: response.content,
523
- refined_metadata: response.metadata || {},
524
- initial_response: initialResult.response,
525
- status: 'success',
526
- };
527
- } catch (error) {
528
- return {
529
- ...initialResult,
530
- refined_response: null,
531
- refined_error: error.message,
532
- initial_response: initialResult.response,
533
- status: 'partial', // Had initial success but refinement failed
534
- };
535
- }
536
- }),
537
- );
538
-
539
- // Process refinement results
540
- refinedPhase = [];
541
- refinementResults.forEach((result) => {
542
- if (result.status === 'fulfilled') {
543
- refinedPhase.push(result.value);
544
- } else {
545
- // This shouldn't happen with our error handling, but just in case
546
- const originalResult = result.value || {};
547
- refinedPhase.push({
548
- ...originalResult,
549
- refined_response: null,
550
- refined_error: 'Refinement phase failed unexpectedly',
551
- status: 'partial',
552
- });
553
- }
554
- });
555
- }
556
-
557
- // Save conversation state (skip on abort to avoid persisting incomplete history)
558
- let conversationState;
559
- if (!signal?.aborted) {
560
- try {
561
- // Build formatted consensus response with actual model responses
562
- let formattedContent = '';
563
-
564
- // Add initial responses
565
- formattedContent += '## Initial Responses\n\n';
566
- for (const result of initialPhase.successful) {
567
- formattedContent += `### ${result.model} (initial response):\n${result.response}\n\n---\n\n`;
568
- }
569
-
570
- // Add refined responses if cross-feedback was enabled
571
- if (refinedPhase) {
572
- formattedContent += '## Refined Responses\n\n';
573
- for (const result of refinedPhase) {
574
- if (result.status === 'success' && result.refined_response) {
575
- formattedContent += `### ${result.model} (refined response):\n${result.refined_response}\n\n---\n\n`;
576
- } else if (result.status === 'partial') {
577
- // Show initial response for models that failed refinement
578
- formattedContent += `### ${result.model} (refinement failed, showing initial):\n${result.initial_response}\n\n---\n\n`;
579
- }
580
- }
581
- }
582
-
583
- // Add summary at the end
584
- formattedContent += `\n**Summary:** Consensus completed with ${initialPhase.successful.length} successful initial responses`;
585
- if (refinedPhase) {
586
- const successfulRefinements = refinedPhase.filter(
587
- (r) => r.status === 'success',
588
- ).length;
589
- formattedContent += ` and ${successfulRefinements} successful refined responses`;
590
- }
591
- formattedContent += '.';
592
- const consensusMessage = {
593
- role: 'assistant',
594
- content: formattedContent,
595
- };
596
-
597
- conversationState = {
598
- messages: [...messages, consensusMessage],
599
- type: 'consensus',
600
- lastUpdated: Date.now(),
601
- consensusData: {
602
- modelsRequested: models.length,
603
- providersSuccessful: initialPhase.successful.length,
604
- providersFailed: initialPhase.failed.length,
605
- crossFeedbackEnabled: enable_cross_feedback,
606
- },
607
- };
608
-
609
- await dependencies.continuationStore.set(
610
- continuationId,
611
- conversationState,
612
- );
613
- } catch (error) {
614
- logger.error('Error saving consensus conversation', { error });
615
- // Continue even if save fails
616
- }
617
- }
618
-
619
- // Export conversation if requested
620
- if (shouldExport && conversationState) {
621
- await exportConversation(conversationState, {
622
- clientCwd: dependencies.config.server?.client_cwd,
623
- continuation_id: continuationId,
624
- models,
625
- temperature,
626
- reasoning_effort,
627
- use_websearch,
628
- files,
629
- images,
630
- enable_cross_feedback,
631
- });
632
- }
633
-
634
- const consensusExecutionTime = (Date.now() - consensusStartTime) / 1000; // Convert to seconds
635
-
636
- // Calculate final success count and collect failure details
637
- let finalSuccessCount;
638
- const failureDetails = [];
639
-
640
- if (enable_cross_feedback && refinedPhase) {
641
- // When cross-feedback is enabled, count only models that succeeded in both phases
642
- finalSuccessCount = refinedPhase.filter(
643
- (r) => r.status === 'success',
644
- ).length;
645
-
646
- // Collect detailed failure information
647
- refinedPhase.forEach((result) => {
648
- if (result.status === 'partial') {
649
- failureDetails.push(`${result.model} (refinement failed)`);
650
- }
651
- });
652
-
653
- // Add models that failed in initial phase
654
- initialPhase.failed.forEach((failure) => {
655
- failureDetails.push(`${failure.model} (initial failed)`);
656
- });
657
- } else {
658
- // When cross-feedback is disabled, count initial successes
659
- finalSuccessCount = initialPhase.successful.length;
660
-
661
- // Collect initial failure information
662
- initialPhase.failed.forEach((failure) => {
663
- failureDetails.push(`${failure.model} (${failure.error})`);
664
- });
665
- }
666
-
667
- // Create models list string for display
668
- const modelsList = providerCalls.map((call) => call.model).join(', ');
669
-
670
- // Create unified status line (similar to async status display)
671
- const finalCount = refinedPhase
672
- ? refinedPhase.filter((r) => r.status === 'success').length
673
- : initialPhase.successful.length;
674
- const totalCount = providerCalls.length;
675
- const statusLine =
676
- config.environment?.nodeEnv !== 'test'
677
- ? `✅ COMPLETED | CONSENSUS | ${continuationId} | ${consensusExecutionTime.toFixed(1)}s elapsed | ${finalCount}/${totalCount} succeeded | ${modelsList}\n`
678
- : '';
679
-
680
- // Always include continuation_id line for clarity
681
- const continuationIdLine = `continuation_id: ${continuationId}\n\n`;
682
-
683
- // Build result object keeping backward compatibility but removing rawResponse
684
- const result = {
685
- status: 'consensus_complete',
686
- models_consulted: models.length,
687
- successful_initial_responses: initialPhase.successful.length,
688
- failed_responses: initialPhase.failed.length,
689
- refined_responses: refinedPhase
690
- ? refinedPhase.filter((r) => r.status === 'success').length
691
- : 0,
692
- phases: {
693
- initial: initialPhase.successful,
694
- ...(refinedPhase !== null && { refined: refinedPhase }),
695
- failed: initialPhase.failed,
696
- },
697
- continuation: {
698
- id: continuationId,
699
- messageCount: messages.length + 1,
700
- ...(isCustomId && { custom_id: true }),
701
- },
702
- settings: {
703
- enable_cross_feedback,
704
- temperature,
705
- models_requested: models,
706
- },
707
- };
708
-
709
- // Apply token limiting to the final response
710
- const tokenLimit = getTokenLimit(config);
711
- const resultStr = JSON.stringify(result, null, 2);
712
- const limitedResult = applyTokenLimit(resultStr, tokenLimit);
713
-
714
- // Add failure details to the response content if there are failures
715
- let finalContent = limitedResult.content;
716
- if (failureDetails.length > 0) {
717
- const failureInfo = formatFailureDetails(failureDetails);
718
- finalContent = limitedResult.content + failureInfo;
719
- }
720
-
721
- // Prepend status line and continuation_id line when appropriate
722
- finalContent = statusLine + continuationIdLine + finalContent;
723
-
724
- // Return with continuation at top level for test compatibility
725
- return createToolResponse({
726
- content: finalContent,
727
- continuation: {
728
- id: continuationId,
729
- messageCount: messages.length + 1,
730
- ...(isCustomId && { custom_id: true }),
731
- },
732
- });
733
- } catch (error) {
734
- if (dependencies?.signal?.aborted || error.name === 'AbortError') {
735
- logger.debug('Consensus tool cancelled by client');
736
- return createToolError('Consensus request cancelled');
737
- }
738
- logger.error('Consensus tool error', { error });
739
- return createToolError('Consensus tool failed', error);
740
- }
741
- }
742
-
743
- /**
744
- * Map model name to provider name (same as chat tool)
745
- * @param {string} model - Model name
746
- * @returns {string} Provider name
747
- */
748
- /**
749
- * Get default model for a provider
750
- */
751
- function getDefaultModelForProvider(providerName) {
752
- const defaults = {
753
- codex: 'codex',
754
- 'gemini-cli': 'gemini',
755
- claude: 'claude',
756
- copilot: 'copilot',
757
- openai: 'gpt-5',
758
- xai: 'grok-4-0709',
759
- google: 'gemini-pro',
760
- anthropic: 'claude-sonnet-4-20250514',
761
- mistral: 'magistral-medium-2506',
762
- deepseek: 'deepseek-reasoner',
763
- openrouter: 'qwen/qwen3-coder',
764
- };
765
-
766
- return defaults[providerName] || 'gpt-5';
767
- }
768
-
769
- /**
770
- * Resolve "auto" model to default model for the provider
771
- */
772
- function resolveAutoModel(model, providerName) {
773
- if (model.toLowerCase() !== 'auto') {
774
- return model;
775
- }
776
-
777
- return getDefaultModelForProvider(providerName);
778
- }
779
-
780
- function mapModelToProvider(model, providers) {
781
- const modelLower = model.toLowerCase();
782
-
783
- // Handle "auto" - prioritize: codex > gemini-cli > claude > copilot > openai
784
- if (modelLower === 'auto') {
785
- if (providers['codex']) {
786
- return 'codex';
787
- }
788
- if (providers['gemini-cli']) {
789
- return 'gemini-cli';
790
- }
791
- if (providers['claude']) {
792
- return 'claude';
793
- }
794
- if (providers['copilot']) {
795
- return 'copilot';
796
- }
797
- return 'openai';
798
- }
799
-
800
- // Check Codex (exact match only - don't route "gpt-5-codex" etc to Codex provider)
801
- if (modelLower === 'codex') {
802
- return 'codex';
803
- }
804
-
805
- // Check Gemini CLI (exact match only - routes to CLI provider instead of Google API)
806
- if (modelLower === 'gemini' || modelLower === 'gemini-cli') {
807
- return 'gemini-cli';
808
- }
809
-
810
- // Check gemini: prefix (e.g., gemini:flash, gemini:pro) - routes to Antigravity
811
- // CLI provider. Must be before the google flash/pro keyword rule so it wins.
812
- if (modelLower.startsWith('gemini:')) {
813
- return 'gemini-cli';
814
- }
815
-
816
- // Check Claude SDK (exact match only - routes to SDK provider instead of Anthropic API)
817
- if (
818
- modelLower === 'claude' ||
819
- modelLower === 'claude-sdk' ||
820
- modelLower === 'claude-code'
821
- ) {
822
- return 'claude';
823
- }
824
-
825
- // Check claude: prefix (e.g., claude:fable, claude:opus) - routes to SDK provider
826
- // Must be before keyword matching to prevent misrouting to Anthropic API
827
- if (modelLower.startsWith('claude:')) {
828
- return 'claude';
829
- }
830
-
831
- // Check Copilot SDK (exact match only - routes to SDK provider)
832
- if (
833
- modelLower === 'copilot' ||
834
- modelLower === 'copilot-sdk' ||
835
- modelLower === 'github-copilot'
836
- ) {
837
- return 'copilot';
838
- }
839
-
840
- // Check copilot: prefix (e.g., copilot:gpt-5.2, copilot:claude-sonnet-4.6)
841
- // Must be before slash-format and keyword matching to prevent misrouting
842
- if (modelLower.startsWith('copilot:')) {
843
- return 'copilot';
844
- }
845
-
846
- // Check OpenRouter-specific patterns first
847
- if (
848
- modelLower === 'openrouter auto' ||
849
- modelLower === 'auto router' ||
850
- modelLower === 'auto-router' ||
851
- modelLower === 'openrouter-auto'
852
- ) {
853
- return 'openrouter';
854
- }
855
-
856
- // If model contains "/", check if native provider supports it
857
- if (modelLower.includes('/')) {
858
- // Check each provider to see if they have this exact model
859
- for (const [providerName, provider] of Object.entries(providers)) {
860
- if (provider && provider.getModelConfig) {
861
- const modelConfig = provider.getModelConfig(model);
862
- if (
863
- modelConfig &&
864
- !modelConfig.isDynamic &&
865
- !modelConfig.needsApiUpdate
866
- ) {
867
- // Model exists in this provider's static list
868
- return providerName;
869
- }
870
- }
871
- }
872
- // No native provider has this model, route to OpenRouter
873
- return 'openrouter';
874
- }
875
-
876
- // For non-slash models, use keyword matching as before
877
-
878
- // OpenAI models
879
- if (
880
- modelLower.includes('gpt') ||
881
- modelLower.includes('o1') ||
882
- modelLower.includes('o3') ||
883
- modelLower.includes('o4')
884
- ) {
885
- return 'openai';
886
- }
887
-
888
- // XAI models
889
- if (modelLower.includes('grok')) {
890
- return 'xai';
891
- }
892
-
893
- // Google models
894
- if (
895
- modelLower.includes('flash') ||
896
- modelLower.includes('pro') ||
897
- modelLower === 'google'
898
- ) {
899
- return 'google';
900
- }
901
-
902
- // Anthropic models
903
- if (
904
- modelLower.includes('claude') ||
905
- modelLower.includes('fable') ||
906
- modelLower.includes('opus') ||
907
- modelLower.includes('sonnet') ||
908
- modelLower.includes('haiku')
909
- ) {
910
- return 'anthropic';
911
- }
912
-
913
- // Mistral models
914
- if (modelLower.includes('mistral') || modelLower.includes('magistral')) {
915
- return 'mistral';
916
- }
917
-
918
- // DeepSeek models
919
- if (
920
- modelLower.includes('deepseek') ||
921
- modelLower === 'reasoner' ||
922
- modelLower === 'r1' ||
923
- modelLower === 'chat'
924
- ) {
925
- return 'deepseek';
926
- }
927
-
928
- // OpenRouter models (specific model patterns)
929
- if (
930
- modelLower.includes('qwen') ||
931
- modelLower.includes('kimi') ||
932
- modelLower.includes('moonshot') ||
933
- modelLower === 'k2'
934
- ) {
935
- return 'openrouter';
936
- }
937
-
938
- // Default fallback
939
- return 'openai';
940
- }
941
-
942
- /**
943
- * Execute consensus with streaming normalization for async execution
944
- * @param {object} args - Original consensus arguments
945
- * @param {object} dependencies - Dependencies with continuationId
946
- * @param {object} context - Job execution context
947
- * @returns {Promise<object>} Complete consensus result
948
- */
949
- async function executeConsensusWithStreaming(args, dependencies, context) {
950
- const {
951
- config,
952
- providers,
953
- continuationStore,
954
- contextProcessor,
955
- providerStreamNormalizer,
956
- continuationId,
957
- isCustomId,
958
- title: passedTitle, // Title passed from initial submission
959
- } = dependencies;
960
-
961
- const {
962
- prompt,
963
- models,
964
- files = [],
965
- images = [],
966
- enable_cross_feedback = true,
967
- cross_feedback_prompt,
968
- temperature = 0.2,
969
- reasoning_effort = 'medium',
970
- use_websearch = false,
971
- export: shouldExport = false,
972
- } = args;
973
-
974
- let conversationHistory = [];
975
-
976
- // Load existing conversation if continuation_id provided
977
- if (continuationId) {
978
- try {
979
- const existingState = await continuationStore.get(continuationId);
980
- if (existingState) {
981
- conversationHistory = existingState.messages || [];
982
- }
983
- } catch (error) {
984
- logger.error('Error loading conversation', { error });
985
- // Continue with fresh conversation on error
986
- }
987
- }
988
-
989
- // Validate file paths before processing
990
- if (files.length > 0 || images.length > 0) {
991
- const validation = await validateAllPaths(
992
- {
993
- files,
994
- images,
995
- },
996
- { clientCwd: config.server?.client_cwd },
997
- );
998
- if (!validation.valid) {
999
- logger.error('File validation failed', { errors: validation.errors });
1000
- throw new Error(
1001
- `File validation failed: ${validation.errors.join(', ')}`,
1002
- );
1003
- }
1004
- }
1005
-
1006
- // Process context (files and images)
1007
- let contextMessage = null;
1008
- if (files.length > 0 || images.length > 0) {
1009
- try {
1010
- const contextRequest = {
1011
- files: Array.isArray(files) ? files : [],
1012
- images: Array.isArray(images) ? images : [],
1013
- };
1014
-
1015
- const contextResult = await contextProcessor.processUnifiedContext(
1016
- contextRequest,
1017
- {
1018
- enforceSecurityCheck: false,
1019
- skipSecurityCheck: true,
1020
- clientCwd: config.server?.client_cwd,
1021
- },
1022
- );
1023
-
1024
- // Create context message from files and images
1025
- const allProcessedFiles = [
1026
- ...contextResult.files,
1027
- ...contextResult.images,
1028
- ];
1029
- if (allProcessedFiles.length > 0) {
1030
- contextMessage = createFileContext(allProcessedFiles, {
1031
- includeMetadata: true,
1032
- includeErrors: true,
1033
- });
1034
- }
1035
- } catch (error) {
1036
- logger.error('Error processing context', { error });
1037
- // Continue without context if processing fails
1038
- }
1039
- }
1040
-
1041
- // Build message array for providers
1042
- const messages = [];
1043
-
1044
- // Add system prompt
1045
- messages.push({
1046
- role: 'system',
1047
- content: CONSENSUS_PROMPT,
1048
- });
1049
-
1050
- // Add conversation history
1051
- messages.push(...conversationHistory);
1052
-
1053
- // Add user prompt with context
1054
- const userMessage = {
1055
- role: 'user',
1056
- content: prompt,
1057
- };
1058
-
1059
- // If we have context (files/images), create complex content array
1060
- if (contextMessage && contextMessage.content) {
1061
- userMessage.content = [
1062
- ...contextMessage.content,
1063
- { type: 'text', text: prompt },
1064
- ];
1065
- }
1066
-
1067
- messages.push(userMessage);
1068
-
1069
- // Resolve model specifications to provider calls
1070
- const providerCalls = [];
1071
- const failedModels = [];
1072
-
1073
- // Special handling for single "auto" model - expand to first 3 available providers
1074
- let modelsToProcess = models;
1075
- if (models.length === 1 && models[0].toLowerCase() === 'auto') {
1076
- // Prioritize subscription-based providers (codex, gemini-cli, claude, copilot) over API-key providers
1077
- const availableProviders = [];
1078
- const providerOrder = [
1079
- 'codex',
1080
- 'gemini-cli',
1081
- 'claude',
1082
- 'copilot',
1083
- 'openai',
1084
- 'google',
1085
- 'xai',
1086
- 'anthropic',
1087
- 'mistral',
1088
- 'deepseek',
1089
- 'openrouter',
1090
- ];
1091
-
1092
- const requestHasImages = Array.isArray(images) && images.length > 0;
1093
-
1094
- for (const providerName of providerOrder) {
1095
- if (availableProviders.length >= 3) break;
1096
- const provider = providers[providerName];
1097
- if (provider && provider.isAvailable(config)) {
1098
- // Skip text-only providers when the request includes images.
1099
- if (
1100
- requestHasImages &&
1101
- !providerSupportsImages(provider, providerName)
1102
- ) {
1103
- continue;
1104
- }
1105
- availableProviders.push(providerName);
1106
- }
1107
- }
1108
-
1109
- if (availableProviders.length === 0) {
1110
- throw new Error(
1111
- 'No providers available. Please configure at least one API key.',
1112
- );
1113
- }
1114
-
1115
- modelsToProcess = availableProviders.map((providerName) =>
1116
- getDefaultModelForProvider(providerName),
1117
- );
1118
-
1119
- logger.debug('Auto-expanded to providers', {
1120
- data: {
1121
- providers: availableProviders,
1122
- models: modelsToProcess,
1123
- },
1124
- });
1125
- }
1126
-
1127
- // Build provider calls array
1128
- for (const modelName of modelsToProcess) {
1129
- if (!modelName || typeof modelName !== 'string') {
1130
- failedModels.push({
1131
- model: modelName || 'unknown',
1132
- error: 'Invalid model specification',
1133
- status: 'failed',
1134
- });
1135
- continue;
1136
- }
1137
-
1138
- const providerName = mapModelToProvider(modelName, providers);
1139
- const resolvedModelName = resolveAutoModel(modelName, providerName);
1140
- const provider = providers[providerName];
1141
-
1142
- if (!provider) {
1143
- failedModels.push({
1144
- model: modelName,
1145
- provider: providerName,
1146
- error: `Provider not found: ${providerName}`,
1147
- status: 'failed',
1148
- });
1149
- continue;
1150
- }
1151
-
1152
- if (!provider.isAvailable(config)) {
1153
- failedModels.push({
1154
- model: modelName,
1155
- provider: providerName,
1156
- error: getProviderUnavailableMessage(providerName),
1157
- status: 'failed',
1158
- });
1159
- continue;
1160
- }
1161
-
1162
- providerCalls.push({
1163
- model: modelName,
1164
- provider: providerName,
1165
- providerInstance: provider,
1166
- options: {
1167
- temperature,
1168
- reasoning_effort,
1169
- use_websearch,
1170
- signal: context?.signal, // Pass AbortSignal for cancellation support
1171
- config,
1172
- model: resolvedModelName,
1173
- },
1174
- });
1175
- }
1176
-
1177
- if (providerCalls.length === 0) {
1178
- throw new Error(
1179
- `No valid providers available for the specified models. Failed models: ${failedModels.map((f) => f.model).join(', ')}`,
1180
- );
1181
- }
1182
-
1183
- // Create models list string for display
1184
- const modelsList = providerCalls.map((call) => call.model).join(', ');
1185
-
1186
- // Initialize SummarizationService
1187
- const summarizationService = new SummarizationService(providers, config);
1188
-
1189
- // Use passed title or generate if not provided
1190
- let title = passedTitle;
1191
- if (!title) {
1192
- try {
1193
- title = await summarizationService.generateTitle(prompt);
1194
- debugLog(`Consensus: Generated title - "${title}"`);
1195
- } catch (error) {
1196
- debugError('Consensus: Error generating title', error);
1197
- // Continue without title if generation fails
1198
- title = prompt.substring(0, 50);
1199
- }
1200
- } else {
1201
- debugLog(`Consensus: Using passed title - "${title}"`);
1202
- }
1203
-
1204
- // Update job status for phase 1 with title
1205
- await context.updateJob({
1206
- models_list: modelsList,
1207
- title,
1208
- consensus_progress: `0/${providerCalls.length} initial`,
1209
- progress: {
1210
- phase: 'initial_consensus',
1211
- total_providers: providerCalls.length,
1212
- completed_providers: 0,
1213
- failed_providers: failedModels.length,
1214
- provider_status: {},
1215
- },
1216
- });
1217
-
1218
- const consensusStartTime = Date.now();
1219
-
1220
- // Phase 1: Initial parallel provider calls with streaming
1221
- logger.debug('Calling providers in parallel with streaming', {
1222
- data: { providerCount: providerCalls.length },
1223
- });
1224
-
1225
- const initialResults = await executeConsensusPhaseWithStreaming(
1226
- providerCalls,
1227
- messages,
1228
- 'initial',
1229
- context,
1230
- providerStreamNormalizer,
1231
- );
1232
-
1233
- // Process initial results
1234
- const initialPhase = {
1235
- successful: [],
1236
- failed: [],
1237
- };
1238
-
1239
- initialResults.forEach((result) => {
1240
- if (result.status === 'success') {
1241
- initialPhase.successful.push(result);
1242
- } else {
1243
- initialPhase.failed.push(result);
1244
- }
1245
- });
1246
-
1247
- // Add pre-failed models to failed list
1248
- initialPhase.failed.push(...failedModels);
1249
-
1250
- let refinedPhase = null;
1251
-
1252
- // Phase 2: Cross-feedback (if enabled and we have multiple successful responses)
1253
- if (enable_cross_feedback && initialPhase.successful.length > 1) {
1254
- logger.debug('Running cross-feedback phase with streaming', {
1255
- data: { responseCount: initialPhase.successful.length },
1256
- });
1257
-
1258
- // Update job status for phase 2
1259
- await context.updateJob({
1260
- progress: {
1261
- phase: 'cross_feedback',
1262
- total_providers: initialPhase.successful.length,
1263
- completed_providers: 0,
1264
- provider_status: {},
1265
- },
1266
- });
1267
-
1268
- // Create cross-feedback prompt
1269
- const feedbackPrompt =
1270
- cross_feedback_prompt ||
1271
- `Based on the other AI responses below, please refine your answer to the original question. Consider different perspectives and provide your final response:
1272
-
1273
- Original Question: ${prompt}
1274
-
1275
- Other AI Responses:
1276
- ${initialPhase.successful.map((r, i) => `${i + 1}. ${r.model}: ${r.response}`).join('\n\n')}
1277
-
1278
- Please provide your refined response:`;
1279
-
1280
- // Build model-specific feedback calls
1281
- const feedbackCalls = initialPhase.successful.map((initialResult) => {
1282
- const call = providerCalls.find((c) => c.model === initialResult.model);
1283
-
1284
- // Build model-specific feedback messages with the assistant's initial response
1285
- const modelFeedbackMessages = [...messages];
1286
- modelFeedbackMessages.push({
1287
- role: 'assistant',
1288
- content: initialResult.response,
1289
- });
1290
- modelFeedbackMessages.push({
1291
- role: 'user',
1292
- content: feedbackPrompt,
1293
- });
1294
-
1295
- return {
1296
- ...call,
1297
- messages: modelFeedbackMessages,
1298
- initialResult,
1299
- };
1300
- });
1301
-
1302
- // Execute refinement phase with streaming
1303
- const refinementResults = await executeConsensusPhaseWithStreaming(
1304
- feedbackCalls,
1305
- null, // messages already embedded in feedbackCalls
1306
- 'refinement',
1307
- context,
1308
- providerStreamNormalizer,
1309
- );
1310
-
1311
- // Process refinement results
1312
- refinedPhase = refinementResults.map((result, index) => {
1313
- const initialResult = feedbackCalls[index].initialResult;
1314
- return {
1315
- ...initialResult,
1316
- refined_response: result.status === 'success' ? result.response : null,
1317
- refined_metadata: result.status === 'success' ? result.metadata : {},
1318
- refined_error: result.status === 'failed' ? result.error : null,
1319
- initial_response: initialResult.response,
1320
- status: result.status === 'success' ? 'success' : 'partial',
1321
- };
1322
- });
1323
- }
1324
-
1325
- // Save conversation state
1326
- let conversationState;
1327
- try {
1328
- // Build formatted consensus response with actual model responses
1329
- let formattedContent = '';
1330
-
1331
- // Add initial responses
1332
- formattedContent += '## Initial Responses\n\n';
1333
- for (const result of initialPhase.successful) {
1334
- formattedContent += `### ${result.model} (initial response):\n${result.response}\n\n---\n\n`;
1335
- }
1336
-
1337
- // Add refined responses if cross-feedback was enabled
1338
- if (refinedPhase) {
1339
- formattedContent += '## Refined Responses\n\n';
1340
- for (const result of refinedPhase) {
1341
- if (result.status === 'success' && result.refined_response) {
1342
- formattedContent += `### ${result.model} (refined response):\n${result.refined_response}\n\n---\n\n`;
1343
- } else if (result.status === 'partial') {
1344
- // Show initial response for models that failed refinement
1345
- formattedContent += `### ${result.model} (refinement failed, showing initial):\n${result.initial_response}\n\n---\n\n`;
1346
- }
1347
- }
1348
- }
1349
-
1350
- // Add summary at the end
1351
- formattedContent += `\n**Summary:** Consensus completed with ${initialPhase.successful.length} successful initial responses`;
1352
- if (refinedPhase) {
1353
- const successfulRefinements = refinedPhase.filter(
1354
- (r) => r.status === 'success',
1355
- ).length;
1356
- formattedContent += ` and ${successfulRefinements} successful refined responses`;
1357
- }
1358
- formattedContent += '.';
1359
- const consensusMessage = {
1360
- role: 'assistant',
1361
- content: formattedContent,
1362
- };
1363
-
1364
- conversationState = {
1365
- messages: [...messages, consensusMessage],
1366
- type: 'consensus',
1367
- lastUpdated: Date.now(),
1368
- consensusData: {
1369
- modelsRequested: models.length,
1370
- providersSuccessful: initialPhase.successful.length,
1371
- providersFailed: initialPhase.failed.length,
1372
- crossFeedbackEnabled: enable_cross_feedback,
1373
- },
1374
- };
1375
-
1376
- await continuationStore.set(continuationId, conversationState);
1377
- } catch (error) {
1378
- logger.error('Error saving consensus conversation', { error });
1379
- }
1380
-
1381
- // Export conversation if requested
1382
- if (shouldExport && conversationState) {
1383
- await exportConversation(conversationState, {
1384
- clientCwd: config.server?.client_cwd,
1385
- continuation_id: continuationId,
1386
- models,
1387
- temperature,
1388
- reasoning_effort,
1389
- use_websearch,
1390
- files,
1391
- images,
1392
- enable_cross_feedback,
1393
- });
1394
- }
1395
-
1396
- const consensusExecutionTime = (Date.now() - consensusStartTime) / 1000;
1397
-
1398
- // Generate final summary from combined responses
1399
- let finalSummary = null;
1400
- const combinedResponses = [];
1401
-
1402
- // Collect all successful responses for summary generation
1403
- if (refinedPhase) {
1404
- // Use refined responses when available
1405
- refinedPhase.forEach((result) => {
1406
- if (result.status === 'success' && result.refined_response) {
1407
- combinedResponses.push(`${result.model}:\n${result.refined_response}`);
1408
- } else if (result.initial_response) {
1409
- // Fall back to initial response if refinement failed
1410
- combinedResponses.push(`${result.model}:\n${result.initial_response}`);
1411
- }
1412
- });
1413
- } else {
1414
- // Use initial responses
1415
- initialPhase.successful.forEach((result) => {
1416
- if (result.response) {
1417
- combinedResponses.push(`${result.model}:\n${result.response}`);
1418
- }
1419
- });
1420
- }
1421
-
1422
- // Generate summary if we have responses
1423
- if (combinedResponses.length > 0) {
1424
- const combinedContent = combinedResponses.join('\n\n---\n\n');
1425
- if (combinedContent.length > 100) {
1426
- try {
1427
- finalSummary =
1428
- await summarizationService.generateFinalSummary(combinedContent);
1429
- debugLog(`Consensus: Generated final summary - "${finalSummary}"`);
1430
-
1431
- // Update job with final summary
1432
- await context.updateJob({
1433
- final_summary: finalSummary,
1434
- });
1435
- } catch (error) {
1436
- debugError('Consensus: Error generating final summary', error);
1437
- // Continue without summary if generation fails
1438
- }
1439
- }
1440
- }
1441
-
1442
- // Calculate final success count and collect failure details
1443
- let finalSuccessCount;
1444
- const failureDetails = [];
1445
-
1446
- if (enable_cross_feedback && refinedPhase) {
1447
- finalSuccessCount = refinedPhase.filter(
1448
- (r) => r.status === 'success',
1449
- ).length;
1450
-
1451
- refinedPhase.forEach((result) => {
1452
- if (result.status === 'partial') {
1453
- failureDetails.push(`${result.model} (refinement failed)`);
1454
- }
1455
- });
1456
-
1457
- initialPhase.failed.forEach((failure) => {
1458
- failureDetails.push(`${failure.model} (initial failed)`);
1459
- });
1460
- } else {
1461
- finalSuccessCount = initialPhase.successful.length;
1462
- initialPhase.failed.forEach((failure) => {
1463
- failureDetails.push(`${failure.model} (${failure.error})`);
1464
- });
1465
- }
1466
-
1467
- // Return complete consensus result for job completion
1468
- return {
1469
- status: 'consensus_complete',
1470
- models_consulted: models.length,
1471
- successful_initial_responses: initialPhase.successful.length,
1472
- failed_responses: initialPhase.failed.length,
1473
- refined_responses: refinedPhase
1474
- ? refinedPhase.filter((r) => r.status === 'success').length
1475
- : 0,
1476
- phases: {
1477
- initial: initialPhase.successful,
1478
- ...(refinedPhase !== null && { refined: refinedPhase }),
1479
- failed: initialPhase.failed,
1480
- },
1481
- continuation: {
1482
- id: continuationId,
1483
- messageCount: messages.length + 1,
1484
- ...(isCustomId && { custom_id: true }),
1485
- },
1486
- settings: {
1487
- enable_cross_feedback,
1488
- temperature,
1489
- models_requested: models,
1490
- },
1491
- metadata: {
1492
- execution_time: consensusExecutionTime,
1493
- async_execution: true,
1494
- successful_models: finalSuccessCount,
1495
- total_models: models.length,
1496
- failure_details: failureDetails,
1497
- title,
1498
- final_summary: finalSummary,
1499
- },
1500
- };
1501
- }
1502
-
1503
- /**
1504
- * Execute a consensus phase (initial or refinement) with streaming support
1505
- * @param {Array} providerCalls - Provider calls to execute
1506
- * @param {Array} messages - Messages to send (null if already embedded in calls)
1507
- * @param {string} phase - Phase name ('initial' or 'refinement')
1508
- * @param {object} context - Job execution context
1509
- * @param {object} streamNormalizer - Stream normalizer instance
1510
- * @returns {Promise<Array>} Results from all providers
1511
- */
1512
- async function executeConsensusPhaseWithStreaming(
1513
- providerCalls,
1514
- messages,
1515
- phase,
1516
- context,
1517
- streamNormalizer,
1518
- ) {
1519
- let completedCount = 0;
1520
- const totalCount = providerCalls.length;
1521
- const providerContents = {}; // Store accumulated content per provider
1522
-
1523
- const results = await Promise.allSettled(
1524
- providerCalls.map(async (call, index) => {
1525
- try {
1526
- // Check for cancellation before starting
1527
- if (context.signal.aborted) {
1528
- throw new Error('Consensus execution was cancelled');
1529
- }
1530
-
1531
- // Update provider status to 'prompting'
1532
- await context.updateJob({
1533
- progress: {
1534
- [`provider_${index}_status`]: 'prompting',
1535
- [`provider_${index}_model`]: call.model,
1536
- },
1537
- });
1538
-
1539
- const messagesToSend = call.messages || messages;
1540
- let response;
1541
-
1542
- // Try streaming: .stream() method first, then invoke({stream:true}) for SDK providers
1543
- let stream = null;
1544
- if (
1545
- call.providerInstance.stream &&
1546
- typeof call.providerInstance.stream === 'function'
1547
- ) {
1548
- stream = call.providerInstance.stream(
1549
- messagesToSend,
1550
- call.options,
1551
- );
1552
- } else {
1553
- // SDK providers (copilot, codex, claude, gemini-cli) stream via invoke
1554
- const streamResult = await call.providerInstance.invoke(
1555
- messagesToSend,
1556
- { ...call.options, stream: true },
1557
- );
1558
- // Check if result is an async iterable (streaming) vs a plain response
1559
- if (streamResult && typeof streamResult[Symbol.asyncIterator] === 'function') {
1560
- stream = streamResult;
1561
- } else {
1562
- // Provider returned a non-streaming response — use it directly
1563
- response = streamResult;
1564
- }
1565
- }
1566
-
1567
- if (stream) {
1568
- const normalizedStream = streamNormalizer.normalize(
1569
- call.provider,
1570
- stream,
1571
- {
1572
- provider: call.provider,
1573
- model: call.options.model,
1574
- requestId: `${context.jobId}-${phase}-${index}`,
1575
- },
1576
- );
1577
-
1578
- // Process normalized stream
1579
- let accumulatedContent = '';
1580
- let finalUsage = null;
1581
- let finalMetadata = {};
1582
-
1583
- await context.updateJob({
1584
- progress: { [`provider_${index}_status`]: 'streaming' },
1585
- });
1586
-
1587
- for await (const event of normalizedStream) {
1588
- if (context.signal.aborted) {
1589
- throw new Error('Consensus execution was cancelled');
1590
- }
1591
-
1592
- switch (event.type) {
1593
- case 'delta':
1594
- accumulatedContent += event.data.textDelta;
1595
- // Store provider's accumulated content
1596
- providerContents[index] = accumulatedContent;
1597
-
1598
- // Combine all provider contents for unified accumulated_content
1599
- const combinedContent = Object.values(providerContents)
1600
- .filter((content) => content && content.length > 0)
1601
- .join('\n\n---\n\n');
1602
-
1603
- // Update with both provider preview and combined accumulated content
1604
- await context.updateJob({
1605
- [`provider_${index}_preview`]:
1606
- accumulatedContent.length > 150
1607
- ? accumulatedContent.substring(0, 150) + '...'
1608
- : accumulatedContent,
1609
- accumulated_content: combinedContent,
1610
- });
1611
- break;
1612
- case 'usage':
1613
- finalUsage = event.data.usage;
1614
- break;
1615
- case 'end':
1616
- accumulatedContent = event.data.content || accumulatedContent;
1617
- finalUsage = event.data.usage || finalUsage;
1618
- finalMetadata = event.data.metadata || finalMetadata;
1619
- break;
1620
- case 'error':
1621
- throw new Error(`Streaming error: ${event.data.error.message}`);
1622
- }
1623
- }
1624
-
1625
- response = {
1626
- content: accumulatedContent,
1627
- metadata: {
1628
- ...finalMetadata,
1629
- usage: finalUsage,
1630
- streaming: true,
1631
- },
1632
- };
1633
-
1634
- // Store final provider content
1635
- providerContents[index] = accumulatedContent;
1636
- }
1637
-
1638
- if (!stream) {
1639
- // Non-streaming path (provider doesn't support streaming at all)
1640
- if (!response) {
1641
- response = await call.providerInstance.invoke(
1642
- messagesToSend,
1643
- call.options,
1644
- );
1645
- }
1646
-
1647
- // Store provider content for non-streaming response
1648
- if (response && response.content) {
1649
- providerContents[index] = response.content;
1650
-
1651
- // Update accumulated content for non-streaming provider
1652
- const combinedContent = Object.values(providerContents)
1653
- .filter((content) => content && content.length > 0)
1654
- .join('\n\n---\n\n');
1655
-
1656
- await context.updateJob({
1657
- accumulated_content: combinedContent,
1658
- });
1659
- }
1660
- }
1661
-
1662
- // Update provider status to 'finished'
1663
- completedCount++;
1664
- const progressText =
1665
- phase === 'initial'
1666
- ? `${completedCount}/${totalCount} initial`
1667
- : phase === 'refinement'
1668
- ? `${completedCount}/${totalCount} refined`
1669
- : `${completedCount}/${totalCount} responded`;
1670
-
1671
- await context.updateJob({
1672
- consensus_progress: progressText,
1673
- progress: {
1674
- [`provider_${index}_status`]: 'finished',
1675
- completed_providers: completedCount,
1676
- },
1677
- });
1678
-
1679
- return {
1680
- model: call.model,
1681
- provider: call.provider,
1682
- status: 'success',
1683
- response: response.content,
1684
- metadata: response.metadata || {},
1685
- };
1686
- } catch (error) {
1687
- // Update provider status to 'failed'
1688
- await context.updateJob({
1689
- progress: {
1690
- [`provider_${index}_status`]: 'failed',
1691
- [`provider_${index}_error`]: error.message,
1692
- },
1693
- });
1694
-
1695
- return {
1696
- model: call.model,
1697
- provider: call.provider,
1698
- status: 'failed',
1699
- error: error.message,
1700
- metadata: {},
1701
- };
1702
- }
1703
- }),
1704
- );
1705
-
1706
- // After all providers complete, update with final combined content
1707
- const finalCombinedContent = Object.values(providerContents)
1708
- .filter((content) => content && content.length > 0)
1709
- .join('\n\n---\n\n');
1710
-
1711
- if (finalCombinedContent) {
1712
- await context.updateJob({
1713
- accumulated_content: finalCombinedContent,
1714
- });
1715
- }
1716
-
1717
- return results.map((result, index) => {
1718
- if (result.status === 'fulfilled') {
1719
- return result.value;
1720
- } else {
1721
- return {
1722
- model: providerCalls[index].model,
1723
- provider: providerCalls[index].provider,
1724
- status: 'failed',
1725
- error: result.reason.message || 'Unknown error',
1726
- metadata: {},
1727
- };
1728
- }
1729
- });
1730
- }
1731
-
1732
- // Tool metadata
1733
- consensusTool.description =
1734
- 'PARALLEL CONSENSUS WITH CROSS-MODEL FEEDBACK - Query multiple models simultaneously, then optionally refine responses based on cross-feedback. For complex decisions, architectural choices, technical evaluations. Use models: ["auto"] for automatic selection. IMPORTANT: Use the "files" parameter to share code/file content instead of pasting into the prompt.';
1735
- consensusTool.inputSchema = {
1736
- type: 'object',
1737
- properties: {
1738
- models: {
1739
- type: 'array',
1740
- items: { type: 'string' },
1741
- minItems: 1,
1742
- description:
1743
- 'List of models to consult. Examples: ["codex", "gemini", "gemini:flash", "claude", "claude:opus", "copilot", "copilot:codex"]',
1744
- },
1745
- files: {
1746
- type: 'array',
1747
- items: { type: 'string' },
1748
- description:
1749
- 'File paths for additional context (absolute or relative paths). Supports line ranges: file.txt{10:50}, file.txt{100:}. Example: ["./docs/architecture.md{1:100}", "./requirements.txt"]. IMPORTANT: Always use this parameter to share file content instead of copying code into the prompt - it provides better formatting, line numbers, and preserves context.',
1750
- },
1751
- images: {
1752
- type: 'array',
1753
- items: { type: 'string' },
1754
- description:
1755
- 'Image paths for visual context (absolute or relative paths, or base64). Example: ["C:\\Users\\username\\current_architecture.png", "./user_flow.jpg"]',
1756
- },
1757
- continuation_id: {
1758
- type: 'string',
1759
- description:
1760
- 'Thread continuation ID for multi-turn conversations. Auto-generated in the first response; pass it back to continue the conversation.',
1761
- },
1762
- enable_cross_feedback: {
1763
- type: 'boolean',
1764
- description:
1765
- 'Enable refinement phase where models see others\' responses and can improve their answers. Example: true (recommended), false (faster single-phase only). Default: true',
1766
- default: true,
1767
- },
1768
- cross_feedback_prompt: {
1769
- type: 'string',
1770
- description:
1771
- 'Custom prompt for refinement phase. Example: "Focus on scalability trade-offs in your refinement" or leave empty for default cross-feedback prompt',
1772
- },
1773
- temperature: {
1774
- type: 'number',
1775
- description:
1776
- 'Response randomness (0.0-1.0). Examples: 0.1 (very focused), 0.2 (analytical - default), 0.5 (balanced). Default: 0.2',
1777
- minimum: 0.0,
1778
- maximum: 1.0,
1779
- default: 0.2,
1780
- },
1781
- reasoning_effort: {
1782
- type: 'string',
1783
- enum: ['none', 'minimal', 'low', 'medium', 'high', 'max'],
1784
- description:
1785
- 'Reasoning depth for thinking models. Examples: "none" (no reasoning, fastest), "low" (light analysis), "medium" (balanced), "high" (complex analysis). Default: "medium"',
1786
- default: 'medium',
1787
- },
1788
- use_websearch: {
1789
- type: 'boolean',
1790
- description:
1791
- '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',
1792
- default: false,
1793
- },
1794
- async: {
1795
- type: 'boolean',
1796
- description:
1797
- 'Execute consensus in background with detailed progress tracking. When true, returns continuation_id immediately and processes request asynchronously with per-provider status updates. Default: false',
1798
- default: false,
1799
- },
1800
- export: {
1801
- type: 'boolean',
1802
- description:
1803
- 'Export conversation to disk. Creates folder with continuation_id name containing numbered request/response files and metadata. Default: false',
1804
- default: false,
1805
- },
1806
- prompt: {
1807
- type: 'string',
1808
- description:
1809
- '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?"',
1810
- },
1811
- },
1812
- required: ['prompt', 'models'],
1813
- };