converse-mcp-server 2.22.8 → 2.26.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.
@@ -0,0 +1,1217 @@
1
+ /**
2
+ * Conversation Tool
3
+ *
4
+ * Turn-based multi-model round-table. Models respond SEQUENTIALLY in the order
5
+ * given; each model sees the full running transcript (prior laps + earlier turns
6
+ * in the current lap) and builds on it. One tool call runs exactly one lap (one
7
+ * turn per model); the caller drives more laps by passing back the continuation_id.
8
+ *
9
+ * This is a sibling of consensus.js (parallel fan-out). It reuses the same
10
+ * infrastructure (context processing, model routing, custom-ID handling, async
11
+ * streaming, summarization, token limiting, export) but replaces the parallel
12
+ * two-phase core with a sequential lap loop.
13
+ *
14
+ * CRITICAL provider constraint: SDK providers (codex, claude, copilot) reduce the
15
+ * message array to ONLY the last `user` message. Therefore each turn's entire
16
+ * context (prior-lap transcript + lap prompt + same-lap turns + framing) is packed
17
+ * into a SINGLE self-contained final user message ("turn packet"). Do not spread
18
+ * turn context across multiple messages.
19
+ */
20
+
21
+ import {
22
+ createToolResponse,
23
+ createToolError,
24
+ formatFailureDetails,
25
+ } from './index.js';
26
+ import {
27
+ createFileContext,
28
+ } from '../utils/contextProcessor.js';
29
+ import {
30
+ generateContinuationId,
31
+ isValidContinuationId,
32
+ } from '../continuationStore.js';
33
+ import { isSafeIdSegment } from '../utils/idValidation.js';
34
+ import { debugLog, debugError } from '../utils/console.js';
35
+ import { createLogger } from '../utils/logger.js';
36
+ import { CONVERSATION_PROMPT } from '../systemPrompts.js';
37
+ import { applyTokenLimit, getTokenLimit } from '../utils/tokenLimiter.js';
38
+ import { validateAllPaths } from '../utils/fileValidator.js';
39
+ import { SummarizationService } from '../services/summarizationService.js';
40
+ import { exportConversation } from '../utils/conversationExporter.js';
41
+ import {
42
+ mapModelToProvider,
43
+ resolveAutoModel,
44
+ getDefaultModelForProvider,
45
+ } from '../utils/modelRouting.js';
46
+
47
+ const logger = createLogger('conversation');
48
+
49
+ /**
50
+ * Render the stored transcript (from prior laps) into labeled text that can be
51
+ * embedded in the next turn's packet. Stored state pairs user (lap prompt) and
52
+ * assistant (lap transcript) messages; we re-render those as readable context so
53
+ * last-user-only SDK providers still see the history (and so a provider does not
54
+ * mistake prior multi-speaker transcript for its own previous output).
55
+ * @param {Array} storedMessages - Stored messages from a prior conversation state
56
+ * @returns {string} Labeled prior-transcript text ('' for a new conversation)
57
+ */
58
+ function renderStoredTranscriptToText(storedMessages = []) {
59
+ if (!Array.isArray(storedMessages) || storedMessages.length === 0) {
60
+ return '';
61
+ }
62
+
63
+ const blocks = [];
64
+ let lapNumber = 0;
65
+ let pendingPrompt = null;
66
+
67
+ const toText = (content) => {
68
+ if (typeof content === 'string') {
69
+ return content;
70
+ }
71
+ if (Array.isArray(content)) {
72
+ // Complex content array (files/images + text) — extract text parts only
73
+ return content
74
+ .filter((part) => part && part.type === 'text' && part.text)
75
+ .map((part) => part.text)
76
+ .join('\n');
77
+ }
78
+ return '';
79
+ };
80
+
81
+ for (const message of storedMessages) {
82
+ if (!message || message.role === 'system') {
83
+ continue;
84
+ }
85
+ if (message.role === 'user') {
86
+ pendingPrompt = toText(message.content);
87
+ } else if (message.role === 'assistant') {
88
+ lapNumber += 1;
89
+ const promptText = pendingPrompt ? `${pendingPrompt}\n\n` : '';
90
+ const assistantText = toText(message.content);
91
+ blocks.push(
92
+ `## Earlier in this round-table (lap ${lapNumber}):\n${promptText}${assistantText}`,
93
+ );
94
+ pendingPrompt = null;
95
+ }
96
+ }
97
+
98
+ return blocks.join('\n\n');
99
+ }
100
+
101
+ /**
102
+ * Build the per-turn framing text for the model at position `i`.
103
+ * @param {object} params
104
+ * @returns {string} Framing text appended to the turn packet
105
+ */
106
+ function buildFramingText({ i, models, turn_prompt }) {
107
+ const total = models.length;
108
+ const selfModel = models[i];
109
+ const prevModel = i > 0 ? models[i - 1] : null;
110
+ const nextModel = i < total - 1 ? models[i + 1] : null;
111
+
112
+ const order = models.join(', ');
113
+ const prevText = prevModel || 'no one (you open the round)';
114
+ const nextText = nextModel || 'no one (you close this round)';
115
+ const handoffText = nextModel
116
+ ? `Your response will be passed to the next participant (${nextModel}).`
117
+ : 'Your response will be returned to the user, as you are the last participant this round.';
118
+
119
+ const lines = [
120
+ `You are participant "${selfModel}" in a multi-model round-table conversation.`,
121
+ `Participants, in speaking order: ${order}.`,
122
+ `You are speaking in position ${i + 1} of ${total}, after ${prevText}, before ${nextText}.`,
123
+ 'The original topic/prompt for this round is shown above, followed by any responses already given this round.',
124
+ 'Respond to the whole conversation so far — build on, challenge, or refine what others have said; do not merely repeat them.',
125
+ handoffText,
126
+ ];
127
+
128
+ if (turn_prompt && typeof turn_prompt === 'string' && turn_prompt.trim()) {
129
+ lines.push(turn_prompt.trim());
130
+ }
131
+
132
+ return lines.join('\n');
133
+ }
134
+
135
+ /**
136
+ * Build the single self-contained turn packet TEXT for the model at position `i`.
137
+ * Order: prior-transcript section, lap prompt, same-lap turns, framing.
138
+ * This is the LAST user message — the only thing last-user-only SDK providers see.
139
+ * @param {object} params
140
+ * @returns {string} Turn packet text
141
+ */
142
+ function buildTurnPacket({
143
+ priorTranscriptText,
144
+ prompt,
145
+ sameLapTurns,
146
+ i,
147
+ models,
148
+ turn_prompt,
149
+ }) {
150
+ const parts = [];
151
+
152
+ if (priorTranscriptText && priorTranscriptText.trim()) {
153
+ parts.push(priorTranscriptText.trim());
154
+ }
155
+
156
+ parts.push(`Original topic for this round:\n${prompt}`);
157
+
158
+ // Same-lap turns from models 0..i-1 (omitted for the opener, i=0)
159
+ if (i > 0 && sameLapTurns.length > 0) {
160
+ const turnBlocks = sameLapTurns.map((turn) => {
161
+ if (turn.status === 'success') {
162
+ return `### ${turn.model} said:\n${turn.response}`;
163
+ }
164
+ return `### ${turn.model} did not respond (error: ${turn.error})`;
165
+ });
166
+ parts.push(turnBlocks.join('\n\n'));
167
+ }
168
+
169
+ parts.push(buildFramingText({ i, models, turn_prompt }));
170
+
171
+ return parts.join('\n\n');
172
+ }
173
+
174
+ /**
175
+ * Format the full lap transcript for storage/display.
176
+ * @param {Array} lapTurns - Turns from the current lap
177
+ * @returns {string} Formatted transcript
178
+ */
179
+ function formatLapTranscript(lapTurns) {
180
+ let content = '';
181
+ let successful = 0;
182
+
183
+ lapTurns.forEach((turn, index) => {
184
+ if (turn.status === 'success') {
185
+ successful += 1;
186
+ content += `### ${turn.model} (turn ${index + 1}):\n${turn.response}\n\n---\n\n`;
187
+ } else {
188
+ content += `### ${turn.model} (turn ${index + 1}, did not respond):\nError: ${turn.error}\n\n---\n\n`;
189
+ }
190
+ });
191
+
192
+ content += `\n**Summary:** Conversation lap completed with ${successful}/${lapTurns.length} successful turns.`;
193
+ return content;
194
+ }
195
+
196
+ /**
197
+ * Resolve the ordered model list into a turn plan. Unlike consensus, unknown or
198
+ * unavailable models are NOT dropped — they are recorded with a preFailReason so
199
+ * they keep their position in the order (and produce a failed turn).
200
+ * @param {Array<string>} models - Ordered model list
201
+ * @param {object} providers - Provider instances
202
+ * @param {object} config - Configuration
203
+ * @returns {Array<object>} Ordered turn plan entries
204
+ */
205
+ function resolveTurnPlan(models, providers, config) {
206
+ // Single "auto" expands to the first available provider's default model only
207
+ // (a single-model round-table is valid). Multiple explicit models resolve per-entry.
208
+ let modelsToProcess = models;
209
+ if (models.length === 1 && String(models[0]).toLowerCase() === 'auto') {
210
+ const providerOrder = [
211
+ 'codex',
212
+ 'gemini-cli',
213
+ 'claude',
214
+ 'copilot',
215
+ 'openai',
216
+ 'google',
217
+ 'xai',
218
+ 'anthropic',
219
+ 'mistral',
220
+ 'deepseek',
221
+ 'openrouter',
222
+ ];
223
+
224
+ let firstAvailable = null;
225
+ for (const providerName of providerOrder) {
226
+ const provider = providers[providerName];
227
+ if (provider && provider.isAvailable(config)) {
228
+ firstAvailable = providerName;
229
+ break;
230
+ }
231
+ }
232
+
233
+ // If a provider is available, use its default model. Otherwise keep "auto"
234
+ // so it resolves to a turn that fails cleanly (all-fail laps must complete).
235
+ modelsToProcess = firstAvailable
236
+ ? [getDefaultModelForProvider(firstAvailable)]
237
+ : ['auto'];
238
+ }
239
+
240
+ return modelsToProcess.map((modelName) => {
241
+ if (!modelName || typeof modelName !== 'string') {
242
+ return {
243
+ model: modelName || 'unknown',
244
+ provider: null,
245
+ providerInstance: null,
246
+ resolvedModel: null,
247
+ preFailReason: 'Invalid model specification',
248
+ };
249
+ }
250
+
251
+ const providerName = mapModelToProvider(modelName, providers);
252
+ const resolvedModel = resolveAutoModel(modelName, providerName);
253
+ const provider = providers[providerName];
254
+
255
+ if (!provider) {
256
+ return {
257
+ model: modelName,
258
+ provider: providerName,
259
+ providerInstance: null,
260
+ resolvedModel,
261
+ preFailReason: `Provider not found: ${providerName}`,
262
+ };
263
+ }
264
+
265
+ if (!provider.isAvailable(config)) {
266
+ return {
267
+ model: modelName,
268
+ provider: providerName,
269
+ providerInstance: null,
270
+ resolvedModel,
271
+ preFailReason: `Provider ${providerName} not available (check API key)`,
272
+ };
273
+ }
274
+
275
+ return {
276
+ model: modelName,
277
+ provider: providerName,
278
+ providerInstance: provider,
279
+ resolvedModel,
280
+ preFailReason: null,
281
+ };
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Process files/images into a context message (shared sync + async helper).
287
+ * @returns {Promise<object|null>} Context message or null
288
+ */
289
+ async function buildContextMessage(files, images, contextProcessor, config) {
290
+ if (files.length === 0 && images.length === 0) {
291
+ return null;
292
+ }
293
+
294
+ try {
295
+ const contextRequest = {
296
+ files: Array.isArray(files) ? files : [],
297
+ images: Array.isArray(images) ? images : [],
298
+ };
299
+
300
+ const contextResult = await contextProcessor.processUnifiedContext(
301
+ contextRequest,
302
+ {
303
+ enforceSecurityCheck: false,
304
+ skipSecurityCheck: true,
305
+ clientCwd: config.server?.client_cwd,
306
+ },
307
+ );
308
+
309
+ const allProcessedFiles = [
310
+ ...contextResult.files,
311
+ ...contextResult.images,
312
+ ];
313
+ if (allProcessedFiles.length > 0) {
314
+ return createFileContext(allProcessedFiles, {
315
+ includeMetadata: true,
316
+ includeErrors: true,
317
+ });
318
+ }
319
+ } catch (error) {
320
+ logger.error('Error processing context', { error });
321
+ // Continue without context if processing fails
322
+ }
323
+
324
+ return null;
325
+ }
326
+
327
+ /**
328
+ * Build the final user message content for a turn. Files/images are attached to
329
+ * THIS message so multimodal providers see them, with the packet text appended.
330
+ * @returns {string|Array} User message content
331
+ */
332
+ function buildTurnUserContent(packetText, contextMessage) {
333
+ if (contextMessage && contextMessage.content) {
334
+ return [...contextMessage.content, { type: 'text', text: packetText }];
335
+ }
336
+ return packetText;
337
+ }
338
+
339
+ /**
340
+ * Build the persisted conversation state for a completed lap. Mirrors consensus's
341
+ * `[...messages, assistantMessage]` shape: one system message at index 0 (added
342
+ * for a fresh conversation), accumulating user (lap prompt) / assistant (lap
343
+ * transcript) pairs.
344
+ * @returns {object} Conversation state to persist
345
+ */
346
+ function buildConversationState(
347
+ priorMessages,
348
+ lapUserMessage,
349
+ assistantMessage,
350
+ models,
351
+ turnsSuccessful,
352
+ turnsFailed,
353
+ ) {
354
+ // priorMessages is the loaded stored history (may include a leading system msg).
355
+ const hasSystem =
356
+ priorMessages.length > 0 && priorMessages[0].role === 'system';
357
+
358
+ const baseMessages = hasSystem
359
+ ? priorMessages
360
+ : [{ role: 'system', content: CONVERSATION_PROMPT }, ...priorMessages];
361
+
362
+ return {
363
+ messages: [...baseMessages, lapUserMessage, assistantMessage],
364
+ type: 'conversation',
365
+ lastUpdated: Date.now(),
366
+ conversationData: {
367
+ modelsOrdered: models,
368
+ turnsSuccessful,
369
+ turnsFailed,
370
+ },
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Conversation tool implementation
376
+ * @param {object} args - Tool arguments
377
+ * @param {object} dependencies - Injected dependencies
378
+ * @returns {object} MCP tool response
379
+ */
380
+ export async function conversationTool(args, dependencies) {
381
+ try {
382
+ const {
383
+ config,
384
+ providers,
385
+ continuationStore,
386
+ contextProcessor,
387
+ jobRunner,
388
+ providerStreamNormalizer,
389
+ signal,
390
+ } = dependencies;
391
+
392
+ // Validate required arguments
393
+ if (
394
+ !args.prompt ||
395
+ typeof args.prompt !== 'string' ||
396
+ !args.prompt.trim()
397
+ ) {
398
+ return createToolError('Prompt is required and must be a string');
399
+ }
400
+
401
+ if (
402
+ !args.models ||
403
+ !Array.isArray(args.models) ||
404
+ args.models.length === 0
405
+ ) {
406
+ return createToolError(
407
+ 'Models array is required and must contain at least one model',
408
+ );
409
+ }
410
+
411
+ // Extract and validate arguments
412
+ const {
413
+ prompt,
414
+ models,
415
+ files = [],
416
+ images = [],
417
+ continuation_id,
418
+ temperature = 0.2,
419
+ reasoning_effort = 'medium',
420
+ use_websearch = false,
421
+ async = false,
422
+ export: shouldExport = false,
423
+ turn_prompt,
424
+ } = args;
425
+
426
+ // Handle async execution mode
427
+ if (async) {
428
+ if (!jobRunner || !providerStreamNormalizer) {
429
+ return createToolError(
430
+ 'Async execution not available - missing async dependencies',
431
+ );
432
+ }
433
+
434
+ // Validate custom continuation ID for async safety (used as path segment)
435
+ if (continuation_id && !isSafeIdSegment(continuation_id)) {
436
+ return createToolError(
437
+ `Invalid continuation_id for async mode: "${continuation_id}". Async IDs must contain only letters, numbers, hyphens, and underscores (max 128 chars).`,
438
+ );
439
+ }
440
+
441
+ const bgContinuationId = continuation_id || generateContinuationId();
442
+
443
+ // Determine if this is a custom ID (non-standard format AND not found in store)
444
+ let isCustomId = false;
445
+ if (continuation_id && !isValidContinuationId(continuation_id)) {
446
+ try {
447
+ const existing = await continuationStore.get(continuation_id);
448
+ isCustomId = !existing;
449
+ } catch {
450
+ isCustomId = true;
451
+ }
452
+ }
453
+
454
+ const modelsList = args.models.join(', ');
455
+
456
+ // Generate title early for initial response
457
+ const summarizationService = new SummarizationService(providers, config);
458
+ let title = null;
459
+ try {
460
+ title = await summarizationService.generateTitle(prompt);
461
+ debugLog(
462
+ `Conversation: Generated title for initial response - "${title}"`,
463
+ );
464
+ } catch (error) {
465
+ debugError(
466
+ 'Conversation: Failed to generate title for initial response',
467
+ error,
468
+ );
469
+ title = prompt.substring(0, 50);
470
+ }
471
+
472
+ try {
473
+ await jobRunner.submit(
474
+ {
475
+ tool: 'conversation',
476
+ sessionId: bgContinuationId,
477
+ options: {
478
+ ...args,
479
+ jobId: bgContinuationId,
480
+ models_list: modelsList,
481
+ title,
482
+ },
483
+ },
484
+ async (context) => {
485
+ return await executeConversationWithStreaming(
486
+ args,
487
+ {
488
+ ...dependencies,
489
+ continuationId: bgContinuationId,
490
+ isCustomId,
491
+ title,
492
+ },
493
+ context,
494
+ );
495
+ },
496
+ );
497
+
498
+ const startTime = new Date()
499
+ .toLocaleString('en-GB', {
500
+ day: '2-digit',
501
+ month: '2-digit',
502
+ year: 'numeric',
503
+ hour: '2-digit',
504
+ minute: '2-digit',
505
+ second: '2-digit',
506
+ hour12: false,
507
+ })
508
+ .replace(',', '');
509
+
510
+ const statusLine = `⏳ SUBMITTED | CONVERSATION | ${bgContinuationId} | 1/1 | Started: ${startTime} | "${title || 'Processing...'}" | ${modelsList}`;
511
+
512
+ return createToolResponse({
513
+ content: `${statusLine}\ncontinuation_id: ${bgContinuationId}`,
514
+ continuation: {
515
+ id: bgContinuationId,
516
+ status: 'processing',
517
+ ...(isCustomId && { custom_id: true }),
518
+ },
519
+ async_execution: true,
520
+ });
521
+ } catch (error) {
522
+ logger.error('Failed to submit async conversation job', { error });
523
+ return createToolError(`Async execution failed: ${error.message}`);
524
+ }
525
+ }
526
+
527
+ // --- Synchronous path ---
528
+
529
+ let conversationHistory = [];
530
+ let continuationId = continuation_id;
531
+ let isCustomId = false;
532
+
533
+ // Load existing conversation if continuation_id provided
534
+ if (continuationId) {
535
+ try {
536
+ const existingState = await continuationStore.get(continuationId);
537
+ if (existingState) {
538
+ conversationHistory = existingState.messages || [];
539
+ } else {
540
+ // Preserve user-provided ID and start fresh conversation
541
+ isCustomId = !isValidContinuationId(continuationId);
542
+ }
543
+ } catch (error) {
544
+ logger.error('Error loading conversation', { error });
545
+ isCustomId = !isValidContinuationId(continuationId);
546
+ }
547
+ } else {
548
+ continuationId = generateContinuationId();
549
+ }
550
+
551
+ // Validate file paths before processing
552
+ if (files.length > 0 || images.length > 0) {
553
+ const validation = await validateAllPaths(
554
+ { files, images },
555
+ { clientCwd: config.server?.client_cwd },
556
+ );
557
+ if (!validation.valid) {
558
+ logger.error('File validation failed', { errors: validation.errors });
559
+ return validation.errorResponse;
560
+ }
561
+ }
562
+
563
+ const contextMessage = await buildContextMessage(
564
+ files,
565
+ images,
566
+ contextProcessor,
567
+ config,
568
+ );
569
+
570
+ // Re-render prior stored laps into labeled text for the turn packets
571
+ const priorTranscriptText = renderStoredTranscriptToText(
572
+ conversationHistory,
573
+ );
574
+
575
+ // Resolve ordered turn plan (unavailable models kept as pre-failed turns)
576
+ const turnPlan = resolveTurnPlan(models, providers, config);
577
+
578
+ const startedAt = Date.now();
579
+ const lapTurns = [];
580
+
581
+ // Sequential lap loop: one turn per model, in order
582
+ for (let i = 0; i < turnPlan.length; i++) {
583
+ // Honor cancellation between turns
584
+ if (signal?.aborted) {
585
+ logger.debug('Conversation tool cancelled by client mid-lap');
586
+ return createToolError('Conversation request cancelled');
587
+ }
588
+
589
+ const plan = turnPlan[i];
590
+
591
+ if (plan.preFailReason) {
592
+ lapTurns.push({
593
+ model: plan.model,
594
+ provider: plan.provider,
595
+ status: 'failed',
596
+ error: plan.preFailReason,
597
+ position: i,
598
+ });
599
+ continue;
600
+ }
601
+
602
+ const packetText = buildTurnPacket({
603
+ priorTranscriptText,
604
+ prompt,
605
+ sameLapTurns: lapTurns,
606
+ i,
607
+ models,
608
+ turn_prompt,
609
+ });
610
+
611
+ const finalUserContent = buildTurnUserContent(packetText, contextMessage);
612
+
613
+ const messages = [
614
+ { role: 'system', content: CONVERSATION_PROMPT },
615
+ { role: 'user', content: finalUserContent },
616
+ ];
617
+
618
+ try {
619
+ const response = await plan.providerInstance.invoke(messages, {
620
+ temperature,
621
+ reasoning_effort,
622
+ use_websearch,
623
+ signal,
624
+ config,
625
+ model: plan.resolvedModel,
626
+ });
627
+
628
+ lapTurns.push({
629
+ model: plan.model,
630
+ provider: plan.provider,
631
+ status: 'success',
632
+ response: response.content,
633
+ metadata: response.metadata || {},
634
+ position: i,
635
+ });
636
+ } catch (error) {
637
+ if (signal?.aborted || error.name === 'AbortError') {
638
+ logger.debug('Conversation tool cancelled during turn');
639
+ return createToolError('Conversation request cancelled');
640
+ }
641
+ lapTurns.push({
642
+ model: plan.model,
643
+ provider: plan.provider,
644
+ status: 'failed',
645
+ error: error.message,
646
+ position: i,
647
+ });
648
+ }
649
+ }
650
+
651
+ const turnsSuccessful = lapTurns.filter(
652
+ (t) => t.status === 'success',
653
+ ).length;
654
+ const turnsFailed = lapTurns.length - turnsSuccessful;
655
+
656
+ // Build the lap user message (lap prompt, with context if present)
657
+ const lapUserMessage = {
658
+ role: 'user',
659
+ content: buildTurnUserContent(prompt, contextMessage),
660
+ };
661
+
662
+ // Labeled lap transcript (### <model> (turn <n>):) — computed once and reused
663
+ // for the assistant message, the persisted state, and the response content.
664
+ const transcript = formatLapTranscript(lapTurns);
665
+
666
+ const assistantMessage = {
667
+ role: 'assistant',
668
+ content: transcript,
669
+ };
670
+
671
+ // Save conversation state (skip on abort to avoid persisting incomplete history)
672
+ let conversationState;
673
+ if (!signal?.aborted) {
674
+ try {
675
+ conversationState = buildConversationState(
676
+ conversationHistory,
677
+ lapUserMessage,
678
+ assistantMessage,
679
+ models,
680
+ turnsSuccessful,
681
+ turnsFailed,
682
+ );
683
+
684
+ await continuationStore.set(continuationId, conversationState);
685
+ } catch (error) {
686
+ logger.error('Error saving conversation', { error });
687
+ // Continue even if save fails
688
+ }
689
+ }
690
+
691
+ // Export conversation if requested
692
+ if (shouldExport && conversationState) {
693
+ await exportConversation(conversationState, {
694
+ clientCwd: config.server?.client_cwd,
695
+ continuation_id: continuationId,
696
+ models,
697
+ temperature,
698
+ reasoning_effort,
699
+ use_websearch,
700
+ files,
701
+ images,
702
+ });
703
+ }
704
+
705
+ const executionTime = (Date.now() - startedAt) / 1000;
706
+ const messageCount = (conversationState?.messages || []).length;
707
+
708
+ // Collect failure details
709
+ const failureDetails = lapTurns
710
+ .filter((t) => t.status === 'failed')
711
+ .map((t) => `${t.model} (${t.error})`);
712
+
713
+ const modelsList = models.join(', ');
714
+ const statusLine =
715
+ config.environment?.nodeEnv !== 'test'
716
+ ? `✅ COMPLETED | CONVERSATION | ${continuationId} | ${executionTime.toFixed(1)}s elapsed | ${turnsSuccessful}/${lapTurns.length} turns | ${modelsList}\n`
717
+ : '';
718
+
719
+ const continuationIdLine = `continuation_id: ${continuationId}\n\n`;
720
+
721
+ const result = {
722
+ status: 'conversation_complete',
723
+ content: transcript,
724
+ models_consulted: models.length,
725
+ successful_turns: turnsSuccessful,
726
+ failed_turns: turnsFailed,
727
+ turns: lapTurns,
728
+ continuation: {
729
+ id: continuationId,
730
+ messageCount,
731
+ ...(isCustomId && { custom_id: true }),
732
+ },
733
+ settings: {
734
+ temperature,
735
+ models_requested: models,
736
+ },
737
+ };
738
+
739
+ const tokenLimit = getTokenLimit(config);
740
+ const resultStr = JSON.stringify(result, null, 2);
741
+ const limitedResult = applyTokenLimit(resultStr, tokenLimit);
742
+
743
+ let finalContent = limitedResult.content;
744
+ if (failureDetails.length > 0) {
745
+ finalContent += formatFailureDetails(failureDetails);
746
+ }
747
+
748
+ finalContent = statusLine + continuationIdLine + finalContent;
749
+
750
+ return createToolResponse({
751
+ content: finalContent,
752
+ continuation: {
753
+ id: continuationId,
754
+ messageCount,
755
+ ...(isCustomId && { custom_id: true }),
756
+ },
757
+ });
758
+ } catch (error) {
759
+ if (dependencies?.signal?.aborted || error.name === 'AbortError') {
760
+ logger.debug('Conversation tool cancelled by client');
761
+ return createToolError('Conversation request cancelled');
762
+ }
763
+ logger.error('Conversation tool error', { error });
764
+ return createToolError('Conversation tool failed', error);
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Execute a single turn with streaming support (async path). Adapts consensus's
770
+ * per-provider streaming to a single provider per call.
771
+ * @returns {Promise<object>} Turn result { model, provider, status, response|error }
772
+ */
773
+ async function executeTurnWithStreaming(
774
+ plan,
775
+ messages,
776
+ options,
777
+ context,
778
+ streamNormalizer,
779
+ turnIndex,
780
+ ) {
781
+ try {
782
+ if (context.signal?.aborted) {
783
+ throw new Error('Conversation execution was cancelled');
784
+ }
785
+
786
+ let response;
787
+ let stream = null;
788
+
789
+ if (
790
+ plan.providerInstance.stream &&
791
+ typeof plan.providerInstance.stream === 'function'
792
+ ) {
793
+ stream = plan.providerInstance.stream(messages, options);
794
+ } else {
795
+ // SDK providers (copilot, codex, claude, gemini-cli) stream via invoke
796
+ const streamResult = await plan.providerInstance.invoke(messages, {
797
+ ...options,
798
+ stream: true,
799
+ });
800
+ if (
801
+ streamResult &&
802
+ typeof streamResult[Symbol.asyncIterator] === 'function'
803
+ ) {
804
+ stream = streamResult;
805
+ } else {
806
+ response = streamResult;
807
+ }
808
+ }
809
+
810
+ if (stream) {
811
+ const normalizedStream = streamNormalizer.normalize(plan.provider, stream, {
812
+ provider: plan.provider,
813
+ model: options.model,
814
+ requestId: `${context.jobId}-turn-${turnIndex}`,
815
+ });
816
+
817
+ let accumulatedContent = '';
818
+ let finalUsage = null;
819
+ let finalMetadata = {};
820
+
821
+ for await (const event of normalizedStream) {
822
+ if (context.signal?.aborted) {
823
+ throw new Error('Conversation execution was cancelled');
824
+ }
825
+
826
+ switch (event.type) {
827
+ case 'delta':
828
+ accumulatedContent += event.data.textDelta;
829
+ break;
830
+ case 'usage':
831
+ finalUsage = event.data.usage;
832
+ break;
833
+ case 'end':
834
+ accumulatedContent = event.data.content || accumulatedContent;
835
+ finalUsage = event.data.usage || finalUsage;
836
+ finalMetadata = event.data.metadata || finalMetadata;
837
+ break;
838
+ case 'error':
839
+ throw new Error(`Streaming error: ${event.data.error.message}`);
840
+ }
841
+ }
842
+
843
+ response = {
844
+ content: accumulatedContent,
845
+ metadata: { ...finalMetadata, usage: finalUsage, streaming: true },
846
+ };
847
+ }
848
+
849
+ if (!stream && !response) {
850
+ response = await plan.providerInstance.invoke(messages, options);
851
+ }
852
+
853
+ return {
854
+ model: plan.model,
855
+ provider: plan.provider,
856
+ status: 'success',
857
+ response: response.content,
858
+ metadata: response.metadata || {},
859
+ };
860
+ } catch (error) {
861
+ // Cancellation must abort the whole lap, not be demoted to a failed turn.
862
+ // Rethrow so it propagates out of executeConversationWithStreaming to the
863
+ // job runner (which marks the job cancelled) and the save block is skipped.
864
+ if (context.signal?.aborted || error.name === 'AbortError') {
865
+ throw error;
866
+ }
867
+ return {
868
+ model: plan.model,
869
+ provider: plan.provider,
870
+ status: 'failed',
871
+ error: error.message,
872
+ };
873
+ }
874
+ }
875
+
876
+ /**
877
+ * Execute a conversation lap with streaming normalization for async execution.
878
+ * Mirrors executeConsensusWithStreaming but sequential.
879
+ * @param {object} args - Original conversation arguments
880
+ * @param {object} dependencies - Dependencies with continuationId
881
+ * @param {object} context - Job execution context
882
+ * @returns {Promise<object>} Complete conversation result (with top-level content)
883
+ */
884
+ async function executeConversationWithStreaming(args, dependencies, context) {
885
+ const {
886
+ config,
887
+ providers,
888
+ continuationStore,
889
+ contextProcessor,
890
+ providerStreamNormalizer,
891
+ continuationId,
892
+ isCustomId,
893
+ title: passedTitle,
894
+ } = dependencies;
895
+
896
+ const {
897
+ prompt,
898
+ models,
899
+ files = [],
900
+ images = [],
901
+ temperature = 0.2,
902
+ reasoning_effort = 'medium',
903
+ use_websearch = false,
904
+ export: shouldExport = false,
905
+ turn_prompt,
906
+ } = args;
907
+
908
+ let conversationHistory = [];
909
+ if (continuationId) {
910
+ try {
911
+ const existingState = await continuationStore.get(continuationId);
912
+ if (existingState) {
913
+ conversationHistory = existingState.messages || [];
914
+ }
915
+ } catch (error) {
916
+ logger.error('Error loading conversation', { error });
917
+ }
918
+ }
919
+
920
+ // Validate file paths before processing
921
+ if (files.length > 0 || images.length > 0) {
922
+ const validation = await validateAllPaths(
923
+ { files, images },
924
+ { clientCwd: config.server?.client_cwd },
925
+ );
926
+ if (!validation.valid) {
927
+ logger.error('File validation failed', { errors: validation.errors });
928
+ throw new Error(
929
+ `File validation failed: ${validation.errors.join(', ')}`,
930
+ );
931
+ }
932
+ }
933
+
934
+ const contextMessage = await buildContextMessage(
935
+ files,
936
+ images,
937
+ contextProcessor,
938
+ config,
939
+ );
940
+
941
+ const priorTranscriptText = renderStoredTranscriptToText(conversationHistory);
942
+ const turnPlan = resolveTurnPlan(models, providers, config);
943
+ const modelsList = models.join(', ');
944
+
945
+ // Use passed title or generate if not provided
946
+ const summarizationService = new SummarizationService(providers, config);
947
+ let title = passedTitle;
948
+ if (!title) {
949
+ try {
950
+ title = await summarizationService.generateTitle(prompt);
951
+ debugLog(`Conversation: Generated title - "${title}"`);
952
+ } catch (error) {
953
+ debugError('Conversation: Error generating title', error);
954
+ title = prompt.substring(0, 50);
955
+ }
956
+ }
957
+
958
+ await context.updateJob({
959
+ models_list: modelsList,
960
+ title,
961
+ conversation_progress: `0/${turnPlan.length}`,
962
+ conversation_phase: 'conversation',
963
+ total_turns: turnPlan.length,
964
+ completed_turns: 0,
965
+ });
966
+
967
+ const startedAt = Date.now();
968
+ const lapTurns = [];
969
+
970
+ for (let i = 0; i < turnPlan.length; i++) {
971
+ if (context.signal?.aborted) {
972
+ throw new Error('Conversation execution was cancelled');
973
+ }
974
+
975
+ const plan = turnPlan[i];
976
+
977
+ if (plan.preFailReason) {
978
+ lapTurns.push({
979
+ model: plan.model,
980
+ provider: plan.provider,
981
+ status: 'failed',
982
+ error: plan.preFailReason,
983
+ position: i,
984
+ });
985
+ } else {
986
+ const packetText = buildTurnPacket({
987
+ priorTranscriptText,
988
+ prompt,
989
+ sameLapTurns: lapTurns,
990
+ i,
991
+ models,
992
+ turn_prompt,
993
+ });
994
+
995
+ const finalUserContent = buildTurnUserContent(packetText, contextMessage);
996
+
997
+ const messages = [
998
+ { role: 'system', content: CONVERSATION_PROMPT },
999
+ { role: 'user', content: finalUserContent },
1000
+ ];
1001
+
1002
+ const turnResult = await executeTurnWithStreaming(
1003
+ plan,
1004
+ messages,
1005
+ {
1006
+ temperature,
1007
+ reasoning_effort,
1008
+ use_websearch,
1009
+ signal: context?.signal,
1010
+ config,
1011
+ model: plan.resolvedModel,
1012
+ },
1013
+ context,
1014
+ providerStreamNormalizer,
1015
+ i,
1016
+ );
1017
+
1018
+ lapTurns.push({ ...turnResult, position: i });
1019
+ }
1020
+
1021
+ // Report per-turn progress with the running transcript.
1022
+ // Use flat keys (not a `progress` object) — asyncJobStore.update() treats the
1023
+ // reserved `progress` key as a numeric 0..1 value, so an object there would
1024
+ // corrupt it. Numeric overall progress is supplied separately as a fraction.
1025
+ await context.updateJob({
1026
+ conversation_progress: `${i + 1}/${turnPlan.length}`,
1027
+ accumulated_content: formatLapTranscript(lapTurns),
1028
+ title,
1029
+ progress: (i + 1) / turnPlan.length,
1030
+ conversation_phase: 'conversation',
1031
+ total_turns: turnPlan.length,
1032
+ completed_turns: i + 1,
1033
+ current_model: plan.model,
1034
+ });
1035
+ }
1036
+
1037
+ const turnsSuccessful = lapTurns.filter((t) => t.status === 'success').length;
1038
+ const turnsFailed = lapTurns.length - turnsSuccessful;
1039
+
1040
+ const lapUserMessage = {
1041
+ role: 'user',
1042
+ content: buildTurnUserContent(prompt, contextMessage),
1043
+ };
1044
+
1045
+ // Final lap transcript — computed once and reused for the assistant message,
1046
+ // the persisted state, and the returned top-level content.
1047
+ const transcript = formatLapTranscript(lapTurns);
1048
+
1049
+ const assistantMessage = {
1050
+ role: 'assistant',
1051
+ content: transcript,
1052
+ };
1053
+
1054
+ // Save conversation state
1055
+ let conversationState;
1056
+ try {
1057
+ conversationState = buildConversationState(
1058
+ conversationHistory,
1059
+ lapUserMessage,
1060
+ assistantMessage,
1061
+ models,
1062
+ turnsSuccessful,
1063
+ turnsFailed,
1064
+ );
1065
+ await continuationStore.set(continuationId, conversationState);
1066
+ } catch (error) {
1067
+ logger.error('Error saving conversation', { error });
1068
+ }
1069
+
1070
+ // Export conversation if requested
1071
+ if (shouldExport && conversationState) {
1072
+ await exportConversation(conversationState, {
1073
+ clientCwd: config.server?.client_cwd,
1074
+ continuation_id: continuationId,
1075
+ models,
1076
+ temperature,
1077
+ reasoning_effort,
1078
+ use_websearch,
1079
+ files,
1080
+ images,
1081
+ });
1082
+ }
1083
+
1084
+ const executionTime = (Date.now() - startedAt) / 1000;
1085
+
1086
+ // Generate final summary from combined successful responses
1087
+ let finalSummary = null;
1088
+ const combinedResponses = lapTurns
1089
+ .filter((t) => t.status === 'success' && t.response)
1090
+ .map((t) => `${t.model}:\n${t.response}`);
1091
+
1092
+ if (combinedResponses.length > 0) {
1093
+ const combinedContent = combinedResponses.join('\n\n---\n\n');
1094
+ if (combinedContent.length > 100) {
1095
+ try {
1096
+ finalSummary =
1097
+ await summarizationService.generateFinalSummary(combinedContent);
1098
+ debugLog(`Conversation: Generated final summary - "${finalSummary}"`);
1099
+ await context.updateJob({ final_summary: finalSummary });
1100
+ } catch (error) {
1101
+ debugError('Conversation: Error generating final summary', error);
1102
+ }
1103
+ }
1104
+ }
1105
+
1106
+ const failureDetails = lapTurns
1107
+ .filter((t) => t.status === 'failed')
1108
+ .map((t) => `${t.model} (${t.error})`);
1109
+
1110
+ const messageCount = (conversationState?.messages || []).length;
1111
+
1112
+ // Top-level `content` is required: formatStatus only renders result.content
1113
+ // when displaying a completed async job.
1114
+ return {
1115
+ status: 'conversation_complete',
1116
+ content: transcript,
1117
+ models_consulted: models.length,
1118
+ successful_turns: turnsSuccessful,
1119
+ failed_turns: turnsFailed,
1120
+ turns: lapTurns,
1121
+ continuation: {
1122
+ id: continuationId,
1123
+ messageCount,
1124
+ ...(isCustomId && { custom_id: true }),
1125
+ },
1126
+ settings: {
1127
+ temperature,
1128
+ models_requested: models,
1129
+ },
1130
+ metadata: {
1131
+ execution_time: executionTime,
1132
+ async_execution: true,
1133
+ successful_models: turnsSuccessful,
1134
+ total_models: models.length,
1135
+ failure_details: failureDetails,
1136
+ title,
1137
+ final_summary: finalSummary,
1138
+ },
1139
+ };
1140
+ }
1141
+
1142
+ // Tool metadata
1143
+ conversationTool.description =
1144
+ 'TURN-BASED ROUND-TABLE - Models respond SEQUENTIALLY in the order given; each model sees the full running transcript and builds on prior turns. One call = one lap; pass continuation_id for more laps. Contrast with consensus (parallel, same prompt). Use the "files" parameter to share code.';
1145
+ conversationTool.inputSchema = {
1146
+ type: 'object',
1147
+ properties: {
1148
+ models: {
1149
+ type: 'array',
1150
+ items: { type: 'string' },
1151
+ minItems: 1,
1152
+ description:
1153
+ 'Ordered list of models for the round-table. ORDER MATTERS: models speak one after another in this exact order, each seeing the transcript of those before it. Examples: ["codex", "gemini", "claude"]. A single model (e.g. ["codex"]) talks to itself across laps. Use ["auto"] to pick the first available provider.',
1154
+ },
1155
+ prompt: {
1156
+ type: 'string',
1157
+ description:
1158
+ 'The topic or question to open the round-table with. Include context and what you want the participants to discuss. Example: "Critique this caching strategy and propose improvements."',
1159
+ },
1160
+ continuation_id: {
1161
+ type: 'string',
1162
+ description:
1163
+ 'Thread continuation ID for running more laps. Auto-generated in the first response; pass it back to run another lap where every model again sees the full accumulated transcript. You MAY change the models list on a resuming lap.',
1164
+ },
1165
+ turn_prompt: {
1166
+ type: 'string',
1167
+ description:
1168
+ 'Optional custom per-turn instruction appended to the round-table framing each model receives. Example: "Focus on security implications in your turn."',
1169
+ },
1170
+ files: {
1171
+ type: 'array',
1172
+ items: { type: 'string' },
1173
+ description:
1174
+ 'File paths for additional context (absolute or relative paths). Supports line ranges: file.txt{10:50}, file.txt{100:}. Files are shared with every participant in the lap. IMPORTANT: Always use this parameter to share file content instead of copying code into the prompt.',
1175
+ },
1176
+ images: {
1177
+ type: 'array',
1178
+ items: { type: 'string' },
1179
+ description:
1180
+ 'Image paths for visual context (absolute or relative paths, or base64). Example: ["C:\\Users\\username\\diagram.png", "./flow.jpg"]',
1181
+ },
1182
+ temperature: {
1183
+ type: 'number',
1184
+ description:
1185
+ 'Response randomness (0.0-1.0). Examples: 0.1 (very focused), 0.2 (analytical - default), 0.5 (balanced). Default: 0.2',
1186
+ minimum: 0.0,
1187
+ maximum: 1.0,
1188
+ default: 0.2,
1189
+ },
1190
+ reasoning_effort: {
1191
+ type: 'string',
1192
+ enum: ['none', 'minimal', 'low', 'medium', 'high', 'max'],
1193
+ description:
1194
+ 'Reasoning depth for thinking models. Examples: "none" (no reasoning, fastest), "low" (light analysis), "medium" (balanced), "high" (complex analysis). Default: "medium"',
1195
+ default: 'medium',
1196
+ },
1197
+ use_websearch: {
1198
+ type: 'boolean',
1199
+ description:
1200
+ 'Enable web search for current information. Only works with models that support web search (OpenAI, XAI, Google). Default: false',
1201
+ default: false,
1202
+ },
1203
+ async: {
1204
+ type: 'boolean',
1205
+ description:
1206
+ 'Execute the lap in background with per-turn progress tracking. When true, returns continuation_id immediately and processes the lap asynchronously. Default: false',
1207
+ default: false,
1208
+ },
1209
+ export: {
1210
+ type: 'boolean',
1211
+ description:
1212
+ 'Export conversation to disk. Creates folder with continuation_id name containing numbered request/response files and metadata. Default: false',
1213
+ default: false,
1214
+ },
1215
+ },
1216
+ required: ['prompt', 'models'],
1217
+ };