converse-mcp-server 3.7.1 → 4.1.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.
- package/.env.example +22 -3
- package/README.md +86 -49
- package/docs/API.md +160 -18
- package/docs/EXAMPLES.md +38 -0
- package/docs/PROVIDERS.md +92 -52
- package/package.json +1 -1
- package/src/config.js +36 -6
- package/src/decisionProviders/index.js +174 -0
- package/src/decisionProviders/systemOne.js +199 -0
- package/src/prompts/helpPrompt.js +50 -3
- package/src/providers/anthropic.js +5 -1
- package/src/providers/claude.js +39 -88
- package/src/providers/codex.js +73 -144
- package/src/providers/copilot.js +42 -149
- package/src/providers/deepseek.js +1 -0
- package/src/providers/gemini-cli.js +64 -97
- package/src/providers/google.js +5 -1
- package/src/providers/mistral.js +5 -1
- package/src/providers/openai-compatible.js +4 -1
- package/src/providers/openai.js +5 -1
- package/src/providers/openrouter.js +2 -1
- package/src/providers/xai.js +5 -1
- package/src/services/summarizationService.js +45 -49
- package/src/tools/chat.js +56 -52
- package/src/tools/decide.js +312 -0
- package/src/tools/index.js +2 -0
- package/src/tools/modes/roundtable.js +60 -49
- package/src/utils/localProviderAuth.js +63 -0
- package/src/utils/modelCatalog.js +38 -0
- package/src/utils/modelRouting.js +580 -343
package/src/tools/chat.js
CHANGED
|
@@ -29,9 +29,8 @@ import { SummarizationService } from '../services/summarizationService.js';
|
|
|
29
29
|
import { exportConversation } from '../utils/conversationExporter.js';
|
|
30
30
|
import { EFFORT_LADDER } from '../utils/reasoningEffort.js';
|
|
31
31
|
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
getAvailableProviders,
|
|
32
|
+
getAutoCandidates,
|
|
33
|
+
getAutoModelSpecs,
|
|
35
34
|
resolveModelSpec,
|
|
36
35
|
} from '../utils/modelRouting.js';
|
|
37
36
|
import {
|
|
@@ -857,24 +856,52 @@ function buildAsyncResult(pipeline, title, finalSummary) {
|
|
|
857
856
|
// --- Model resolution helpers ------------------------------------------------
|
|
858
857
|
|
|
859
858
|
/**
|
|
860
|
-
*
|
|
861
|
-
* chat-mode failover). Skips text-only providers when the request has images.
|
|
859
|
+
* Convert router candidates into the engine's call-plan candidate shape.
|
|
862
860
|
*/
|
|
863
|
-
function
|
|
864
|
-
return
|
|
865
|
-
name,
|
|
866
|
-
providerInstance:
|
|
867
|
-
resolvedModel:
|
|
868
|
-
displayModel
|
|
861
|
+
function toPlanCandidates(candidates, displayModel) {
|
|
862
|
+
return candidates.map((c) => ({
|
|
863
|
+
name: c.providerName,
|
|
864
|
+
providerInstance: c.provider,
|
|
865
|
+
resolvedModel: c.resolvedModel,
|
|
866
|
+
displayModel,
|
|
867
|
+
resolveOptions: c.options,
|
|
869
868
|
}));
|
|
870
869
|
}
|
|
871
870
|
|
|
871
|
+
/**
|
|
872
|
+
* Resolve one explicit spec into a call plan, or a pre-failed entry carrying
|
|
873
|
+
* the router's error (unknown names include "did you mean" suggestions). A
|
|
874
|
+
* bare model name served by several providers yields a multi-candidate plan
|
|
875
|
+
* that fails over in provider priority order.
|
|
876
|
+
*/
|
|
877
|
+
function resolveExplicitPlan(spec, providers, config) {
|
|
878
|
+
const resolution = resolveModelSpec(spec, providers, config);
|
|
879
|
+
if (resolution.status !== 'ok') {
|
|
880
|
+
return {
|
|
881
|
+
preFailed: {
|
|
882
|
+
model: spec,
|
|
883
|
+
...(resolution.providerName && { provider: resolution.providerName }),
|
|
884
|
+
error: resolution.error,
|
|
885
|
+
},
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
return {
|
|
889
|
+
plan: {
|
|
890
|
+
modelSpec: spec,
|
|
891
|
+
displayModel: spec,
|
|
892
|
+
threadKey: spec,
|
|
893
|
+
candidates: toPlanCandidates(resolution.candidates, spec),
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
872
898
|
/**
|
|
873
899
|
* Resolve chat-mode call plans. Each "auto" spec (whether the list is exactly
|
|
874
900
|
* ["auto"] or "auto" appears alongside explicit models) yields a plan with the
|
|
875
901
|
* full provider-priority candidate list (failover); explicit models yield one
|
|
876
|
-
*
|
|
877
|
-
*
|
|
902
|
+
* plan each, with failover candidates when a bare name is served by several
|
|
903
|
+
* providers. Unavailable/unknown explicit models are returned as pre-failed
|
|
904
|
+
* entries (surfaced as per-model failures).
|
|
878
905
|
*/
|
|
879
906
|
function resolveChatCallPlans(models, providers, config, hasImages) {
|
|
880
907
|
const callPlans = [];
|
|
@@ -882,7 +909,10 @@ function resolveChatCallPlans(models, providers, config, hasImages) {
|
|
|
882
909
|
|
|
883
910
|
for (const spec of models) {
|
|
884
911
|
if (String(spec).toLowerCase() === 'auto') {
|
|
885
|
-
const candidates =
|
|
912
|
+
const candidates = toPlanCandidates(
|
|
913
|
+
getAutoCandidates(providers, config, { hasImages }),
|
|
914
|
+
'auto',
|
|
915
|
+
);
|
|
886
916
|
if (candidates.length === 0) {
|
|
887
917
|
// A single ["auto"] with no providers is a hard error; an "auto" entry
|
|
888
918
|
// in a multi-model list becomes a per-model failure instead.
|
|
@@ -909,28 +939,11 @@ function resolveChatCallPlans(models, providers, config, hasImages) {
|
|
|
909
939
|
continue;
|
|
910
940
|
}
|
|
911
941
|
|
|
912
|
-
const {
|
|
913
|
-
if (
|
|
914
|
-
preFailed.push(
|
|
915
|
-
model: spec,
|
|
916
|
-
provider: providerName,
|
|
917
|
-
error: `Provider not found for model: ${spec}`,
|
|
918
|
-
});
|
|
919
|
-
} else if (status === 'unavailable') {
|
|
920
|
-
preFailed.push({
|
|
921
|
-
model: spec,
|
|
922
|
-
provider: providerName,
|
|
923
|
-
error: getProviderUnavailableMessage(providerName),
|
|
924
|
-
});
|
|
942
|
+
const { plan, preFailed: failed } = resolveExplicitPlan(spec, providers, config);
|
|
943
|
+
if (failed) {
|
|
944
|
+
preFailed.push(failed);
|
|
925
945
|
} else {
|
|
926
|
-
callPlans.push(
|
|
927
|
-
modelSpec: spec,
|
|
928
|
-
displayModel: spec,
|
|
929
|
-
threadKey: spec,
|
|
930
|
-
candidates: [
|
|
931
|
-
{ name: providerName, providerInstance: provider, resolvedModel, displayModel: spec, resolveOptions: options },
|
|
932
|
-
],
|
|
933
|
-
});
|
|
946
|
+
callPlans.push(plan);
|
|
934
947
|
}
|
|
935
948
|
}
|
|
936
949
|
return { callPlans, preFailed, error: null };
|
|
@@ -938,15 +951,15 @@ function resolveChatCallPlans(models, providers, config, hasImages) {
|
|
|
938
951
|
|
|
939
952
|
/**
|
|
940
953
|
* Resolve consensus-mode call plans. Single "auto" expands to the first 3
|
|
941
|
-
* available providers' default models; each spec
|
|
954
|
+
* available providers' default models (as `namespace:model` specs); each spec
|
|
955
|
+
* becomes one plan.
|
|
942
956
|
*/
|
|
943
957
|
function resolveConsensusCallPlans(models, providers, config, images) {
|
|
944
958
|
const hasImages = Array.isArray(images) && images.length > 0;
|
|
945
959
|
|
|
946
960
|
let modelsToProcess = models;
|
|
947
961
|
if (models.length === 1 && String(models[0]).toLowerCase() === 'auto') {
|
|
948
|
-
|
|
949
|
-
modelsToProcess = available.map((name) => getDefaultModelForProvider(name));
|
|
962
|
+
modelsToProcess = getAutoModelSpecs(providers, config, { hasImages, limit: 3 });
|
|
950
963
|
}
|
|
951
964
|
|
|
952
965
|
const resolved = [];
|
|
@@ -956,20 +969,11 @@ function resolveConsensusCallPlans(models, providers, config, images) {
|
|
|
956
969
|
preFailed.push({ model: spec || 'unknown', error: 'Invalid model specification' });
|
|
957
970
|
continue;
|
|
958
971
|
}
|
|
959
|
-
const {
|
|
960
|
-
if (
|
|
961
|
-
preFailed.push(
|
|
962
|
-
} else if (status === 'unavailable') {
|
|
963
|
-
preFailed.push({ model: spec, provider: providerName, error: getProviderUnavailableMessage(providerName) });
|
|
972
|
+
const { plan, preFailed: failed } = resolveExplicitPlan(spec, providers, config);
|
|
973
|
+
if (failed) {
|
|
974
|
+
preFailed.push(failed);
|
|
964
975
|
} else {
|
|
965
|
-
resolved.push(
|
|
966
|
-
modelSpec: spec,
|
|
967
|
-
displayModel: spec,
|
|
968
|
-
threadKey: spec,
|
|
969
|
-
candidates: [
|
|
970
|
-
{ name: providerName, providerInstance: provider, resolvedModel, displayModel: spec, resolveOptions: options },
|
|
971
|
-
],
|
|
972
|
-
});
|
|
976
|
+
resolved.push(plan);
|
|
973
977
|
}
|
|
974
978
|
}
|
|
975
979
|
return { resolved, preFailed };
|
|
@@ -1161,7 +1165,7 @@ chatTool.inputSchema = {
|
|
|
1161
1165
|
items: { type: 'string' },
|
|
1162
1166
|
minItems: 1,
|
|
1163
1167
|
description:
|
|
1164
|
-
'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"].',
|
|
1168
|
+
'Models to use. Examples: ["auto"] (recommended), ["codex"], ["codex", "gemini", "claude"], ["codex:astra"], ["gpt-6-astra"]. Forms: "provider" (its default model), "provider:model" (that provider only), or a bare "model" (served by the first configured provider that offers it, local CLI providers first, failing over to the next). Providers: codex, gemini (agy), claude, copilot, openai, google, xai, anthropic, mistral, deepseek, openrouter. Unknown names are rejected with suggestions. 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"].',
|
|
1165
1169
|
},
|
|
1166
1170
|
mode: {
|
|
1167
1171
|
type: 'string',
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide Tool - System One decision models
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createToolResponse, createToolError } from './index.js';
|
|
11
|
+
import { resolveDecisionModel } from '../decisionProviders/index.js';
|
|
12
|
+
import { callSystemOne } from '../decisionProviders/systemOne.js';
|
|
13
|
+
import { validateAllPaths } from '../utils/fileValidator.js';
|
|
14
|
+
import { createLogger } from '../utils/logger.js';
|
|
15
|
+
|
|
16
|
+
const logger = createLogger('decide');
|
|
17
|
+
|
|
18
|
+
const QUESTION_TYPES = ['noul', 'choice', 'score'];
|
|
19
|
+
const QUESTION_FIELDS = ['type', 'instructions', 'criteria'];
|
|
20
|
+
const MAX_CHOICE_OPTIONS = 255;
|
|
21
|
+
const MIN_SCORE_LEVELS = 2;
|
|
22
|
+
const MAX_SCORE_LEVELS = 10;
|
|
23
|
+
|
|
24
|
+
function isPlainObject(value) {
|
|
25
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Criteria descriptions may be plain text or structured reference data. */
|
|
29
|
+
function isCriterionValue(value) {
|
|
30
|
+
return (typeof value === 'string' && value.trim() !== '') || isPlainObject(value) || Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function validateState(state) {
|
|
34
|
+
if (typeof state === 'string') {
|
|
35
|
+
return state.trim() ? null : '"state" must not be empty.';
|
|
36
|
+
}
|
|
37
|
+
if (isPlainObject(state) || Array.isArray(state)) return null;
|
|
38
|
+
return '"state" must be a string, an object, or an array.';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateQuestion(key, question) {
|
|
42
|
+
const at = `questions.${key}`;
|
|
43
|
+
if (!isPlainObject(question)) return `${at} must be an object with "type" and "instructions".`;
|
|
44
|
+
|
|
45
|
+
const unknown = Object.keys(question).filter((f) => !QUESTION_FIELDS.includes(f));
|
|
46
|
+
if (unknown.length) {
|
|
47
|
+
return `${at} has unknown field(s): ${unknown.join(', ')}. Allowed: ${QUESTION_FIELDS.join(', ')}.`;
|
|
48
|
+
}
|
|
49
|
+
if (!QUESTION_TYPES.includes(question.type)) {
|
|
50
|
+
return `${at}.type must be one of: ${QUESTION_TYPES.join(', ')}.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { instructions, criteria } = question;
|
|
54
|
+
const hasInstructions =
|
|
55
|
+
(typeof instructions === 'string' && instructions.trim() !== '') ||
|
|
56
|
+
(isPlainObject(instructions) && Object.keys(instructions).length > 0) ||
|
|
57
|
+
(Array.isArray(instructions) && instructions.length > 0);
|
|
58
|
+
if (!hasInstructions) {
|
|
59
|
+
return `${at}.instructions must be a non-empty string, object, or array.`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (question.type === 'noul') {
|
|
63
|
+
if (criteria === undefined) return null;
|
|
64
|
+
const keys = isPlainObject(criteria) ? Object.keys(criteria).sort() : null;
|
|
65
|
+
if (!keys || keys.join(',') !== 'false,true' || !isCriterionValue(criteria.true) || !isCriterionValue(criteria.false)) {
|
|
66
|
+
return `${at}.criteria for a noul question must be { "true": "...", "false": "..." } with both descriptions.`;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (question.type === 'choice') {
|
|
72
|
+
if (!isPlainObject(criteria)) {
|
|
73
|
+
return `${at}.criteria for a choice question must map option names to descriptions (or null).`;
|
|
74
|
+
}
|
|
75
|
+
const options = Object.entries(criteria);
|
|
76
|
+
if (options.length < 2 || options.length > MAX_CHOICE_OPTIONS) {
|
|
77
|
+
return `${at}.criteria must have 2 to ${MAX_CHOICE_OPTIONS} options (got ${options.length}).`;
|
|
78
|
+
}
|
|
79
|
+
const bad = options.filter(([, v]) => v !== null && !isCriterionValue(v)).map(([k]) => k);
|
|
80
|
+
if (bad.length) {
|
|
81
|
+
return `${at}.criteria option(s) ${bad.join(', ')} need a description string, object, array, or null.`;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!Array.isArray(criteria)) {
|
|
87
|
+
return `${at}.criteria for a score question must be an ordered array of level descriptions.`;
|
|
88
|
+
}
|
|
89
|
+
if (criteria.length < MIN_SCORE_LEVELS || criteria.length > MAX_SCORE_LEVELS) {
|
|
90
|
+
return `${at}.criteria must have ${MIN_SCORE_LEVELS} to ${MAX_SCORE_LEVELS} levels (got ${criteria.length}).`;
|
|
91
|
+
}
|
|
92
|
+
if (!criteria.every(isCriterionValue)) {
|
|
93
|
+
return `${at}.criteria levels must each be a non-empty description.`;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check questions locally: the API reports schema faults as nested validation
|
|
100
|
+
* dumps, and a local message is both clearer and free.
|
|
101
|
+
* @returns {string|null} First problem found
|
|
102
|
+
*/
|
|
103
|
+
export function validateQuestions(questions) {
|
|
104
|
+
if (!isPlainObject(questions) || Object.keys(questions).length === 0) {
|
|
105
|
+
return '"questions" must be an object with at least one named question.';
|
|
106
|
+
}
|
|
107
|
+
for (const [key, question] of Object.entries(questions)) {
|
|
108
|
+
if (!key.trim()) return 'Question names must not be empty.';
|
|
109
|
+
const error = validateQuestion(key, question);
|
|
110
|
+
if (error) return error;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read files into a `{ path: content }` map. Every file must load as text:
|
|
117
|
+
* a silently missing file would change the state being judged.
|
|
118
|
+
*/
|
|
119
|
+
async function loadFiles(files, contextProcessor, config) {
|
|
120
|
+
const validation = await validateAllPaths(
|
|
121
|
+
{ files },
|
|
122
|
+
{ clientCwd: config?.server?.client_cwd },
|
|
123
|
+
);
|
|
124
|
+
if (!validation.valid) {
|
|
125
|
+
return { error: validation.errors.join('; ') };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const result = await contextProcessor.processUnifiedContext(
|
|
129
|
+
{ files },
|
|
130
|
+
{ enforceSecurityCheck: false, skipSecurityCheck: true, clientCwd: config?.server?.client_cwd },
|
|
131
|
+
);
|
|
132
|
+
const problems = [];
|
|
133
|
+
const contents = {};
|
|
134
|
+
for (const file of result.files) {
|
|
135
|
+
if (file.type === 'error') {
|
|
136
|
+
problems.push(`${file.originalPath}: ${file.error}`);
|
|
137
|
+
} else if (file.type !== 'text') {
|
|
138
|
+
problems.push(`${file.originalPath}: decision models accept text only`);
|
|
139
|
+
} else {
|
|
140
|
+
contents[file.originalPath] = file.content;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (result.errors?.length) problems.push(...result.errors.map((e) => e.message));
|
|
144
|
+
return problems.length ? { error: problems.join('; ') } : { contents };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function fixed(n) {
|
|
148
|
+
return typeof n === 'number' ? n.toFixed(2) : String(n);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function byProbability(probabilities, label = (k) => k) {
|
|
152
|
+
return Object.entries(probabilities || {})
|
|
153
|
+
.sort(([, a], [, b]) => b - a)
|
|
154
|
+
.map(([k, p]) => `${label(k)} ${fixed(p)}`)
|
|
155
|
+
.join(', ');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function summarizeAnswer(key, answer) {
|
|
159
|
+
const type = answer?.type ?? 'unknown';
|
|
160
|
+
if (type === 'noul') {
|
|
161
|
+
return `- ${key} (noul): ${fixed(answer.noul)}`;
|
|
162
|
+
}
|
|
163
|
+
if (type === 'choice') {
|
|
164
|
+
return `- ${key} (choice): ${answer.choice} · confidence ${fixed(answer.confidence)} · ${byProbability(answer.probabilities)}`;
|
|
165
|
+
}
|
|
166
|
+
if (type === 'score') {
|
|
167
|
+
const levels = Object.keys(answer.legend || answer.probabilities || {});
|
|
168
|
+
const range = levels.length ? ` on 0–${levels.length - 1}` : '';
|
|
169
|
+
const label = (k) => (answer.legend?.[k] ? `${k} ${answer.legend[k]}` : k);
|
|
170
|
+
return `- ${key} (score): ${fixed(answer.score)}${range} · confidence ${fixed(answer.confidence)} · ${byProbability(answer.probabilities, label)}`;
|
|
171
|
+
}
|
|
172
|
+
return `- ${key} (${type}): ${JSON.stringify(answer)}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatResult(response, candidate, failures) {
|
|
176
|
+
const usage = response.usage || {};
|
|
177
|
+
const header = [
|
|
178
|
+
`Decision · ${response.model || candidate.model} via ${candidate.provider.label}`,
|
|
179
|
+
usage.input_tokens !== undefined ? `${usage.input_tokens} input tokens` : null,
|
|
180
|
+
typeof usage.cost === 'number' ? `$${usage.cost.toFixed(6)}` : null,
|
|
181
|
+
].filter(Boolean).join(' · ');
|
|
182
|
+
|
|
183
|
+
const lines = [header];
|
|
184
|
+
for (const failure of failures) {
|
|
185
|
+
lines.push(`(${failure.provider} failed, fell back: ${failure.message})`);
|
|
186
|
+
}
|
|
187
|
+
lines.push(...Object.entries(response.answers).map(([key, answer]) => summarizeAnswer(key, answer)));
|
|
188
|
+
|
|
189
|
+
const payload = {
|
|
190
|
+
model: response.model ?? candidate.model,
|
|
191
|
+
provider: candidate.providerName,
|
|
192
|
+
answers: response.answers,
|
|
193
|
+
usage: {
|
|
194
|
+
input_tokens: usage.input_tokens ?? null,
|
|
195
|
+
output_tokens: usage.output_tokens ?? null,
|
|
196
|
+
cost: typeof usage.cost === 'number' ? usage.cost : null,
|
|
197
|
+
},
|
|
198
|
+
...(response.id ? { id: response.id } : {}),
|
|
199
|
+
};
|
|
200
|
+
return `${lines.join('\n')}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Decide MCP Tool
|
|
205
|
+
* @param {object} args - Tool arguments
|
|
206
|
+
* @param {string|object|Array} [args.state] - Material to judge
|
|
207
|
+
* @param {object} args.questions - Named typed questions
|
|
208
|
+
* @param {string} [args.model] - Model spec, default "auto"
|
|
209
|
+
* @param {string[]} [args.files] - Text files added to the state
|
|
210
|
+
* @param {object} dependencies - Injected dependencies (config, contextProcessor, signal)
|
|
211
|
+
* @returns {Promise<object>} MCP tool response
|
|
212
|
+
*/
|
|
213
|
+
export async function decideTool(args, dependencies) {
|
|
214
|
+
const { config, contextProcessor, signal } = dependencies;
|
|
215
|
+
const { state, questions, model = 'auto', files = [] } = args;
|
|
216
|
+
|
|
217
|
+
if (!Array.isArray(files) || !files.every((f) => typeof f === 'string' && f.trim())) {
|
|
218
|
+
return createToolError('"files" must be an array of file paths.');
|
|
219
|
+
}
|
|
220
|
+
if (state === undefined && files.length === 0) {
|
|
221
|
+
return createToolError('Provide "state", "files", or both: there is nothing to judge.');
|
|
222
|
+
}
|
|
223
|
+
const stateError = state === undefined ? null : validateState(state);
|
|
224
|
+
if (stateError) return createToolError(stateError);
|
|
225
|
+
const questionError = validateQuestions(questions);
|
|
226
|
+
if (questionError) return createToolError(questionError);
|
|
227
|
+
|
|
228
|
+
const route = resolveDecisionModel(model, config);
|
|
229
|
+
if (route.status !== 'ok') return createToolError(route.error);
|
|
230
|
+
|
|
231
|
+
let finalState = state;
|
|
232
|
+
if (files.length > 0) {
|
|
233
|
+
const loaded = await loadFiles(files, contextProcessor, config);
|
|
234
|
+
if (loaded.error) return createToolError(`Could not load files: ${loaded.error}`);
|
|
235
|
+
finalState = state === undefined ? { files: loaded.contents } : { input: state, files: loaded.contents };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const failures = [];
|
|
239
|
+
for (const candidate of route.candidates) {
|
|
240
|
+
try {
|
|
241
|
+
const response = await callSystemOne({
|
|
242
|
+
baseURL: candidate.provider.baseURL,
|
|
243
|
+
headers: candidate.provider.headers(config),
|
|
244
|
+
body: { model: candidate.model, state: finalState, questions },
|
|
245
|
+
signal,
|
|
246
|
+
});
|
|
247
|
+
return createToolResponse(formatResult(response, candidate, failures));
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (signal?.aborted) return createToolError('Decision request cancelled.');
|
|
250
|
+
logger.error('Decision request failed', { provider: candidate.providerName, model: candidate.model, error: error.message });
|
|
251
|
+
failures.push({ provider: candidate.providerName, message: error.message });
|
|
252
|
+
if (error.terminal) break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const detail = failures.map((f) => `${f.provider}: ${f.message}`).join('; ');
|
|
257
|
+
return createToolError(`Decision request failed (${detail})`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
decideTool.description =
|
|
261
|
+
'DECIDE — ask a System One decision model (TypeSafe Jev) typed questions about a state and get calibrated answers, not text. ' +
|
|
262
|
+
'Question types: "noul" (yes/no → probability 0..1), "choice" (pick one of 2–255 named options → choice, per-option probabilities, confidence), ' +
|
|
263
|
+
'"score" (ordered rubric of 2–10 levels → weighted position, per-level probabilities, confidence). ' +
|
|
264
|
+
'Batch many questions into one call: they are judged in parallel and in isolation against the same state at almost no extra cost. ' +
|
|
265
|
+
'Best for fast atomic judgments (classify, route, verify, rank). Keep each question literal and narrow; do counting, arithmetic, and date comparison in code; ' +
|
|
266
|
+
'split compound judgments into separate questions; treat low confidence as a signal to escalate. Text only, no explanations are returned. ' +
|
|
267
|
+
'Limits: ~64k tokens per request, ~32k for state plus the longest question.';
|
|
268
|
+
|
|
269
|
+
decideTool.inputSchema = {
|
|
270
|
+
type: 'object',
|
|
271
|
+
properties: {
|
|
272
|
+
state: {
|
|
273
|
+
anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array' }],
|
|
274
|
+
description:
|
|
275
|
+
'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.',
|
|
276
|
+
},
|
|
277
|
+
questions: {
|
|
278
|
+
type: 'object',
|
|
279
|
+
description:
|
|
280
|
+
'Named questions, all answered against the same state. The name is your own label and is returned as the answer key. ' +
|
|
281
|
+
'Each question: { "type": "noul"|"choice"|"score", "instructions": string|object|array, "criteria": ... }. ' +
|
|
282
|
+
'criteria — noul: optional { "true": "...", "false": "..." }; choice: required { "<option>": "description" | null } (2–255 options); ' +
|
|
283
|
+
'score: required ordered array of level descriptions, lowest first (2–10 levels). ' +
|
|
284
|
+
'instructions may be an object bundling the question with reference data, referenced by `name` in the text. ' +
|
|
285
|
+
'Example: { "team": { "type": "choice", "instructions": "Which team should handle this?", "criteria": { "billing": "Payments, refunds", "technical": "Bugs, outages" } } }',
|
|
286
|
+
additionalProperties: {
|
|
287
|
+
type: 'object',
|
|
288
|
+
properties: {
|
|
289
|
+
type: { type: 'string', enum: QUESTION_TYPES },
|
|
290
|
+
instructions: { anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array' }] },
|
|
291
|
+
criteria: { anyOf: [{ type: 'object' }, { type: 'array' }] },
|
|
292
|
+
},
|
|
293
|
+
required: ['type', 'instructions'],
|
|
294
|
+
additionalProperties: false,
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
model: {
|
|
298
|
+
type: 'string',
|
|
299
|
+
description:
|
|
300
|
+
'Decision model. "auto" (default): TypeSafe, falling back to OpenRouter. "jev-latest", "jev-1.13": first configured provider that serves it, with fallback. ' +
|
|
301
|
+
'"typesafe:jev-1.13.0", "openrouter:~typesafe/jev-latest": that provider only. Providers: typesafe (TYPESAFE_API_KEY), openrouter (OPENROUTER_API_KEY).',
|
|
302
|
+
},
|
|
303
|
+
files: {
|
|
304
|
+
type: 'array',
|
|
305
|
+
items: { type: 'string' },
|
|
306
|
+
description:
|
|
307
|
+
'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.',
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
required: ['questions'],
|
|
311
|
+
additionalProperties: false,
|
|
312
|
+
};
|
package/src/tools/index.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { chatTool } from './chat.js';
|
|
10
10
|
import { checkStatusTool } from './checkStatus.js';
|
|
11
11
|
import { cancelJobTool } from './cancelJob.js';
|
|
12
|
+
import { decideTool } from './decide.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Tool registry map
|
|
@@ -19,6 +20,7 @@ const tools = {
|
|
|
19
20
|
chat: chatTool,
|
|
20
21
|
check_status: checkStatusTool,
|
|
21
22
|
cancel_job: cancelJobTool,
|
|
23
|
+
decide: decideTool,
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -20,12 +20,8 @@
|
|
|
20
20
|
|
|
21
21
|
import { debugLog } from '../../utils/console.js';
|
|
22
22
|
import { acquireProviderStream } from './streamShared.js';
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
getProviderUnavailableMessage,
|
|
26
|
-
getAvailableProviders,
|
|
27
|
-
resolveModelSpec,
|
|
28
|
-
} from '../../utils/modelRouting.js';
|
|
23
|
+
import { getAutoModelSpecs, resolveModelSpec } from '../../utils/modelRouting.js';
|
|
24
|
+
import { shouldFailoverToNextProvider } from './parallel.js';
|
|
29
25
|
|
|
30
26
|
/**
|
|
31
27
|
* Render a stored transcript (from prior laps or a prior chat/consensus thread)
|
|
@@ -169,7 +165,9 @@ export function formatLapTranscript(lapTurns) {
|
|
|
169
165
|
* Resolve the ordered model list into a turn plan. Unlike the parallel engine,
|
|
170
166
|
* unknown or unavailable models are NOT dropped — they are recorded with a
|
|
171
167
|
* preFailReason so they keep their position in the order (and produce a failed
|
|
172
|
-
* turn).
|
|
168
|
+
* turn). A bare model name served by several providers carries every serving
|
|
169
|
+
* provider in `candidates`, tried in order when a turn fails with a
|
|
170
|
+
* failover-worthy error.
|
|
173
171
|
* @param {Array<string>} models - Ordered model list
|
|
174
172
|
* @param {object} providers - Provider instances
|
|
175
173
|
* @param {object} config - Configuration
|
|
@@ -181,16 +179,14 @@ export function resolveTurnPlan(models, providers, config, hasImages = false) {
|
|
|
181
179
|
// (a single-model round-table is valid). Multiple explicit models resolve per-entry.
|
|
182
180
|
let modelsToProcess = models;
|
|
183
181
|
if (models.length === 1 && String(models[0]).toLowerCase() === 'auto') {
|
|
184
|
-
const [firstAvailable] =
|
|
182
|
+
const [firstAvailable] = getAutoModelSpecs(providers, config, {
|
|
185
183
|
hasImages,
|
|
186
184
|
limit: 1,
|
|
187
185
|
});
|
|
188
186
|
|
|
189
187
|
// If a provider is available, use its default model. Otherwise keep "auto"
|
|
190
188
|
// so it resolves to a turn that fails cleanly (all-fail laps must complete).
|
|
191
|
-
modelsToProcess = firstAvailable
|
|
192
|
-
? [getDefaultModelForProvider(firstAvailable)]
|
|
193
|
-
: ['auto'];
|
|
189
|
+
modelsToProcess = firstAvailable ? [firstAvailable] : ['auto'];
|
|
194
190
|
}
|
|
195
191
|
|
|
196
192
|
return modelsToProcess.map((modelName) => {
|
|
@@ -200,39 +196,31 @@ export function resolveTurnPlan(models, providers, config, hasImages = false) {
|
|
|
200
196
|
provider: null,
|
|
201
197
|
providerInstance: null,
|
|
202
198
|
resolvedModel: null,
|
|
199
|
+
candidates: [],
|
|
203
200
|
preFailReason: 'Invalid model specification',
|
|
204
201
|
};
|
|
205
202
|
}
|
|
206
203
|
|
|
207
|
-
const
|
|
208
|
-
resolveModelSpec(modelName, providers, config);
|
|
204
|
+
const resolution = resolveModelSpec(modelName, providers, config);
|
|
209
205
|
|
|
210
|
-
if (status
|
|
206
|
+
if (resolution.status !== 'ok') {
|
|
211
207
|
return {
|
|
212
208
|
model: modelName,
|
|
213
|
-
provider: providerName,
|
|
209
|
+
provider: resolution.providerName,
|
|
214
210
|
providerInstance: null,
|
|
215
|
-
resolvedModel,
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (status === 'unavailable') {
|
|
221
|
-
return {
|
|
222
|
-
model: modelName,
|
|
223
|
-
provider: providerName,
|
|
224
|
-
providerInstance: null,
|
|
225
|
-
resolvedModel,
|
|
226
|
-
preFailReason: getProviderUnavailableMessage(providerName),
|
|
211
|
+
resolvedModel: null,
|
|
212
|
+
candidates: [],
|
|
213
|
+
preFailReason: resolution.error,
|
|
227
214
|
};
|
|
228
215
|
}
|
|
229
216
|
|
|
230
217
|
return {
|
|
231
218
|
model: modelName,
|
|
232
|
-
provider: providerName,
|
|
233
|
-
providerInstance: provider,
|
|
234
|
-
resolvedModel,
|
|
235
|
-
resolveOptions: options,
|
|
219
|
+
provider: resolution.providerName,
|
|
220
|
+
providerInstance: resolution.provider,
|
|
221
|
+
resolvedModel: resolution.resolvedModel,
|
|
222
|
+
resolveOptions: resolution.options,
|
|
223
|
+
candidates: resolution.candidates,
|
|
236
224
|
preFailReason: null,
|
|
237
225
|
};
|
|
238
226
|
});
|
|
@@ -254,7 +242,9 @@ function buildTurnUserContent(packetText, contextMessage) {
|
|
|
254
242
|
* Execute a single turn. Streams (updating job progress) when a job context is
|
|
255
243
|
* present; otherwise performs a plain invoke. Cancellation propagates by throwing
|
|
256
244
|
* so the lap aborts rather than demoting to a failed turn.
|
|
257
|
-
* @returns {Promise<object>} Turn result { model, provider, status, response|error }
|
|
245
|
+
* @returns {Promise<object>} Turn result { model, provider, status, response|error };
|
|
246
|
+
* failed turns also carry the thrown `cause` for the failover decision
|
|
247
|
+
* (stripped before the turn is recorded)
|
|
258
248
|
*/
|
|
259
249
|
async function executeTurn(
|
|
260
250
|
plan,
|
|
@@ -353,6 +343,7 @@ async function executeTurn(
|
|
|
353
343
|
provider: plan.provider,
|
|
354
344
|
status: 'failed',
|
|
355
345
|
error: error.message,
|
|
346
|
+
cause: error,
|
|
356
347
|
};
|
|
357
348
|
}
|
|
358
349
|
}
|
|
@@ -436,24 +427,44 @@ export async function runRoundtableLap({
|
|
|
436
427
|
{ role: 'user', content: finalUserContent },
|
|
437
428
|
];
|
|
438
429
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
430
|
+
let turnResult;
|
|
431
|
+
for (let ci = 0; ci < plan.candidates.length; ci++) {
|
|
432
|
+
const candidate = plan.candidates[ci];
|
|
433
|
+
turnResult = await executeTurn(
|
|
434
|
+
{
|
|
435
|
+
model: plan.model,
|
|
436
|
+
provider: candidate.providerName,
|
|
437
|
+
providerInstance: candidate.provider,
|
|
438
|
+
},
|
|
439
|
+
messages,
|
|
440
|
+
{
|
|
441
|
+
reasoning_effort,
|
|
442
|
+
signal: activeSignal,
|
|
443
|
+
config,
|
|
444
|
+
model: candidate.resolvedModel,
|
|
445
|
+
// Web search opt-in from an OpenRouter `:online` decoration; only ever
|
|
446
|
+
// set for OpenRouter turns.
|
|
447
|
+
...(candidate.options?.web_search && { web_search: true }),
|
|
448
|
+
},
|
|
449
|
+
context,
|
|
450
|
+
providerStreamNormalizer,
|
|
451
|
+
i,
|
|
452
|
+
);
|
|
453
|
+
const isLastCandidate = ci === plan.candidates.length - 1;
|
|
454
|
+
if (
|
|
455
|
+
turnResult.status === 'success' ||
|
|
456
|
+
isLastCandidate ||
|
|
457
|
+
!shouldFailoverToNextProvider(turnResult.cause)
|
|
458
|
+
) {
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
debugLog(
|
|
462
|
+
`[Roundtable] Turn ${i + 1} (${plan.model}) failed on ${candidate.providerName}; failing over`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
455
465
|
|
|
456
|
-
|
|
466
|
+
const { cause: _cause, ...turn } = turnResult;
|
|
467
|
+
lapTurns.push({ ...turn, position: i });
|
|
457
468
|
}
|
|
458
469
|
|
|
459
470
|
if (context) {
|