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.
- package/.env.example +6 -3
- package/README.md +96 -94
- package/docs/API.md +703 -1562
- package/docs/ARCHITECTURE.md +13 -11
- package/docs/EXAMPLES.md +241 -667
- package/docs/PROVIDERS.md +104 -79
- package/package.json +1 -1
- package/src/async/asyncJobStore.js +2 -2
- package/src/async/jobRunner.js +6 -1
- package/src/async/providerStreamNormalizer.js +59 -1
- package/src/config.js +4 -3
- package/src/prompts/helpPrompt.js +43 -61
- package/src/providers/anthropic.js +0 -31
- package/src/providers/claude.js +0 -14
- package/src/providers/codex.js +15 -21
- package/src/providers/copilot.js +36 -202
- package/src/providers/deepseek.js +73 -53
- package/src/providers/gemini-cli.js +0 -3
- package/src/providers/google.js +10 -27
- package/src/providers/interface.js +0 -3
- package/src/providers/mistral.js +169 -63
- package/src/providers/openai-compatible.js +113 -28
- package/src/providers/openai.js +14 -66
- package/src/providers/openrouter-discovery.js +308 -0
- package/src/providers/openrouter.js +452 -282
- package/src/providers/xai.js +298 -280
- package/src/services/summarizationService.js +4 -14
- package/src/systemPrompts.js +19 -2
- package/src/tools/cancelJob.js +7 -1
- package/src/tools/chat.js +957 -995
- package/src/tools/checkStatus.js +1 -0
- package/src/tools/index.js +2 -6
- package/src/tools/modes/parallel.js +488 -0
- package/src/tools/modes/roundtable.js +495 -0
- package/src/tools/modes/streamShared.js +31 -0
- package/src/utils/contextProcessor.js +1 -38
- package/src/utils/conversationExporter.js +6 -26
- package/src/utils/formatStatus.js +46 -19
- package/src/utils/modelRouting.js +207 -4
- package/src/providers/openrouter-endpoints-client.js +0 -220
- package/src/tools/consensus.js +0 -1813
- package/src/tools/conversation.js +0 -1233
package/src/tools/chat.js
CHANGED
|
@@ -1,95 +1,140 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Chat Tool
|
|
2
|
+
* Chat Tool (unified)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* A single MCP tool with three execution modes:
|
|
5
|
+
* - `chat` : 1..N models invoked in parallel; each responds independently.
|
|
6
|
+
* - `consensus` : ≥2 models in parallel, then a cross-feedback refinement phase.
|
|
7
|
+
* - `roundtable` : models respond sequentially, each seeing the full transcript.
|
|
8
|
+
*
|
|
9
|
+
* This module is the shared shell: it validates arguments, loads/normalizes
|
|
10
|
+
* continuation history, builds file/image context, dispatches to the parallel or
|
|
11
|
+
* roundtable execution engine, and owns persistence, export, token-limiting, and
|
|
12
|
+
* MCP-response construction. The engines (`modes/parallel.js`, `modes/roundtable.js`)
|
|
13
|
+
* only resolve/invoke and return invocation data.
|
|
6
14
|
*/
|
|
7
15
|
|
|
8
|
-
import { createToolResponse, createToolError } from './index.js';
|
|
9
|
-
import {
|
|
10
|
-
processUnifiedContext,
|
|
11
|
-
createFileContext,
|
|
12
|
-
} from '../utils/contextProcessor.js';
|
|
16
|
+
import { createToolResponse, createToolError, formatFailureDetails } from './index.js';
|
|
17
|
+
import { createFileContext } from '../utils/contextProcessor.js';
|
|
13
18
|
import {
|
|
14
19
|
generateContinuationId,
|
|
15
|
-
addMessageToHistory,
|
|
16
20
|
isValidContinuationId,
|
|
17
21
|
} from '../continuationStore.js';
|
|
18
22
|
import { isSafeIdSegment } from '../utils/idValidation.js';
|
|
19
|
-
import {
|
|
23
|
+
import { debugError } from '../utils/console.js';
|
|
20
24
|
import { createLogger } from '../utils/logger.js';
|
|
21
|
-
import {
|
|
25
|
+
import { getSystemPromptForMode } from '../systemPrompts.js';
|
|
22
26
|
import { applyTokenLimit, getTokenLimit } from '../utils/tokenLimiter.js';
|
|
23
27
|
import { validateAllPaths } from '../utils/fileValidator.js';
|
|
24
28
|
import { SummarizationService } from '../services/summarizationService.js';
|
|
25
29
|
import { exportConversation } from '../utils/conversationExporter.js';
|
|
26
|
-
import { isRecoverableError, retryWithBackoff } from '../utils/errorHandler.js';
|
|
27
30
|
import {
|
|
28
|
-
|
|
31
|
+
getDefaultModelForProvider,
|
|
29
32
|
getProviderUnavailableMessage,
|
|
33
|
+
getAvailableProviders,
|
|
34
|
+
resolveModelSpec,
|
|
30
35
|
} from '../utils/modelRouting.js';
|
|
36
|
+
import {
|
|
37
|
+
runChatMode,
|
|
38
|
+
runConsensusMode,
|
|
39
|
+
getProviderRetryOptions,
|
|
40
|
+
} from './modes/parallel.js';
|
|
41
|
+
import {
|
|
42
|
+
runRoundtableLap,
|
|
43
|
+
renderStoredTranscriptToText,
|
|
44
|
+
} from './modes/roundtable.js';
|
|
31
45
|
|
|
32
46
|
const logger = createLogger('chat');
|
|
33
47
|
|
|
48
|
+
const VALID_MODES = ['chat', 'consensus', 'roundtable'];
|
|
49
|
+
|
|
50
|
+
// SDK providers that consume ONLY the last user message. On a resumed thread in
|
|
51
|
+
// chat/consensus modes, prior history must be packed into that final message for
|
|
52
|
+
// these providers (codex is exempt in chat mode when it can reuse its own thread).
|
|
53
|
+
const LAST_USER_ONLY = new Set(['codex', 'claude', 'copilot']);
|
|
54
|
+
|
|
34
55
|
/**
|
|
35
|
-
*
|
|
56
|
+
* Unified chat tool implementation.
|
|
36
57
|
* @param {object} args - Tool arguments
|
|
37
|
-
* @param {object} dependencies - Injected dependencies
|
|
58
|
+
* @param {object} dependencies - Injected dependencies
|
|
38
59
|
* @returns {object} MCP tool response
|
|
39
60
|
*/
|
|
40
61
|
export async function chatTool(args, dependencies) {
|
|
41
62
|
try {
|
|
42
|
-
const {
|
|
43
|
-
|
|
44
|
-
providers,
|
|
45
|
-
continuationStore,
|
|
46
|
-
contextProcessor,
|
|
47
|
-
jobRunner,
|
|
48
|
-
providerStreamNormalizer,
|
|
49
|
-
signal,
|
|
50
|
-
} = dependencies;
|
|
63
|
+
const { config, providers, continuationStore, jobRunner, providerStreamNormalizer } =
|
|
64
|
+
dependencies;
|
|
51
65
|
|
|
52
|
-
|
|
53
|
-
if (!args.prompt || typeof args.prompt !== 'string') {
|
|
66
|
+
if (!args.prompt || typeof args.prompt !== 'string' || !args.prompt.trim()) {
|
|
54
67
|
return createToolError('Prompt is required and must be a string');
|
|
55
68
|
}
|
|
56
69
|
|
|
57
|
-
//
|
|
70
|
+
// The router applies no JSON-Schema defaults, so apply them in code.
|
|
71
|
+
const mode = args.mode ?? 'chat';
|
|
72
|
+
const models = args.models ?? ['auto'];
|
|
73
|
+
const reasoning_effort = args.reasoning_effort ?? 'medium';
|
|
58
74
|
const {
|
|
59
75
|
prompt,
|
|
60
|
-
model = 'auto',
|
|
61
76
|
files = [],
|
|
62
|
-
continuation_id,
|
|
63
|
-
temperature = 0.5,
|
|
64
|
-
use_websearch = false,
|
|
65
77
|
images = [],
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
async = false,
|
|
78
|
+
continuation_id,
|
|
79
|
+
async: isAsync = false,
|
|
69
80
|
export: shouldExport = false,
|
|
70
81
|
} = args;
|
|
71
82
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
if (!VALID_MODES.includes(mode)) {
|
|
84
|
+
return createToolError(
|
|
85
|
+
`Invalid mode "${mode}". Valid modes are: ${VALID_MODES.join(', ')}.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const modelsError = validateModels(models, mode);
|
|
90
|
+
if (modelsError) {
|
|
91
|
+
return createToolError(modelsError);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Consensus needs ≥2 *resolved* (available) models, checked AFTER auto
|
|
95
|
+
// expansion so the default ["auto"] is valid.
|
|
96
|
+
if (mode === 'consensus') {
|
|
97
|
+
const { resolved } = resolveConsensusCallPlans(
|
|
98
|
+
models,
|
|
99
|
+
providers,
|
|
100
|
+
config,
|
|
101
|
+
images,
|
|
102
|
+
);
|
|
103
|
+
if (resolved.length < 2) {
|
|
104
|
+
return createToolError(
|
|
105
|
+
'Consensus mode requires at least 2 available models. ' +
|
|
106
|
+
'Provide 2+ models (or "auto" when 2+ providers are configured), ' +
|
|
107
|
+
'or use mode "chat" for a single model.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const normalizedArgs = {
|
|
113
|
+
prompt,
|
|
114
|
+
mode,
|
|
115
|
+
models,
|
|
116
|
+
reasoning_effort,
|
|
117
|
+
files,
|
|
118
|
+
images,
|
|
119
|
+
continuation_id,
|
|
120
|
+
export: shouldExport,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (isAsync) {
|
|
75
124
|
if (!jobRunner || !providerStreamNormalizer) {
|
|
76
125
|
return createToolError(
|
|
77
126
|
'Async execution not available - missing async dependencies',
|
|
78
127
|
);
|
|
79
128
|
}
|
|
80
129
|
|
|
81
|
-
// Validate custom continuation ID for async safety (used as filesystem path segment)
|
|
82
130
|
if (continuation_id && !isSafeIdSegment(continuation_id)) {
|
|
83
131
|
return createToolError(
|
|
84
132
|
`Invalid continuation_id for async mode: "${continuation_id}". Async IDs must contain only letters, numbers, hyphens, and underscores (max 128 chars).`,
|
|
85
133
|
);
|
|
86
134
|
}
|
|
87
135
|
|
|
88
|
-
|
|
89
|
-
const conversationContinuationId =
|
|
90
|
-
continuation_id || generateContinuationId();
|
|
136
|
+
const jobContinuationId = continuation_id || generateContinuationId();
|
|
91
137
|
|
|
92
|
-
// Determine if this is a custom ID (non-standard format AND not found in store)
|
|
93
138
|
let isCustomId = false;
|
|
94
139
|
if (continuation_id && !isValidContinuationId(continuation_id)) {
|
|
95
140
|
try {
|
|
@@ -100,77 +145,46 @@ export async function chatTool(args, dependencies) {
|
|
|
100
145
|
}
|
|
101
146
|
}
|
|
102
147
|
|
|
103
|
-
|
|
104
|
-
const providerName = mapModelToProvider(args.model || 'auto', providers);
|
|
105
|
-
const resolvedModel =
|
|
106
|
-
providers[providerName]?.resolveModel?.(args.model) ||
|
|
107
|
-
args.model ||
|
|
108
|
-
'auto';
|
|
109
|
-
|
|
110
|
-
// Generate title early for initial response
|
|
148
|
+
const modelsList = models.join(', ');
|
|
111
149
|
const summarizationService = new SummarizationService(providers, config);
|
|
112
150
|
let title = null;
|
|
113
151
|
try {
|
|
114
152
|
title = await summarizationService.generateTitle(prompt);
|
|
115
|
-
debugLog(`Chat: Generated title for initial response - "${title}"`);
|
|
116
153
|
} catch (error) {
|
|
117
|
-
debugError(
|
|
118
|
-
'Chat: Failed to generate title for initial response',
|
|
119
|
-
error,
|
|
120
|
-
);
|
|
154
|
+
debugError('Chat: Failed to generate title for initial response', error);
|
|
121
155
|
title = prompt.substring(0, 50);
|
|
122
156
|
}
|
|
123
157
|
|
|
124
158
|
try {
|
|
125
|
-
|
|
126
|
-
const jobId = await jobRunner.submit(
|
|
159
|
+
await jobRunner.submit(
|
|
127
160
|
{
|
|
128
161
|
tool: 'chat',
|
|
129
|
-
|
|
162
|
+
mode,
|
|
163
|
+
sessionId: jobContinuationId,
|
|
130
164
|
options: {
|
|
131
|
-
...
|
|
132
|
-
jobId:
|
|
133
|
-
continuation_id:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
title,
|
|
165
|
+
...normalizedArgs,
|
|
166
|
+
jobId: jobContinuationId,
|
|
167
|
+
continuation_id: jobContinuationId,
|
|
168
|
+
mode,
|
|
169
|
+
models_list: modelsList,
|
|
170
|
+
title,
|
|
137
171
|
},
|
|
138
172
|
},
|
|
139
173
|
async (context) => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
{
|
|
144
|
-
...dependencies,
|
|
145
|
-
continuationId: conversationContinuationId,
|
|
146
|
-
isCustomId,
|
|
147
|
-
title, // Pass title to execution context
|
|
148
|
-
},
|
|
174
|
+
return await runAsyncJob(
|
|
175
|
+
{ ...normalizedArgs, continuation_id: jobContinuationId },
|
|
176
|
+
{ ...dependencies, isCustomId, title },
|
|
149
177
|
context,
|
|
150
178
|
);
|
|
151
179
|
},
|
|
152
180
|
);
|
|
153
181
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.toLocaleString('en-GB', {
|
|
157
|
-
day: '2-digit',
|
|
158
|
-
month: '2-digit',
|
|
159
|
-
year: 'numeric',
|
|
160
|
-
hour: '2-digit',
|
|
161
|
-
minute: '2-digit',
|
|
162
|
-
second: '2-digit',
|
|
163
|
-
hour12: false,
|
|
164
|
-
})
|
|
165
|
-
.replace(',', '');
|
|
166
|
-
|
|
167
|
-
const statusLine = `⏳ SUBMITTED | CHAT | ${conversationContinuationId} | 1/1 | Started: ${startTime} | "${title || 'Processing...'}" | ${providerName}/${resolvedModel}`;
|
|
168
|
-
|
|
169
|
-
// Return formatted response with status line and continuation_id
|
|
182
|
+
const statusLine = `⏳ SUBMITTED | ${mode.toUpperCase()} | ${jobContinuationId} | 1/1 | Started: ${formatStartTime()} | "${title || 'Processing...'}" | ${modelsList}`;
|
|
183
|
+
|
|
170
184
|
return createToolResponse({
|
|
171
|
-
content: `${statusLine}\ncontinuation_id: ${
|
|
185
|
+
content: `${statusLine}\ncontinuation_id: ${jobContinuationId}`,
|
|
172
186
|
continuation: {
|
|
173
|
-
id:
|
|
187
|
+
id: jobContinuationId,
|
|
174
188
|
status: 'processing',
|
|
175
189
|
...(isCustomId && { custom_id: true }),
|
|
176
190
|
},
|
|
@@ -182,1062 +196,1010 @@ export async function chatTool(args, dependencies) {
|
|
|
182
196
|
}
|
|
183
197
|
}
|
|
184
198
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
// Load existing conversation if continuation_id provided
|
|
190
|
-
if (continuationId) {
|
|
191
|
-
try {
|
|
192
|
-
const existingState = await continuationStore.get(continuationId);
|
|
193
|
-
if (existingState) {
|
|
194
|
-
conversationHistory = existingState.messages || [];
|
|
195
|
-
} else {
|
|
196
|
-
// Preserve user-provided ID and start fresh conversation
|
|
197
|
-
isCustomId = !isValidContinuationId(continuationId);
|
|
198
|
-
}
|
|
199
|
-
} catch (error) {
|
|
200
|
-
logger.error('Error loading conversation', { error });
|
|
201
|
-
// Preserve user-provided ID on error
|
|
202
|
-
isCustomId = !isValidContinuationId(continuationId);
|
|
203
|
-
}
|
|
204
|
-
} else {
|
|
205
|
-
// Generate new continuation ID for new conversation
|
|
206
|
-
continuationId = generateContinuationId();
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Validate file paths before processing
|
|
210
|
-
if (files.length > 0 || images.length > 0) {
|
|
211
|
-
const validation = await validateAllPaths(
|
|
212
|
-
{ files, images },
|
|
213
|
-
{ clientCwd: config.server?.client_cwd },
|
|
214
|
-
);
|
|
215
|
-
if (!validation.valid) {
|
|
216
|
-
logger.error('File validation failed', { errors: validation.errors });
|
|
217
|
-
return validation.errorResponse;
|
|
218
|
-
}
|
|
199
|
+
// Synchronous path
|
|
200
|
+
const pipeline = await runUnifiedChat(normalizedArgs, dependencies, null);
|
|
201
|
+
if (pipeline.error) {
|
|
202
|
+
return pipeline.error;
|
|
219
203
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
images: Array.isArray(images) ? images : [],
|
|
228
|
-
webSearch: use_websearch ? prompt : null,
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
const contextResult = await contextProcessor.processUnifiedContext(
|
|
232
|
-
contextRequest,
|
|
233
|
-
{
|
|
234
|
-
enforceSecurityCheck: false, // Allow files from any location
|
|
235
|
-
skipSecurityCheck: true, // Legacy flag for backward compatibility
|
|
236
|
-
clientCwd: config.server?.client_cwd, // Use auto-detected client working directory
|
|
237
|
-
},
|
|
238
|
-
);
|
|
239
|
-
|
|
240
|
-
// Create context message from files and images
|
|
241
|
-
const allProcessedFiles = [
|
|
242
|
-
...contextResult.files,
|
|
243
|
-
...contextResult.images,
|
|
244
|
-
];
|
|
245
|
-
if (allProcessedFiles.length > 0) {
|
|
246
|
-
contextMessage = createFileContext(allProcessedFiles, {
|
|
247
|
-
includeMetadata: true,
|
|
248
|
-
includeErrors: true,
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Add web search results if available (placeholder for now)
|
|
253
|
-
if (contextResult.webSearch && !contextResult.webSearch.placeholder) {
|
|
254
|
-
// Future implementation: add web search results to context
|
|
255
|
-
logger.debug('Web search results available but not yet implemented');
|
|
256
|
-
}
|
|
257
|
-
} catch (error) {
|
|
258
|
-
logger.error('Error processing context', { error });
|
|
259
|
-
// Continue without context if processing fails
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Build message array for provider
|
|
264
|
-
const messages = [];
|
|
265
|
-
|
|
266
|
-
// Add system prompt only if not already in conversation history
|
|
267
|
-
if (
|
|
268
|
-
conversationHistory.length === 0 ||
|
|
269
|
-
conversationHistory[0].role !== 'system'
|
|
270
|
-
) {
|
|
271
|
-
messages.push({
|
|
272
|
-
role: 'system',
|
|
273
|
-
content: CHAT_PROMPT,
|
|
274
|
-
});
|
|
204
|
+
return buildSyncResponse(pipeline, config);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (dependencies?.signal?.aborted || error.name === 'AbortError') {
|
|
207
|
+
const cancelledMode = args?.mode ?? 'chat';
|
|
208
|
+
const label = cancelledMode.charAt(0).toUpperCase() + cancelledMode.slice(1);
|
|
209
|
+
logger.debug('Chat tool cancelled by client');
|
|
210
|
+
return createToolError(`${label} request cancelled`);
|
|
275
211
|
}
|
|
212
|
+
logger.error('Chat tool error', { error });
|
|
213
|
+
return createToolError('Chat tool failed', error);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
276
216
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
// Create complex content array
|
|
289
|
-
userMessage.content = [
|
|
290
|
-
...contextMessage.content, // Include all file/image parts
|
|
291
|
-
{ type: 'text', text: prompt }, // Add the user prompt as text
|
|
292
|
-
];
|
|
217
|
+
/**
|
|
218
|
+
* Validate the models array and (for chat/consensus) reject duplicates.
|
|
219
|
+
* @returns {string|null} Error message or null when valid
|
|
220
|
+
*/
|
|
221
|
+
function validateModels(models, mode) {
|
|
222
|
+
if (!Array.isArray(models) || models.length === 0) {
|
|
223
|
+
return 'Models must be a non-empty array of model names';
|
|
224
|
+
}
|
|
225
|
+
for (const entry of models) {
|
|
226
|
+
if (!entry || typeof entry !== 'string' || !entry.trim()) {
|
|
227
|
+
return 'Each model must be a non-empty string';
|
|
293
228
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const providerCandidates = [];
|
|
302
|
-
|
|
303
|
-
if (model === 'auto') {
|
|
304
|
-
// Auto-select providers in priority order
|
|
305
|
-
// Prioritize subscription-based providers (codex, gemini-cli, claude, copilot) over API-key providers
|
|
306
|
-
const providerOrder = [
|
|
307
|
-
'codex',
|
|
308
|
-
'gemini-cli',
|
|
309
|
-
'claude',
|
|
310
|
-
'copilot',
|
|
311
|
-
'openai',
|
|
312
|
-
'google',
|
|
313
|
-
'xai',
|
|
314
|
-
'anthropic',
|
|
315
|
-
'mistral',
|
|
316
|
-
'deepseek',
|
|
317
|
-
'openrouter',
|
|
318
|
-
];
|
|
319
|
-
|
|
320
|
-
const requestHasImages = Array.isArray(images) && images.length > 0;
|
|
321
|
-
|
|
322
|
-
for (const name of providerOrder) {
|
|
323
|
-
const provider = providers[name];
|
|
324
|
-
if (provider && provider.isAvailable && provider.isAvailable(config)) {
|
|
325
|
-
// When the request has images, skip text-only providers (e.g.
|
|
326
|
-
// gemini-cli, copilot) so auto routing lands on an image-capable one.
|
|
327
|
-
if (requestHasImages && !providerSupportsImages(provider, name)) {
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
providerCandidates.push({ name, provider });
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
if (providerCandidates.length === 0) {
|
|
335
|
-
return createToolError(
|
|
336
|
-
'No providers available. Please configure at least one API key.',
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
} else {
|
|
340
|
-
// Use specified provider/model
|
|
341
|
-
providerName = mapModelToProvider(model, providers);
|
|
342
|
-
selectedProvider = providers[providerName];
|
|
343
|
-
|
|
344
|
-
if (!selectedProvider) {
|
|
345
|
-
return createToolError(`Provider not found for model: ${model}`);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
if (!selectedProvider.isAvailable(config)) {
|
|
349
|
-
return createToolError(getProviderUnavailableMessage(providerName));
|
|
229
|
+
}
|
|
230
|
+
if (mode !== 'roundtable') {
|
|
231
|
+
const seen = new Set();
|
|
232
|
+
for (const entry of models) {
|
|
233
|
+
const key = entry.trim().toLowerCase();
|
|
234
|
+
if (seen.has(key)) {
|
|
235
|
+
return `Duplicate model "${entry}" is not allowed in mode "${mode}". Duplicate models are only allowed in mode "roundtable".`;
|
|
350
236
|
}
|
|
351
|
-
|
|
352
|
-
providerCandidates.push({
|
|
353
|
-
name: providerName,
|
|
354
|
-
provider: selectedProvider,
|
|
355
|
-
});
|
|
237
|
+
seen.add(key);
|
|
356
238
|
}
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
357
242
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
reasoning_effort,
|
|
374
|
-
verbosity,
|
|
375
|
-
use_websearch,
|
|
376
|
-
signal,
|
|
377
|
-
config,
|
|
378
|
-
continuation_id, // Pass for thread resumption
|
|
379
|
-
continuationStore, // Pass store for state management
|
|
380
|
-
};
|
|
381
|
-
|
|
382
|
-
try {
|
|
383
|
-
response = await retryWithBackoff(
|
|
384
|
-
() => selectedProvider.invoke(messages, providerOptions),
|
|
385
|
-
getProviderRetryOptions(config, providerName),
|
|
386
|
-
);
|
|
387
|
-
break;
|
|
388
|
-
} catch (error) {
|
|
389
|
-
lastError = error;
|
|
390
|
-
logger.error('Provider error', {
|
|
391
|
-
error,
|
|
392
|
-
data: { provider: providerName },
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
if (
|
|
396
|
-
model !== 'auto' ||
|
|
397
|
-
!shouldFailoverToNextProvider(error) ||
|
|
398
|
-
candidate === providerCandidates[providerCandidates.length - 1]
|
|
399
|
-
) {
|
|
400
|
-
return createToolError(`Provider error: ${error.message}`);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
243
|
+
/**
|
|
244
|
+
* The async job runner: executes the pipeline with streaming, then generates a
|
|
245
|
+
* final summary and returns the completion result for the job store.
|
|
246
|
+
*/
|
|
247
|
+
async function runAsyncJob(args, dependencies, context) {
|
|
248
|
+
const { providers, config, title: passedTitle } = dependencies;
|
|
249
|
+
|
|
250
|
+
const pipeline = await runUnifiedChat(args, dependencies, context);
|
|
251
|
+
if (pipeline.error) {
|
|
252
|
+
// Validation errors are surfaced eagerly in chatTool; reaching here means a
|
|
253
|
+
// runtime failure — throw so the job is marked failed.
|
|
254
|
+
throw new Error(
|
|
255
|
+
pipeline.errorMessage || 'Chat job failed during execution',
|
|
256
|
+
);
|
|
257
|
+
}
|
|
404
258
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
259
|
+
const summarizationService = new SummarizationService(providers, config);
|
|
260
|
+
let finalSummary = null;
|
|
261
|
+
if (pipeline.combinedForSummary && pipeline.combinedForSummary.length > 100) {
|
|
262
|
+
try {
|
|
263
|
+
finalSummary = await summarizationService.generateFinalSummary(
|
|
264
|
+
pipeline.combinedForSummary,
|
|
408
265
|
);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
// Validate response
|
|
413
|
-
if (!response || !response.content) {
|
|
414
|
-
return createToolError('Provider returned invalid response');
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Add assistant response to conversation history
|
|
418
|
-
const assistantMessage = {
|
|
419
|
-
role: 'assistant',
|
|
420
|
-
content: response.content,
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
const updatedMessages = [...messages, assistantMessage];
|
|
424
|
-
|
|
425
|
-
// Save conversation state (skip on abort to avoid persisting incomplete history)
|
|
426
|
-
if (!signal?.aborted) {
|
|
427
|
-
try {
|
|
428
|
-
const conversationState = {
|
|
429
|
-
messages: updatedMessages,
|
|
430
|
-
provider: providerName,
|
|
431
|
-
model,
|
|
432
|
-
lastUpdated: Date.now(),
|
|
433
|
-
// Store Codex thread ID if available (for thread resumption)
|
|
434
|
-
codexThreadId: response.metadata?.threadId,
|
|
435
|
-
};
|
|
436
|
-
|
|
437
|
-
await continuationStore.set(continuationId, conversationState);
|
|
438
|
-
} catch (error) {
|
|
439
|
-
logger.error('Error saving conversation', { error });
|
|
440
|
-
// Continue even if save fails
|
|
266
|
+
if (finalSummary) {
|
|
267
|
+
await context.updateJob({ final_summary: finalSummary });
|
|
441
268
|
}
|
|
269
|
+
} catch (error) {
|
|
270
|
+
debugError('Chat: Failed to generate final summary', error);
|
|
442
271
|
}
|
|
272
|
+
}
|
|
443
273
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
await exportConversation(
|
|
447
|
-
{
|
|
448
|
-
messages: updatedMessages,
|
|
449
|
-
provider: providerName,
|
|
450
|
-
model,
|
|
451
|
-
lastUpdated: Date.now(),
|
|
452
|
-
codexThreadId: response.metadata?.threadId,
|
|
453
|
-
},
|
|
454
|
-
{
|
|
455
|
-
clientCwd: config.server?.client_cwd,
|
|
456
|
-
continuation_id: continuationId,
|
|
457
|
-
model,
|
|
458
|
-
temperature,
|
|
459
|
-
reasoning_effort,
|
|
460
|
-
verbosity,
|
|
461
|
-
use_websearch,
|
|
462
|
-
files,
|
|
463
|
-
images,
|
|
464
|
-
},
|
|
465
|
-
);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Create unified status line (similar to async status display)
|
|
469
|
-
const statusLine =
|
|
470
|
-
config.environment?.nodeEnv !== 'test'
|
|
471
|
-
? `✅ COMPLETED | CHAT | ${continuationId} | ${executionTime.toFixed(1)}s elapsed | ${providerName}/${resolvedModel}\n`
|
|
472
|
-
: '';
|
|
473
|
-
|
|
474
|
-
// Always include continuation_id line for clarity
|
|
475
|
-
const continuationIdLine = `continuation_id: ${continuationId}\n\n`;
|
|
476
|
-
|
|
477
|
-
const result = {
|
|
478
|
-
content: statusLine + continuationIdLine + response.content,
|
|
479
|
-
continuation: {
|
|
480
|
-
id: continuationId,
|
|
481
|
-
provider: providerName,
|
|
482
|
-
model,
|
|
483
|
-
messageCount: updatedMessages.filter((msg) => msg.role !== 'system')
|
|
484
|
-
.length,
|
|
485
|
-
...(isCustomId && { custom_id: true }),
|
|
486
|
-
},
|
|
487
|
-
};
|
|
488
|
-
|
|
489
|
-
// Add metadata if available
|
|
490
|
-
if (response.metadata) {
|
|
491
|
-
result.metadata = response.metadata;
|
|
492
|
-
}
|
|
274
|
+
return buildAsyncResult(pipeline, passedTitle, finalSummary);
|
|
275
|
+
}
|
|
493
276
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
277
|
+
/**
|
|
278
|
+
* The shared pipeline: load history, build context, resolve + invoke via the
|
|
279
|
+
* mode's engine, persist, export. Returns a normalized pipeline result (or an
|
|
280
|
+
* `{ error }` for path-validation failures).
|
|
281
|
+
*/
|
|
282
|
+
async function runUnifiedChat(args, dependencies, context) {
|
|
283
|
+
const {
|
|
284
|
+
config,
|
|
285
|
+
providers,
|
|
286
|
+
continuationStore,
|
|
287
|
+
contextProcessor,
|
|
288
|
+
providerStreamNormalizer,
|
|
289
|
+
} = dependencies;
|
|
290
|
+
const signal = context ? context.signal : dependencies.signal;
|
|
291
|
+
const { prompt, mode, models, reasoning_effort, files, images, export: shouldExport } = args;
|
|
498
292
|
|
|
499
|
-
|
|
500
|
-
|
|
293
|
+
// Continuation load
|
|
294
|
+
let continuationId = args.continuation_id;
|
|
295
|
+
let isCustomId = false;
|
|
296
|
+
let existingState = null;
|
|
297
|
+
if (continuationId) {
|
|
501
298
|
try {
|
|
502
|
-
|
|
503
|
-
} catch (
|
|
504
|
-
|
|
505
|
-
finalResult = result;
|
|
299
|
+
existingState = await continuationStore.get(continuationId);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
logger.error('Error loading conversation', { error });
|
|
506
302
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
} catch (error) {
|
|
510
|
-
if (dependencies?.signal?.aborted || error.name === 'AbortError') {
|
|
511
|
-
logger.debug('Chat tool cancelled by client');
|
|
512
|
-
return createToolError('Chat request cancelled');
|
|
303
|
+
if (!existingState) {
|
|
304
|
+
isCustomId = !isValidContinuationId(continuationId);
|
|
513
305
|
}
|
|
514
|
-
|
|
515
|
-
|
|
306
|
+
} else {
|
|
307
|
+
continuationId = generateContinuationId();
|
|
516
308
|
}
|
|
517
|
-
}
|
|
518
309
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
function resolveAutoModel(model, providerName) {
|
|
528
|
-
if (model.toLowerCase() !== 'auto') {
|
|
529
|
-
return model;
|
|
530
|
-
}
|
|
310
|
+
const priorHistory = existingState?.messages || [];
|
|
311
|
+
// Strip any stored leading system message so exactly one current-mode system
|
|
312
|
+
// prompt leads the invoked/persisted history (prevents a cross-mode resume
|
|
313
|
+
// running the previous mode's prompt).
|
|
314
|
+
const normalizedHistory =
|
|
315
|
+
priorHistory.length > 0 && priorHistory[0].role === 'system'
|
|
316
|
+
? priorHistory.slice(1)
|
|
317
|
+
: priorHistory;
|
|
531
318
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
openrouter: 'qwen/qwen3-coder',
|
|
544
|
-
};
|
|
319
|
+
// Path validation
|
|
320
|
+
if (files.length > 0 || images.length > 0) {
|
|
321
|
+
const validation = await validateAllPaths(
|
|
322
|
+
{ files, images },
|
|
323
|
+
{ clientCwd: config.server?.client_cwd },
|
|
324
|
+
);
|
|
325
|
+
if (!validation.valid) {
|
|
326
|
+
logger.error('File validation failed', { errors: validation.errors });
|
|
327
|
+
return { error: validation.errorResponse };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
545
330
|
|
|
546
|
-
|
|
547
|
-
|
|
331
|
+
const contextMessage = await buildContextMessage(
|
|
332
|
+
files,
|
|
333
|
+
images,
|
|
334
|
+
contextProcessor,
|
|
335
|
+
config,
|
|
336
|
+
);
|
|
548
337
|
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
const
|
|
338
|
+
const systemPrompt = getSystemPromptForMode(mode);
|
|
339
|
+
const systemMessage = { role: 'system', content: systemPrompt };
|
|
340
|
+
const hasImages = Array.isArray(images) && images.length > 0;
|
|
552
341
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
342
|
+
const common = {
|
|
343
|
+
mode,
|
|
344
|
+
models,
|
|
345
|
+
prompt,
|
|
346
|
+
reasoning_effort,
|
|
347
|
+
files,
|
|
348
|
+
images,
|
|
349
|
+
continuationId,
|
|
350
|
+
isCustomId,
|
|
351
|
+
existingState,
|
|
352
|
+
normalizedHistory,
|
|
353
|
+
systemMessage,
|
|
354
|
+
systemPrompt,
|
|
355
|
+
contextMessage,
|
|
356
|
+
hasImages,
|
|
357
|
+
shouldExport,
|
|
358
|
+
signal,
|
|
359
|
+
context,
|
|
360
|
+
providers,
|
|
361
|
+
config,
|
|
362
|
+
continuationStore,
|
|
363
|
+
providerStreamNormalizer,
|
|
558
364
|
};
|
|
559
|
-
}
|
|
560
365
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
return true;
|
|
366
|
+
if (mode === 'roundtable') {
|
|
367
|
+
return await runRoundtablePipeline(common);
|
|
564
368
|
}
|
|
565
|
-
|
|
566
|
-
const message = (error && error.message) || '';
|
|
567
|
-
return /(api key|authentication|unauthorized|forbidden|invalid|not available)/i.test(
|
|
568
|
-
message,
|
|
569
|
-
);
|
|
369
|
+
return await runParallelPipeline(common);
|
|
570
370
|
}
|
|
571
371
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
372
|
+
/**
|
|
373
|
+
* Build the per-provider message array for a chat/consensus candidate. API and
|
|
374
|
+
* gemini-cli providers get the full role-separated history; last-user-only SDK
|
|
375
|
+
* providers get the prior transcript packed into a single final user message,
|
|
376
|
+
* except Codex in chat mode when it can resume its own thread.
|
|
377
|
+
*/
|
|
378
|
+
function makeMessageBuilder(common, priorTranscriptText, userMessage, packedUserMessage) {
|
|
379
|
+
const fullMessages = [common.systemMessage, ...common.normalizedHistory, userMessage];
|
|
380
|
+
return (candidate, callPlan) => {
|
|
381
|
+
if (!LAST_USER_ONLY.has(candidate.name)) {
|
|
382
|
+
return fullMessages;
|
|
582
383
|
}
|
|
583
|
-
if (
|
|
584
|
-
return
|
|
384
|
+
if (!priorTranscriptText) {
|
|
385
|
+
return [common.systemMessage, userMessage];
|
|
585
386
|
}
|
|
586
|
-
|
|
587
|
-
|
|
387
|
+
const codexReusesThread =
|
|
388
|
+
common.mode === 'chat' &&
|
|
389
|
+
candidate.name === 'codex' &&
|
|
390
|
+
!!common.existingState?.providerThreads?.[callPlan.threadKey];
|
|
391
|
+
if (codexReusesThread) {
|
|
392
|
+
return fullMessages;
|
|
588
393
|
}
|
|
589
|
-
return
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// Check Codex (exact match only - don't route "gpt-5-codex" etc to Codex provider)
|
|
593
|
-
if (modelLower === 'codex') {
|
|
594
|
-
return 'codex';
|
|
595
|
-
}
|
|
394
|
+
return [common.systemMessage, packedUserMessage];
|
|
395
|
+
};
|
|
396
|
+
}
|
|
596
397
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
398
|
+
/**
|
|
399
|
+
* Parallel-mode pipeline (chat + consensus).
|
|
400
|
+
*/
|
|
401
|
+
async function runParallelPipeline(common) {
|
|
402
|
+
const {
|
|
403
|
+
mode,
|
|
404
|
+
models,
|
|
405
|
+
prompt,
|
|
406
|
+
reasoning_effort,
|
|
407
|
+
continuationId,
|
|
408
|
+
isCustomId,
|
|
409
|
+
existingState,
|
|
410
|
+
normalizedHistory,
|
|
411
|
+
contextMessage,
|
|
412
|
+
signal,
|
|
413
|
+
context,
|
|
414
|
+
providers,
|
|
415
|
+
config,
|
|
416
|
+
continuationStore,
|
|
417
|
+
providerStreamNormalizer,
|
|
418
|
+
} = common;
|
|
419
|
+
|
|
420
|
+
const userContent = contextMessage?.content
|
|
421
|
+
? [...contextMessage.content, { type: 'text', text: prompt }]
|
|
422
|
+
: prompt;
|
|
423
|
+
const userMessage = { role: 'user', content: userContent };
|
|
424
|
+
|
|
425
|
+
const priorTranscriptText = renderStoredTranscriptToText(normalizedHistory);
|
|
426
|
+
const packedText = priorTranscriptText ? `${priorTranscriptText}\n\n${prompt}` : prompt;
|
|
427
|
+
const packedUserContent = contextMessage?.content
|
|
428
|
+
? [...contextMessage.content, { type: 'text', text: packedText }]
|
|
429
|
+
: packedText;
|
|
430
|
+
const packedUserMessage = { role: 'user', content: packedUserContent };
|
|
431
|
+
|
|
432
|
+
const buildMessagesForCandidate = makeMessageBuilder(
|
|
433
|
+
common,
|
|
434
|
+
priorTranscriptText,
|
|
435
|
+
userMessage,
|
|
436
|
+
packedUserMessage,
|
|
437
|
+
);
|
|
601
438
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
439
|
+
const optionsForCandidate = (candidate, callPlan) => {
|
|
440
|
+
const options = {
|
|
441
|
+
model: candidate.resolvedModel,
|
|
442
|
+
reasoning_effort,
|
|
443
|
+
config,
|
|
444
|
+
};
|
|
445
|
+
// Web search opt-in from an OpenRouter `:online` decoration (parsed by the
|
|
446
|
+
// shared resolver). Only ever set for OpenRouter candidates.
|
|
447
|
+
if (candidate.resolveOptions?.web_search) {
|
|
448
|
+
options.web_search = true;
|
|
449
|
+
}
|
|
450
|
+
// Codex thread reuse is chat-mode-scoped, and only Codex consumes these
|
|
451
|
+
// options — passing them to other providers would leak `threadKey` into
|
|
452
|
+
// their API payloads via generic option passthrough.
|
|
453
|
+
if (mode === 'chat' && candidate.name === 'codex') {
|
|
454
|
+
options.continuation_id = continuationId;
|
|
455
|
+
options.continuationStore = continuationStore;
|
|
456
|
+
options.threadKey = callPlan.threadKey;
|
|
457
|
+
}
|
|
458
|
+
return options;
|
|
459
|
+
};
|
|
607
460
|
|
|
608
|
-
|
|
609
|
-
if (
|
|
610
|
-
modelLower === 'claude' ||
|
|
611
|
-
modelLower === 'claude-sdk' ||
|
|
612
|
-
modelLower === 'claude-code'
|
|
613
|
-
) {
|
|
614
|
-
return 'claude';
|
|
615
|
-
}
|
|
461
|
+
const startedAt = Date.now();
|
|
616
462
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
463
|
+
if (mode === 'consensus') {
|
|
464
|
+
const { resolved, preFailed } = resolveConsensusCallPlans(
|
|
465
|
+
models,
|
|
466
|
+
providers,
|
|
467
|
+
config,
|
|
468
|
+
common.images,
|
|
469
|
+
);
|
|
622
470
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
471
|
+
if (resolved.length === 0) {
|
|
472
|
+
return {
|
|
473
|
+
error: createToolError(
|
|
474
|
+
'No providers available. Please configure at least one API key.',
|
|
475
|
+
),
|
|
476
|
+
errorMessage: 'No providers available',
|
|
477
|
+
};
|
|
478
|
+
}
|
|
631
479
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
480
|
+
const { initial, refined } = await runConsensusMode({
|
|
481
|
+
callPlans: resolved,
|
|
482
|
+
buildMessagesForCandidate,
|
|
483
|
+
optionsForCandidate,
|
|
484
|
+
prompt,
|
|
485
|
+
signal,
|
|
486
|
+
context,
|
|
487
|
+
providerStreamNormalizer,
|
|
488
|
+
});
|
|
637
489
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
modelLower === 'openrouter-auto'
|
|
644
|
-
) {
|
|
645
|
-
return 'openrouter';
|
|
646
|
-
}
|
|
490
|
+
const successful = initial.filter((r) => r.status === 'success');
|
|
491
|
+
const failed = [
|
|
492
|
+
...initial.filter((r) => r.status === 'failed'),
|
|
493
|
+
...preFailed.map((f) => ({ ...f, status: 'failed' })),
|
|
494
|
+
];
|
|
647
495
|
|
|
648
|
-
|
|
649
|
-
if (modelLower.includes('/')) {
|
|
650
|
-
// Check each provider to see if they have this exact model
|
|
651
|
-
for (const [providerName, provider] of Object.entries(providers)) {
|
|
652
|
-
if (provider && provider.getModelConfig) {
|
|
653
|
-
const modelConfig = provider.getModelConfig(model);
|
|
654
|
-
if (
|
|
655
|
-
modelConfig &&
|
|
656
|
-
!modelConfig.isDynamic &&
|
|
657
|
-
!modelConfig.needsApiUpdate
|
|
658
|
-
) {
|
|
659
|
-
// Model exists in this provider's static list
|
|
660
|
-
return providerName;
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
// No native provider has this model, route to OpenRouter
|
|
665
|
-
return 'openrouter';
|
|
666
|
-
}
|
|
496
|
+
const formattedContent = formatConsensusContent(successful, refined);
|
|
667
497
|
|
|
668
|
-
|
|
498
|
+
await persistAndExport(common, {
|
|
499
|
+
userMessage,
|
|
500
|
+
assistantContent: formattedContent,
|
|
501
|
+
extraState: {
|
|
502
|
+
consensusData: {
|
|
503
|
+
modelsRequested: models.length,
|
|
504
|
+
providersSuccessful: successful.length,
|
|
505
|
+
providersFailed: failed.length,
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
});
|
|
669
509
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
510
|
+
const finalCount = refined
|
|
511
|
+
? refined.filter((r) => r.status === 'success').length
|
|
512
|
+
: successful.length;
|
|
513
|
+
const totalCount = resolved.length;
|
|
514
|
+
const failureDetails = collectConsensusFailures(successful, failed, refined);
|
|
515
|
+
const combinedForSummary = successful
|
|
516
|
+
.map((r) => `${r.model}:\n${refined ? refinedText(refined, r) : r.response}`)
|
|
517
|
+
.join('\n\n---\n\n');
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
kind: 'consensus',
|
|
521
|
+
continuationId,
|
|
522
|
+
isCustomId,
|
|
523
|
+
executionTime: (Date.now() - startedAt) / 1000,
|
|
524
|
+
structuredResult: {
|
|
525
|
+
status: 'consensus_complete',
|
|
526
|
+
models_consulted: models.length,
|
|
527
|
+
successful_initial_responses: successful.length,
|
|
528
|
+
failed_responses: failed.length,
|
|
529
|
+
refined_responses: refined
|
|
530
|
+
? refined.filter((r) => r.status === 'success').length
|
|
531
|
+
: 0,
|
|
532
|
+
phases: {
|
|
533
|
+
initial: successful,
|
|
534
|
+
...(refined !== null && { refined }),
|
|
535
|
+
failed,
|
|
536
|
+
},
|
|
537
|
+
continuation: {
|
|
538
|
+
id: continuationId,
|
|
539
|
+
messageCount: normalizedHistory.length + 3,
|
|
540
|
+
...(isCustomId && { custom_id: true }),
|
|
541
|
+
},
|
|
542
|
+
settings: { models_requested: models },
|
|
543
|
+
},
|
|
544
|
+
formattedContent,
|
|
545
|
+
finalCount,
|
|
546
|
+
totalCount,
|
|
547
|
+
failureDetails,
|
|
548
|
+
modelsList: resolved.map((c) => c.displayModel).join(', '),
|
|
549
|
+
combinedForSummary,
|
|
550
|
+
};
|
|
678
551
|
}
|
|
679
552
|
|
|
680
|
-
//
|
|
681
|
-
|
|
682
|
-
|
|
553
|
+
// mode === 'chat'
|
|
554
|
+
const { callPlans, preFailed, error } = resolveChatCallPlans(
|
|
555
|
+
models,
|
|
556
|
+
providers,
|
|
557
|
+
config,
|
|
558
|
+
common.hasImages,
|
|
559
|
+
);
|
|
560
|
+
if (error) {
|
|
561
|
+
return { error: createToolError(error), errorMessage: error };
|
|
683
562
|
}
|
|
684
563
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
564
|
+
const { results } = await runChatMode({
|
|
565
|
+
callPlans,
|
|
566
|
+
buildMessagesForCandidate,
|
|
567
|
+
optionsForCandidate,
|
|
568
|
+
signal,
|
|
569
|
+
context,
|
|
570
|
+
providerStreamNormalizer,
|
|
571
|
+
retryOptionsFor: (providerName) => getProviderRetryOptions(config, providerName),
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
const allResults = [
|
|
575
|
+
...results,
|
|
576
|
+
...preFailed.map((f) => ({ ...f, status: 'failed' })),
|
|
577
|
+
];
|
|
578
|
+
const successful = allResults.filter((r) => r.status === 'success');
|
|
579
|
+
|
|
580
|
+
if (successful.length === 0) {
|
|
581
|
+
if (allResults.length > 1) {
|
|
582
|
+
// Multi-model all-failed: surface every model's failure.
|
|
583
|
+
const details = allResults.map((r) => `${r.model} (${r.error})`).join('; ');
|
|
584
|
+
return {
|
|
585
|
+
error: createToolError(`All models failed: ${details}`),
|
|
586
|
+
errorMessage: details,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const message =
|
|
590
|
+
allResults.find((r) => r.error)?.error || 'Provider returned no response';
|
|
591
|
+
return { error: createToolError(`Provider error: ${message}`), errorMessage: message };
|
|
692
592
|
}
|
|
693
593
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
modelLower.includes('opus') ||
|
|
699
|
-
modelLower.includes('sonnet') ||
|
|
700
|
-
modelLower.includes('haiku')
|
|
701
|
-
) {
|
|
702
|
-
return 'anthropic';
|
|
703
|
-
}
|
|
594
|
+
const isMulti = models.length > 1;
|
|
595
|
+
const combinedContent = isMulti
|
|
596
|
+
? formatChatMultiContent(successful)
|
|
597
|
+
: successful[0].response;
|
|
704
598
|
|
|
705
|
-
//
|
|
706
|
-
|
|
707
|
-
|
|
599
|
+
// Merge new Codex thread IDs into the existing per-spec thread map.
|
|
600
|
+
const providerThreads = { ...(existingState?.providerThreads || {}) };
|
|
601
|
+
for (const r of successful) {
|
|
602
|
+
if (r.metadata?.threadId && r.threadKey) {
|
|
603
|
+
providerThreads[r.threadKey] = r.metadata.threadId;
|
|
604
|
+
}
|
|
708
605
|
}
|
|
709
606
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
modelLower === 'chat'
|
|
716
|
-
) {
|
|
717
|
-
return 'deepseek';
|
|
718
|
-
}
|
|
607
|
+
await persistAndExport(common, {
|
|
608
|
+
userMessage,
|
|
609
|
+
assistantContent: combinedContent,
|
|
610
|
+
extraState: { models, providerThreads },
|
|
611
|
+
});
|
|
719
612
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
modelLower.includes('moonshot') ||
|
|
725
|
-
modelLower === 'k2'
|
|
726
|
-
) {
|
|
727
|
-
return 'openrouter';
|
|
728
|
-
}
|
|
613
|
+
const failed = allResults.filter((r) => r.status === 'failed');
|
|
614
|
+
const failureDetails = failed.map((f) => `${f.model} (${f.error})`);
|
|
615
|
+
const winner = successful[0];
|
|
616
|
+
const messageCount = normalizedHistory.length + 2; // user + assistant (system excluded)
|
|
729
617
|
|
|
730
|
-
|
|
731
|
-
|
|
618
|
+
return {
|
|
619
|
+
kind: 'chat',
|
|
620
|
+
continuationId,
|
|
621
|
+
isCustomId,
|
|
622
|
+
executionTime: (Date.now() - startedAt) / 1000,
|
|
623
|
+
content: combinedContent,
|
|
624
|
+
isMulti,
|
|
625
|
+
successCount: successful.length,
|
|
626
|
+
totalCount: allResults.length,
|
|
627
|
+
failureDetails,
|
|
628
|
+
messageCount,
|
|
629
|
+
modelsList: models.join(', '),
|
|
630
|
+
provider: winner.provider,
|
|
631
|
+
model: winner.resolvedModel,
|
|
632
|
+
combinedForSummary: combinedContent,
|
|
633
|
+
};
|
|
732
634
|
}
|
|
733
635
|
|
|
734
636
|
/**
|
|
735
|
-
*
|
|
736
|
-
* @param {object} args - Original chat arguments
|
|
737
|
-
* @param {object} dependencies - Dependencies with continuationId
|
|
738
|
-
* @param {object} context - Job execution context
|
|
739
|
-
* @returns {Promise<object>} Complete chat result
|
|
637
|
+
* Roundtable-mode pipeline.
|
|
740
638
|
*/
|
|
741
|
-
async function
|
|
639
|
+
async function runRoundtablePipeline(common) {
|
|
742
640
|
const {
|
|
641
|
+
models,
|
|
642
|
+
prompt,
|
|
643
|
+
reasoning_effort,
|
|
644
|
+
continuationId,
|
|
645
|
+
isCustomId,
|
|
646
|
+
normalizedHistory,
|
|
647
|
+
systemPrompt,
|
|
648
|
+
contextMessage,
|
|
649
|
+
hasImages,
|
|
650
|
+
signal,
|
|
651
|
+
context,
|
|
652
|
+
providers,
|
|
743
653
|
config,
|
|
654
|
+
providerStreamNormalizer,
|
|
655
|
+
} = common;
|
|
656
|
+
|
|
657
|
+
const startedAt = Date.now();
|
|
658
|
+
|
|
659
|
+
const lap = await runRoundtableLap({
|
|
660
|
+
models,
|
|
661
|
+
prompt,
|
|
662
|
+
priorHistory: normalizedHistory,
|
|
663
|
+
contextMessage,
|
|
664
|
+
systemPrompt,
|
|
744
665
|
providers,
|
|
745
|
-
|
|
746
|
-
|
|
666
|
+
config,
|
|
667
|
+
signal,
|
|
668
|
+
reasoning_effort,
|
|
669
|
+
hasImages,
|
|
670
|
+
context,
|
|
747
671
|
providerStreamNormalizer,
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const { lapTurns, transcript, turnsSuccessful, turnsFailed, lapUserMessage } = lap;
|
|
675
|
+
|
|
676
|
+
const conversationState = await persistAndExport(common, {
|
|
677
|
+
userMessage: lapUserMessage,
|
|
678
|
+
assistantContent: transcript,
|
|
679
|
+
extraState: {
|
|
680
|
+
roundtableData: {
|
|
681
|
+
modelsOrdered: models,
|
|
682
|
+
turnsSuccessful,
|
|
683
|
+
turnsFailed,
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const failureDetails = lapTurns
|
|
689
|
+
.filter((t) => t.status === 'failed')
|
|
690
|
+
.map((t) => `${t.model} (${t.error})`);
|
|
691
|
+
const messageCount = (conversationState?.messages || []).length;
|
|
692
|
+
const combinedForSummary = lapTurns
|
|
693
|
+
.filter((t) => t.status === 'success' && t.response)
|
|
694
|
+
.map((t) => `${t.model}:\n${t.response}`)
|
|
695
|
+
.join('\n\n---\n\n');
|
|
696
|
+
|
|
697
|
+
return {
|
|
698
|
+
kind: 'roundtable',
|
|
748
699
|
continuationId,
|
|
749
700
|
isCustomId,
|
|
750
|
-
|
|
751
|
-
|
|
701
|
+
executionTime: (Date.now() - startedAt) / 1000,
|
|
702
|
+
transcript,
|
|
703
|
+
structuredResult: {
|
|
704
|
+
status: 'roundtable_complete',
|
|
705
|
+
content: transcript,
|
|
706
|
+
models_consulted: models.length,
|
|
707
|
+
successful_turns: turnsSuccessful,
|
|
708
|
+
failed_turns: turnsFailed,
|
|
709
|
+
turns: lapTurns,
|
|
710
|
+
continuation: {
|
|
711
|
+
id: continuationId,
|
|
712
|
+
messageCount,
|
|
713
|
+
...(isCustomId && { custom_id: true }),
|
|
714
|
+
},
|
|
715
|
+
settings: { models_requested: models },
|
|
716
|
+
},
|
|
717
|
+
turnsSuccessful,
|
|
718
|
+
totalTurns: lapTurns.length,
|
|
719
|
+
failureDetails,
|
|
720
|
+
modelsList: models.join(', '),
|
|
721
|
+
combinedForSummary,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
752
724
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
725
|
+
/**
|
|
726
|
+
* Build the synchronous MCP response from a pipeline result.
|
|
727
|
+
*/
|
|
728
|
+
function buildSyncResponse(pipeline, config) {
|
|
729
|
+
const isTest = config.environment?.nodeEnv === 'test';
|
|
730
|
+
const tokenLimit = getTokenLimit(config);
|
|
731
|
+
const idLine = `continuation_id: ${pipeline.continuationId}\n\n`;
|
|
732
|
+
|
|
733
|
+
if (pipeline.kind === 'chat') {
|
|
734
|
+
const statusLine = isTest
|
|
735
|
+
? ''
|
|
736
|
+
: pipeline.isMulti
|
|
737
|
+
? `✅ COMPLETED | CHAT | ${pipeline.continuationId} | ${pipeline.executionTime.toFixed(1)}s elapsed | ${pipeline.successCount}/${pipeline.totalCount} succeeded | ${pipeline.modelsList}\n`
|
|
738
|
+
: `✅ COMPLETED | CHAT | ${pipeline.continuationId} | ${pipeline.executionTime.toFixed(1)}s elapsed | ${pipeline.provider}/${pipeline.model}\n`;
|
|
767
739
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
740
|
+
const result = {
|
|
741
|
+
content: statusLine + idLine + pipeline.content,
|
|
742
|
+
continuation: {
|
|
743
|
+
id: pipeline.continuationId,
|
|
744
|
+
messageCount: pipeline.messageCount,
|
|
745
|
+
...(pipeline.isMulti
|
|
746
|
+
? { models: pipeline.modelsList }
|
|
747
|
+
: { provider: pipeline.provider, model: pipeline.model }),
|
|
748
|
+
...(pipeline.isCustomId && { custom_id: true }),
|
|
749
|
+
},
|
|
750
|
+
};
|
|
751
|
+
if (pipeline.failureDetails.length > 0) {
|
|
752
|
+
result.content += formatFailureDetails(pipeline.failureDetails);
|
|
777
753
|
}
|
|
778
|
-
} else {
|
|
779
|
-
debugLog(`Chat: Using passed title - "${title}"`);
|
|
780
|
-
}
|
|
781
754
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
// Load existing conversation if continuation_id provided
|
|
785
|
-
if (continuationId) {
|
|
755
|
+
const limited = applyTokenLimit(JSON.stringify(result, null, 2), tokenLimit);
|
|
756
|
+
let finalResult;
|
|
786
757
|
try {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
}
|
|
791
|
-
} catch (error) {
|
|
792
|
-
logger.error('Error loading conversation', { error });
|
|
793
|
-
// Continue with fresh conversation on error
|
|
758
|
+
finalResult = JSON.parse(limited.content);
|
|
759
|
+
} catch {
|
|
760
|
+
finalResult = result;
|
|
794
761
|
}
|
|
762
|
+
return createToolResponse(finalResult);
|
|
795
763
|
}
|
|
796
764
|
|
|
797
|
-
//
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
{
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
765
|
+
// consensus / roundtable — structured JSON body
|
|
766
|
+
const modeLabel = pipeline.kind.toUpperCase();
|
|
767
|
+
const countField =
|
|
768
|
+
pipeline.kind === 'consensus'
|
|
769
|
+
? `${pipeline.finalCount}/${pipeline.totalCount} succeeded`
|
|
770
|
+
: `${pipeline.turnsSuccessful}/${pipeline.totalTurns} turns`;
|
|
771
|
+
const statusLine = isTest
|
|
772
|
+
? ''
|
|
773
|
+
: `✅ COMPLETED | ${modeLabel} | ${pipeline.continuationId} | ${pipeline.executionTime.toFixed(1)}s elapsed | ${countField} | ${pipeline.modelsList}\n`;
|
|
774
|
+
|
|
775
|
+
const limited = applyTokenLimit(
|
|
776
|
+
JSON.stringify(pipeline.structuredResult, null, 2),
|
|
777
|
+
tokenLimit,
|
|
778
|
+
);
|
|
779
|
+
let finalContent = limited.content;
|
|
780
|
+
if (pipeline.failureDetails.length > 0) {
|
|
781
|
+
finalContent += formatFailureDetails(pipeline.failureDetails);
|
|
809
782
|
}
|
|
783
|
+
finalContent = statusLine + idLine + finalContent;
|
|
810
784
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
files: Array.isArray(files) ? files : [],
|
|
817
|
-
images: Array.isArray(images) ? images : [],
|
|
818
|
-
webSearch: use_websearch ? prompt : null,
|
|
819
|
-
};
|
|
785
|
+
return createToolResponse({
|
|
786
|
+
content: finalContent,
|
|
787
|
+
continuation: pipeline.structuredResult.continuation,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
820
790
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
791
|
+
/**
|
|
792
|
+
* Build the async job-completion result object from a pipeline result.
|
|
793
|
+
*/
|
|
794
|
+
function buildAsyncResult(pipeline, title, finalSummary) {
|
|
795
|
+
const baseMetadata = {
|
|
796
|
+
execution_time: pipeline.executionTime,
|
|
797
|
+
async_execution: true,
|
|
798
|
+
title,
|
|
799
|
+
final_summary: finalSummary,
|
|
800
|
+
};
|
|
829
801
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
802
|
+
if (pipeline.kind === 'chat') {
|
|
803
|
+
return {
|
|
804
|
+
status: 'chat_complete',
|
|
805
|
+
content: pipeline.content,
|
|
806
|
+
continuation: {
|
|
807
|
+
id: pipeline.continuationId,
|
|
808
|
+
messageCount: pipeline.messageCount,
|
|
809
|
+
...(pipeline.isCustomId && { custom_id: true }),
|
|
810
|
+
},
|
|
811
|
+
metadata: {
|
|
812
|
+
...baseMetadata,
|
|
813
|
+
provider: pipeline.provider,
|
|
814
|
+
model: pipeline.model,
|
|
815
|
+
successful_models: pipeline.successCount,
|
|
816
|
+
total_models: pipeline.totalCount,
|
|
817
|
+
failure_details: pipeline.failureDetails,
|
|
818
|
+
},
|
|
819
|
+
};
|
|
845
820
|
}
|
|
846
821
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
822
|
+
if (pipeline.kind === 'consensus') {
|
|
823
|
+
return {
|
|
824
|
+
...pipeline.structuredResult,
|
|
825
|
+
content: pipeline.formattedContent,
|
|
826
|
+
continuation: {
|
|
827
|
+
id: pipeline.continuationId,
|
|
828
|
+
...(pipeline.isCustomId && { custom_id: true }),
|
|
829
|
+
},
|
|
830
|
+
metadata: {
|
|
831
|
+
...baseMetadata,
|
|
832
|
+
successful_models: pipeline.finalCount,
|
|
833
|
+
total_models: pipeline.totalCount,
|
|
834
|
+
failure_details: pipeline.failureDetails,
|
|
835
|
+
},
|
|
836
|
+
};
|
|
859
837
|
}
|
|
860
838
|
|
|
861
|
-
//
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
839
|
+
// roundtable
|
|
840
|
+
return {
|
|
841
|
+
...pipeline.structuredResult,
|
|
842
|
+
content: pipeline.transcript,
|
|
843
|
+
continuation: {
|
|
844
|
+
id: pipeline.continuationId,
|
|
845
|
+
...(pipeline.isCustomId && { custom_id: true }),
|
|
846
|
+
},
|
|
847
|
+
metadata: {
|
|
848
|
+
...baseMetadata,
|
|
849
|
+
successful_models: pipeline.turnsSuccessful,
|
|
850
|
+
total_models: pipeline.totalTurns,
|
|
851
|
+
failure_details: pipeline.failureDetails,
|
|
852
|
+
},
|
|
868
853
|
};
|
|
854
|
+
}
|
|
869
855
|
|
|
870
|
-
|
|
871
|
-
if (contextMessage && contextMessage.content) {
|
|
872
|
-
userMessage.content = [
|
|
873
|
-
...contextMessage.content,
|
|
874
|
-
{ type: 'text', text: prompt },
|
|
875
|
-
];
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
messages.push(userMessage);
|
|
879
|
-
|
|
880
|
-
// Select provider
|
|
881
|
-
let selectedProvider;
|
|
882
|
-
let providerName;
|
|
883
|
-
|
|
884
|
-
if (model === 'auto') {
|
|
885
|
-
// Auto-select first available provider using same priority as sync path
|
|
886
|
-
const providerOrder = [
|
|
887
|
-
'codex',
|
|
888
|
-
'gemini-cli',
|
|
889
|
-
'claude',
|
|
890
|
-
'copilot',
|
|
891
|
-
'openai',
|
|
892
|
-
'google',
|
|
893
|
-
'xai',
|
|
894
|
-
'anthropic',
|
|
895
|
-
'mistral',
|
|
896
|
-
'deepseek',
|
|
897
|
-
'openrouter',
|
|
898
|
-
];
|
|
856
|
+
// --- Model resolution helpers ------------------------------------------------
|
|
899
857
|
|
|
900
|
-
|
|
858
|
+
/**
|
|
859
|
+
* Build the full provider-priority candidate list for an "auto" spec (used for
|
|
860
|
+
* chat-mode failover). Skips text-only providers when the request has images.
|
|
861
|
+
*/
|
|
862
|
+
function buildAutoCandidates(providers, config, hasImages) {
|
|
863
|
+
return getAvailableProviders(providers, config, { hasImages }).map((name) => ({
|
|
864
|
+
name,
|
|
865
|
+
providerInstance: providers[name],
|
|
866
|
+
resolvedModel: getDefaultModelForProvider(name),
|
|
867
|
+
displayModel: 'auto',
|
|
868
|
+
}));
|
|
869
|
+
}
|
|
901
870
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
871
|
+
/**
|
|
872
|
+
* Resolve chat-mode call plans. Each "auto" spec (whether the list is exactly
|
|
873
|
+
* ["auto"] or "auto" appears alongside explicit models) yields a plan with the
|
|
874
|
+
* full provider-priority candidate list (failover); explicit models yield one
|
|
875
|
+
* single-candidate plan each. Unavailable/unknown explicit models are returned
|
|
876
|
+
* as pre-failed entries (surfaced as per-model failures).
|
|
877
|
+
*/
|
|
878
|
+
function resolveChatCallPlans(models, providers, config, hasImages) {
|
|
879
|
+
const callPlans = [];
|
|
880
|
+
const preFailed = [];
|
|
881
|
+
|
|
882
|
+
for (const spec of models) {
|
|
883
|
+
if (String(spec).toLowerCase() === 'auto') {
|
|
884
|
+
const candidates = buildAutoCandidates(providers, config, hasImages);
|
|
885
|
+
if (candidates.length === 0) {
|
|
886
|
+
// A single ["auto"] with no providers is a hard error; an "auto" entry
|
|
887
|
+
// in a multi-model list becomes a per-model failure instead.
|
|
888
|
+
if (models.length === 1) {
|
|
889
|
+
return {
|
|
890
|
+
callPlans: [],
|
|
891
|
+
preFailed: [],
|
|
892
|
+
error:
|
|
893
|
+
'No providers available. Please configure at least one API key.',
|
|
894
|
+
};
|
|
908
895
|
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
896
|
+
preFailed.push({
|
|
897
|
+
model: 'auto',
|
|
898
|
+
error: 'No providers available for "auto".',
|
|
899
|
+
});
|
|
900
|
+
continue;
|
|
912
901
|
}
|
|
902
|
+
callPlans.push({
|
|
903
|
+
modelSpec: spec,
|
|
904
|
+
displayModel: 'auto',
|
|
905
|
+
threadKey: spec,
|
|
906
|
+
candidates,
|
|
907
|
+
});
|
|
908
|
+
continue;
|
|
913
909
|
}
|
|
914
910
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
911
|
+
const { providerName, provider, resolvedModel, status, options } = resolveModelSpec(spec, providers, config);
|
|
912
|
+
if (status === 'not_found') {
|
|
913
|
+
preFailed.push({
|
|
914
|
+
model: spec,
|
|
915
|
+
provider: providerName,
|
|
916
|
+
error: `Provider not found for model: ${spec}`,
|
|
917
|
+
});
|
|
918
|
+
} else if (status === 'unavailable') {
|
|
919
|
+
preFailed.push({
|
|
920
|
+
model: spec,
|
|
921
|
+
provider: providerName,
|
|
922
|
+
error: getProviderUnavailableMessage(providerName),
|
|
923
|
+
});
|
|
924
|
+
} else {
|
|
925
|
+
callPlans.push({
|
|
926
|
+
modelSpec: spec,
|
|
927
|
+
displayModel: spec,
|
|
928
|
+
threadKey: spec,
|
|
929
|
+
candidates: [
|
|
930
|
+
{ name: providerName, providerInstance: provider, resolvedModel, displayModel: spec, resolveOptions: options },
|
|
931
|
+
],
|
|
932
|
+
});
|
|
931
933
|
}
|
|
932
934
|
}
|
|
935
|
+
return { callPlans, preFailed, error: null };
|
|
936
|
+
}
|
|
933
937
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
verbosity,
|
|
941
|
-
use_websearch,
|
|
942
|
-
config,
|
|
943
|
-
continuation_id: continuationId, // Pass for thread resumption
|
|
944
|
-
continuationStore, // Pass store for state management
|
|
945
|
-
};
|
|
946
|
-
|
|
947
|
-
// For streaming, add the stream flag and signal separately
|
|
948
|
-
const streamingOptions = {
|
|
949
|
-
...providerOptions,
|
|
950
|
-
stream: true,
|
|
951
|
-
signal: context?.signal, // Pass AbortSignal for cancellation support
|
|
952
|
-
};
|
|
953
|
-
|
|
954
|
-
// Check if provider supports streaming (by checking if invoke can return a stream)
|
|
955
|
-
let response;
|
|
956
|
-
const startTime = Date.now();
|
|
957
|
-
|
|
958
|
-
// Always use streaming for async execution in background
|
|
959
|
-
if (context?.jobId) {
|
|
960
|
-
// Use streaming with normalization
|
|
961
|
-
debugLog(`Chat: Using streaming for provider ${providerName}`);
|
|
962
|
-
|
|
963
|
-
const stream = await selectedProvider.invoke(messages, streamingOptions);
|
|
964
|
-
const normalizedStream = providerStreamNormalizer.normalize(
|
|
965
|
-
providerName,
|
|
966
|
-
stream,
|
|
967
|
-
{
|
|
968
|
-
provider: providerName,
|
|
969
|
-
model: resolvedModel,
|
|
970
|
-
requestId: context.jobId,
|
|
971
|
-
},
|
|
972
|
-
);
|
|
973
|
-
|
|
974
|
-
// Process normalized stream and build final response
|
|
975
|
-
let accumulatedContent = '';
|
|
976
|
-
let finalUsage = null;
|
|
977
|
-
let finalMetadata = {};
|
|
978
|
-
|
|
979
|
-
for await (const event of normalizedStream) {
|
|
980
|
-
// Check for cancellation
|
|
981
|
-
if (context.signal.aborted) {
|
|
982
|
-
throw new Error('Chat execution was cancelled');
|
|
983
|
-
}
|
|
938
|
+
/**
|
|
939
|
+
* Resolve consensus-mode call plans. Single "auto" expands to the first 3
|
|
940
|
+
* available providers' default models; each spec becomes a single-candidate plan.
|
|
941
|
+
*/
|
|
942
|
+
function resolveConsensusCallPlans(models, providers, config, images) {
|
|
943
|
+
const hasImages = Array.isArray(images) && images.length > 0;
|
|
984
944
|
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
provider: providerName,
|
|
991
|
-
model: resolvedModel,
|
|
992
|
-
title: title || undefined, // Include title if generated
|
|
993
|
-
progress: {
|
|
994
|
-
phase: 'streaming_started',
|
|
995
|
-
provider: providerName,
|
|
996
|
-
model: resolvedModel,
|
|
997
|
-
},
|
|
998
|
-
});
|
|
999
|
-
break;
|
|
1000
|
-
|
|
1001
|
-
case 'delta':
|
|
1002
|
-
accumulatedContent += event.data.textDelta;
|
|
1003
|
-
// Update job with progress and full accumulated content
|
|
1004
|
-
await context.updateJob({
|
|
1005
|
-
accumulated_content: accumulatedContent, // Store full content
|
|
1006
|
-
progress: {
|
|
1007
|
-
phase: 'streaming',
|
|
1008
|
-
provider: providerName,
|
|
1009
|
-
model: resolvedModel,
|
|
1010
|
-
content_length: accumulatedContent.length,
|
|
1011
|
-
},
|
|
1012
|
-
});
|
|
1013
|
-
break;
|
|
945
|
+
let modelsToProcess = models;
|
|
946
|
+
if (models.length === 1 && String(models[0]).toLowerCase() === 'auto') {
|
|
947
|
+
const available = getAvailableProviders(providers, config, { hasImages, limit: 3 });
|
|
948
|
+
modelsToProcess = available.map((name) => getDefaultModelForProvider(name));
|
|
949
|
+
}
|
|
1014
950
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
951
|
+
const resolved = [];
|
|
952
|
+
const preFailed = [];
|
|
953
|
+
for (const spec of modelsToProcess) {
|
|
954
|
+
if (!spec || typeof spec !== 'string') {
|
|
955
|
+
preFailed.push({ model: spec || 'unknown', error: 'Invalid model specification' });
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
const { providerName, provider, resolvedModel, status, options } = resolveModelSpec(spec, providers, config);
|
|
959
|
+
if (status === 'not_found') {
|
|
960
|
+
preFailed.push({ model: spec, provider: providerName, error: `Provider not found: ${providerName}` });
|
|
961
|
+
} else if (status === 'unavailable') {
|
|
962
|
+
preFailed.push({ model: spec, provider: providerName, error: getProviderUnavailableMessage(providerName) });
|
|
963
|
+
} else {
|
|
964
|
+
resolved.push({
|
|
965
|
+
modelSpec: spec,
|
|
966
|
+
displayModel: spec,
|
|
967
|
+
threadKey: spec,
|
|
968
|
+
candidates: [
|
|
969
|
+
{ name: providerName, providerInstance: provider, resolvedModel, displayModel: spec, resolveOptions: options },
|
|
970
|
+
],
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
return { resolved, preFailed };
|
|
975
|
+
}
|
|
1024
976
|
|
|
1025
|
-
|
|
1026
|
-
finalUsage = event.data.usage;
|
|
1027
|
-
break;
|
|
977
|
+
// --- Formatting helpers ------------------------------------------------------
|
|
1028
978
|
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
979
|
+
function formatChatMultiContent(successful) {
|
|
980
|
+
let content = '';
|
|
981
|
+
for (const r of successful) {
|
|
982
|
+
content += `### ${r.model}:\n${r.response}\n\n---\n\n`;
|
|
983
|
+
}
|
|
984
|
+
return content.trimEnd();
|
|
985
|
+
}
|
|
1034
986
|
|
|
1035
|
-
|
|
1036
|
-
|
|
987
|
+
function formatConsensusContent(successful, refined) {
|
|
988
|
+
let content = '## Initial Responses\n\n';
|
|
989
|
+
for (const r of successful) {
|
|
990
|
+
content += `### ${r.model} (initial response):\n${r.response}\n\n---\n\n`;
|
|
991
|
+
}
|
|
992
|
+
if (refined) {
|
|
993
|
+
content += '## Refined Responses\n\n';
|
|
994
|
+
for (const r of refined) {
|
|
995
|
+
if (r.status === 'success' && r.refined_response) {
|
|
996
|
+
content += `### ${r.model} (refined response):\n${r.refined_response}\n\n---\n\n`;
|
|
997
|
+
} else if (r.status === 'partial') {
|
|
998
|
+
content += `### ${r.model} (refinement failed, showing initial):\n${r.initial_response}\n\n---\n\n`;
|
|
1037
999
|
}
|
|
1038
1000
|
}
|
|
1039
|
-
|
|
1040
|
-
response = {
|
|
1041
|
-
content: accumulatedContent,
|
|
1042
|
-
metadata: {
|
|
1043
|
-
...finalMetadata,
|
|
1044
|
-
usage: finalUsage,
|
|
1045
|
-
streaming: true,
|
|
1046
|
-
},
|
|
1047
|
-
};
|
|
1048
|
-
} else {
|
|
1049
|
-
// Fall back to regular invoke
|
|
1050
|
-
debugLog(`Chat: Using regular invoke for provider ${providerName}`);
|
|
1051
|
-
response = await selectedProvider.invoke(messages, providerOptions);
|
|
1052
1001
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
if (!response || !response.content) {
|
|
1058
|
-
throw new Error('Provider returned invalid response');
|
|
1002
|
+
content += `\n**Summary:** Consensus completed with ${successful.length} successful initial responses`;
|
|
1003
|
+
if (refined) {
|
|
1004
|
+
const successfulRefinements = refined.filter((r) => r.status === 'success').length;
|
|
1005
|
+
content += ` and ${successfulRefinements} successful refined responses`;
|
|
1059
1006
|
}
|
|
1007
|
+
content += '.';
|
|
1008
|
+
return content;
|
|
1009
|
+
}
|
|
1060
1010
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
context.updateJob
|
|
1066
|
-
) {
|
|
1067
|
-
try {
|
|
1068
|
-
await context.updateJob({
|
|
1069
|
-
reasoning_summary: response.metadata.usage.reasoning_summary,
|
|
1070
|
-
});
|
|
1071
|
-
debugLog('Chat: Stored reasoning summary');
|
|
1072
|
-
} catch (error) {
|
|
1073
|
-
debugError('Chat: Failed to store reasoning summary', error);
|
|
1074
|
-
}
|
|
1011
|
+
function refinedText(refined, result) {
|
|
1012
|
+
const match = refined.find((r) => r.model === result.model);
|
|
1013
|
+
if (match && match.status === 'success' && match.refined_response) {
|
|
1014
|
+
return match.refined_response;
|
|
1075
1015
|
}
|
|
1016
|
+
return result.response;
|
|
1017
|
+
}
|
|
1076
1018
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
if (
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
);
|
|
1084
|
-
debugLog(`Chat: Generated final summary - "${finalSummary}"`);
|
|
1085
|
-
// Store final summary in job
|
|
1086
|
-
if (finalSummary && context && context.updateJob) {
|
|
1087
|
-
await context.updateJob({
|
|
1088
|
-
final_summary: finalSummary,
|
|
1089
|
-
});
|
|
1019
|
+
function collectConsensusFailures(successful, failed, refined) {
|
|
1020
|
+
const details = [];
|
|
1021
|
+
if (refined) {
|
|
1022
|
+
refined.forEach((r) => {
|
|
1023
|
+
if (r.status === 'partial') {
|
|
1024
|
+
details.push(`${r.model} (refinement failed)`);
|
|
1090
1025
|
}
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
}
|
|
1026
|
+
});
|
|
1027
|
+
failed.forEach((f) => details.push(`${f.model} (initial failed)`));
|
|
1028
|
+
} else {
|
|
1029
|
+
failed.forEach((f) => details.push(`${f.model} (${f.error})`));
|
|
1095
1030
|
}
|
|
1031
|
+
return details;
|
|
1032
|
+
}
|
|
1096
1033
|
|
|
1097
|
-
|
|
1098
|
-
const assistantMessage = {
|
|
1099
|
-
role: 'assistant',
|
|
1100
|
-
content: response.content,
|
|
1101
|
-
};
|
|
1034
|
+
// --- Shared side-effect + context helpers ------------------------------------
|
|
1102
1035
|
|
|
1103
|
-
|
|
1036
|
+
/**
|
|
1037
|
+
* Build the persisted conversation state (aborted-guarded), persist it, export
|
|
1038
|
+
* it when requested, and return it. Returns undefined when the request was
|
|
1039
|
+
* aborted (matching the historical `const persist = !signal?.aborted` guard).
|
|
1040
|
+
* @param {object} common - Shared pipeline context
|
|
1041
|
+
* @param {object} parts - Varying per-mode parts
|
|
1042
|
+
* @param {object} parts.userMessage - The turn's user message
|
|
1043
|
+
* @param {string} parts.assistantContent - The assistant body to persist
|
|
1044
|
+
* @param {object} [parts.extraState] - Extra per-mode state fields to merge
|
|
1045
|
+
* @returns {Promise<object|undefined>} The persisted state, or undefined if skipped
|
|
1046
|
+
*/
|
|
1047
|
+
async function persistAndExport(common, { userMessage, assistantContent, extraState = {} }) {
|
|
1048
|
+
if (common.signal?.aborted) {
|
|
1049
|
+
return undefined;
|
|
1050
|
+
}
|
|
1051
|
+
const conversationState = {
|
|
1052
|
+
messages: [
|
|
1053
|
+
common.systemMessage,
|
|
1054
|
+
...common.normalizedHistory,
|
|
1055
|
+
userMessage,
|
|
1056
|
+
{ role: 'assistant', content: assistantContent },
|
|
1057
|
+
],
|
|
1058
|
+
mode: common.mode,
|
|
1059
|
+
lastUpdated: Date.now(),
|
|
1060
|
+
...extraState,
|
|
1061
|
+
};
|
|
1062
|
+
await persistState(common.continuationStore, common.continuationId, conversationState);
|
|
1063
|
+
await maybeExport(common.shouldExport, conversationState, {
|
|
1064
|
+
config: common.config,
|
|
1065
|
+
continuationId: common.continuationId,
|
|
1066
|
+
models: common.models,
|
|
1067
|
+
reasoning_effort: common.reasoning_effort,
|
|
1068
|
+
mode: common.mode,
|
|
1069
|
+
common,
|
|
1070
|
+
});
|
|
1071
|
+
return conversationState;
|
|
1072
|
+
}
|
|
1104
1073
|
|
|
1105
|
-
|
|
1074
|
+
async function persistState(continuationStore, continuationId, state) {
|
|
1106
1075
|
try {
|
|
1107
|
-
|
|
1108
|
-
messages: updatedMessages,
|
|
1109
|
-
provider: providerName,
|
|
1110
|
-
model,
|
|
1111
|
-
lastUpdated: Date.now(),
|
|
1112
|
-
// Store Codex thread ID if available (for thread resumption)
|
|
1113
|
-
codexThreadId: response.metadata?.threadId,
|
|
1114
|
-
};
|
|
1115
|
-
|
|
1116
|
-
await continuationStore.set(continuationId, conversationState);
|
|
1076
|
+
await continuationStore.set(continuationId, state);
|
|
1117
1077
|
} catch (error) {
|
|
1118
1078
|
logger.error('Error saving conversation', { error });
|
|
1119
|
-
// Continue even if save fails
|
|
1120
1079
|
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async function maybeExport(shouldExport, conversationState, opts) {
|
|
1083
|
+
if (!shouldExport || !conversationState) {
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const { config, continuationId, models, reasoning_effort, mode, common } = opts;
|
|
1087
|
+
await exportConversation(conversationState, {
|
|
1088
|
+
clientCwd: config.server?.client_cwd,
|
|
1089
|
+
continuation_id: continuationId,
|
|
1090
|
+
mode,
|
|
1091
|
+
models,
|
|
1092
|
+
reasoning_effort,
|
|
1093
|
+
files: common.files,
|
|
1094
|
+
images: common.images,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1121
1097
|
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1098
|
+
/**
|
|
1099
|
+
* Process files/images into a single context message (shared sync + async).
|
|
1100
|
+
* @returns {Promise<object|null>} Context message or null
|
|
1101
|
+
*/
|
|
1102
|
+
async function buildContextMessage(files, images, contextProcessor, config) {
|
|
1103
|
+
if ((!files || files.length === 0) && (!images || images.length === 0)) {
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
try {
|
|
1107
|
+
const contextResult = await contextProcessor.processUnifiedContext(
|
|
1125
1108
|
{
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
model,
|
|
1129
|
-
lastUpdated: Date.now(),
|
|
1130
|
-
codexThreadId: response.metadata?.threadId,
|
|
1109
|
+
files: Array.isArray(files) ? files : [],
|
|
1110
|
+
images: Array.isArray(images) ? images : [],
|
|
1131
1111
|
},
|
|
1132
1112
|
{
|
|
1113
|
+
enforceSecurityCheck: false,
|
|
1114
|
+
skipSecurityCheck: true,
|
|
1133
1115
|
clientCwd: config.server?.client_cwd,
|
|
1134
|
-
continuation_id: continuationId,
|
|
1135
|
-
model,
|
|
1136
|
-
temperature,
|
|
1137
|
-
reasoning_effort,
|
|
1138
|
-
verbosity,
|
|
1139
|
-
use_websearch,
|
|
1140
|
-
files,
|
|
1141
|
-
images,
|
|
1142
1116
|
},
|
|
1143
1117
|
);
|
|
1118
|
+
const allProcessedFiles = [...contextResult.files, ...contextResult.images];
|
|
1119
|
+
if (allProcessedFiles.length > 0) {
|
|
1120
|
+
return createFileContext(allProcessedFiles, {
|
|
1121
|
+
includeMetadata: true,
|
|
1122
|
+
includeErrors: true,
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
logger.error('Error processing context', { error });
|
|
1144
1127
|
}
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1145
1130
|
|
|
1146
|
-
|
|
1147
|
-
return
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
},
|
|
1159
|
-
metadata: {
|
|
1160
|
-
provider: providerName,
|
|
1161
|
-
model: resolvedModel,
|
|
1162
|
-
execution_time: executionTime,
|
|
1163
|
-
async_execution: true,
|
|
1164
|
-
...response.metadata,
|
|
1165
|
-
},
|
|
1166
|
-
};
|
|
1131
|
+
function formatStartTime() {
|
|
1132
|
+
return new Date()
|
|
1133
|
+
.toLocaleString('en-GB', {
|
|
1134
|
+
day: '2-digit',
|
|
1135
|
+
month: '2-digit',
|
|
1136
|
+
year: 'numeric',
|
|
1137
|
+
hour: '2-digit',
|
|
1138
|
+
minute: '2-digit',
|
|
1139
|
+
second: '2-digit',
|
|
1140
|
+
hour12: false,
|
|
1141
|
+
})
|
|
1142
|
+
.replace(',', '');
|
|
1167
1143
|
}
|
|
1168
1144
|
|
|
1169
|
-
// Tool metadata
|
|
1145
|
+
// --- Tool metadata -----------------------------------------------------------
|
|
1146
|
+
|
|
1170
1147
|
chatTool.description =
|
|
1171
|
-
'
|
|
1148
|
+
'UNIFIED CHAT — talk to one or more AI models. mode "chat" (default): 1..N models answer independently in parallel. mode "consensus": ≥2 models answer, then refine after seeing each other. mode "roundtable": models answer sequentially, each building on the running transcript. Supports files, images, and continuation_id for multi-turn threads (you may switch modes on resume). Use model "auto" for automatic selection. IMPORTANT: use the "files" parameter to share code/file content instead of pasting into the prompt.';
|
|
1149
|
+
|
|
1172
1150
|
chatTool.inputSchema = {
|
|
1173
1151
|
type: 'object',
|
|
1174
1152
|
properties: {
|
|
1175
|
-
|
|
1153
|
+
prompt: {
|
|
1176
1154
|
type: 'string',
|
|
1177
1155
|
description:
|
|
1178
|
-
'
|
|
1156
|
+
'Your question, topic, or task with relevant context. More detail enables better responses. Example: "How should I structure the authentication module for this Express.js API?"',
|
|
1179
1157
|
},
|
|
1180
|
-
|
|
1158
|
+
models: {
|
|
1181
1159
|
type: 'array',
|
|
1182
1160
|
items: { type: 'string' },
|
|
1161
|
+
minItems: 1,
|
|
1183
1162
|
description:
|
|
1184
|
-
'
|
|
1163
|
+
'Models to use. Examples: ["auto"] (recommended), ["codex"], ["codex", "gemini", "claude"]. In mode "chat" each model answers independently; in "consensus" they refine after seeing each other; in "roundtable" they speak in the given ORDER, each seeing the transcript. Default: ["auto"].',
|
|
1185
1164
|
},
|
|
1186
|
-
|
|
1187
|
-
type: '
|
|
1188
|
-
|
|
1165
|
+
mode: {
|
|
1166
|
+
type: 'string',
|
|
1167
|
+
enum: ['chat', 'consensus', 'roundtable'],
|
|
1189
1168
|
description:
|
|
1190
|
-
'
|
|
1169
|
+
'Execution mode. "chat" (default): independent parallel answers. "consensus": ≥2 models answer then refine via cross-feedback. "roundtable": sequential turn-based dialogue in the given model order. Default: "chat".',
|
|
1191
1170
|
},
|
|
1192
1171
|
continuation_id: {
|
|
1193
1172
|
type: 'string',
|
|
1194
1173
|
description:
|
|
1195
|
-
'Continuation ID for persistent
|
|
1174
|
+
'Continuation ID for a persistent multi-turn thread. Auto-generated in the first response; pass it back to continue. You MAY change the mode or models on a resuming turn.',
|
|
1196
1175
|
},
|
|
1197
|
-
|
|
1198
|
-
type: '
|
|
1176
|
+
files: {
|
|
1177
|
+
type: 'array',
|
|
1178
|
+
items: { type: 'string' },
|
|
1199
1179
|
description:
|
|
1200
|
-
'
|
|
1201
|
-
minimum: 0.0,
|
|
1202
|
-
maximum: 1.0,
|
|
1203
|
-
default: 0.5,
|
|
1180
|
+
'File paths to include as context (absolute or relative). Supports line ranges: file.txt{10:50}, file.txt{100:}. Example: ["./src/utils/auth.js{50:100}", "./config.json"]. IMPORTANT: Always use this parameter to share file content instead of copying code into the prompt.',
|
|
1204
1181
|
},
|
|
1205
|
-
|
|
1206
|
-
type: '
|
|
1207
|
-
|
|
1182
|
+
images: {
|
|
1183
|
+
type: 'array',
|
|
1184
|
+
items: { type: 'string' },
|
|
1208
1185
|
description:
|
|
1209
|
-
'
|
|
1210
|
-
default: 'medium',
|
|
1186
|
+
'Image paths for visual context (absolute or relative paths, or base64 data). Example: ["C:\\Users\\username\\diagram.png", "./screenshot.jpg", "data:image/jpeg;base64,/9j/4AAQ..."]',
|
|
1211
1187
|
},
|
|
1212
|
-
|
|
1188
|
+
reasoning_effort: {
|
|
1213
1189
|
type: 'string',
|
|
1214
|
-
enum: ['low', 'medium', 'high'],
|
|
1215
|
-
description:
|
|
1216
|
-
'Output verbosity for GPT-5 models. Examples: "low" (concise answers), "medium" (balanced), "high" (thorough explanations). Default: "medium"',
|
|
1217
|
-
default: 'medium',
|
|
1218
|
-
},
|
|
1219
|
-
use_websearch: {
|
|
1220
|
-
type: 'boolean',
|
|
1190
|
+
enum: ['none', 'minimal', 'low', 'medium', 'high', 'max'],
|
|
1221
1191
|
description:
|
|
1222
|
-
'
|
|
1223
|
-
default: false,
|
|
1192
|
+
'Reasoning depth for thinking models. Examples: "none" (no reasoning, fastest - GPT-5.1+ only), "minimal", "low", "medium" (balanced), "high", "max". Default: "medium"',
|
|
1224
1193
|
},
|
|
1225
1194
|
async: {
|
|
1226
1195
|
type: 'boolean',
|
|
1227
1196
|
description:
|
|
1228
|
-
'Execute
|
|
1229
|
-
default: false,
|
|
1197
|
+
'Execute in the background. When true, returns a continuation_id immediately and processes the request asynchronously; poll with check_status. Default: false',
|
|
1230
1198
|
},
|
|
1231
1199
|
export: {
|
|
1232
1200
|
type: 'boolean',
|
|
1233
1201
|
description:
|
|
1234
|
-
'Export conversation to disk. Creates folder
|
|
1235
|
-
default: false,
|
|
1236
|
-
},
|
|
1237
|
-
prompt: {
|
|
1238
|
-
type: 'string',
|
|
1239
|
-
description:
|
|
1240
|
-
'Your question or topic with relevant context. More detail enables better responses. Example: "How should I structure the authentication module for this Express.js API?"',
|
|
1202
|
+
'Export the conversation to disk. Creates a folder named for the continuation_id with numbered request/response files and metadata. Default: false',
|
|
1241
1203
|
},
|
|
1242
1204
|
},
|
|
1243
1205
|
required: ['prompt'],
|