toolnexus 0.17.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.
@@ -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
+ };
package/dist/client.d.ts CHANGED
@@ -17,10 +17,19 @@ export interface ClientOptions {
17
17
  systemPrompt?: string;
18
18
  maxTurns?: number;
19
19
  hooks?: Hooks;
20
- /** Retries on transient LLM errors (429/5xx/network). Default 2. */
20
+ /** Retries on transient LLM errors (`429`/`500`/`502`/`503`/`504`/`529` + network). Default 2.
21
+ * Widen the status set with `retryableStatuses`. */
21
22
  retries?: number;
22
23
  /** Base backoff in ms (exponential + jitter). Default 500. */
23
24
  retryBaseMs?: number;
25
+ /**
26
+ * Extra HTTP statuses to treat as retryable, ADDED to the default set
27
+ * (`429`/`500`/`502`/`503`/`504`/`529`). It can only widen: a host cannot remove `429` and
28
+ * lose `Retry-After` handling with it. This sets the DEFAULT classification; `onError` still
29
+ * runs per attempt and has the final say, so `onError` returning `"fail"` overrides a status
30
+ * listed here. Example: a Cloudflare-fronted origin that answers `520`–`527`.
31
+ */
32
+ retryableStatuses?: readonly number[];
24
33
  /** Whole-run deadline in ms; aborts the run (and its in-flight request) when exceeded. */
25
34
  timeoutMs?: number;
26
35
  /** Conversation provider for `ask(prompt, { id })`. Default: in-memory (process lifetime).
@@ -69,7 +78,8 @@ export interface ErrorInfo {
69
78
  status?: number;
70
79
  /** Zero-based attempt index (0 = first try). */
71
80
  attempt: number;
72
- /** Whether `status`/the error is in the default retryable set (429/5xx/network). */
81
+ /** Whether `status`/the error is in the default retryable set
82
+ * (429/500/502/503/504/529/network, plus anything `retryableStatuses` added). */
73
83
  retryable: boolean;
74
84
  }
75
85
  /**
@@ -99,6 +109,18 @@ export type MetricEvent = {
99
109
  totalTokens: number;
100
110
  ms: number;
101
111
  error?: string;
112
+ } | {
113
+ event: "classifier.evaluate";
114
+ model: string;
115
+ status: "ok" | "error";
116
+ ms: number;
117
+ promptTokens: number;
118
+ completionTokens: number;
119
+ error?: string;
120
+ } | {
121
+ event: "classifier.warning";
122
+ question: string;
123
+ warning: string;
102
124
  };
103
125
  /**
104
126
  * Where `ask()` conversations are remembered — two methods. Ship the in-memory
@@ -319,7 +341,8 @@ export declare class Client {
319
341
  private system;
320
342
  /** Build a run-scoped abort signal from the optional run-level timeout + an external signal. */
321
343
  private makeSignal;
322
- /** fetch with retry + exponential backoff on 429/5xx/network, honoring Retry-After; aborts via signal.
344
+ /** fetch with retry + exponential backoff on the default retryable set (429/500/502/503/504/529)
345
+ * plus network errors and anything `retryableStatuses` added, honoring Retry-After; aborts via signal.
323
346
  * The retry-vs-fail decision is the host's `onError` (default: retryable-within-budget ⇒ retry). */
324
347
  private llmFetch;
325
348
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */