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
@@ -245,6 +245,7 @@ async function listAllJobs(asyncJobStore, fileCache, options = {}) {
245
245
  jobId: cachedJob.jobId,
246
246
  status: cachedJob.status || 'completed',
247
247
  tool: cachedJob.tool || 'unknown',
248
+ mode: cachedJob.mode || cachedJob.tool,
248
249
  createdAt: cachedJob.createdAt || cachedJob.startedAt,
249
250
  updatedAt:
250
251
  cachedJob.completedAt || cachedJob.updatedAt || Date.now(),
@@ -7,8 +7,6 @@
7
7
 
8
8
  // Import individual tools
9
9
  import { chatTool } from './chat.js';
10
- import { consensusTool } from './consensus.js';
11
- import { conversationTool } from './conversation.js';
12
10
  import { checkStatusTool } from './checkStatus.js';
13
11
  import { cancelJobTool } from './cancelJob.js';
14
12
 
@@ -19,8 +17,6 @@ import { cancelJobTool } from './cancelJob.js';
19
17
  */
20
18
  const tools = {
21
19
  chat: chatTool,
22
- consensus: consensusTool,
23
- conversation: conversationTool,
24
20
  check_status: checkStatusTool,
25
21
  cancel_job: cancelJobTool,
26
22
  };
@@ -47,8 +43,8 @@ export function getTools(config = null) {
47
43
  // Clone the tool to avoid mutating the original
48
44
  const clonedTool = tool;
49
45
 
50
- // For chat, consensus, and conversation tools, remove the 'async' parameter from their schemas
51
- if (name === 'chat' || name === 'consensus' || name === 'conversation') {
46
+ // For the chat tool, remove the 'async' parameter from its schema
47
+ if (name === 'chat') {
52
48
  // Create a modified inputSchema without the async parameter
53
49
  const modifiedSchema = {
54
50
  ...tool.inputSchema,
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Parallel Mode Engine
3
+ *
4
+ * Serves the `chat` and `consensus` modes. Both fan out a set of call plans and
5
+ * invoke providers concurrently; consensus additionally runs a cross-feedback
6
+ * refinement phase. This is an execution core: it resolves candidates, invokes,
7
+ * streams per-call progress, and RETURNS invocation data (per-call responses and
8
+ * failures, selected candidate, metadata/thread IDs). Persistence, export, and
9
+ * MCP-response construction live in the unified chat tool's shared shell.
10
+ *
11
+ * A call plan is `{ modelSpec, displayModel, threadKey, candidates: [{ name,
12
+ * providerInstance, resolvedModel }, ...] }`. In `chat` mode the "auto" spec
13
+ * yields one call plan with the full provider-priority candidate list and the
14
+ * engine fails over across candidates in order; an explicit model yields one
15
+ * candidate (no failover). In `consensus` mode every call plan has a single
16
+ * candidate and there is no failover.
17
+ */
18
+
19
+ import { createLogger } from '../../utils/logger.js';
20
+ import {
21
+ isRecoverableError,
22
+ retryWithBackoff,
23
+ } from '../../utils/errorHandler.js';
24
+ import { acquireProviderStream } from './streamShared.js';
25
+
26
+ const logger = createLogger('parallel');
27
+
28
+ /**
29
+ * Decide whether a provider error should advance auto-mode to the next candidate.
30
+ * @param {Error} error
31
+ * @returns {boolean}
32
+ */
33
+ export function shouldFailoverToNextProvider(error) {
34
+ if (isRecoverableError(error)) {
35
+ return true;
36
+ }
37
+
38
+ const message = (error && error.message) || '';
39
+ return /(api key|authentication|unauthorized|forbidden|invalid|not available)/i.test(
40
+ message,
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Per-provider retry options, matching the chat tool's historical behavior.
46
+ * @param {object} config
47
+ * @param {string} providerName
48
+ * @returns {object}
49
+ */
50
+ export function getProviderRetryOptions(config, providerName) {
51
+ const nodeEnv = config?.environment?.nodeEnv || process.env.NODE_ENV;
52
+ const isTest = nodeEnv === 'test';
53
+
54
+ return {
55
+ retries: isTest ? 1 : 3,
56
+ delay: isTest ? 0 : 500,
57
+ maxDelay: isTest ? 0 : 10000,
58
+ operation: `provider-invoke:${providerName}`,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * The single cross-feedback refinement prompt template. This is now the only
64
+ * template — there is no per-call override.
65
+ * @param {string} prompt - Original question
66
+ * @param {Array} successful - Successful phase-1 results
67
+ * @returns {string}
68
+ */
69
+ function buildFeedbackPrompt(prompt, successful) {
70
+ return `Based on the other AI responses below, please refine your answer to the original question. Consider different perspectives and provide your final response:
71
+
72
+ Original Question: ${prompt}
73
+
74
+ Other AI Responses:
75
+ ${successful.map((r, i) => `${i + 1}. ${r.model}: ${r.response}`).join('\n\n')}
76
+
77
+ Please provide your refined response:`;
78
+ }
79
+
80
+ /**
81
+ * Join every call's accumulated streaming content for the unified
82
+ * `accumulated_content` field shown by check_status.
83
+ * @param {object} contents - Map of index -> accumulated text
84
+ * @returns {string}
85
+ */
86
+ function combineContents(contents) {
87
+ return Object.values(contents)
88
+ .filter((content) => content && content.length > 0)
89
+ .join('\n\n---\n\n');
90
+ }
91
+
92
+ /**
93
+ * Invoke a single candidate, streaming through the normalizer when a job context
94
+ * is present (updating flat `provider_${index}_*` progress keys) or performing a
95
+ * plain invoke otherwise.
96
+ * @returns {Promise<object>} Provider response { content, metadata }
97
+ */
98
+ async function invokeCandidate({
99
+ candidate,
100
+ messages,
101
+ options,
102
+ index,
103
+ phaseLabel,
104
+ context,
105
+ streamNormalizer,
106
+ providerContents,
107
+ }) {
108
+ // Reset this call's partial content at the start of every attempt so a retried
109
+ // or failed-over attempt never leaves stale streamed text behind.
110
+ providerContents[index] = '';
111
+
112
+ if (!context) {
113
+ const response = await candidate.providerInstance.invoke(messages, options);
114
+ if (response?.content) {
115
+ providerContents[index] = response.content;
116
+ }
117
+ return response;
118
+ }
119
+
120
+ await context.updateJob({
121
+ [`provider_${index}_status`]: 'prompting',
122
+ [`provider_${index}_model`]: candidate.displayModel,
123
+ });
124
+
125
+ const { stream, response: acquiredResponse } = await acquireProviderStream(
126
+ candidate.providerInstance,
127
+ messages,
128
+ options,
129
+ );
130
+ let response = acquiredResponse;
131
+
132
+ if (stream) {
133
+ const normalizedStream = streamNormalizer.normalize(candidate.name, stream, {
134
+ provider: candidate.name,
135
+ model: options.model,
136
+ requestId: `${context.jobId}-${phaseLabel}-${index}`,
137
+ });
138
+
139
+ let accumulatedContent = '';
140
+ let finalUsage = null;
141
+ let finalMetadata = {};
142
+
143
+ await context.updateJob({ [`provider_${index}_status`]: 'streaming' });
144
+
145
+ for await (const event of normalizedStream) {
146
+ if (context.signal?.aborted) {
147
+ throw new Error('Execution was cancelled');
148
+ }
149
+
150
+ switch (event.type) {
151
+ case 'delta':
152
+ accumulatedContent += event.data.textDelta;
153
+ providerContents[index] = accumulatedContent;
154
+ await context.updateJob({
155
+ [`provider_${index}_preview`]:
156
+ accumulatedContent.length > 150
157
+ ? accumulatedContent.substring(0, 150) + '...'
158
+ : accumulatedContent,
159
+ accumulated_content: combineContents(providerContents),
160
+ });
161
+ break;
162
+ case 'usage':
163
+ finalUsage = event.data.usage;
164
+ break;
165
+ case 'end':
166
+ accumulatedContent = event.data.content || accumulatedContent;
167
+ finalUsage = event.data.usage || finalUsage;
168
+ finalMetadata = event.data.metadata || finalMetadata;
169
+ break;
170
+ case 'error':
171
+ throw new Error(`Streaming error: ${event.data.error.message}`);
172
+ }
173
+ }
174
+
175
+ response = {
176
+ content: accumulatedContent,
177
+ metadata: { ...finalMetadata, usage: finalUsage, streaming: true },
178
+ };
179
+ providerContents[index] = accumulatedContent;
180
+ } else {
181
+ if (!response) {
182
+ response = await candidate.providerInstance.invoke(messages, options);
183
+ }
184
+ if (response?.content) {
185
+ providerContents[index] = response.content;
186
+ await context.updateJob({
187
+ accumulated_content: combineContents(providerContents),
188
+ });
189
+ }
190
+ }
191
+
192
+ return response;
193
+ }
194
+
195
+ /**
196
+ * Run one fan-out phase over the given call plans.
197
+ * @param {object} params
198
+ * @returns {Promise<Array>} Per-call-plan results
199
+ */
200
+ async function runPhase({
201
+ callPlans,
202
+ buildMessagesForCandidate,
203
+ optionsForCandidate,
204
+ activeSignal,
205
+ context,
206
+ streamNormalizer,
207
+ progressKey,
208
+ phaseWord,
209
+ phaseLabel,
210
+ retryOptionsFor,
211
+ }) {
212
+ const providerContents = {};
213
+ const totalCount = callPlans.length;
214
+ let completedCount = 0;
215
+
216
+ if (context) {
217
+ await context.updateJob({
218
+ [progressKey]: `0/${totalCount}${phaseWord ? ` ${phaseWord}` : ''}`,
219
+ });
220
+ }
221
+
222
+ const settled = await Promise.allSettled(
223
+ callPlans.map(async (callPlan, index) => {
224
+ let lastError = null;
225
+
226
+ for (let ci = 0; ci < callPlan.candidates.length; ci++) {
227
+ const candidate = callPlan.candidates[ci];
228
+
229
+ // Never fail over (or start a new candidate) once aborted.
230
+ if (activeSignal?.aborted) {
231
+ throw new Error('Execution was cancelled');
232
+ }
233
+
234
+ const messages = buildMessagesForCandidate(candidate, callPlan);
235
+ const options = {
236
+ ...optionsForCandidate(candidate, callPlan),
237
+ signal: activeSignal,
238
+ };
239
+
240
+ try {
241
+ const attempt = () =>
242
+ invokeCandidate({
243
+ candidate,
244
+ messages,
245
+ options,
246
+ index,
247
+ phaseLabel,
248
+ context,
249
+ streamNormalizer,
250
+ providerContents,
251
+ });
252
+
253
+ const retryOpts = retryOptionsFor
254
+ ? retryOptionsFor(candidate.name)
255
+ : null;
256
+ const response = retryOpts
257
+ ? await retryWithBackoff(attempt, retryOpts)
258
+ : await attempt();
259
+
260
+ if (!response || !response.content) {
261
+ throw new Error('Provider returned invalid response');
262
+ }
263
+
264
+ completedCount++;
265
+ if (context) {
266
+ await context.updateJob({
267
+ [progressKey]: `${completedCount}/${totalCount}${phaseWord ? ` ${phaseWord}` : ''}`,
268
+ [`provider_${index}_status`]: 'finished',
269
+ });
270
+ }
271
+
272
+ return {
273
+ modelSpec: callPlan.modelSpec,
274
+ model: callPlan.displayModel,
275
+ provider: candidate.name,
276
+ providerInstance: candidate.providerInstance,
277
+ resolvedModel: candidate.resolvedModel,
278
+ threadKey: callPlan.threadKey,
279
+ status: 'success',
280
+ response: response.content,
281
+ metadata: response.metadata || {},
282
+ };
283
+ } catch (error) {
284
+ lastError = error;
285
+
286
+ // Cancellation aborts the whole phase — never demote to failed or fail over.
287
+ if (activeSignal?.aborted || error.name === 'AbortError') {
288
+ throw error;
289
+ }
290
+
291
+ const isLastCandidate = ci === callPlan.candidates.length - 1;
292
+ const terminal =
293
+ isLastCandidate || !shouldFailoverToNextProvider(error);
294
+
295
+ // Partial text streamed before the error. On a terminal failure we
296
+ // RETAIN it (design: partial text received before an in-band stream
297
+ // error is kept, with the operation ultimately marked failed rather
298
+ // than successfully completed); on failover we clear it so the next
299
+ // candidate starts clean — it may later overwrite the preview as it
300
+ // streams.
301
+ const partial = providerContents[index] || '';
302
+
303
+ if (context) {
304
+ if (terminal) {
305
+ await context.updateJob({
306
+ [`provider_${index}_status`]: 'failed',
307
+ [`provider_${index}_error`]: error.message,
308
+ accumulated_content: combineContents(providerContents),
309
+ });
310
+ } else {
311
+ providerContents[index] = '';
312
+ await context.updateJob({
313
+ [`provider_${index}_status`]: 'failed',
314
+ [`provider_${index}_error`]: error.message,
315
+ [`provider_${index}_preview`]: null,
316
+ accumulated_content: combineContents(providerContents),
317
+ });
318
+ }
319
+ }
320
+
321
+ if (terminal) {
322
+ const failure = {
323
+ modelSpec: callPlan.modelSpec,
324
+ model: callPlan.displayModel,
325
+ provider: candidate.name,
326
+ resolvedModel: candidate.resolvedModel,
327
+ threadKey: callPlan.threadKey,
328
+ status: 'failed',
329
+ error: error.message,
330
+ metadata: {},
331
+ };
332
+ if (partial) {
333
+ failure.partial_content = partial.slice(0, 2000);
334
+ }
335
+ return failure;
336
+ }
337
+ // otherwise continue to the next candidate
338
+ }
339
+ }
340
+
341
+ return {
342
+ modelSpec: callPlan.modelSpec,
343
+ model: callPlan.displayModel,
344
+ threadKey: callPlan.threadKey,
345
+ status: 'failed',
346
+ error: (lastError && lastError.message) || 'Unknown error',
347
+ metadata: {},
348
+ };
349
+ }),
350
+ );
351
+
352
+ return settled.map((result, index) => {
353
+ if (result.status === 'fulfilled') {
354
+ return result.value;
355
+ }
356
+ // A rejected settle means cancellation propagated; re-throw to the caller so
357
+ // the shell/job runner treats the whole operation as cancelled.
358
+ throw result.reason instanceof Error
359
+ ? result.reason
360
+ : new Error(callPlans[index] ? 'Execution was cancelled' : 'Execution failed');
361
+ });
362
+ }
363
+
364
+ /**
365
+ * Run `chat` mode: a single fan-out phase with per-call candidate failover.
366
+ * @param {object} params
367
+ * @returns {Promise<{results: Array}>}
368
+ */
369
+ export async function runChatMode({
370
+ callPlans,
371
+ buildMessagesForCandidate,
372
+ optionsForCandidate,
373
+ signal,
374
+ context = null,
375
+ providerStreamNormalizer,
376
+ retryOptionsFor,
377
+ }) {
378
+ const activeSignal = context ? context.signal : signal;
379
+
380
+ const results = await runPhase({
381
+ callPlans,
382
+ buildMessagesForCandidate,
383
+ optionsForCandidate,
384
+ activeSignal,
385
+ context,
386
+ streamNormalizer: providerStreamNormalizer,
387
+ progressKey: 'chat_progress',
388
+ phaseWord: '',
389
+ phaseLabel: 'chat',
390
+ retryOptionsFor,
391
+ });
392
+
393
+ return { results };
394
+ }
395
+
396
+ /**
397
+ * Run `consensus` mode: an initial fan-out phase, then (when ≥2 phase-1 responses
398
+ * succeeded and the request was not aborted) a cross-feedback refinement phase.
399
+ * @param {object} params
400
+ * @returns {Promise<{initial: Array, refined: Array|null}>}
401
+ */
402
+ export async function runConsensusMode({
403
+ callPlans,
404
+ buildMessagesForCandidate,
405
+ optionsForCandidate,
406
+ prompt,
407
+ signal,
408
+ context = null,
409
+ providerStreamNormalizer,
410
+ }) {
411
+ const activeSignal = context ? context.signal : signal;
412
+
413
+ const initial = await runPhase({
414
+ callPlans,
415
+ buildMessagesForCandidate,
416
+ optionsForCandidate,
417
+ activeSignal,
418
+ context,
419
+ streamNormalizer: providerStreamNormalizer,
420
+ progressKey: 'consensus_progress',
421
+ phaseWord: 'initial',
422
+ phaseLabel: 'initial',
423
+ retryOptionsFor: null,
424
+ });
425
+
426
+ const successful = initial.filter((r) => r.status === 'success');
427
+
428
+ let refined = null;
429
+
430
+ // Refinement runs whenever ≥2 phase-1 responses succeeded (no gating flag).
431
+ if (successful.length > 1 && !activeSignal?.aborted) {
432
+ logger.debug('Running cross-feedback refinement phase', {
433
+ data: { responseCount: successful.length },
434
+ });
435
+
436
+ const feedbackPrompt = buildFeedbackPrompt(prompt, successful);
437
+
438
+ const refineCallPlans = successful.map((r) => ({
439
+ modelSpec: r.modelSpec,
440
+ displayModel: r.model,
441
+ threadKey: r.threadKey,
442
+ initialResponse: r.response,
443
+ candidates: [
444
+ {
445
+ name: r.provider,
446
+ providerInstance: r.providerInstance,
447
+ resolvedModel: r.resolvedModel,
448
+ displayModel: r.model,
449
+ },
450
+ ],
451
+ }));
452
+
453
+ const refinedResults = await runPhase({
454
+ callPlans: refineCallPlans,
455
+ buildMessagesForCandidate: (candidate, callPlan) => [
456
+ ...buildMessagesForCandidate(candidate, callPlan),
457
+ { role: 'assistant', content: callPlan.initialResponse },
458
+ { role: 'user', content: feedbackPrompt },
459
+ ],
460
+ optionsForCandidate,
461
+ activeSignal,
462
+ context,
463
+ streamNormalizer: providerStreamNormalizer,
464
+ progressKey: 'consensus_progress',
465
+ phaseWord: 'refined',
466
+ phaseLabel: 'refinement',
467
+ retryOptionsFor: null,
468
+ });
469
+
470
+ // Map refinement outcomes back onto the phase-1 successes.
471
+ refined = refinedResults.map((result) => {
472
+ const initialResult = successful.find(
473
+ (s) => s.modelSpec === result.modelSpec,
474
+ );
475
+ return {
476
+ model: result.model,
477
+ provider: result.provider,
478
+ initial_response: initialResult ? initialResult.response : null,
479
+ refined_response: result.status === 'success' ? result.response : null,
480
+ refined_metadata: result.status === 'success' ? result.metadata : {},
481
+ refined_error: result.status === 'failed' ? result.error : null,
482
+ status: result.status === 'success' ? 'success' : 'partial',
483
+ };
484
+ });
485
+ }
486
+
487
+ return { initial, refined };
488
+ }