toolnexus 0.16.0 → 0.18.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/README.md +46 -0
- package/dist/agents/agent.d.ts +11 -1
- package/dist/agents/agent.js +0 -9
- package/dist/agents/compaction.d.ts +0 -25
- package/dist/agents/compaction.js +41 -2
- package/dist/agents/loop.d.ts +3 -2
- package/dist/agents/loop.js +10 -14
- package/dist/agents/runtime.d.ts +4 -3
- package/dist/agents/runtime.js +8 -17
- package/dist/builtin.js +24 -3
- package/dist/classifier.d.ts +334 -0
- package/dist/classifier.js +630 -0
- package/dist/client.d.ts +40 -7
- package/dist/client.js +39 -33
- package/dist/content.d.ts +158 -0
- package/dist/content.js +379 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mcp.d.ts +9 -0
- package/dist/mcp.js +59 -4
- package/dist/mcpserve.js +27 -1
- package/dist/retry.d.ts +30 -0
- package/dist/retry.js +55 -0
- package/dist/translate.js +30 -2
- package/dist/types.d.ts +8 -0
- package/dist/wire.d.ts +40 -0
- package/dist/wire.js +104 -0
- package/package.json +5 -3
- package/src/agents/agent.ts +2 -1
- package/src/agents/compaction.ts +39 -1
- package/src/agents/loop.ts +17 -4
- package/src/agents/runtime.ts +13 -5
- package/src/builtin.ts +23 -3
- package/src/classifier.ts +832 -0
- package/src/client.ts +84 -44
- package/src/content.ts +448 -0
- package/src/index.ts +3 -0
- package/src/mcp.ts +55 -4
- package/src/mcpserve.ts +26 -3
- package/src/retry.ts +57 -0
- package/src/translate.ts +25 -1
- package/src/types.ts +9 -0
- package/src/wire.ts +131 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import { retryAfterMs, isRetryableStatus } from "./retry.js";
|
|
2
|
+
// ---------------------------------------------------------------- constants
|
|
3
|
+
/** The System One endpoint base. */
|
|
4
|
+
export const DEFAULT_CLASSIFIER_BASE_URL = "https://api.typesafe.ai/v1";
|
|
5
|
+
/** The floating model alias. Pin it (e.g. `jev-1.13.0`) once thresholds are tuned. */
|
|
6
|
+
export const DEFAULT_CLASSIFIER_MODEL = "jev-latest";
|
|
7
|
+
/** The NAME of the env var holding the credential — never the value. */
|
|
8
|
+
export const DEFAULT_CLASSIFIER_API_KEY_ENV = "TYPESAFE_API_KEY";
|
|
9
|
+
/** Bounds ONE request; a classifier has no loop to bound. */
|
|
10
|
+
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 10_000;
|
|
11
|
+
/** Client-side cap on a choice's named options (§8B). */
|
|
12
|
+
export const MAX_CHOICE_OPTIONS = 255;
|
|
13
|
+
/** Bounds of a score rubric (§8B). */
|
|
14
|
+
export const MIN_SCORE_LEVELS = 2;
|
|
15
|
+
export const MAX_SCORE_LEVELS = 10;
|
|
16
|
+
/**
|
|
17
|
+
* The ABSOLUTE tolerance on `max|p - 1/n|`, compared INCLUSIVELY (§8B). Pinned across every
|
|
18
|
+
* port; `examples/judge/near-uniform.json` pins both sides at 0.0499 / 0.0501.
|
|
19
|
+
*/
|
|
20
|
+
export const NEAR_UNIFORM_TOLERANCE = 0.05;
|
|
21
|
+
/** A noul question. Pass `criteria` only when you mean it — absent ≠ empty on the wire. */
|
|
22
|
+
export function noul(instructions, criteria) {
|
|
23
|
+
return criteria === undefined ? { type: "noul", instructions } : { type: "noul", instructions, criteria };
|
|
24
|
+
}
|
|
25
|
+
/** A choice question. Every description must say what picking that option would MEAN. */
|
|
26
|
+
export function choice(instructions, criteria) {
|
|
27
|
+
return { type: "choice", instructions, criteria: { ...criteria } };
|
|
28
|
+
}
|
|
29
|
+
/** A score question. The array order IS the level numbering and is never reordered. */
|
|
30
|
+
export function score(instructions, criteria) {
|
|
31
|
+
return { type: "score", instructions, criteria: [...criteria] };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Build a choice from any (name, description) pairs — a Tool, a skill, an agent, an A2A card
|
|
35
|
+
* skill. The description must say what picking that option would MEAN; see the encoding
|
|
36
|
+
* obligation on `ChoiceQuestion`.
|
|
37
|
+
*/
|
|
38
|
+
export function choiceOver(instructions, items) {
|
|
39
|
+
return choice(instructions, items);
|
|
40
|
+
}
|
|
41
|
+
/** The wire form of one question — the object itself, minus an absent `criteria`. */
|
|
42
|
+
function questionWire(q) {
|
|
43
|
+
switch (q.type) {
|
|
44
|
+
case "noul":
|
|
45
|
+
return q.criteria === undefined
|
|
46
|
+
? { type: "noul", instructions: q.instructions }
|
|
47
|
+
: { type: "noul", instructions: q.instructions, criteria: { true: q.criteria.true, false: q.criteria.false } };
|
|
48
|
+
case "choice":
|
|
49
|
+
return { type: "choice", instructions: q.instructions, criteria: { ...q.criteria } };
|
|
50
|
+
case "score":
|
|
51
|
+
return { type: "score", instructions: q.instructions, criteria: [...q.criteria] };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Client-side limits, enforced BEFORE the request. The error names the key and the limit. */
|
|
55
|
+
function validateQuestion(key, q) {
|
|
56
|
+
if (q.type === "choice") {
|
|
57
|
+
const n = Object.keys(q.criteria ?? {}).length;
|
|
58
|
+
if (n < 1 || n > MAX_CHOICE_OPTIONS) {
|
|
59
|
+
throw new Error(`classifier: question "${key}": a choice needs 1..${MAX_CHOICE_OPTIONS} options, got ${n}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else if (q.type === "score") {
|
|
63
|
+
const n = (q.criteria ?? []).length;
|
|
64
|
+
if (n < MIN_SCORE_LEVELS || n > MAX_SCORE_LEVELS) {
|
|
65
|
+
throw new Error(`classifier: question "${key}": a score needs ${MIN_SCORE_LEVELS}..${MAX_SCORE_LEVELS} ordered levels, got ${n}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// ---------------------------------------------------------------- the wire
|
|
70
|
+
/**
|
|
71
|
+
* Canonical JSON: object keys sorted recursively in CODE-UNIT (ASCII) order, arrays NEVER
|
|
72
|
+
* reordered, compact separators, and `<>&'"` plus non-ASCII transmitted raw.
|
|
73
|
+
*
|
|
74
|
+
* Hand-rolled because `JSON.stringify` has no recursive key sort, and its `replacer` ARRAY form
|
|
75
|
+
* applies one key list at EVERY depth — it cannot express "sort whatever keys are here". The
|
|
76
|
+
* per-value escaping and number formatting ARE delegated to `JSON.stringify`, which emits the
|
|
77
|
+
* shortest round-tripping decimal (so 1.21 is "1.21" and integer 0 is "0", not "0.0") and does
|
|
78
|
+
* not escape `<`, `>`, `&` or non-ASCII.
|
|
79
|
+
*
|
|
80
|
+
* `undefined` is ABSENT, never `null`: an object key whose value is undefined is dropped.
|
|
81
|
+
*/
|
|
82
|
+
export function canonicalJson(value) {
|
|
83
|
+
if (Array.isArray(value))
|
|
84
|
+
return "[" + value.map((v) => (v === undefined ? "null" : canonicalJson(v))).join(",") + "]";
|
|
85
|
+
if (value !== null && typeof value === "object") {
|
|
86
|
+
const obj = value;
|
|
87
|
+
// `sort()` with no comparator compares UTF-16 code units — ASCII order, and NOT the
|
|
88
|
+
// culture-sensitive order `localeCompare` would give ("10_alpha" < "Alpha" < "beta" < "café").
|
|
89
|
+
const keys = Object.keys(obj)
|
|
90
|
+
.filter((k) => obj[k] !== undefined)
|
|
91
|
+
.sort();
|
|
92
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJson(obj[k])).join(",") + "}";
|
|
93
|
+
}
|
|
94
|
+
const s = JSON.stringify(value);
|
|
95
|
+
return s === undefined ? "null" : s;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The bytes the byte-identity claim covers: `model` + `questions`.
|
|
99
|
+
*
|
|
100
|
+
* `state` is deliberately NOT here. It is transmitted verbatim as the host supplied it and is
|
|
101
|
+
* OUTSIDE the claim, because numbers do not canonicalise across languages (`-0.0` renders four
|
|
102
|
+
* ways across our own seven runtimes). Do not re-widen this: a caller who needs their state
|
|
103
|
+
* pinned canonicalises it themselves before handing it over.
|
|
104
|
+
*/
|
|
105
|
+
export function canonicalRequest(model, questions) {
|
|
106
|
+
const qs = {};
|
|
107
|
+
for (const [key, q] of Object.entries(questions)) {
|
|
108
|
+
if (!q)
|
|
109
|
+
throw new Error(`classifier: question "${key}" is missing`);
|
|
110
|
+
qs[key] = questionWire(q);
|
|
111
|
+
}
|
|
112
|
+
return canonicalJson({ model, questions: qs });
|
|
113
|
+
}
|
|
114
|
+
/** The legend in LEVEL order, which the map itself loses ("2" sorts before "10" as a level). */
|
|
115
|
+
export function levels(answer) {
|
|
116
|
+
return Object.keys(answer.legend)
|
|
117
|
+
.sort((a, b) => (a.length !== b.length ? a.length - b.length : a < b ? -1 : a > b ? 1 : 0))
|
|
118
|
+
.map((k) => answer.legend[k]);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* One answer per question, keyed by the CALLER's keys. The keys are addressing, not content:
|
|
122
|
+
* they are never transmitted, so a key may be a tool, skill or agent name verbatim.
|
|
123
|
+
*/
|
|
124
|
+
export class Decision {
|
|
125
|
+
model;
|
|
126
|
+
answers;
|
|
127
|
+
usage;
|
|
128
|
+
calibrated;
|
|
129
|
+
constructor(
|
|
130
|
+
/** What actually answered, which may be more specific than the model that was asked for. */
|
|
131
|
+
model, answers, usage,
|
|
132
|
+
/**
|
|
133
|
+
* Whether the probabilities are calibrated. `systemone` reports true; `llm` reports false
|
|
134
|
+
* unless it derived them from provider token probabilities. A THRESHOLD TUNED AGAINST ONE
|
|
135
|
+
* BACKEND DOES NOT TRANSFER TO ANOTHER.
|
|
136
|
+
*/
|
|
137
|
+
calibrated) {
|
|
138
|
+
this.model = model;
|
|
139
|
+
this.answers = answers;
|
|
140
|
+
this.usage = usage;
|
|
141
|
+
this.calibrated = calibrated;
|
|
142
|
+
}
|
|
143
|
+
/** Typed read. Throws (never returns undefined) on a wrong-type or absent key. */
|
|
144
|
+
noul(key) {
|
|
145
|
+
return this.typed(key, "noul");
|
|
146
|
+
}
|
|
147
|
+
choice(key) {
|
|
148
|
+
return this.typed(key, "choice");
|
|
149
|
+
}
|
|
150
|
+
score(key) {
|
|
151
|
+
return this.typed(key, "score");
|
|
152
|
+
}
|
|
153
|
+
typed(key, want) {
|
|
154
|
+
const a = this.answers[key];
|
|
155
|
+
if (a === undefined)
|
|
156
|
+
throw new Error(`classifier: no answer "${key}" in this decision`);
|
|
157
|
+
if (a.type !== want)
|
|
158
|
+
throw new Error(`classifier: answer "${key}" is a ${a.type} answer, not ${want}`);
|
|
159
|
+
return a;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Whether a choice answer's probability map is indistinguishable from flat (§8B):
|
|
164
|
+
*
|
|
165
|
+
* nearUniform ⇔ max over i of |p_i - 1/n| <= 0.05
|
|
166
|
+
*
|
|
167
|
+
* `n` is the number of ENTRIES IN THE MAP and the values are taken AS RETURNED — not
|
|
168
|
+
* renormalised, not sorted, not rounded; an offered option absent from the map counts as 0 by
|
|
169
|
+
* not being an entry. The tolerance is ABSOLUTE (a relative band collapses below the wire's
|
|
170
|
+
* two-decimal rounding on a 255-option roster) and the comparison is INCLUSIVE. `n === 1` is
|
|
171
|
+
* trivially uniform. An empty map has no distribution at all and is reported false.
|
|
172
|
+
*/
|
|
173
|
+
export function nearUniform(probabilities) {
|
|
174
|
+
const values = Object.values(probabilities ?? {});
|
|
175
|
+
if (values.length === 0)
|
|
176
|
+
return false;
|
|
177
|
+
if (values.length === 1)
|
|
178
|
+
return true;
|
|
179
|
+
const target = 1 / values.length;
|
|
180
|
+
return values.every((p) => Math.abs(p - target) <= NEAR_UNIFORM_TOLERANCE);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Decode a backend response into a Decision. The discriminated decode is the single place
|
|
184
|
+
* `nearUniform` is derived. A malformed answer is an error, never a repaired one (ADR 0020).
|
|
185
|
+
*/
|
|
186
|
+
export function decodeDecision(raw, fallbackModel) {
|
|
187
|
+
if (raw === null || typeof raw !== "object")
|
|
188
|
+
throw new Error("classifier: response is not an object");
|
|
189
|
+
const body = raw;
|
|
190
|
+
const answers = {};
|
|
191
|
+
for (const [key, value] of Object.entries(body.answers ?? {})) {
|
|
192
|
+
if (value === null || typeof value !== "object")
|
|
193
|
+
throw new Error(`classifier: answer "${key}" is not an object`);
|
|
194
|
+
const a = value;
|
|
195
|
+
switch (a.type) {
|
|
196
|
+
case "noul":
|
|
197
|
+
answers[key] = { type: "noul", noul: num(a.noul) };
|
|
198
|
+
break;
|
|
199
|
+
case "choice":
|
|
200
|
+
answers[key] = {
|
|
201
|
+
type: "choice",
|
|
202
|
+
choice: String(a.choice ?? ""),
|
|
203
|
+
probabilities: numbers(a.probabilities),
|
|
204
|
+
confidence: num(a.confidence),
|
|
205
|
+
nearUniform: nearUniform(numbers(a.probabilities)),
|
|
206
|
+
};
|
|
207
|
+
break;
|
|
208
|
+
case "score":
|
|
209
|
+
answers[key] = {
|
|
210
|
+
type: "score",
|
|
211
|
+
score: num(a.score),
|
|
212
|
+
legend: strings(a.legend),
|
|
213
|
+
probabilities: numbers(a.probabilities),
|
|
214
|
+
confidence: num(a.confidence),
|
|
215
|
+
};
|
|
216
|
+
break;
|
|
217
|
+
default:
|
|
218
|
+
throw new Error(`classifier: answer "${key}": unknown type ${JSON.stringify(a.type)}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const usage = {
|
|
222
|
+
inputTokens: num(body.usage?.input_tokens),
|
|
223
|
+
outputTokens: num(body.usage?.output_tokens),
|
|
224
|
+
};
|
|
225
|
+
// Absent stays absent: a cost of 0 is a real cost and undefined is "not reported".
|
|
226
|
+
if (body.usage?.cost !== undefined)
|
|
227
|
+
usage.cost = num(body.usage.cost);
|
|
228
|
+
// Absent ⇒ true: the systemone wire reports calibration by being itself. A backend that is
|
|
229
|
+
// not calibrated says so explicitly.
|
|
230
|
+
return new Decision(String(body.model ?? fallbackModel), answers, usage, body.calibrated !== false);
|
|
231
|
+
}
|
|
232
|
+
function num(v) {
|
|
233
|
+
return typeof v === "number" ? v : 0;
|
|
234
|
+
}
|
|
235
|
+
/** A zero probability stays an ENTRY: the map is copied key-for-key, never filtered. */
|
|
236
|
+
function numbers(v) {
|
|
237
|
+
const out = {};
|
|
238
|
+
for (const [k, p] of Object.entries((v ?? {})))
|
|
239
|
+
out[k] = num(p);
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
function strings(v) {
|
|
243
|
+
const out = {};
|
|
244
|
+
for (const [k, s] of Object.entries((v ?? {})))
|
|
245
|
+
out[k] = String(s);
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
// ---------------------------------------------------------------- classifier
|
|
249
|
+
/** The whole seam: one verb. */
|
|
250
|
+
export class Classifier {
|
|
251
|
+
opts;
|
|
252
|
+
style;
|
|
253
|
+
baseUrl;
|
|
254
|
+
model;
|
|
255
|
+
apiKeyEnv;
|
|
256
|
+
timeoutMs;
|
|
257
|
+
corpus = new Map();
|
|
258
|
+
/** The once-per-question-key set for the degenerate warning, so a per-turn judge does not
|
|
259
|
+
* flood the sink. */
|
|
260
|
+
warned = new Set();
|
|
261
|
+
constructor(opts) {
|
|
262
|
+
this.opts = opts;
|
|
263
|
+
this.style = opts.style ?? "systemone";
|
|
264
|
+
this.baseUrl = opts.baseUrl ?? DEFAULT_CLASSIFIER_BASE_URL;
|
|
265
|
+
this.model = opts.model ?? DEFAULT_CLASSIFIER_MODEL;
|
|
266
|
+
this.apiKeyEnv = opts.apiKeyEnv ?? DEFAULT_CLASSIFIER_API_KEY_ENV;
|
|
267
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS;
|
|
268
|
+
switch (this.style) {
|
|
269
|
+
case "systemone":
|
|
270
|
+
break;
|
|
271
|
+
case "llm":
|
|
272
|
+
if (!opts.client)
|
|
273
|
+
throw new Error('classifier: style "llm" requires `client`');
|
|
274
|
+
break;
|
|
275
|
+
case "custom":
|
|
276
|
+
if (!opts.evaluate)
|
|
277
|
+
throw new Error('classifier: style "custom" requires `evaluate`');
|
|
278
|
+
break;
|
|
279
|
+
case "static":
|
|
280
|
+
for (const [i, rec] of (opts.decisions ?? []).entries()) {
|
|
281
|
+
try {
|
|
282
|
+
this.corpus.set(this.staticKey(rec.state, rec.questions), rec.response);
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
throw new Error(`classifier: recorded decision ${i}: ${e.message}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
break;
|
|
289
|
+
default: {
|
|
290
|
+
const unknown = this.style;
|
|
291
|
+
throw new Error(`classifier: unknown style ${JSON.stringify(unknown)}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The whole contract: a state plus typed questions in, a Decision out. Questions are
|
|
297
|
+
* INDEPENDENT — one answer is never context for another.
|
|
298
|
+
*/
|
|
299
|
+
async evaluate(state, questions, signal) {
|
|
300
|
+
const start = Date.now();
|
|
301
|
+
const keys = Object.keys(questions ?? {}).sort();
|
|
302
|
+
if (keys.length === 0) {
|
|
303
|
+
const err = new Error("classifier: no questions to evaluate");
|
|
304
|
+
this.emitError(start, err);
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
// Limits are enforced CLIENT-SIDE, before the request: the caller finds out faster and
|
|
309
|
+
// more legibly than from the backend's own 400. Keys are walked in sorted order so the
|
|
310
|
+
// same malformed set always names the same key first.
|
|
311
|
+
for (const key of keys) {
|
|
312
|
+
const q = questions[key];
|
|
313
|
+
if (!q)
|
|
314
|
+
throw new Error(`classifier: question "${key}" is missing`);
|
|
315
|
+
validateQuestion(key, q);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch (e) {
|
|
319
|
+
this.emitError(start, e);
|
|
320
|
+
throw e;
|
|
321
|
+
}
|
|
322
|
+
// Detection, never repair (ADR 0020/0021): the request goes out BYTE-UNCHANGED and the
|
|
323
|
+
// warning is the entire observable effect.
|
|
324
|
+
this.reportDegenerate(keys, questions);
|
|
325
|
+
let decision;
|
|
326
|
+
try {
|
|
327
|
+
switch (this.style) {
|
|
328
|
+
case "custom":
|
|
329
|
+
decision = await this.opts.evaluate(state, questions, signal);
|
|
330
|
+
break;
|
|
331
|
+
case "static":
|
|
332
|
+
decision = this.evaluateStatic(state, questions);
|
|
333
|
+
break;
|
|
334
|
+
case "llm":
|
|
335
|
+
decision = await this.evaluateLLM(state, questions, signal);
|
|
336
|
+
break;
|
|
337
|
+
default:
|
|
338
|
+
decision = await this.evaluateSystemOne(state, questions, signal);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch (e) {
|
|
342
|
+
this.emitError(start, e);
|
|
343
|
+
throw e;
|
|
344
|
+
}
|
|
345
|
+
this.emit({
|
|
346
|
+
event: "classifier.evaluate",
|
|
347
|
+
model: decision.model,
|
|
348
|
+
status: "ok",
|
|
349
|
+
ms: Date.now() - start,
|
|
350
|
+
promptTokens: decision.usage.inputTokens,
|
|
351
|
+
completionTokens: decision.usage.outputTokens,
|
|
352
|
+
});
|
|
353
|
+
return decision;
|
|
354
|
+
}
|
|
355
|
+
// ---------------------------------------------------------------- degenerate
|
|
356
|
+
/**
|
|
357
|
+
* Emits ONE warning per degenerate question key per classifier, naming the key, and changes
|
|
358
|
+
* nothing about the request. Repairing would invent option descriptions the caller did not
|
|
359
|
+
* write, and the library has no way to know what the options mean.
|
|
360
|
+
*/
|
|
361
|
+
reportDegenerate(keys, questions) {
|
|
362
|
+
for (const key of keys) {
|
|
363
|
+
const q = questions[key];
|
|
364
|
+
if (q.type !== "choice")
|
|
365
|
+
continue;
|
|
366
|
+
const reason = degenerateCriteria(q.criteria);
|
|
367
|
+
if (!reason || this.warned.has(key))
|
|
368
|
+
continue;
|
|
369
|
+
this.warned.add(key);
|
|
370
|
+
this.emit({
|
|
371
|
+
event: "classifier.warning",
|
|
372
|
+
question: key,
|
|
373
|
+
warning: `classifier: question "${key}" has degenerate criteria (${reason}) — every option reads ` +
|
|
374
|
+
"the same to the model and the answer ranks at chance; describe what picking each " +
|
|
375
|
+
"option would MEAN (SPEC.md §8B)",
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// ---------------------------------------------------------------- backends
|
|
380
|
+
/**
|
|
381
|
+
* Assembles the request: the canonical model + questions, plus state VERBATIM as the host
|
|
382
|
+
* supplied it, then the §8 Gap 1 pipeline in §8 order (base → requestParams merge →
|
|
383
|
+
* bodyTransform → marshal).
|
|
384
|
+
*/
|
|
385
|
+
body(state, questions) {
|
|
386
|
+
const qs = {};
|
|
387
|
+
for (const [key, q] of Object.entries(questions))
|
|
388
|
+
qs[key] = questionWire(q);
|
|
389
|
+
let body = { model: this.model, questions: qs, state };
|
|
390
|
+
// A requestParams key WINS on collision.
|
|
391
|
+
if (this.opts.requestParams)
|
|
392
|
+
body = { ...body, ...this.opts.requestParams };
|
|
393
|
+
if (this.opts.bodyTransform)
|
|
394
|
+
body = this.opts.bodyTransform(body) || body;
|
|
395
|
+
return canonicalJson(body);
|
|
396
|
+
}
|
|
397
|
+
/** Identifies a recorded decision by the canonical request AND the state. */
|
|
398
|
+
staticKey(state, questions) {
|
|
399
|
+
return canonicalRequest(this.model, questions) + "\u0000" + canonicalJson(state);
|
|
400
|
+
}
|
|
401
|
+
evaluateStatic(state, questions) {
|
|
402
|
+
const raw = this.corpus.get(this.staticKey(state, questions));
|
|
403
|
+
if (raw === undefined)
|
|
404
|
+
throw new Error("classifier: static: no recorded decision for this request+state");
|
|
405
|
+
return decodeDecision(raw, this.model);
|
|
406
|
+
}
|
|
407
|
+
async evaluateSystemOne(state, questions, signal) {
|
|
408
|
+
const text = await this.post(this.body(state, questions), signal);
|
|
409
|
+
let parsed;
|
|
410
|
+
try {
|
|
411
|
+
parsed = JSON.parse(text);
|
|
412
|
+
}
|
|
413
|
+
catch (e) {
|
|
414
|
+
throw new Error(`classifier: response was not JSON: ${e.message}`);
|
|
415
|
+
}
|
|
416
|
+
return decodeDecision(parsed, this.model);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* The one POST, with the §8 retry budget, reusing the client's `ErrorInfo`/`ErrorTier`
|
|
420
|
+
* classifier and the `Retry-After` delay-seconds rule verbatim.
|
|
421
|
+
*
|
|
422
|
+
* NO CREDENTIAL VALUE AND NO EXPANDED HEADER VALUE APPEARS ON ANY PATH OUT OF HERE: an
|
|
423
|
+
* authentication failure names the status and the endpoint, nothing else, and a 401/403 body
|
|
424
|
+
* is never echoed back (a gateway happily reflects a bad Authorization header into its own
|
|
425
|
+
* 401 text).
|
|
426
|
+
*/
|
|
427
|
+
async post(payload, signal) {
|
|
428
|
+
const endpoint = this.baseUrl.replace(/\/+$/, "") + "/systemone";
|
|
429
|
+
const retries = this.opts.retries ?? 2;
|
|
430
|
+
const base = this.opts.retryBaseMs ?? 500;
|
|
431
|
+
const classify = this.opts.onError ?? ((info) => (info.retryable ? "retry" : "fail"));
|
|
432
|
+
const doFetch = this.opts.fetch ?? fetch;
|
|
433
|
+
let lastErr;
|
|
434
|
+
for (let attempt = 0;; attempt++) {
|
|
435
|
+
const timer = new AbortController();
|
|
436
|
+
const t = setTimeout(() => timer.abort(new Error(`classifier: request timeout after ${this.timeoutMs}ms`)), this.timeoutMs);
|
|
437
|
+
t.unref?.();
|
|
438
|
+
const onOuter = () => timer.abort(signal.reason);
|
|
439
|
+
if (signal) {
|
|
440
|
+
if (signal.aborted) {
|
|
441
|
+
clearTimeout(t);
|
|
442
|
+
throw signal.reason ?? new Error("aborted");
|
|
443
|
+
}
|
|
444
|
+
signal.addEventListener("abort", onOuter, { once: true });
|
|
445
|
+
}
|
|
446
|
+
let res;
|
|
447
|
+
let thrown;
|
|
448
|
+
try {
|
|
449
|
+
res = await doFetch(endpoint, {
|
|
450
|
+
method: "POST",
|
|
451
|
+
headers: this.requestHeaders(),
|
|
452
|
+
body: payload,
|
|
453
|
+
signal: timer.signal,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
catch (e) {
|
|
457
|
+
thrown = e;
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
clearTimeout(t);
|
|
461
|
+
signal?.removeEventListener("abort", onOuter);
|
|
462
|
+
}
|
|
463
|
+
if (res && res.ok)
|
|
464
|
+
return await res.text();
|
|
465
|
+
if (signal?.aborted)
|
|
466
|
+
throw signal.reason ?? new Error("aborted"); // caller cancellation is never retried
|
|
467
|
+
let retryAfter = null;
|
|
468
|
+
if (res) {
|
|
469
|
+
const text = await res.text().catch(() => "");
|
|
470
|
+
retryAfter = res.headers.get("retry-after");
|
|
471
|
+
lastErr = new Error(`classifier: POST ${endpoint}: HTTP ${res.status}${cause(res.status, text)}`);
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
lastErr = new Error(`classifier: POST ${endpoint}: ${errText(thrown)}`);
|
|
475
|
+
}
|
|
476
|
+
const retryable = res ? res.status === 408 || isRetryableStatus(res.status, this.opts.retryableStatuses) : true;
|
|
477
|
+
if (attempt >= retries)
|
|
478
|
+
throw lastErr;
|
|
479
|
+
const tier = classify(res ? { status: res.status, attempt, retryable } : { error: thrown, attempt, retryable });
|
|
480
|
+
if (tier !== "retry")
|
|
481
|
+
throw lastErr;
|
|
482
|
+
// `??`, not `||`: Retry-After: 0 means "retry now", not "no opinion".
|
|
483
|
+
await sleep(retryAfterMs(retryAfter) ?? base * 2 ** attempt);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/** Credential and `${ENV_VAR}` headers resolve AT CALL TIME. Never logged, never returned. */
|
|
487
|
+
requestHeaders() {
|
|
488
|
+
const headers = { "Content-Type": "application/json" };
|
|
489
|
+
const key = process.env[this.apiKeyEnv];
|
|
490
|
+
if (key)
|
|
491
|
+
headers["Authorization"] = `Bearer ${key}`;
|
|
492
|
+
for (const [k, v] of Object.entries(this.opts.headers ?? {}))
|
|
493
|
+
headers[k] = expandEnv(v);
|
|
494
|
+
return headers;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* The three question types rendered as ONE structured-output call on any §8 Client — the
|
|
498
|
+
* vendor-neutral fallback, so a host with no System One credential runs the same questions on
|
|
499
|
+
* a cheap chat model. `calibrated` is FALSE: the numbers are the model's self-report, not
|
|
500
|
+
* token probabilities.
|
|
501
|
+
*/
|
|
502
|
+
async evaluateLLM(state, questions, signal) {
|
|
503
|
+
const qs = {};
|
|
504
|
+
for (const [key, q] of Object.entries(questions))
|
|
505
|
+
qs[key] = questionWire(q);
|
|
506
|
+
const prompt = "Answer every question about the state below. Questions are INDEPENDENT: one answer is " +
|
|
507
|
+
"never context for another.\n\n" +
|
|
508
|
+
`STATE:\n${canonicalJson(state)}\n\nQUESTIONS:\n${canonicalJson(qs)}\n\n` +
|
|
509
|
+
"Reply with JSON only, no prose and no code fence, shaped exactly:\n" +
|
|
510
|
+
'{"answers":{"<key>":{"type":"noul","noul":0.0}}}\n' +
|
|
511
|
+
'A "noul" answer is {"type":"noul","noul":<0..1>}. A "choice" answer is ' +
|
|
512
|
+
'{"type":"choice","choice":"<one offered option id>","probabilities":{"<every offered option id>":<0..1>},"confidence":<0..1>}. ' +
|
|
513
|
+
'A "score" answer is {"type":"score","score":<a number within the rubric bounds, fractional allowed>,' +
|
|
514
|
+
'"legend":{"0":"<level 0>",…},"probabilities":{"0":<0..1>,…},"confidence":<0..1>}.';
|
|
515
|
+
const run = await this.opts.client.run(prompt, { toolkit: EMPTY_TOOLKIT, signal });
|
|
516
|
+
const payload = firstJsonObject(run.text);
|
|
517
|
+
let parsed;
|
|
518
|
+
try {
|
|
519
|
+
parsed = JSON.parse(payload);
|
|
520
|
+
}
|
|
521
|
+
catch (e) {
|
|
522
|
+
// Never repaired: an unparseable answer is no answer (ADR 0020).
|
|
523
|
+
throw new Error(`classifier: llm: ${e.message}`);
|
|
524
|
+
}
|
|
525
|
+
const d = decodeDecision(parsed, this.model);
|
|
526
|
+
return new Decision(d.model || this.model, d.answers, { inputTokens: run.usage.promptTokens, outputTokens: run.usage.completionTokens },
|
|
527
|
+
// The model reports no calibration and none is derived here (ADR 0020).
|
|
528
|
+
false);
|
|
529
|
+
}
|
|
530
|
+
// ---------------------------------------------------------------- metrics
|
|
531
|
+
emit(ev) {
|
|
532
|
+
this.opts.onMetric?.(ev);
|
|
533
|
+
}
|
|
534
|
+
emitError(start, e) {
|
|
535
|
+
this.emit({
|
|
536
|
+
event: "classifier.evaluate",
|
|
537
|
+
model: this.model,
|
|
538
|
+
status: "error",
|
|
539
|
+
ms: Date.now() - start,
|
|
540
|
+
promptTokens: 0,
|
|
541
|
+
completionTokens: 0,
|
|
542
|
+
error: errText(e),
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/** Builds a Classifier, applying the §8B defaults and rejecting a style whose required option
|
|
547
|
+
* is missing before any call is made. */
|
|
548
|
+
export function createClassifier(opts = {}) {
|
|
549
|
+
return new Classifier(opts);
|
|
550
|
+
}
|
|
551
|
+
// ---------------------------------------------------------------- helpers
|
|
552
|
+
/**
|
|
553
|
+
* The §8B predicate. Degenerate ⇔ ANY of:
|
|
554
|
+
* 1. every value is empty (empty string or absent), or
|
|
555
|
+
* 2. every value equals its own key, or
|
|
556
|
+
* 3. every value is identical to every other value (n >= 2).
|
|
557
|
+
*
|
|
558
|
+
* A single-option choice (n === 1) is NEVER reported: there is nothing to differentiate. Returns
|
|
559
|
+
* the reason, or "" when the criteria are fine.
|
|
560
|
+
*/
|
|
561
|
+
export function degenerateCriteria(criteria) {
|
|
562
|
+
const entries = Object.entries(criteria ?? {});
|
|
563
|
+
if (entries.length < 2)
|
|
564
|
+
return "";
|
|
565
|
+
if (entries.every(([, v]) => (v ?? "") === ""))
|
|
566
|
+
return "every description is empty";
|
|
567
|
+
if (entries.every(([k, v]) => v === k))
|
|
568
|
+
return "every description is just its own option id";
|
|
569
|
+
const first = entries[0][1];
|
|
570
|
+
if (entries.every(([, v]) => v === first))
|
|
571
|
+
return "every description is identical";
|
|
572
|
+
return "";
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Surfaces a backend's reported cause intact — EXCEPT on an authentication status: a 401/403
|
|
576
|
+
* body routinely reflects the credential or the header that was sent, so it never reaches a log,
|
|
577
|
+
* a metric, an error or a return value.
|
|
578
|
+
*/
|
|
579
|
+
function cause(status, body) {
|
|
580
|
+
if (status === 401 || status === 403)
|
|
581
|
+
return "";
|
|
582
|
+
const s = body.trim();
|
|
583
|
+
if (!s)
|
|
584
|
+
return "";
|
|
585
|
+
return ": " + (s.length > 200 ? s.slice(0, 200) + "…" : s);
|
|
586
|
+
}
|
|
587
|
+
/** `${ENV_VAR}` expands at call time; an unset var expands to the empty string, as §2. */
|
|
588
|
+
function expandEnv(value) {
|
|
589
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => process.env[name] ?? "");
|
|
590
|
+
}
|
|
591
|
+
/** The outermost JSON object of a model reply, which may arrive wrapped in a fence or prose. */
|
|
592
|
+
function firstJsonObject(s) {
|
|
593
|
+
const start = s.indexOf("{");
|
|
594
|
+
const end = s.lastIndexOf("}");
|
|
595
|
+
if (start < 0 || end <= start)
|
|
596
|
+
throw new Error("classifier: llm: no JSON object in the reply");
|
|
597
|
+
return s.slice(start, end + 1);
|
|
598
|
+
}
|
|
599
|
+
function errText(e) {
|
|
600
|
+
return e instanceof Error ? e.message : String(e);
|
|
601
|
+
}
|
|
602
|
+
function sleep(ms) {
|
|
603
|
+
// NOT unref'd, unlike the timeout watchdog above. This timer is the retry
|
|
604
|
+
// backoff we are about to await, so it is real pending work: unref'ing it
|
|
605
|
+
// tells Node the process need not stay alive for it, and the loop can resolve
|
|
606
|
+
// before the retry ever happens. The §8 client's `delay` has never unref'd for
|
|
607
|
+
// the same reason. Caught on Node 22, where it cancelled every test after the
|
|
608
|
+
// first one to exercise a classifier retry ("Promise resolution is still
|
|
609
|
+
// pending but the event loop has already resolved"); Node 24 happened to hide it.
|
|
610
|
+
return new Promise((resolve) => {
|
|
611
|
+
setTimeout(resolve, ms);
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* The `llm` style declares no tools — a classifier has no loop and executes nothing. The §8
|
|
616
|
+
* `run` takes a Toolkit, so this is the empty one; the cast is the price of not exposing a
|
|
617
|
+
* public "no tools" Toolkit constructor just for this path.
|
|
618
|
+
*/
|
|
619
|
+
const EMPTY_TOOLKIT = {
|
|
620
|
+
skillsPrompt: () => "",
|
|
621
|
+
toOpenAI: () => [],
|
|
622
|
+
toAnthropic: () => [],
|
|
623
|
+
toGemini: () => [],
|
|
624
|
+
get: () => undefined,
|
|
625
|
+
list: () => [],
|
|
626
|
+
execute: async () => {
|
|
627
|
+
throw new Error("classifier: the llm style declares no tools");
|
|
628
|
+
},
|
|
629
|
+
close: async () => { },
|
|
630
|
+
};
|