converse-mcp-server 2.22.8 → 2.25.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.
- package/LICENSE +18 -18
- package/docs/ALTERNATIVE_PROVIDERS.md +449 -449
- package/docs/API.md +164 -4
- package/docs/ARCHITECTURE.md +551 -551
- package/docs/EXAMPLES.md +98 -0
- package/package.json +12 -12
- package/src/async/asyncJobStore.js +2 -2
- package/src/providers/anthropic.js +35 -4
- package/src/providers/claude.js +1 -1
- package/src/providers/gemini-cli.js +33 -2
- package/src/providers/google.js +26 -0
- package/src/systemPrompts.js +33 -0
- package/src/tools/conversation.js +1217 -0
- package/src/tools/index.js +4 -2
- package/src/utils/formatStatus.js +9 -0
- package/src/utils/modelRouting.js +200 -0
|
@@ -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
|
+
};
|