converse-mcp-server 4.5.2 → 4.6.0

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.
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * Decide Tool - System One decision models
3
3
  *
4
- * Asks a decision model (TypeSafe's Jev family) typed questions about a state
5
- * and returns calibrated answers: a probability for yes/no questions, a
6
- * probability distribution for choices and rubric scores. Decision models
7
- * never generate text, so this is a separate tool rather than a chat mode.
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 { callSystemOne } from '../decisionProviders/systemOne.js';
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
- if (!criteria.every(isCriterionValue)) {
93
- return `${at}.criteria levels must each be a non-empty description.`;
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}: decision models accept text only`);
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
  }
@@ -174,6 +253,9 @@ function summarizeAnswer(key, answer) {
174
253
  const label = (k) => (answer.legend?.[k] ? `${k} ${answer.legend[k]}` : k);
175
254
  return `- ${key} (score): ${fixed(answer.score)}${range} · confidence ${fixed(answer.confidence)} · ${byProbability(answer.probabilities, label)}`;
176
255
  }
256
+ if (type === 'refusal') {
257
+ return `- ${key} (refusal): the model declined to answer`;
258
+ }
177
259
  return `- ${key} (${type}): ${JSON.stringify(answer)}`;
178
260
  }
179
261
 
@@ -212,18 +294,22 @@ function formatResult(response, candidate, failures) {
212
294
  * @param {object} args.questions - Named typed questions
213
295
  * @param {string} [args.model] - Model spec, default "auto"
214
296
  * @param {string[]} [args.files] - Text files added to the state
297
+ * @param {string[]} [args.images] - Image paths or base64 data URLs
215
298
  * @param {object} dependencies - Injected dependencies (config, contextProcessor, signal)
216
299
  * @returns {Promise<object>} MCP tool response
217
300
  */
218
301
  export async function decideTool(args, dependencies) {
219
302
  const { config, contextProcessor, signal } = dependencies;
220
- const { state, questions, model = 'auto', files = [] } = args;
303
+ const { state, questions, model = 'auto', files = [], images = [] } = args;
221
304
 
222
305
  if (!Array.isArray(files) || !files.every((f) => typeof f === 'string' && f.trim())) {
223
306
  return createToolError('"files" must be an array of file paths.');
224
307
  }
225
- if (state === undefined && files.length === 0) {
226
- return createToolError('Provide "state", "files", or both: there is nothing to judge.');
308
+ if (!Array.isArray(images) || !images.every((i) => typeof i === 'string' && i.trim())) {
309
+ return createToolError('"images" must be an array of image paths or base64 data URLs.');
310
+ }
311
+ if (state === undefined && files.length === 0 && images.length === 0) {
312
+ return createToolError('Provide "state", "files", or "images": there is nothing to judge.');
227
313
  }
228
314
  const stateError = state === undefined ? null : validateState(state);
229
315
  if (stateError) return createToolError(stateError);
@@ -232,6 +318,13 @@ export async function decideTool(args, dependencies) {
232
318
 
233
319
  const route = resolveDecisionModel(model, config);
234
320
  if (route.status !== 'ok') return createToolError(route.error);
321
+ const candidates = route.candidates.filter((c) => c.maxImages >= images.length);
322
+ if (candidates.length === 0) {
323
+ return createToolError(
324
+ `"${model}" cannot take ${images.length} image(s) on any configured host. ` +
325
+ 'Images work with gpt-6-luna (OpenAI) and clef / clef-flash (Cloudflare, up to 4); Jev and OpenRouter routes are text only.',
326
+ );
327
+ }
235
328
 
236
329
  let finalState = state;
237
330
  if (files.length > 0) {
@@ -239,22 +332,28 @@ export async function decideTool(args, dependencies) {
239
332
  if (loaded.error) return createToolError(`Could not load files: ${loaded.error}`);
240
333
  finalState = state === undefined ? { files: loaded.contents } : { input: state, files: loaded.contents };
241
334
  }
335
+ let loadedImages = [];
336
+ if (images.length > 0) {
337
+ const loaded = await loadImages(images, contextProcessor, config);
338
+ if (loaded.error) return createToolError(`Could not load images: ${loaded.error}`);
339
+ loadedImages = loaded.images;
340
+ }
242
341
 
243
342
  const failures = [];
244
- for (const candidate of route.candidates) {
343
+ // A request fault on one host repeats on every host of the same model, but
344
+ // another model behind a different API may still accept the request.
345
+ const rejectedFamilies = new Set();
346
+ for (const candidate of candidates) {
347
+ if (rejectedFamilies.has(candidate.family)) continue;
245
348
  try {
246
- const response = await callSystemOne({
247
- baseURL: candidate.provider.baseURL,
248
- headers: candidate.provider.headers(config),
249
- body: { model: candidate.model, state: finalState, questions },
250
- signal,
251
- });
252
- return createToolResponse(formatResult(response, candidate, failures));
349
+ const response = await askDecisionModel(candidate, { state: finalState, questions, images: loadedImages, config, signal });
350
+ const answers = labelLegends(response.answers, questions);
351
+ return createToolResponse(formatResult({ ...response, answers }, candidate, failures));
253
352
  } catch (error) {
254
353
  if (signal?.aborted) return createToolError('Decision request cancelled.');
255
354
  logger.error('Decision request failed', { provider: candidate.providerName, model: candidate.model, error: error.message });
256
355
  failures.push({ provider: candidate.providerName, message: error.message });
257
- if (error.terminal) break;
356
+ if (error.terminal) rejectedFamilies.add(candidate.family);
258
357
  }
259
358
  }
260
359
 
@@ -263,15 +362,16 @@ export async function decideTool(args, dependencies) {
263
362
  }
264
363
 
265
364
  decideTool.description =
266
- 'DECIDE — ask a System One decision model (TypeSafe Jev) typed questions about a state and get calibrated answers, not text. ' +
365
+ '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
366
  'Question types: "noul" (yes/no → probability 0..1), "choice" (pick one of 2–255 named options → choice, per-option probabilities, confidence), ' +
268
367
  '"score" (ordered rubric of 2–10 levels → weighted position, per-level probabilities, confidence). ' +
269
368
  '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
369
  '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
370
  '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
371
  '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
- 'Text only, no explanations are returned. ' +
274
- 'Limits: ~64k tokens per request, ~32k for state plus the longest question.';
372
+ '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. ' +
373
+ 'gpt-6-luna may answer a question with type "refusal" instead of a value: treat it as unanswered, never as a no. ' +
374
+ '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
375
 
276
376
  decideTool.inputSchema = {
277
377
  type: 'object',
@@ -279,7 +379,7 @@ decideTool.inputSchema = {
279
379
  state: {
280
380
  anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array' }],
281
381
  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" is given.',
382
+ '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
383
  },
284
384
  questions: {
285
385
  type: 'object',
@@ -287,7 +387,7 @@ decideTool.inputSchema = {
287
387
  'Named questions, all answered against the same state. The name is your own label and is returned as the answer key. ' +
288
388
  'Each question: { "type": "noul"|"choice"|"score", "instructions": string|object|array, "criteria": ... }. ' +
289
389
  'criteria — noul: optional { "true": "...", "false": "..." }; choice: required { "<option>": "description" | null } (2–255 options); ' +
290
- 'score: required ordered array of level descriptions, lowest first (2–10 levels). ' +
390
+ '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
391
  'instructions may be an object bundling the question with reference data, referenced by `name` in the text. ' +
292
392
  'Example: { "team": { "type": "choice", "instructions": "Which team should handle this?", "criteria": { "billing": "Payments, refunds", "technical": "Bugs, outages" } } }',
293
393
  additionalProperties: {
@@ -304,14 +404,23 @@ decideTool.inputSchema = {
304
404
  model: {
305
405
  type: 'string',
306
406
  description:
307
- 'Decision model. "auto" (default): TypeSafe, falling back to OpenRouter. "jev-latest", "jev-1.13": first configured provider that serves it, with fallback. ' +
308
- '"typesafe:jev-1.13.0", "openrouter:~typesafe/jev-latest": that provider only. Providers: typesafe (TYPESAFE_API_KEY), openrouter (OPENROUTER_API_KEY).',
407
+ 'Decision model. "auto" (default): gpt-6-luna, then jev-latest, then clef, each on every configured host. ' +
408
+ '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. ' +
409
+ '"provider:model" (e.g. "openai:gpt-6-luna", "cloudflare:clef-flash", "openrouter:jev-latest"): that provider only. ' +
410
+ 'Providers: openai (OPENAI_API_KEY), typesafe (TYPESAFE_API_KEY), cloudflare (CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN), openrouter (OPENROUTER_API_KEY).',
309
411
  },
310
412
  files: {
311
413
  type: 'array',
312
414
  items: { type: 'string' },
313
415
  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}. Images are rejected.',
416
+ '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".',
417
+ },
418
+ images: {
419
+ type: 'array',
420
+ items: { type: 'string' },
421
+ description:
422
+ 'Images judged together with the state: file paths or base64 data URLs, PNG/JPEG/WebP. Refer to them in questions ("Does the image show ..."). ' +
423
+ '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
424
  },
316
425
  },
317
426
  required: ['questions'],