converse-mcp-server 4.0.0 → 4.1.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 +5 -0
- package/README.md +19 -0
- package/docs/API.md +105 -0
- package/package.json +1 -1
- package/src/config.js +7 -1
- package/src/decisionProviders/index.js +174 -0
- package/src/decisionProviders/systemOne.js +199 -0
- package/src/prompts/helpPrompt.js +23 -1
- package/src/providers/openrouter.js +1 -1
- package/src/tools/decide.js +319 -0
- package/src/tools/index.js +2 -0
package/.env.example
CHANGED
|
@@ -51,6 +51,11 @@ OPENROUTER_API_KEY=your_openrouter_api_key_here
|
|
|
51
51
|
# OPENROUTER_REFERER=https://github.com/FallDownTheSystem/converse
|
|
52
52
|
# OPENROUTER_TITLE=Converse
|
|
53
53
|
|
|
54
|
+
# Get your TypeSafe API key from: https://typesafe.ai
|
|
55
|
+
# Enables the decide tool's native host for Jev decision models. Without it,
|
|
56
|
+
# decide reaches Jev through OPENROUTER_API_KEY instead.
|
|
57
|
+
TYPESAFE_API_KEY=your_typesafe_api_key_here
|
|
58
|
+
|
|
54
59
|
# ============================================
|
|
55
60
|
# Codex Configuration (Optional)
|
|
56
61
|
# ============================================
|
package/README.md
CHANGED
|
@@ -182,6 +182,25 @@ Cancel running asynchronous operations when needed.
|
|
|
182
182
|
}
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
+
### 4. Decide Tool
|
|
186
|
+
|
|
187
|
+
Ask a System One decision model (TypeSafe's Jev) typed questions about a state and get calibrated answers instead of text: a yes probability (`noul`), a chosen option with per-option probabilities (`choice`), or a rubric position with per-level probabilities (`score`). Batch many questions into one call; they are judged in parallel against the same state. Needs `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY` (TypeSafe first, falling back to OpenRouter).
|
|
188
|
+
|
|
189
|
+
```javascript
|
|
190
|
+
{
|
|
191
|
+
"state": { "message": "I was charged twice for order A-104. Please fix this ASAP." },
|
|
192
|
+
"questions": {
|
|
193
|
+
"urgent": { "type": "noul", "instructions": "Does the message convey urgency?" },
|
|
194
|
+
"team": { "type": "choice", "instructions": "Which team should handle this?",
|
|
195
|
+
"criteria": { "billing": "Payments, refunds", "technical": "Bugs, outages" } },
|
|
196
|
+
"frustration": { "type": "score", "instructions": "How frustrated is the customer?",
|
|
197
|
+
"criteria": ["Calm", "Frustrated", "Very angry"] }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
See [docs/API.md](docs/API.md#decide-tool) for the full schema, model routing, and usage guidance.
|
|
203
|
+
|
|
185
204
|
## 🤖 AI Summarization Feature
|
|
186
205
|
|
|
187
206
|
When enabled, the server automatically generates intelligent titles and summaries for better context understanding:
|
package/docs/API.md
CHANGED
|
@@ -366,6 +366,110 @@ The response renders a human-readable status (start time, elapsed time, turn/mod
|
|
|
366
366
|
|
|
367
367
|
Only jobs in a `queued` or `running` state can be cancelled; already-completed, failed, or cancelled jobs return a non-cancellable status.
|
|
368
368
|
|
|
369
|
+
## Decide Tool
|
|
370
|
+
|
|
371
|
+
**Description**: Ask a System One decision model (TypeSafe's Jev family) typed questions about a state. Decision models return calibrated probabilities, never text, so they have their own tool and their own providers: `chat` routing never reaches them, and `decide` never reaches a chat model.
|
|
372
|
+
|
|
373
|
+
### Request Schema
|
|
374
|
+
|
|
375
|
+
```json
|
|
376
|
+
{
|
|
377
|
+
"type": "object",
|
|
378
|
+
"properties": {
|
|
379
|
+
"state": { "anyOf": [{ "type": "string" }, { "type": "object" }, { "type": "array" }] },
|
|
380
|
+
"questions": {
|
|
381
|
+
"type": "object",
|
|
382
|
+
"additionalProperties": {
|
|
383
|
+
"type": "object",
|
|
384
|
+
"properties": {
|
|
385
|
+
"type": { "type": "string", "enum": ["noul", "choice", "score"] },
|
|
386
|
+
"instructions": { "anyOf": [{ "type": "string" }, { "type": "object" }, { "type": "array" }] },
|
|
387
|
+
"criteria": { "anyOf": [{ "type": "object" }, { "type": "array" }] }
|
|
388
|
+
},
|
|
389
|
+
"required": ["type", "instructions"]
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
"model": { "type": "string", "description": "Default: \"auto\"" },
|
|
393
|
+
"files": { "type": "array", "items": { "type": "string" } }
|
|
394
|
+
},
|
|
395
|
+
"required": ["questions"],
|
|
396
|
+
"additionalProperties": false
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
- **`state`**: the material to judge. An object with descriptively named fields works best; use an array for sequences such as chat messages. Optional when `files` is given.
|
|
401
|
+
- **`questions`**: named questions, each judged in parallel and in isolation against the same state. The name is your own label and becomes the answer key. Batching many questions into one call adds almost no latency or cost.
|
|
402
|
+
- **`files`**: text files added to the state as `{ "files": { "<path>": "<content>" } }`. When `state` is also given, it moves to `input`. Line ranges (`file.txt{10:50}`) are supported; images are rejected.
|
|
403
|
+
|
|
404
|
+
| Type | `criteria` | Answer |
|
|
405
|
+
|---|---|---|
|
|
406
|
+
| `noul` | Optional `{ "true": "...", "false": "..." }` | `noul`: probability 0..1 of yes |
|
|
407
|
+
| `choice` | Required `{ "<option>": "description" \| null }`, 2–255 options | `choice`, per-option `probabilities`, `confidence` |
|
|
408
|
+
| `score` | Required ordered array of levels, lowest first, 2–10 levels | `score` (probability-weighted level position), `legend`, per-level `probabilities`, `confidence` |
|
|
409
|
+
|
|
410
|
+
`instructions` and criteria descriptions may be objects or arrays that bundle reference data with the question; refer to their fields by `` `name` `` in the text. Questions are validated before any request is sent.
|
|
411
|
+
|
|
412
|
+
### Models and Providers
|
|
413
|
+
|
|
414
|
+
| Provider | Key | Models |
|
|
415
|
+
|---|---|---|
|
|
416
|
+
| `typesafe` (native, `https://api.typesafe.ai/v1/systemone`) | `TYPESAFE_API_KEY` | `jev-latest`, `jev-1.13.0` (alias `jev-1.13`), `jev-preview`, any versioned `jev-X.Y.Z` |
|
|
417
|
+
| `openrouter` (`https://openrouter.ai/api/v1/systemone`) | `OPENROUTER_API_KEY` | `~typesafe/jev-latest` (alias `jev-latest`), `typesafe/jev-1.13` (aliases `jev-1.13`, `jev-1.13.0`), any `vendor/model` slug |
|
|
418
|
+
|
|
419
|
+
- `auto` (default): TypeSafe's default model, falling back to OpenRouter's.
|
|
420
|
+
- A bare name such as `jev-1.13` goes to every configured provider that serves it, native first, and each provider receives its own model ID. OpenRouter does not serve `jev-preview` or patch-level IDs other than those listed above.
|
|
421
|
+
- `typesafe:jev-1.13.0` or `openrouter:~typesafe/jev-latest` pins one provider.
|
|
422
|
+
|
|
423
|
+
Each provider call retries timeouts, 408, 429 and 5xx with backoff, honoring `Retry-After`. Auth failures, exhausted retries and malformed responses fall back to the next provider. A 400/422 request fault stops immediately, because every host would reject it the same way.
|
|
424
|
+
|
|
425
|
+
### Example Usage
|
|
426
|
+
|
|
427
|
+
```json
|
|
428
|
+
{
|
|
429
|
+
"state": { "message": "I was charged twice for order A-104. Please fix this ASAP." },
|
|
430
|
+
"questions": {
|
|
431
|
+
"urgent": { "type": "noul", "instructions": "Does the message convey urgency?" },
|
|
432
|
+
"team": {
|
|
433
|
+
"type": "choice",
|
|
434
|
+
"instructions": "Which team should handle this?",
|
|
435
|
+
"criteria": { "billing": "Payments, invoicing, refunds", "technical": "Bugs, outages", "sales": null }
|
|
436
|
+
},
|
|
437
|
+
"frustration": {
|
|
438
|
+
"type": "score",
|
|
439
|
+
"instructions": "How frustrated is the customer?",
|
|
440
|
+
"criteria": ["Calm", "Frustrated", "Very angry"]
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Response Format
|
|
447
|
+
|
|
448
|
+
A one-line summary per answer (options at 0.00 omitted), followed by the answers with full distributions, in the same JSON shape whichever provider served them:
|
|
449
|
+
|
|
450
|
+
````
|
|
451
|
+
Decision · typesafe/jev-1.13-20260917 via OpenRouter · 394 input tokens · $0.000017
|
|
452
|
+
- urgent (noul): 0.97
|
|
453
|
+
- team (choice): billing · confidence 1.00 · billing 1.00
|
|
454
|
+
- frustration (score): 1.24 on 0–2 · confidence 0.64 · 1 Frustrated 0.76, 2 Very angry 0.24
|
|
455
|
+
|
|
456
|
+
```json
|
|
457
|
+
{
|
|
458
|
+
"model": "typesafe/jev-1.13-20260917",
|
|
459
|
+
"provider": "openrouter",
|
|
460
|
+
"answers": { "urgent": { "type": "noul", "noul": 0.97 }, "...": {} },
|
|
461
|
+
"usage": { "input_tokens": 394, "output_tokens": 70, "cost": 0.000016548 },
|
|
462
|
+
"id": "gen-dec-..."
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
````
|
|
466
|
+
|
|
467
|
+
`usage.cost` is reported by OpenRouter only (`null` from TypeSafe). A fallback is noted under the summary line.
|
|
468
|
+
|
|
469
|
+
### Usage Guidance
|
|
470
|
+
|
|
471
|
+
Jev reads questions literally and is weak at counting, arithmetic, date comparison, and multi-hop reasoning; do those in code and ask narrow, atomic questions. Split compound judgments into separate questions and combine them in code. Treat low `confidence` as a signal to escalate to a generative model or a human. Limits: text only, about 64k tokens per request and 32k for the state plus the longest single question.
|
|
472
|
+
|
|
369
473
|
## Supported Models
|
|
370
474
|
|
|
371
475
|
Provide models as plain name strings in the `models` array. Each entry is `auto`, a provider name (its default model), `provider:model` (that model on that provider only), or a bare model ID/alias (the first set-up provider that offers it). Names that match no provider's list are rejected with suggestions. See [Model Selection](#model-selection) for the full rules.
|
|
@@ -714,6 +818,7 @@ ANTHROPIC_API_KEY=sk-ant-...
|
|
|
714
818
|
MISTRAL_API_KEY=...
|
|
715
819
|
DEEPSEEK_API_KEY=...
|
|
716
820
|
OPENROUTER_API_KEY=sk-or-...
|
|
821
|
+
TYPESAFE_API_KEY=... # decide tool only
|
|
717
822
|
```
|
|
718
823
|
|
|
719
824
|
**MCP client configuration:**
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -214,6 +214,12 @@ const CONFIG_SCHEMA = {
|
|
|
214
214
|
secret: true,
|
|
215
215
|
description: 'OpenRouter API key',
|
|
216
216
|
},
|
|
217
|
+
TYPESAFE_API_KEY: {
|
|
218
|
+
type: 'string',
|
|
219
|
+
required: false,
|
|
220
|
+
secret: true,
|
|
221
|
+
description: 'TypeSafe API key (System One decision models for the decide tool)',
|
|
222
|
+
},
|
|
217
223
|
},
|
|
218
224
|
|
|
219
225
|
// Provider-specific configuration
|
|
@@ -707,7 +713,7 @@ export async function loadConfig() {
|
|
|
707
713
|
|
|
708
714
|
if (availableKeys.length === 0 && !hasVertexAI && !hasSdkProvider) {
|
|
709
715
|
errors.push(
|
|
710
|
-
'At least one API key must be configured: OPENAI_API_KEY, XAI_API_KEY, GOOGLE_API_KEY, GEMINI_API_KEY, ANTHROPIC_API_KEY, MISTRAL_API_KEY, DEEPSEEK_API_KEY, or
|
|
716
|
+
'At least one API key must be configured: OPENAI_API_KEY, XAI_API_KEY, GOOGLE_API_KEY, GEMINI_API_KEY, ANTHROPIC_API_KEY, MISTRAL_API_KEY, DEEPSEEK_API_KEY, OPENROUTER_API_KEY, or TYPESAFE_API_KEY. Alternatively, configure Google Vertex AI or use an SDK-based provider (codex, claude, copilot) or the Antigravity CLI (gemini-cli).',
|
|
711
717
|
);
|
|
712
718
|
}
|
|
713
719
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision Providers
|
|
3
|
+
*
|
|
4
|
+
* Hosts of System One decision models (TypeSafe's Jev family). These are kept
|
|
5
|
+
* apart from the chat provider registry on purpose: a decision model takes a
|
|
6
|
+
* state plus typed questions and returns probabilities, never text, so it must
|
|
7
|
+
* never be reachable from `chat` routing ("auto", bare names, failover), and
|
|
8
|
+
* the `decide` tool must never reach a chat model.
|
|
9
|
+
*
|
|
10
|
+
* Spec grammar mirrors chat routing:
|
|
11
|
+
* - `auto` (or empty) — every configured provider's default, in priority order
|
|
12
|
+
* - `provider` / `provider:` — that provider's default model
|
|
13
|
+
* - `provider:model` — that provider only
|
|
14
|
+
* - `model` — every provider that serves the name, in priority order; the
|
|
15
|
+
* first is used and the rest are failover candidates
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getCustomHeaders as getOpenRouterAttributionHeaders } from '../providers/openrouter.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Model catalogs map each provider's own model ID to the names that route to
|
|
22
|
+
* it. OpenRouter only knows `~typesafe/jev-latest` and `typesafe/jev-1.13`
|
|
23
|
+
* (it rejects `jev-1.13.0` and `jev-preview`), so native IDs are translated
|
|
24
|
+
* per provider rather than by a blanket prefix rewrite.
|
|
25
|
+
*/
|
|
26
|
+
export const DECISION_PROVIDERS = {
|
|
27
|
+
typesafe: {
|
|
28
|
+
name: 'typesafe',
|
|
29
|
+
label: 'TypeSafe',
|
|
30
|
+
baseURL: 'https://api.typesafe.ai',
|
|
31
|
+
apiKeyEnv: 'TYPESAFE_API_KEY',
|
|
32
|
+
defaultModel: 'jev-latest',
|
|
33
|
+
models: {
|
|
34
|
+
'jev-latest': ['jev', '~typesafe/jev-latest'],
|
|
35
|
+
'jev-1.13.0': ['jev-1.13', 'typesafe/jev-1.13'],
|
|
36
|
+
'jev-preview': [],
|
|
37
|
+
},
|
|
38
|
+
// TypeSafe accepts every published versioned ID, listed or not.
|
|
39
|
+
passthrough: (name) => /^jev-\d+\.\d+(\.\d+)?$/i.test(name),
|
|
40
|
+
headers: (config) => ({ Authorization: `Bearer ${config.apiKeys.typesafe}` }),
|
|
41
|
+
isAvailable: (config) => Boolean(config?.apiKeys?.typesafe),
|
|
42
|
+
},
|
|
43
|
+
openrouter: {
|
|
44
|
+
name: 'openrouter',
|
|
45
|
+
label: 'OpenRouter',
|
|
46
|
+
baseURL: 'https://openrouter.ai/api',
|
|
47
|
+
apiKeyEnv: 'OPENROUTER_API_KEY',
|
|
48
|
+
defaultModel: '~typesafe/jev-latest',
|
|
49
|
+
models: {
|
|
50
|
+
'~typesafe/jev-latest': ['jev-latest', 'jev'],
|
|
51
|
+
'typesafe/jev-1.13': ['jev-1.13', 'jev-1.13.0'],
|
|
52
|
+
},
|
|
53
|
+
// Decision models OpenRouter adds later are reachable by full slug.
|
|
54
|
+
passthrough: (name) => name.includes('/'),
|
|
55
|
+
headers: (config) => ({
|
|
56
|
+
Authorization: `Bearer ${config.apiKeys.openrouter}`,
|
|
57
|
+
...getOpenRouterAttributionHeaders(config),
|
|
58
|
+
}),
|
|
59
|
+
isAvailable: (config) => Boolean(config?.apiKeys?.openrouter),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Failover order: the native host first, then OpenRouter. */
|
|
64
|
+
export const DECISION_PROVIDER_PRIORITY = ['typesafe', 'openrouter'];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a name against one provider's catalog, then its passthrough rule.
|
|
68
|
+
* @returns {string|null} The model ID to send to that provider
|
|
69
|
+
*/
|
|
70
|
+
export function findDecisionModel(provider, name) {
|
|
71
|
+
const wanted = String(name).trim().toLowerCase();
|
|
72
|
+
if (!wanted) return null;
|
|
73
|
+
for (const [id, aliases] of Object.entries(provider.models)) {
|
|
74
|
+
if (id.toLowerCase() === wanted || aliases.some((a) => a.toLowerCase() === wanted)) {
|
|
75
|
+
return id;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return provider.passthrough(String(name).trim()) ? String(name).trim() : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function setupHint(provider) {
|
|
82
|
+
return `set ${provider.apiKeyEnv}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function knownModels() {
|
|
86
|
+
const names = new Set();
|
|
87
|
+
for (const providerName of DECISION_PROVIDER_PRIORITY) {
|
|
88
|
+
const provider = DECISION_PROVIDERS[providerName];
|
|
89
|
+
for (const [id, aliases] of Object.entries(provider.models)) {
|
|
90
|
+
names.add(id);
|
|
91
|
+
aliases.forEach((a) => names.add(a));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...names];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ok(candidates) {
|
|
98
|
+
return { status: 'ok', candidates, error: null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function fail(status, error) {
|
|
102
|
+
return { status, candidates: [], error };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve a decision model spec into an ordered candidate list.
|
|
107
|
+
* @param {string} spec - Model spec (see module grammar)
|
|
108
|
+
* @param {object} config - Server configuration (for API keys)
|
|
109
|
+
* @returns {{ status: 'ok'|'unknown'|'unavailable', candidates: Array<{ providerName: string, provider: object, model: string }>, error: string|null }}
|
|
110
|
+
*/
|
|
111
|
+
export function resolveDecisionModel(spec, config) {
|
|
112
|
+
const raw = String(spec ?? '').trim();
|
|
113
|
+
const namespaces = DECISION_PROVIDER_PRIORITY.join(', ');
|
|
114
|
+
|
|
115
|
+
if (!raw || raw.toLowerCase() === 'auto') {
|
|
116
|
+
const candidates = DECISION_PROVIDER_PRIORITY
|
|
117
|
+
.map((name) => DECISION_PROVIDERS[name])
|
|
118
|
+
.filter((provider) => provider.isAvailable(config))
|
|
119
|
+
.map((provider) => ({ providerName: provider.name, provider, model: provider.defaultModel }));
|
|
120
|
+
if (candidates.length === 0) {
|
|
121
|
+
return fail(
|
|
122
|
+
'unavailable',
|
|
123
|
+
`No decision provider is configured: ${DECISION_PROVIDER_PRIORITY.map((n) => `${n} (${setupHint(DECISION_PROVIDERS[n])})`).join('; ')}.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return ok(candidates);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const colon = raw.indexOf(':');
|
|
130
|
+
const hasNamespace = colon > 0 && !raw.slice(0, colon).includes('/');
|
|
131
|
+
const namespace = hasNamespace ? raw.slice(0, colon).toLowerCase() : raw.toLowerCase();
|
|
132
|
+
|
|
133
|
+
if (hasNamespace || DECISION_PROVIDERS[namespace]) {
|
|
134
|
+
const provider = DECISION_PROVIDERS[namespace];
|
|
135
|
+
if (!provider) {
|
|
136
|
+
return fail('unknown', `Unknown decision provider "${raw.slice(0, colon)}". Providers: ${namespaces}.`);
|
|
137
|
+
}
|
|
138
|
+
const name = hasNamespace ? raw.slice(colon + 1).trim() : '';
|
|
139
|
+
const model = name ? findDecisionModel(provider, name) : provider.defaultModel;
|
|
140
|
+
if (!model) {
|
|
141
|
+
return fail(
|
|
142
|
+
'unknown',
|
|
143
|
+
`${provider.label} does not serve "${name}". Models: ${Object.keys(provider.models).join(', ')}.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (!provider.isAvailable(config)) {
|
|
147
|
+
return fail('unavailable', `${provider.label} is not configured (${setupHint(provider)}).`);
|
|
148
|
+
}
|
|
149
|
+
return ok([{ providerName: provider.name, provider, model }]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const matches = DECISION_PROVIDER_PRIORITY
|
|
153
|
+
.map((providerName) => {
|
|
154
|
+
const provider = DECISION_PROVIDERS[providerName];
|
|
155
|
+
const model = findDecisionModel(provider, raw);
|
|
156
|
+
return model ? { providerName, provider, model } : null;
|
|
157
|
+
})
|
|
158
|
+
.filter(Boolean);
|
|
159
|
+
|
|
160
|
+
if (matches.length === 0) {
|
|
161
|
+
return fail(
|
|
162
|
+
'unknown',
|
|
163
|
+
`Unknown decision model "${raw}". Use "auto", a provider (${namespaces}), "provider:model", or one of: ${knownModels().join(', ')}.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const available = matches.filter((m) => m.provider.isAvailable(config));
|
|
167
|
+
if (available.length === 0) {
|
|
168
|
+
return fail(
|
|
169
|
+
'unavailable',
|
|
170
|
+
`"${raw}" is served by ${matches.map((m) => `${m.providerName} (${setupHint(m.provider)})`).join('; ')}, but none is configured.`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return ok(available);
|
|
174
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System One HTTP client
|
|
3
|
+
*
|
|
4
|
+
* One client for every host of the System One decision API. TypeSafe serves it
|
|
5
|
+
* natively at `/v1/systemone`; OpenRouter serves the same request/response
|
|
6
|
+
* schema at the same path under its own base URL, so providers differ only in
|
|
7
|
+
* base URL, key, and headers.
|
|
8
|
+
*
|
|
9
|
+
* Plain fetch rather than @typesafe-ai/sdk: the schema is small, the tool
|
|
10
|
+
* needs its own abort signal and error mapping, and the SDK's model listing
|
|
11
|
+
* breaks against OpenRouter.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
15
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
16
|
+
const BACKOFF_INITIAL_MS = 500;
|
|
17
|
+
const BACKOFF_MAX_MS = 5_000;
|
|
18
|
+
// A longer server-requested wait is better spent failing over to another host.
|
|
19
|
+
const MAX_RETRY_AFTER_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Error from a System One call. `retryable` marks failures worth repeating or
|
|
23
|
+
* failing over (network, timeout, 408/429/5xx, auth); `terminal` marks request
|
|
24
|
+
* faults that every host would reject the same way.
|
|
25
|
+
*/
|
|
26
|
+
export class DecisionError extends Error {
|
|
27
|
+
constructor(message, { status = null, retryable = false, terminal = false, requestId = null, retryAfterMs = null } = {}) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'DecisionError';
|
|
30
|
+
this.status = status;
|
|
31
|
+
this.retryable = retryable;
|
|
32
|
+
this.terminal = terminal;
|
|
33
|
+
this.requestId = requestId;
|
|
34
|
+
this.retryAfterMs = retryAfterMs;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render a validation issue list (zod-style `[{path, message}]`) as one line
|
|
40
|
+
* per issue; anything else is returned as-is.
|
|
41
|
+
*/
|
|
42
|
+
function formatIssues(text) {
|
|
43
|
+
let issues;
|
|
44
|
+
try {
|
|
45
|
+
issues = JSON.parse(text);
|
|
46
|
+
} catch {
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
if (!Array.isArray(issues) || !issues.every((i) => i && typeof i.message === 'string')) {
|
|
50
|
+
return text;
|
|
51
|
+
}
|
|
52
|
+
return issues
|
|
53
|
+
.map((i) => (Array.isArray(i.path) && i.path.length ? `${i.path.join('.')}: ${i.message}` : i.message))
|
|
54
|
+
.join('; ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Extract a readable message from an error body. OpenRouter wraps errors as
|
|
59
|
+
* `{ error: { message } }`; TypeSafe answers `{ detail: { error_type, message } }`,
|
|
60
|
+
* or `{ detail }` as a string or a list of validation issues.
|
|
61
|
+
*/
|
|
62
|
+
export function extractErrorMessage(body, rawText) {
|
|
63
|
+
if (typeof body?.detail?.message === 'string') {
|
|
64
|
+
const type = body.detail.error_type ? `${body.detail.error_type}: ` : '';
|
|
65
|
+
return `${type}${body.detail.message}`;
|
|
66
|
+
}
|
|
67
|
+
const nested = body?.error?.message ?? body?.error ?? body?.detail ?? body?.message;
|
|
68
|
+
if (typeof nested === 'string') {
|
|
69
|
+
const inner = nested.match(/^HTTP \d+: (\{.*\})$/s);
|
|
70
|
+
if (inner) {
|
|
71
|
+
try {
|
|
72
|
+
return extractErrorMessage(JSON.parse(inner[1]), inner[1]);
|
|
73
|
+
} catch {
|
|
74
|
+
return nested;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return formatIssues(nested);
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(nested)) {
|
|
80
|
+
return nested
|
|
81
|
+
.map((d) => (d?.loc ? `${d.loc.join('.')}: ${d.msg}` : d?.msg || JSON.stringify(d)))
|
|
82
|
+
.join('; ');
|
|
83
|
+
}
|
|
84
|
+
return rawText?.trim() || 'No error details returned';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseRetryAfter(headers) {
|
|
88
|
+
const ms = Number(headers.get('retry-after-ms'));
|
|
89
|
+
if (Number.isFinite(ms) && ms > 0) return ms;
|
|
90
|
+
const value = headers.get('retry-after');
|
|
91
|
+
if (!value) return null;
|
|
92
|
+
const seconds = Number(value);
|
|
93
|
+
if (Number.isFinite(seconds)) return seconds * 1000;
|
|
94
|
+
const date = Date.parse(value);
|
|
95
|
+
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sleep(ms, signal) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
if (signal?.aborted) {
|
|
101
|
+
reject(signal.reason);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
signal?.removeEventListener('abort', onAbort);
|
|
106
|
+
resolve();
|
|
107
|
+
}, ms);
|
|
108
|
+
function onAbort() {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
reject(signal.reason);
|
|
111
|
+
}
|
|
112
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function sendOnce({ url, headers, body, signal, timeoutMs }) {
|
|
117
|
+
const attemptSignal = signal
|
|
118
|
+
? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
|
|
119
|
+
: AbortSignal.timeout(timeoutMs);
|
|
120
|
+
|
|
121
|
+
let response;
|
|
122
|
+
try {
|
|
123
|
+
response = await fetch(url, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
126
|
+
body: JSON.stringify(body),
|
|
127
|
+
signal: attemptSignal,
|
|
128
|
+
});
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (signal?.aborted) throw error;
|
|
131
|
+
const timedOut = error?.name === 'TimeoutError';
|
|
132
|
+
throw new DecisionError(
|
|
133
|
+
timedOut ? `Request timed out after ${timeoutMs / 1000}s` : `Connection failed: ${error.message}`,
|
|
134
|
+
{ retryable: true },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const rawText = await response.text();
|
|
139
|
+
let parsed = null;
|
|
140
|
+
try {
|
|
141
|
+
parsed = rawText ? JSON.parse(rawText) : null;
|
|
142
|
+
} catch {
|
|
143
|
+
parsed = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
const status = response.status;
|
|
148
|
+
throw new DecisionError(`HTTP ${status}: ${extractErrorMessage(parsed, rawText)}`, {
|
|
149
|
+
status,
|
|
150
|
+
retryable: status === 408 || status === 429 || status >= 500 || status === 401 || status === 403,
|
|
151
|
+
terminal: status === 400 || status === 422,
|
|
152
|
+
requestId: response.headers.get('x-typesafe-request-id') || response.headers.get('x-generation-id'),
|
|
153
|
+
retryAfterMs: parseRetryAfter(response.headers),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!parsed || typeof parsed.answers !== 'object' || parsed.answers === null) {
|
|
158
|
+
throw new DecisionError('Malformed response: missing "answers" object', { status: response.status });
|
|
159
|
+
}
|
|
160
|
+
return parsed;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* POST a decision request, retrying transient failures (except auth, which
|
|
165
|
+
* retrying cannot fix) with exponential backoff that honors Retry-After.
|
|
166
|
+
* @param {object} params
|
|
167
|
+
* @param {string} params.baseURL - Host base, e.g. https://api.typesafe.ai
|
|
168
|
+
* @param {object} params.headers - Auth and attribution headers
|
|
169
|
+
* @param {object} params.body - `{ model, state, questions }`
|
|
170
|
+
* @param {AbortSignal} [params.signal] - Caller cancellation
|
|
171
|
+
* @param {number} [params.timeoutMs] - Per-attempt timeout
|
|
172
|
+
* @param {number} [params.maxRetries] - Retries after the first attempt
|
|
173
|
+
* @returns {Promise<object>} Parsed response body
|
|
174
|
+
*/
|
|
175
|
+
export async function callSystemOne({
|
|
176
|
+
baseURL,
|
|
177
|
+
headers,
|
|
178
|
+
body,
|
|
179
|
+
signal,
|
|
180
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
181
|
+
maxRetries = DEFAULT_MAX_RETRIES,
|
|
182
|
+
}) {
|
|
183
|
+
const url = `${baseURL.replace(/\/+$/, '')}/v1/systemone`;
|
|
184
|
+
for (let attempt = 0; ; attempt++) {
|
|
185
|
+
try {
|
|
186
|
+
return await sendOnce({ url, headers, body, signal, timeoutMs });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const authFailure = error.status === 401 || error.status === 403;
|
|
189
|
+
if (!(error instanceof DecisionError) || !error.retryable || authFailure || attempt >= maxRetries) {
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
if (error.retryAfterMs !== null && error.retryAfterMs > MAX_RETRY_AFTER_MS) {
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
const backoff = Math.min(BACKOFF_INITIAL_MS * 2 ** attempt, BACKOFF_MAX_MS);
|
|
196
|
+
await sleep(error.retryAfterMs ?? backoff, signal);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -152,6 +152,27 @@ function generateToolExamplesFromSchema(toolName, inputSchema) {
|
|
|
152
152
|
].join('\n');
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
if (toolName === 'decide') {
|
|
156
|
+
const decideExample = {
|
|
157
|
+
state: { message: 'I was charged twice for order A-104. Please fix this ASAP.' },
|
|
158
|
+
questions: {
|
|
159
|
+
urgent: { type: 'noul', instructions: 'Does the message convey urgency?' },
|
|
160
|
+
team: {
|
|
161
|
+
type: 'choice',
|
|
162
|
+
instructions: 'Which team should handle this?',
|
|
163
|
+
criteria: { billing: 'Payments, invoicing, refunds', technical: 'Bugs, outages', sales: null },
|
|
164
|
+
},
|
|
165
|
+
frustration: {
|
|
166
|
+
type: 'score',
|
|
167
|
+
instructions: 'How frustrated is the customer?',
|
|
168
|
+
criteria: ['Calm', 'Frustrated', 'Very angry'],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
model: 'auto',
|
|
172
|
+
};
|
|
173
|
+
return `\`\`\`json\n${JSON.stringify(decideExample, null, 2)}\n\`\`\``;
|
|
174
|
+
}
|
|
175
|
+
|
|
155
176
|
if (toolName === 'check_status' || toolName === 'cancel_job') {
|
|
156
177
|
if (properties.continuation_id)
|
|
157
178
|
example.continuation_id = SAMPLE_VALUES.continuation_id;
|
|
@@ -412,8 +433,9 @@ export function generateHelpContent(config = null) {
|
|
|
412
433
|
prop.default !== undefined
|
|
413
434
|
? ` (default: ${JSON.stringify(prop.default)})`
|
|
414
435
|
: '';
|
|
436
|
+
const type = prop.type ?? prop.anyOf?.map((s) => s.type).join(' | ');
|
|
415
437
|
params.push(
|
|
416
|
-
`- **${name}** (${isRequired ? 'required' : 'optional'}, ${
|
|
438
|
+
`- **${name}** (${isRequired ? 'required' : 'optional'}, ${type}): ${prop.description}${defaultValue}`,
|
|
417
439
|
);
|
|
418
440
|
}
|
|
419
441
|
|
|
@@ -203,7 +203,7 @@ function validateApiKey(apiKey) {
|
|
|
203
203
|
* `X-Title`), while still accepting the legacy `openrouterreferer`/
|
|
204
204
|
* `openroutertitle` config-key spellings as input.
|
|
205
205
|
*/
|
|
206
|
-
function getCustomHeaders(config) {
|
|
206
|
+
export function getCustomHeaders(config) {
|
|
207
207
|
const headers = {};
|
|
208
208
|
|
|
209
209
|
const referer =
|
|
@@ -0,0 +1,319 @@
|
|
|
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
|
+
/**
|
|
152
|
+
* Options at or rounding to zero are left out of the summary line; the JSON
|
|
153
|
+
* block still carries the full distribution.
|
|
154
|
+
*/
|
|
155
|
+
function byProbability(probabilities, label = (k) => k) {
|
|
156
|
+
return Object.entries(probabilities || {})
|
|
157
|
+
.filter(([, p]) => fixed(p) !== '0.00')
|
|
158
|
+
.sort(([, a], [, b]) => b - a)
|
|
159
|
+
.map(([k, p]) => `${label(k)} ${fixed(p)}`)
|
|
160
|
+
.join(', ');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function summarizeAnswer(key, answer) {
|
|
164
|
+
const type = answer?.type ?? 'unknown';
|
|
165
|
+
if (type === 'noul') {
|
|
166
|
+
return `- ${key} (noul): ${fixed(answer.noul)}`;
|
|
167
|
+
}
|
|
168
|
+
if (type === 'choice') {
|
|
169
|
+
return `- ${key} (choice): ${answer.choice} · confidence ${fixed(answer.confidence)} · ${byProbability(answer.probabilities)}`;
|
|
170
|
+
}
|
|
171
|
+
if (type === 'score') {
|
|
172
|
+
const levels = Object.keys(answer.legend || answer.probabilities || {});
|
|
173
|
+
const range = levels.length ? ` on 0–${levels.length - 1}` : '';
|
|
174
|
+
const label = (k) => (answer.legend?.[k] ? `${k} ${answer.legend[k]}` : k);
|
|
175
|
+
return `- ${key} (score): ${fixed(answer.score)}${range} · confidence ${fixed(answer.confidence)} · ${byProbability(answer.probabilities, label)}`;
|
|
176
|
+
}
|
|
177
|
+
return `- ${key} (${type}): ${JSON.stringify(answer)}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function formatResult(response, candidate, failures) {
|
|
181
|
+
const usage = response.usage || {};
|
|
182
|
+
const header = [
|
|
183
|
+
`Decision · ${response.model || candidate.model} via ${candidate.provider.label}`,
|
|
184
|
+
usage.input_tokens !== undefined ? `${usage.input_tokens} input tokens` : null,
|
|
185
|
+
typeof usage.cost === 'number' ? `$${usage.cost.toFixed(6)}` : null,
|
|
186
|
+
].filter(Boolean).join(' · ');
|
|
187
|
+
|
|
188
|
+
const lines = [header];
|
|
189
|
+
for (const failure of failures) {
|
|
190
|
+
lines.push(`(${failure.provider} failed, fell back: ${failure.message})`);
|
|
191
|
+
}
|
|
192
|
+
lines.push(...Object.entries(response.answers).map(([key, answer]) => summarizeAnswer(key, answer)));
|
|
193
|
+
|
|
194
|
+
const payload = {
|
|
195
|
+
model: response.model ?? candidate.model,
|
|
196
|
+
provider: candidate.providerName,
|
|
197
|
+
answers: response.answers,
|
|
198
|
+
usage: {
|
|
199
|
+
input_tokens: usage.input_tokens ?? null,
|
|
200
|
+
output_tokens: usage.output_tokens ?? null,
|
|
201
|
+
cost: typeof usage.cost === 'number' ? usage.cost : null,
|
|
202
|
+
},
|
|
203
|
+
...(response.id ? { id: response.id } : {}),
|
|
204
|
+
};
|
|
205
|
+
return `${lines.join('\n')}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Decide MCP Tool
|
|
210
|
+
* @param {object} args - Tool arguments
|
|
211
|
+
* @param {string|object|Array} [args.state] - Material to judge
|
|
212
|
+
* @param {object} args.questions - Named typed questions
|
|
213
|
+
* @param {string} [args.model] - Model spec, default "auto"
|
|
214
|
+
* @param {string[]} [args.files] - Text files added to the state
|
|
215
|
+
* @param {object} dependencies - Injected dependencies (config, contextProcessor, signal)
|
|
216
|
+
* @returns {Promise<object>} MCP tool response
|
|
217
|
+
*/
|
|
218
|
+
export async function decideTool(args, dependencies) {
|
|
219
|
+
const { config, contextProcessor, signal } = dependencies;
|
|
220
|
+
const { state, questions, model = 'auto', files = [] } = args;
|
|
221
|
+
|
|
222
|
+
if (!Array.isArray(files) || !files.every((f) => typeof f === 'string' && f.trim())) {
|
|
223
|
+
return createToolError('"files" must be an array of file paths.');
|
|
224
|
+
}
|
|
225
|
+
if (state === undefined && files.length === 0) {
|
|
226
|
+
return createToolError('Provide "state", "files", or both: there is nothing to judge.');
|
|
227
|
+
}
|
|
228
|
+
const stateError = state === undefined ? null : validateState(state);
|
|
229
|
+
if (stateError) return createToolError(stateError);
|
|
230
|
+
const questionError = validateQuestions(questions);
|
|
231
|
+
if (questionError) return createToolError(questionError);
|
|
232
|
+
|
|
233
|
+
const route = resolveDecisionModel(model, config);
|
|
234
|
+
if (route.status !== 'ok') return createToolError(route.error);
|
|
235
|
+
|
|
236
|
+
let finalState = state;
|
|
237
|
+
if (files.length > 0) {
|
|
238
|
+
const loaded = await loadFiles(files, contextProcessor, config);
|
|
239
|
+
if (loaded.error) return createToolError(`Could not load files: ${loaded.error}`);
|
|
240
|
+
finalState = state === undefined ? { files: loaded.contents } : { input: state, files: loaded.contents };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const failures = [];
|
|
244
|
+
for (const candidate of route.candidates) {
|
|
245
|
+
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));
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (signal?.aborted) return createToolError('Decision request cancelled.');
|
|
255
|
+
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) break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const detail = failures.map((f) => `${f.provider}: ${f.message}`).join('; ');
|
|
262
|
+
return createToolError(`Decision request failed (${detail})`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
decideTool.description =
|
|
266
|
+
'DECIDE — ask a System One decision model (TypeSafe Jev) typed questions about a state and get calibrated answers, not text. ' +
|
|
267
|
+
'Question types: "noul" (yes/no → probability 0..1), "choice" (pick one of 2–255 named options → choice, per-option probabilities, confidence), ' +
|
|
268
|
+
'"score" (ordered rubric of 2–10 levels → weighted position, per-level probabilities, confidence). ' +
|
|
269
|
+
'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
|
+
'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
|
+
'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
|
+
'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.';
|
|
275
|
+
|
|
276
|
+
decideTool.inputSchema = {
|
|
277
|
+
type: 'object',
|
|
278
|
+
properties: {
|
|
279
|
+
state: {
|
|
280
|
+
anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array' }],
|
|
281
|
+
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.',
|
|
283
|
+
},
|
|
284
|
+
questions: {
|
|
285
|
+
type: 'object',
|
|
286
|
+
description:
|
|
287
|
+
'Named questions, all answered against the same state. The name is your own label and is returned as the answer key. ' +
|
|
288
|
+
'Each question: { "type": "noul"|"choice"|"score", "instructions": string|object|array, "criteria": ... }. ' +
|
|
289
|
+
'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). ' +
|
|
291
|
+
'instructions may be an object bundling the question with reference data, referenced by `name` in the text. ' +
|
|
292
|
+
'Example: { "team": { "type": "choice", "instructions": "Which team should handle this?", "criteria": { "billing": "Payments, refunds", "technical": "Bugs, outages" } } }',
|
|
293
|
+
additionalProperties: {
|
|
294
|
+
type: 'object',
|
|
295
|
+
properties: {
|
|
296
|
+
type: { type: 'string', enum: QUESTION_TYPES },
|
|
297
|
+
instructions: { anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array' }] },
|
|
298
|
+
criteria: { anyOf: [{ type: 'object' }, { type: 'array' }] },
|
|
299
|
+
},
|
|
300
|
+
required: ['type', 'instructions'],
|
|
301
|
+
additionalProperties: false,
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
model: {
|
|
305
|
+
type: 'string',
|
|
306
|
+
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).',
|
|
309
|
+
},
|
|
310
|
+
files: {
|
|
311
|
+
type: 'array',
|
|
312
|
+
items: { type: 'string' },
|
|
313
|
+
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.',
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
required: ['questions'],
|
|
318
|
+
additionalProperties: false,
|
|
319
|
+
};
|
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
|
/**
|