converse-mcp-server 4.5.3 → 4.6.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 +7 -0
- package/README.md +5 -3
- package/docs/API.md +44 -20
- package/docs/PROVIDERS.md +6 -4
- package/package.json +10 -10
- package/src/config.js +16 -1
- package/src/decisionProviders/formats.js +230 -0
- package/src/decisionProviders/{systemOne.js → http.js} +60 -26
- package/src/decisionProviders/index.js +167 -73
- package/src/providers/anthropic.js +32 -3
- package/src/providers/claude.js +19 -0
- package/src/providers/copilot.js +25 -1
- package/src/tools/decide.js +145 -34
package/src/tools/decide.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Decide Tool - System One decision models
|
|
3
3
|
*
|
|
4
|
-
* Asks a decision model (TypeSafe's Jev
|
|
5
|
-
*
|
|
6
|
-
* probability
|
|
7
|
-
* never generate text, so this is a
|
|
4
|
+
* Asks a decision model (OpenAI's gpt-6-luna, TypeSafe's Jev, Cloudflare's
|
|
5
|
+
* Clef) typed questions about a state and returns calibrated answers: a
|
|
6
|
+
* probability for yes/no questions, a probability distribution for choices
|
|
7
|
+
* and rubric scores. Decision models never generate text, so this is a
|
|
8
|
+
* separate tool rather than a chat mode. Questions and answers use the System
|
|
9
|
+
* One schema whatever the host; providers translate.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import { createToolResponse, createToolError } from './index.js';
|
|
11
|
-
import { resolveDecisionModel } from '../decisionProviders/index.js';
|
|
12
|
-
import {
|
|
13
|
+
import { askDecisionModel, resolveDecisionModel } from '../decisionProviders/index.js';
|
|
14
|
+
import { isLabeledLevel } from '../decisionProviders/formats.js';
|
|
13
15
|
import { validateAllPaths } from '../utils/fileValidator.js';
|
|
14
16
|
import { createLogger } from '../utils/logger.js';
|
|
15
17
|
|
|
@@ -89,12 +91,43 @@ function validateQuestion(key, question) {
|
|
|
89
91
|
if (criteria.length < MIN_SCORE_LEVELS || criteria.length > MAX_SCORE_LEVELS) {
|
|
90
92
|
return `${at}.criteria must have ${MIN_SCORE_LEVELS} to ${MAX_SCORE_LEVELS} levels (got ${criteria.length}).`;
|
|
91
93
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
for (const [index, level] of criteria.entries()) {
|
|
95
|
+
const error = validateScoreLevel(level);
|
|
96
|
+
if (error) return `${at}.criteria[${index}] ${error}`;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validateScoreLevel(level) {
|
|
102
|
+
if (!isLabeledLevel(level)) {
|
|
103
|
+
return isCriterionValue(level) ? null : 'must be a non-empty description or { "label", "description" }.';
|
|
104
|
+
}
|
|
105
|
+
const unknown = Object.keys(level).filter((f) => f !== 'label' && f !== 'description');
|
|
106
|
+
if (unknown.length) return `has unknown field(s): ${unknown.join(', ')}. A labeled level takes "label" and "description".`;
|
|
107
|
+
if (typeof level.label !== 'string' || !level.label.trim()) return 'needs a non-empty "label" string.';
|
|
108
|
+
if (level.description !== undefined && !isCriterionValue(level.description)) {
|
|
109
|
+
return '"description" must be a non-empty string, object, or array.';
|
|
94
110
|
}
|
|
95
111
|
return null;
|
|
96
112
|
}
|
|
97
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Show each labeled level's short label in the score legend, whatever the
|
|
116
|
+
* host echoed back: System One hosts only see the flattened description.
|
|
117
|
+
*/
|
|
118
|
+
function labelLegends(answers, questions) {
|
|
119
|
+
return Object.fromEntries(
|
|
120
|
+
Object.entries(answers).map(([key, answer]) => {
|
|
121
|
+
const levels = questions[key]?.type === 'score' ? questions[key].criteria : null;
|
|
122
|
+
if (answer?.type !== 'score' || !levels?.some(isLabeledLevel)) return [key, answer];
|
|
123
|
+
const legend = Object.fromEntries(
|
|
124
|
+
levels.map((level, i) => [String(i), isLabeledLevel(level) ? level.label : answer.legend?.[i] ?? level]),
|
|
125
|
+
);
|
|
126
|
+
return [key, { ...answer, legend }];
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
98
131
|
/**
|
|
99
132
|
* Check questions locally: the API reports schema faults as nested validation
|
|
100
133
|
* dumps, and a local message is both clearer and free.
|
|
@@ -135,7 +168,7 @@ async function loadFiles(files, contextProcessor, config) {
|
|
|
135
168
|
if (file.type === 'error') {
|
|
136
169
|
problems.push(`${file.originalPath}: ${file.error}`);
|
|
137
170
|
} else if (file.type !== 'text') {
|
|
138
|
-
problems.push(`${file.originalPath}:
|
|
171
|
+
problems.push(`${file.originalPath}: not a text file (pass images in "images")`);
|
|
139
172
|
} else {
|
|
140
173
|
contents[file.originalPath] = file.content;
|
|
141
174
|
}
|
|
@@ -144,6 +177,52 @@ async function loadFiles(files, contextProcessor, config) {
|
|
|
144
177
|
return problems.length ? { error: problems.join('; ') } : { contents };
|
|
145
178
|
}
|
|
146
179
|
|
|
180
|
+
// The formats every image-capable host accepts (Clef takes no GIF or BMP).
|
|
181
|
+
const IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
|
182
|
+
const DATA_URL = /^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Load images from paths or base64 data URLs into `{ mimeType, base64 }`.
|
|
186
|
+
* Every image must load: judging a partial set would change the answer.
|
|
187
|
+
*/
|
|
188
|
+
async function loadImages(images, contextProcessor, config) {
|
|
189
|
+
const loaded = new Array(images.length);
|
|
190
|
+
const problems = [];
|
|
191
|
+
const paths = [];
|
|
192
|
+
images.forEach((image, i) => {
|
|
193
|
+
if (!image.startsWith('data:')) {
|
|
194
|
+
paths.push(i);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const match = image.match(DATA_URL);
|
|
198
|
+
if (!match) problems.push(`image ${i + 1}: not a base64 data URL`);
|
|
199
|
+
else loaded[i] = { mimeType: match[1].toLowerCase(), base64: match[2].replace(/\s/g, '') };
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (paths.length > 0) {
|
|
203
|
+
const pathList = paths.map((i) => images[i]);
|
|
204
|
+
const validation = await validateAllPaths({ images: pathList }, { clientCwd: config?.server?.client_cwd });
|
|
205
|
+
if (!validation.valid) return { error: validation.errors.join('; ') };
|
|
206
|
+
const result = await contextProcessor.processUnifiedContext(
|
|
207
|
+
{ images: pathList },
|
|
208
|
+
{ enforceSecurityCheck: false, skipSecurityCheck: true, clientCwd: config?.server?.client_cwd },
|
|
209
|
+
);
|
|
210
|
+
result.images.forEach((file, j) => {
|
|
211
|
+
if (file.type === 'error') problems.push(`${file.originalPath}: ${file.error}`);
|
|
212
|
+
else if (file.type !== 'image') problems.push(`${file.originalPath}: not an image file`);
|
|
213
|
+
else loaded[paths[j]] = { mimeType: file.mimeType, base64: file.content };
|
|
214
|
+
});
|
|
215
|
+
if (result.errors?.length) problems.push(...result.errors.map((e) => e.message));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
loaded.forEach((image, i) => {
|
|
219
|
+
if (image && !IMAGE_MIME_TYPES.includes(image.mimeType)) {
|
|
220
|
+
problems.push(`image ${i + 1}: ${image.mimeType} is not supported (use PNG, JPEG, or WebP)`);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
return problems.length ? { error: problems.join('; ') } : { images: loaded };
|
|
224
|
+
}
|
|
225
|
+
|
|
147
226
|
function fixed(n) {
|
|
148
227
|
return typeof n === 'number' ? n.toFixed(2) : String(n);
|
|
149
228
|
}
|
|
@@ -171,9 +250,13 @@ function summarizeAnswer(key, answer) {
|
|
|
171
250
|
if (type === 'score') {
|
|
172
251
|
const levels = Object.keys(answer.legend || answer.probabilities || {});
|
|
173
252
|
const range = levels.length ? ` on 0–${levels.length - 1}` : '';
|
|
174
|
-
|
|
253
|
+
// Quoted so a numeric label ("4") stays distinct from its level index (3).
|
|
254
|
+
const label = (k) => (answer.legend?.[k] ? `${k} "${answer.legend[k]}"` : k);
|
|
175
255
|
return `- ${key} (score): ${fixed(answer.score)}${range} · confidence ${fixed(answer.confidence)} · ${byProbability(answer.probabilities, label)}`;
|
|
176
256
|
}
|
|
257
|
+
if (type === 'refusal') {
|
|
258
|
+
return `- ${key} (refusal): the model declined to answer`;
|
|
259
|
+
}
|
|
177
260
|
return `- ${key} (${type}): ${JSON.stringify(answer)}`;
|
|
178
261
|
}
|
|
179
262
|
|
|
@@ -187,7 +270,7 @@ function formatResult(response, candidate, failures) {
|
|
|
187
270
|
|
|
188
271
|
const lines = [header];
|
|
189
272
|
for (const failure of failures) {
|
|
190
|
-
lines.push(`(${failure.provider} failed, fell back: ${failure.message})`);
|
|
273
|
+
lines.push(`(${failure.provider} ${failure.notSent ? 'skipped' : 'failed'}, fell back: ${failure.message})`);
|
|
191
274
|
}
|
|
192
275
|
lines.push(...Object.entries(response.answers).map(([key, answer]) => summarizeAnswer(key, answer)));
|
|
193
276
|
|
|
@@ -212,18 +295,22 @@ function formatResult(response, candidate, failures) {
|
|
|
212
295
|
* @param {object} args.questions - Named typed questions
|
|
213
296
|
* @param {string} [args.model] - Model spec, default "auto"
|
|
214
297
|
* @param {string[]} [args.files] - Text files added to the state
|
|
298
|
+
* @param {string[]} [args.images] - Image paths or base64 data URLs
|
|
215
299
|
* @param {object} dependencies - Injected dependencies (config, contextProcessor, signal)
|
|
216
300
|
* @returns {Promise<object>} MCP tool response
|
|
217
301
|
*/
|
|
218
302
|
export async function decideTool(args, dependencies) {
|
|
219
303
|
const { config, contextProcessor, signal } = dependencies;
|
|
220
|
-
const { state, questions, model = 'auto', files = [] } = args;
|
|
304
|
+
const { state, questions, model = 'auto', files = [], images = [] } = args;
|
|
221
305
|
|
|
222
306
|
if (!Array.isArray(files) || !files.every((f) => typeof f === 'string' && f.trim())) {
|
|
223
307
|
return createToolError('"files" must be an array of file paths.');
|
|
224
308
|
}
|
|
225
|
-
if (
|
|
226
|
-
return createToolError('
|
|
309
|
+
if (!Array.isArray(images) || !images.every((i) => typeof i === 'string' && i.trim())) {
|
|
310
|
+
return createToolError('"images" must be an array of image paths or base64 data URLs.');
|
|
311
|
+
}
|
|
312
|
+
if (state === undefined && files.length === 0 && images.length === 0) {
|
|
313
|
+
return createToolError('Provide "state", "files", or "images": there is nothing to judge.');
|
|
227
314
|
}
|
|
228
315
|
const stateError = state === undefined ? null : validateState(state);
|
|
229
316
|
if (stateError) return createToolError(stateError);
|
|
@@ -232,6 +319,13 @@ export async function decideTool(args, dependencies) {
|
|
|
232
319
|
|
|
233
320
|
const route = resolveDecisionModel(model, config);
|
|
234
321
|
if (route.status !== 'ok') return createToolError(route.error);
|
|
322
|
+
const candidates = route.candidates.filter((c) => c.maxImages >= images.length);
|
|
323
|
+
if (candidates.length === 0) {
|
|
324
|
+
return createToolError(
|
|
325
|
+
`"${model}" cannot take ${images.length} image(s) on any configured host. ` +
|
|
326
|
+
'Images work with gpt-6-luna (OpenAI) and clef / clef-flash (Cloudflare, up to 4); Jev and OpenRouter routes are text only.',
|
|
327
|
+
);
|
|
328
|
+
}
|
|
235
329
|
|
|
236
330
|
let finalState = state;
|
|
237
331
|
if (files.length > 0) {
|
|
@@ -239,39 +333,47 @@ export async function decideTool(args, dependencies) {
|
|
|
239
333
|
if (loaded.error) return createToolError(`Could not load files: ${loaded.error}`);
|
|
240
334
|
finalState = state === undefined ? { files: loaded.contents } : { input: state, files: loaded.contents };
|
|
241
335
|
}
|
|
336
|
+
let loadedImages = [];
|
|
337
|
+
if (images.length > 0) {
|
|
338
|
+
const loaded = await loadImages(images, contextProcessor, config);
|
|
339
|
+
if (loaded.error) return createToolError(`Could not load images: ${loaded.error}`);
|
|
340
|
+
loadedImages = loaded.images;
|
|
341
|
+
}
|
|
242
342
|
|
|
243
343
|
const failures = [];
|
|
244
|
-
|
|
344
|
+
// A request fault on one host repeats on every host of the same model, but
|
|
345
|
+
// another model behind a different API may still accept the request.
|
|
346
|
+
const rejectedFamilies = new Set();
|
|
347
|
+
for (const candidate of candidates) {
|
|
348
|
+
if (rejectedFamilies.has(candidate.family)) continue;
|
|
245
349
|
try {
|
|
246
|
-
const response = await
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
body: { model: candidate.model, state: finalState, questions },
|
|
250
|
-
signal,
|
|
251
|
-
});
|
|
252
|
-
return createToolResponse(formatResult(response, candidate, failures));
|
|
350
|
+
const response = await askDecisionModel(candidate, { state: finalState, questions, images: loadedImages, config, signal });
|
|
351
|
+
const answers = labelLegends(response.answers, questions);
|
|
352
|
+
return createToolResponse(formatResult({ ...response, answers }, candidate, failures));
|
|
253
353
|
} catch (error) {
|
|
254
354
|
if (signal?.aborted) return createToolError('Decision request cancelled.');
|
|
255
355
|
logger.error('Decision request failed', { provider: candidate.providerName, model: candidate.model, error: error.message });
|
|
256
|
-
failures.push({ provider: candidate.providerName, message: error.message });
|
|
257
|
-
if (error.terminal)
|
|
356
|
+
failures.push({ provider: candidate.providerName, message: error.message, notSent: Boolean(error.notSent) });
|
|
357
|
+
if (error.terminal) rejectedFamilies.add(candidate.family);
|
|
258
358
|
}
|
|
259
359
|
}
|
|
260
360
|
|
|
261
|
-
const
|
|
262
|
-
|
|
361
|
+
const noneSent = failures.every((f) => f.notSent);
|
|
362
|
+
const detail = failures.map((f) => `${f.provider}${f.notSent && !noneSent ? ', not sent' : ''}: ${f.message}`).join('; ');
|
|
363
|
+
return createToolError(`Decision request ${noneSent ? 'not sent' : 'failed'} (${detail})`);
|
|
263
364
|
}
|
|
264
365
|
|
|
265
366
|
decideTool.description =
|
|
266
|
-
'DECIDE — ask a
|
|
367
|
+
'DECIDE — ask a decision model (OpenAI gpt-6-luna, TypeSafe Jev, Cloudflare Clef) typed questions about a state and get calibrated answers, not text. ' +
|
|
267
368
|
'Question types: "noul" (yes/no → probability 0..1), "choice" (pick one of 2–255 named options → choice, per-option probabilities, confidence), ' +
|
|
268
369
|
'"score" (ordered rubric of 2–10 levels → weighted position, per-level probabilities, confidence). ' +
|
|
269
370
|
'Batch independent questions over the same state into one call: they are judged in parallel and in isolation, so none sees another\'s answer; each extra question adds its own input tokens. ' +
|
|
270
371
|
'Best for fast semantic judgments (classify, route, select, verify, rank). Ask one narrow, coherent judgment per question, with its full meaning in the question; ' +
|
|
271
372
|
'split independently useful dimensions, but a bounded action choice or contextual interpretation is a valid single question. Do counting, arithmetic, and date comparison in code. ' +
|
|
272
373
|
'confidence measures how concentrated the distribution is, not permission to act: take the top option to pick a best, and treat a noul near 0.5 as "yes and no equally likely". ' +
|
|
273
|
-
'
|
|
274
|
-
'
|
|
374
|
+
'No explanations are returned. Images (PNG/JPEG/WebP) work with gpt-6-luna and Clef (up to 4, ~180 KB total); Jev is text only, and "auto" skips models that cannot take the images. ' +
|
|
375
|
+
'gpt-6-luna may answer a question with type "refusal" instead of a value: treat it as unanswered, never as a no. ' +
|
|
376
|
+
'Limits per model: Jev ~64k tokens per request (~32k for state plus the longest question); Clef ~64k tokens and at most 64 questions named with letters, digits, "_", ".", "-".';
|
|
275
377
|
|
|
276
378
|
decideTool.inputSchema = {
|
|
277
379
|
type: 'object',
|
|
@@ -279,7 +381,7 @@ decideTool.inputSchema = {
|
|
|
279
381
|
state: {
|
|
280
382
|
anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array' }],
|
|
281
383
|
description:
|
|
282
|
-
'The material to judge: plain text, or JSON (an object with descriptively named fields is best; an array for sequences such as messages). Optional when "files"
|
|
384
|
+
'The material to judge: plain text, or JSON (an object with descriptively named fields is best; an array for sequences such as messages). Optional when "files" or "images" are given.',
|
|
283
385
|
},
|
|
284
386
|
questions: {
|
|
285
387
|
type: 'object',
|
|
@@ -287,7 +389,7 @@ decideTool.inputSchema = {
|
|
|
287
389
|
'Named questions, all answered against the same state. The name is your own label and is returned as the answer key. ' +
|
|
288
390
|
'Each question: { "type": "noul"|"choice"|"score", "instructions": string|object|array, "criteria": ... }. ' +
|
|
289
391
|
'criteria — noul: optional { "true": "...", "false": "..." }; choice: required { "<option>": "description" | null } (2–255 options); ' +
|
|
290
|
-
'score: required ordered array of
|
|
392
|
+
'score: required ordered array of levels, lowest first (2–10 levels); each level is a description, or { "label": "Short name", "description": "..." } to keep the legend short. ' +
|
|
291
393
|
'instructions may be an object bundling the question with reference data, referenced by `name` in the text. ' +
|
|
292
394
|
'Example: { "team": { "type": "choice", "instructions": "Which team should handle this?", "criteria": { "billing": "Payments, refunds", "technical": "Bugs, outages" } } }',
|
|
293
395
|
additionalProperties: {
|
|
@@ -304,14 +406,23 @@ decideTool.inputSchema = {
|
|
|
304
406
|
model: {
|
|
305
407
|
type: 'string',
|
|
306
408
|
description:
|
|
307
|
-
'Decision model. "auto" (default):
|
|
308
|
-
'"
|
|
409
|
+
'Decision model. "auto" (default): gpt-6-luna, then jev-latest, then clef, each on every configured host. ' +
|
|
410
|
+
'A model name ("gpt-6-luna"/"luna", "jev-latest"/"jev", "jev-1.13", "jev-preview", "clef", "clef-flash"): its native host first, falling back to OpenRouter. ' +
|
|
411
|
+
'"provider:model" (e.g. "openai:gpt-6-luna", "cloudflare:clef-flash", "openrouter:jev-latest"): that provider only. ' +
|
|
412
|
+
'Providers: openai (OPENAI_API_KEY), typesafe (TYPESAFE_API_KEY), cloudflare (CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN), openrouter (OPENROUTER_API_KEY).',
|
|
309
413
|
},
|
|
310
414
|
files: {
|
|
311
415
|
type: 'array',
|
|
312
416
|
items: { type: 'string' },
|
|
313
417
|
description:
|
|
314
|
-
'Text files added to the state as { "files": { "<path>": "<content>" } }; a given state moves to "input". Supports line ranges: file.txt{10:50}.
|
|
418
|
+
'Text files added to the state as { "files": { "<path>": "<content>" } }; a given state moves to "input". Supports line ranges: file.txt{10:50}. Image files belong in "images".',
|
|
419
|
+
},
|
|
420
|
+
images: {
|
|
421
|
+
type: 'array',
|
|
422
|
+
items: { type: 'string' },
|
|
423
|
+
description:
|
|
424
|
+
'Images judged together with the state: file paths or base64 data URLs, PNG/JPEG/WebP. Refer to them in questions ("Does the image show ..."). ' +
|
|
425
|
+
'Supported by gpt-6-luna and clef/clef-flash (Clef: up to 4, about 180 KB in total); other models are skipped or rejected.',
|
|
315
426
|
},
|
|
316
427
|
},
|
|
317
428
|
required: ['questions'],
|