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,832 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifier (SPEC.md §8B) — the contract for a JUDGMENT, as `Tool` is the contract for an
|
|
3
|
+
* ACTION. A System One model takes a state plus pre-declared, typed questions and returns
|
|
4
|
+
* calibrated answers with no free text. It has no messages, no tool calling and no streaming,
|
|
5
|
+
* so it never enters the §8 client loop.
|
|
6
|
+
*
|
|
7
|
+
* A classifier INTERPRETS; it never AUTHORISES. Schema validity is not correctness: a decision
|
|
8
|
+
* can be confidently wrong, and "cannot hallucinate" means only that the returned value is in
|
|
9
|
+
* the declared schema. Numeric limits, permission checks and allowlists stay in code. Nothing
|
|
10
|
+
* here is a security control.
|
|
11
|
+
*/
|
|
12
|
+
import type { Client } from "./client.js"
|
|
13
|
+
import { type ErrorInfo, type ErrorTier, type MetricEvent } from "./client.js"
|
|
14
|
+
import { retryAfterMs, isRetryableStatus } from "./retry.js"
|
|
15
|
+
import type { Toolkit } from "./toolkit.js"
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------- constants
|
|
18
|
+
|
|
19
|
+
/** The System One endpoint base. */
|
|
20
|
+
export const DEFAULT_CLASSIFIER_BASE_URL = "https://api.typesafe.ai/v1"
|
|
21
|
+
/** The floating model alias. Pin it (e.g. `jev-1.13.0`) once thresholds are tuned. */
|
|
22
|
+
export const DEFAULT_CLASSIFIER_MODEL = "jev-latest"
|
|
23
|
+
/** The NAME of the env var holding the credential — never the value. */
|
|
24
|
+
export const DEFAULT_CLASSIFIER_API_KEY_ENV = "TYPESAFE_API_KEY"
|
|
25
|
+
/** Bounds ONE request; a classifier has no loop to bound. */
|
|
26
|
+
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 10_000
|
|
27
|
+
|
|
28
|
+
/** Client-side cap on a choice's named options (§8B). */
|
|
29
|
+
export const MAX_CHOICE_OPTIONS = 255
|
|
30
|
+
/** Bounds of a score rubric (§8B). */
|
|
31
|
+
export const MIN_SCORE_LEVELS = 2
|
|
32
|
+
export const MAX_SCORE_LEVELS = 10
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The ABSOLUTE tolerance on `max|p - 1/n|`, compared INCLUSIVELY (§8B). Pinned across every
|
|
36
|
+
* port; `examples/judge/near-uniform.json` pins both sides at 0.0499 / 0.0501.
|
|
37
|
+
*/
|
|
38
|
+
export const NEAR_UNIFORM_TOLERANCE = 0.05
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------- questions
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Labels for the true and false cases of a noul question. Absent and empty are DIFFERENT values
|
|
44
|
+
* and both are preserved on the wire: omit `criteria` entirely and the field is absent; pass
|
|
45
|
+
* `{ true: "", false: "" }` and it is emitted with empty values.
|
|
46
|
+
*/
|
|
47
|
+
export interface NoulCriteria {
|
|
48
|
+
true: string
|
|
49
|
+
false: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The probability that a statement holds, one number in 0..1. It carries NO confidence. */
|
|
53
|
+
export interface NoulQuestion {
|
|
54
|
+
type: "noul"
|
|
55
|
+
instructions: string
|
|
56
|
+
/** Optional. Omitted ⇒ the field is ABSENT from the request, not null and not {}. */
|
|
57
|
+
criteria?: NoulCriteria
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One option from a named set, 1..255 options.
|
|
62
|
+
*
|
|
63
|
+
* THE ENCODING OBLIGATION IS THE CALLER'S (§8B, docs/adr/0021 D1): `criteria[id]` is the only
|
|
64
|
+
* thing that differentiates one option from another to the model. Passing the id itself, an
|
|
65
|
+
* empty string, or one value repeated is schema-valid, returns HTTP 200 and a well-formed
|
|
66
|
+
* distribution — and ranks at chance (measured: 17 apples described by consequence, 0/1/0
|
|
67
|
+
* described by id).
|
|
68
|
+
*/
|
|
69
|
+
export interface ChoiceQuestion {
|
|
70
|
+
type: "choice"
|
|
71
|
+
instructions: string
|
|
72
|
+
/** option id -> what picking it would MEAN. */
|
|
73
|
+
criteria: Record<string, string>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A rating against an ORDERED rubric of 2..10 levels. The array order IS the level numbering,
|
|
78
|
+
* so it is never sorted — a "sort everything" canonicaliser silently renumbers the rubric.
|
|
79
|
+
*/
|
|
80
|
+
export interface ScoreQuestion {
|
|
81
|
+
type: "score"
|
|
82
|
+
instructions: string
|
|
83
|
+
criteria: string[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The closed set of question types, discriminated by `type`. */
|
|
87
|
+
export type Question = NoulQuestion | ChoiceQuestion | ScoreQuestion
|
|
88
|
+
|
|
89
|
+
/** A noul question. Pass `criteria` only when you mean it — absent ≠ empty on the wire. */
|
|
90
|
+
export function noul(instructions: string, criteria?: NoulCriteria): NoulQuestion {
|
|
91
|
+
return criteria === undefined ? { type: "noul", instructions } : { type: "noul", instructions, criteria }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A choice question. Every description must say what picking that option would MEAN. */
|
|
95
|
+
export function choice(instructions: string, criteria: Record<string, string>): ChoiceQuestion {
|
|
96
|
+
return { type: "choice", instructions, criteria: { ...criteria } }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A score question. The array order IS the level numbering and is never reordered. */
|
|
100
|
+
export function score(instructions: string, criteria: string[]): ScoreQuestion {
|
|
101
|
+
return { type: "score", instructions, criteria: [...criteria] }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build a choice from any (name, description) pairs — a Tool, a skill, an agent, an A2A card
|
|
106
|
+
* skill. The description must say what picking that option would MEAN; see the encoding
|
|
107
|
+
* obligation on `ChoiceQuestion`.
|
|
108
|
+
*/
|
|
109
|
+
export function choiceOver(instructions: string, items: Record<string, string>): ChoiceQuestion {
|
|
110
|
+
return choice(instructions, items)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The wire form of one question — the object itself, minus an absent `criteria`. */
|
|
114
|
+
function questionWire(q: Question): Record<string, unknown> {
|
|
115
|
+
switch (q.type) {
|
|
116
|
+
case "noul":
|
|
117
|
+
return q.criteria === undefined
|
|
118
|
+
? { type: "noul", instructions: q.instructions }
|
|
119
|
+
: { type: "noul", instructions: q.instructions, criteria: { true: q.criteria.true, false: q.criteria.false } }
|
|
120
|
+
case "choice":
|
|
121
|
+
return { type: "choice", instructions: q.instructions, criteria: { ...q.criteria } }
|
|
122
|
+
case "score":
|
|
123
|
+
return { type: "score", instructions: q.instructions, criteria: [...q.criteria] }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Client-side limits, enforced BEFORE the request. The error names the key and the limit. */
|
|
128
|
+
function validateQuestion(key: string, q: Question): void {
|
|
129
|
+
if (q.type === "choice") {
|
|
130
|
+
const n = Object.keys(q.criteria ?? {}).length
|
|
131
|
+
if (n < 1 || n > MAX_CHOICE_OPTIONS) {
|
|
132
|
+
throw new Error(`classifier: question "${key}": a choice needs 1..${MAX_CHOICE_OPTIONS} options, got ${n}`)
|
|
133
|
+
}
|
|
134
|
+
} else if (q.type === "score") {
|
|
135
|
+
const n = (q.criteria ?? []).length
|
|
136
|
+
if (n < MIN_SCORE_LEVELS || n > MAX_SCORE_LEVELS) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`classifier: question "${key}": a score needs ${MIN_SCORE_LEVELS}..${MAX_SCORE_LEVELS} ordered levels, got ${n}`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------- the wire
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Canonical JSON: object keys sorted recursively in CODE-UNIT (ASCII) order, arrays NEVER
|
|
148
|
+
* reordered, compact separators, and `<>&'"` plus non-ASCII transmitted raw.
|
|
149
|
+
*
|
|
150
|
+
* Hand-rolled because `JSON.stringify` has no recursive key sort, and its `replacer` ARRAY form
|
|
151
|
+
* applies one key list at EVERY depth — it cannot express "sort whatever keys are here". The
|
|
152
|
+
* per-value escaping and number formatting ARE delegated to `JSON.stringify`, which emits the
|
|
153
|
+
* shortest round-tripping decimal (so 1.21 is "1.21" and integer 0 is "0", not "0.0") and does
|
|
154
|
+
* not escape `<`, `>`, `&` or non-ASCII.
|
|
155
|
+
*
|
|
156
|
+
* `undefined` is ABSENT, never `null`: an object key whose value is undefined is dropped.
|
|
157
|
+
*/
|
|
158
|
+
export function canonicalJson(value: unknown): string {
|
|
159
|
+
if (Array.isArray(value)) return "[" + value.map((v) => (v === undefined ? "null" : canonicalJson(v))).join(",") + "]"
|
|
160
|
+
if (value !== null && typeof value === "object") {
|
|
161
|
+
const obj = value as Record<string, unknown>
|
|
162
|
+
// `sort()` with no comparator compares UTF-16 code units — ASCII order, and NOT the
|
|
163
|
+
// culture-sensitive order `localeCompare` would give ("10_alpha" < "Alpha" < "beta" < "café").
|
|
164
|
+
const keys = Object.keys(obj)
|
|
165
|
+
.filter((k) => obj[k] !== undefined)
|
|
166
|
+
.sort()
|
|
167
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJson(obj[k])).join(",") + "}"
|
|
168
|
+
}
|
|
169
|
+
const s = JSON.stringify(value)
|
|
170
|
+
return s === undefined ? "null" : s
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The bytes the byte-identity claim covers: `model` + `questions`.
|
|
175
|
+
*
|
|
176
|
+
* `state` is deliberately NOT here. It is transmitted verbatim as the host supplied it and is
|
|
177
|
+
* OUTSIDE the claim, because numbers do not canonicalise across languages (`-0.0` renders four
|
|
178
|
+
* ways across our own seven runtimes). Do not re-widen this: a caller who needs their state
|
|
179
|
+
* pinned canonicalises it themselves before handing it over.
|
|
180
|
+
*/
|
|
181
|
+
export function canonicalRequest(model: string, questions: Record<string, Question>): string {
|
|
182
|
+
const qs: Record<string, unknown> = {}
|
|
183
|
+
for (const [key, q] of Object.entries(questions)) {
|
|
184
|
+
if (!q) throw new Error(`classifier: question "${key}" is missing`)
|
|
185
|
+
qs[key] = questionWire(q)
|
|
186
|
+
}
|
|
187
|
+
return canonicalJson({ model, questions: qs })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------- answers
|
|
191
|
+
|
|
192
|
+
/** The probability that a statement holds. NO confidence: the number is the answer. */
|
|
193
|
+
export interface NoulAnswer {
|
|
194
|
+
type: "noul"
|
|
195
|
+
noul: number
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** One option from the offered set, with a probability for EVERY offered option. */
|
|
199
|
+
export interface ChoiceAnswer {
|
|
200
|
+
type: "choice"
|
|
201
|
+
choice: string
|
|
202
|
+
probabilities: Record<string, number>
|
|
203
|
+
confidence: number
|
|
204
|
+
/**
|
|
205
|
+
* DERIVED from `probabilities` on decode and never read from the wire (§8B):
|
|
206
|
+
* `max|p - 1/n| <= 0.05`, inclusive, `n` = the number of entries in the map.
|
|
207
|
+
*
|
|
208
|
+
* ADVISORY, NOT a correctness signal. It detects an encoding that gave the model nothing to
|
|
209
|
+
* rank on — it cannot distinguish a good encoding from a subtly wrong one. `calibrated`
|
|
210
|
+
* carries the same caveat.
|
|
211
|
+
*/
|
|
212
|
+
nearUniform: boolean
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A rating against the ordered rubric. `score` MAY fall between levels (1.21 is a real answer). */
|
|
216
|
+
export interface ScoreAnswer {
|
|
217
|
+
type: "score"
|
|
218
|
+
score: number
|
|
219
|
+
legend: Record<string, string>
|
|
220
|
+
probabilities: Record<string, number>
|
|
221
|
+
confidence: number
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* One typed answer, discriminated by `type`. Named `DecisionAnswer` because §10 already owns
|
|
226
|
+
* `Answer` (the suspension resolution) — same idea, different seam.
|
|
227
|
+
*/
|
|
228
|
+
export type DecisionAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer
|
|
229
|
+
|
|
230
|
+
/** The wire's usage block. `cost` is absent on some backends. */
|
|
231
|
+
export interface ClassifierUsage {
|
|
232
|
+
inputTokens: number
|
|
233
|
+
outputTokens: number
|
|
234
|
+
cost?: number
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The legend in LEVEL order, which the map itself loses ("2" sorts before "10" as a level). */
|
|
238
|
+
export function levels(answer: ScoreAnswer): string[] {
|
|
239
|
+
return Object.keys(answer.legend)
|
|
240
|
+
.sort((a, b) => (a.length !== b.length ? a.length - b.length : a < b ? -1 : a > b ? 1 : 0))
|
|
241
|
+
.map((k) => answer.legend[k])
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* One answer per question, keyed by the CALLER's keys. The keys are addressing, not content:
|
|
246
|
+
* they are never transmitted, so a key may be a tool, skill or agent name verbatim.
|
|
247
|
+
*/
|
|
248
|
+
export class Decision {
|
|
249
|
+
constructor(
|
|
250
|
+
/** What actually answered, which may be more specific than the model that was asked for. */
|
|
251
|
+
readonly model: string,
|
|
252
|
+
readonly answers: Record<string, DecisionAnswer>,
|
|
253
|
+
readonly usage: ClassifierUsage,
|
|
254
|
+
/**
|
|
255
|
+
* Whether the probabilities are calibrated. `systemone` reports true; `llm` reports false
|
|
256
|
+
* unless it derived them from provider token probabilities. A THRESHOLD TUNED AGAINST ONE
|
|
257
|
+
* BACKEND DOES NOT TRANSFER TO ANOTHER.
|
|
258
|
+
*/
|
|
259
|
+
readonly calibrated: boolean,
|
|
260
|
+
) {}
|
|
261
|
+
|
|
262
|
+
/** Typed read. Throws (never returns undefined) on a wrong-type or absent key. */
|
|
263
|
+
noul(key: string): NoulAnswer {
|
|
264
|
+
return this.typed(key, "noul")
|
|
265
|
+
}
|
|
266
|
+
choice(key: string): ChoiceAnswer {
|
|
267
|
+
return this.typed(key, "choice")
|
|
268
|
+
}
|
|
269
|
+
score(key: string): ScoreAnswer {
|
|
270
|
+
return this.typed(key, "score")
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private typed<T extends DecisionAnswer["type"]>(key: string, want: T): Extract<DecisionAnswer, { type: T }> {
|
|
274
|
+
const a = this.answers[key]
|
|
275
|
+
if (a === undefined) throw new Error(`classifier: no answer "${key}" in this decision`)
|
|
276
|
+
if (a.type !== want) throw new Error(`classifier: answer "${key}" is a ${a.type} answer, not ${want}`)
|
|
277
|
+
return a as Extract<DecisionAnswer, { type: T }>
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Whether a choice answer's probability map is indistinguishable from flat (§8B):
|
|
283
|
+
*
|
|
284
|
+
* nearUniform ⇔ max over i of |p_i - 1/n| <= 0.05
|
|
285
|
+
*
|
|
286
|
+
* `n` is the number of ENTRIES IN THE MAP and the values are taken AS RETURNED — not
|
|
287
|
+
* renormalised, not sorted, not rounded; an offered option absent from the map counts as 0 by
|
|
288
|
+
* not being an entry. The tolerance is ABSOLUTE (a relative band collapses below the wire's
|
|
289
|
+
* two-decimal rounding on a 255-option roster) and the comparison is INCLUSIVE. `n === 1` is
|
|
290
|
+
* trivially uniform. An empty map has no distribution at all and is reported false.
|
|
291
|
+
*/
|
|
292
|
+
export function nearUniform(probabilities: Record<string, number>): boolean {
|
|
293
|
+
const values = Object.values(probabilities ?? {})
|
|
294
|
+
if (values.length === 0) return false
|
|
295
|
+
if (values.length === 1) return true
|
|
296
|
+
const target = 1 / values.length
|
|
297
|
+
return values.every((p) => Math.abs(p - target) <= NEAR_UNIFORM_TOLERANCE)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Decode a backend response into a Decision. The discriminated decode is the single place
|
|
302
|
+
* `nearUniform` is derived. A malformed answer is an error, never a repaired one (ADR 0020).
|
|
303
|
+
*/
|
|
304
|
+
export function decodeDecision(raw: unknown, fallbackModel: string): Decision {
|
|
305
|
+
if (raw === null || typeof raw !== "object") throw new Error("classifier: response is not an object")
|
|
306
|
+
const body = raw as Record<string, any>
|
|
307
|
+
const answers: Record<string, DecisionAnswer> = {}
|
|
308
|
+
for (const [key, value] of Object.entries(body.answers ?? {})) {
|
|
309
|
+
if (value === null || typeof value !== "object") throw new Error(`classifier: answer "${key}" is not an object`)
|
|
310
|
+
const a = value as Record<string, any>
|
|
311
|
+
switch (a.type) {
|
|
312
|
+
case "noul":
|
|
313
|
+
answers[key] = { type: "noul", noul: num(a.noul) }
|
|
314
|
+
break
|
|
315
|
+
case "choice":
|
|
316
|
+
answers[key] = {
|
|
317
|
+
type: "choice",
|
|
318
|
+
choice: String(a.choice ?? ""),
|
|
319
|
+
probabilities: numbers(a.probabilities),
|
|
320
|
+
confidence: num(a.confidence),
|
|
321
|
+
nearUniform: nearUniform(numbers(a.probabilities)),
|
|
322
|
+
}
|
|
323
|
+
break
|
|
324
|
+
case "score":
|
|
325
|
+
answers[key] = {
|
|
326
|
+
type: "score",
|
|
327
|
+
score: num(a.score),
|
|
328
|
+
legend: strings(a.legend),
|
|
329
|
+
probabilities: numbers(a.probabilities),
|
|
330
|
+
confidence: num(a.confidence),
|
|
331
|
+
}
|
|
332
|
+
break
|
|
333
|
+
default:
|
|
334
|
+
throw new Error(`classifier: answer "${key}": unknown type ${JSON.stringify(a.type)}`)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const usage: ClassifierUsage = {
|
|
338
|
+
inputTokens: num(body.usage?.input_tokens),
|
|
339
|
+
outputTokens: num(body.usage?.output_tokens),
|
|
340
|
+
}
|
|
341
|
+
// Absent stays absent: a cost of 0 is a real cost and undefined is "not reported".
|
|
342
|
+
if (body.usage?.cost !== undefined) usage.cost = num(body.usage.cost)
|
|
343
|
+
// Absent ⇒ true: the systemone wire reports calibration by being itself. A backend that is
|
|
344
|
+
// not calibrated says so explicitly.
|
|
345
|
+
return new Decision(String(body.model ?? fallbackModel), answers, usage, body.calibrated !== false)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function num(v: unknown): number {
|
|
349
|
+
return typeof v === "number" ? v : 0
|
|
350
|
+
}
|
|
351
|
+
/** A zero probability stays an ENTRY: the map is copied key-for-key, never filtered. */
|
|
352
|
+
function numbers(v: unknown): Record<string, number> {
|
|
353
|
+
const out: Record<string, number> = {}
|
|
354
|
+
for (const [k, p] of Object.entries((v ?? {}) as Record<string, unknown>)) out[k] = num(p)
|
|
355
|
+
return out
|
|
356
|
+
}
|
|
357
|
+
function strings(v: unknown): Record<string, string> {
|
|
358
|
+
const out: Record<string, string> = {}
|
|
359
|
+
for (const [k, s] of Object.entries((v ?? {}) as Record<string, unknown>)) out[k] = String(s)
|
|
360
|
+
return out
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------- options
|
|
364
|
+
|
|
365
|
+
/** `"systemone" | "llm" | "custom" | "static"` — the same slot as the client's `style`. */
|
|
366
|
+
export type ClassifierStyle = "systemone" | "llm" | "custom" | "static"
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* One entry of the `static` corpus. It is keyed on the canonical request AND the state: several
|
|
370
|
+
* recorded entries legitimately share one questions payload and differ only in state (the three
|
|
371
|
+
* guard bands of `examples/judge/decisions.json` do exactly that), so a corpus keyed on the
|
|
372
|
+
* canonical request alone cannot tell them apart.
|
|
373
|
+
*/
|
|
374
|
+
export interface RecordedDecision {
|
|
375
|
+
state: unknown
|
|
376
|
+
questions: Record<string, Question>
|
|
377
|
+
/** The recorded backend response body, verbatim. */
|
|
378
|
+
response: unknown
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The host's own backend (`style: "custom"`). Every wire option is ignored. */
|
|
382
|
+
export type EvaluateFn = (
|
|
383
|
+
state: unknown,
|
|
384
|
+
questions: Record<string, Question>,
|
|
385
|
+
signal?: AbortSignal,
|
|
386
|
+
) => Promise<Decision> | Decision
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Mirrors §8 `ClientOptions` field-for-field wherever a field makes sense, so a host that has
|
|
390
|
+
* configured one has configured the other.
|
|
391
|
+
*/
|
|
392
|
+
export interface ClassifierOptions {
|
|
393
|
+
/** Default `"systemone"`. */
|
|
394
|
+
style?: ClassifierStyle
|
|
395
|
+
/** Default `https://api.typesafe.ai/v1`. OpenRouter serves this wire today. */
|
|
396
|
+
baseUrl?: string
|
|
397
|
+
/** Default `"jev-latest"`. Pin it once thresholds are tuned. */
|
|
398
|
+
model?: string
|
|
399
|
+
/** The NAME of the env var holding the credential, never the value — read at call time and
|
|
400
|
+
* never logged. §8's `apiKey` takes a value; this option deliberately does not. */
|
|
401
|
+
apiKeyEnv?: string
|
|
402
|
+
/** Extra headers. Values expand `${ENV_VAR}` from the environment AT CALL TIME and are NEVER
|
|
403
|
+
* logged, identically to remote-MCP headers (§2). */
|
|
404
|
+
headers?: Record<string, string>
|
|
405
|
+
/** Bounds ONE request (a classifier has no loop to bound). Default 10 s. */
|
|
406
|
+
timeoutMs?: number
|
|
407
|
+
/** §8 Gap 2 injectable transport. Scope is the classifier path only. */
|
|
408
|
+
fetch?: typeof fetch
|
|
409
|
+
/** Retries on transient errors (`408`/`429`/`500`/`502`/`503`/`504`/`529` + network). Default 2.
|
|
410
|
+
* Widen the status set with `retryableStatuses`. */
|
|
411
|
+
retries?: number
|
|
412
|
+
/** Base backoff in ms (exponential). Default 500. */
|
|
413
|
+
retryBaseMs?: number
|
|
414
|
+
/**
|
|
415
|
+
* Extra HTTP statuses to treat as retryable, ADDED to the default set
|
|
416
|
+
* (`429`/`500`/`502`/`503`/`504`/`529` plus `408` here). It can only widen: a host cannot remove `429` and
|
|
417
|
+
* lose `Retry-After` handling with it. This sets the DEFAULT classification; `onError` still
|
|
418
|
+
* runs per attempt and has the final say, so `onError` returning `"fail"` overrides a status
|
|
419
|
+
* listed here. Example: a Cloudflare-fronted origin that answers `520`–`527`.
|
|
420
|
+
*/
|
|
421
|
+
retryableStatuses?: readonly number[]
|
|
422
|
+
|
|
423
|
+
/** §8 Resilience. REUSES the client's `ErrorInfo`/`ErrorTier` and the `Retry-After`
|
|
424
|
+
* delay-seconds rule verbatim — there is no second retry policy, and no `"suspend"` tier. */
|
|
425
|
+
onError?: (info: ErrorInfo) => ErrorTier
|
|
426
|
+
/** §8 Gap 1. Extra top-level keys shallow-merged into the body after the classifier builds its
|
|
427
|
+
* own; a `requestParams` key WINS on collision. Omit ⇒ body byte-identical. */
|
|
428
|
+
requestParams?: Record<string, unknown>
|
|
429
|
+
/** §8 Gap 1. Receives the assembled body after the `requestParams` merge and returns the body
|
|
430
|
+
* to send. Order: base body → requestParams merge → bodyTransform → marshal, exactly as §8. */
|
|
431
|
+
bodyTransform?: (body: Record<string, any>) => Record<string, any> | undefined | void
|
|
432
|
+
/** Emits `classifier.evaluate` events into the SAME §8 sink, and carries the
|
|
433
|
+
* degenerate-criteria warning as `classifier.warning`. */
|
|
434
|
+
onMetric?: (ev: MetricEvent) => void
|
|
435
|
+
/** `style: "llm"` only — the §8 Client to emulate over. */
|
|
436
|
+
client?: Client
|
|
437
|
+
/** `style: "custom"` only — the host's own function. */
|
|
438
|
+
evaluate?: EvaluateFn
|
|
439
|
+
/** `style: "static"` only — the recorded corpus. */
|
|
440
|
+
decisions?: RecordedDecision[]
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------- classifier
|
|
444
|
+
|
|
445
|
+
/** The whole seam: one verb. */
|
|
446
|
+
export class Classifier {
|
|
447
|
+
private readonly style: ClassifierStyle
|
|
448
|
+
private readonly baseUrl: string
|
|
449
|
+
private readonly model: string
|
|
450
|
+
private readonly apiKeyEnv: string
|
|
451
|
+
private readonly timeoutMs: number
|
|
452
|
+
private readonly corpus = new Map<string, unknown>()
|
|
453
|
+
/** The once-per-question-key set for the degenerate warning, so a per-turn judge does not
|
|
454
|
+
* flood the sink. */
|
|
455
|
+
private readonly warned = new Set<string>()
|
|
456
|
+
|
|
457
|
+
constructor(private readonly opts: ClassifierOptions) {
|
|
458
|
+
this.style = opts.style ?? "systemone"
|
|
459
|
+
this.baseUrl = opts.baseUrl ?? DEFAULT_CLASSIFIER_BASE_URL
|
|
460
|
+
this.model = opts.model ?? DEFAULT_CLASSIFIER_MODEL
|
|
461
|
+
this.apiKeyEnv = opts.apiKeyEnv ?? DEFAULT_CLASSIFIER_API_KEY_ENV
|
|
462
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS
|
|
463
|
+
switch (this.style) {
|
|
464
|
+
case "systemone":
|
|
465
|
+
break
|
|
466
|
+
case "llm":
|
|
467
|
+
if (!opts.client) throw new Error('classifier: style "llm" requires `client`')
|
|
468
|
+
break
|
|
469
|
+
case "custom":
|
|
470
|
+
if (!opts.evaluate) throw new Error('classifier: style "custom" requires `evaluate`')
|
|
471
|
+
break
|
|
472
|
+
case "static":
|
|
473
|
+
for (const [i, rec] of (opts.decisions ?? []).entries()) {
|
|
474
|
+
try {
|
|
475
|
+
this.corpus.set(this.staticKey(rec.state, rec.questions), rec.response)
|
|
476
|
+
} catch (e) {
|
|
477
|
+
throw new Error(`classifier: recorded decision ${i}: ${(e as Error).message}`)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
break
|
|
481
|
+
default: {
|
|
482
|
+
const unknown: never = this.style
|
|
483
|
+
throw new Error(`classifier: unknown style ${JSON.stringify(unknown)}`)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The whole contract: a state plus typed questions in, a Decision out. Questions are
|
|
490
|
+
* INDEPENDENT — one answer is never context for another.
|
|
491
|
+
*/
|
|
492
|
+
async evaluate(state: unknown, questions: Record<string, Question>, signal?: AbortSignal): Promise<Decision> {
|
|
493
|
+
const start = Date.now()
|
|
494
|
+
const keys = Object.keys(questions ?? {}).sort()
|
|
495
|
+
if (keys.length === 0) {
|
|
496
|
+
const err = new Error("classifier: no questions to evaluate")
|
|
497
|
+
this.emitError(start, err)
|
|
498
|
+
throw err
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
// Limits are enforced CLIENT-SIDE, before the request: the caller finds out faster and
|
|
502
|
+
// more legibly than from the backend's own 400. Keys are walked in sorted order so the
|
|
503
|
+
// same malformed set always names the same key first.
|
|
504
|
+
for (const key of keys) {
|
|
505
|
+
const q = questions[key]
|
|
506
|
+
if (!q) throw new Error(`classifier: question "${key}" is missing`)
|
|
507
|
+
validateQuestion(key, q)
|
|
508
|
+
}
|
|
509
|
+
} catch (e) {
|
|
510
|
+
this.emitError(start, e)
|
|
511
|
+
throw e
|
|
512
|
+
}
|
|
513
|
+
// Detection, never repair (ADR 0020/0021): the request goes out BYTE-UNCHANGED and the
|
|
514
|
+
// warning is the entire observable effect.
|
|
515
|
+
this.reportDegenerate(keys, questions)
|
|
516
|
+
|
|
517
|
+
let decision: Decision
|
|
518
|
+
try {
|
|
519
|
+
switch (this.style) {
|
|
520
|
+
case "custom":
|
|
521
|
+
decision = await this.opts.evaluate!(state, questions, signal)
|
|
522
|
+
break
|
|
523
|
+
case "static":
|
|
524
|
+
decision = this.evaluateStatic(state, questions)
|
|
525
|
+
break
|
|
526
|
+
case "llm":
|
|
527
|
+
decision = await this.evaluateLLM(state, questions, signal)
|
|
528
|
+
break
|
|
529
|
+
default:
|
|
530
|
+
decision = await this.evaluateSystemOne(state, questions, signal)
|
|
531
|
+
}
|
|
532
|
+
} catch (e) {
|
|
533
|
+
this.emitError(start, e)
|
|
534
|
+
throw e
|
|
535
|
+
}
|
|
536
|
+
this.emit({
|
|
537
|
+
event: "classifier.evaluate",
|
|
538
|
+
model: decision.model,
|
|
539
|
+
status: "ok",
|
|
540
|
+
ms: Date.now() - start,
|
|
541
|
+
promptTokens: decision.usage.inputTokens,
|
|
542
|
+
completionTokens: decision.usage.outputTokens,
|
|
543
|
+
})
|
|
544
|
+
return decision
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ---------------------------------------------------------------- degenerate
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Emits ONE warning per degenerate question key per classifier, naming the key, and changes
|
|
551
|
+
* nothing about the request. Repairing would invent option descriptions the caller did not
|
|
552
|
+
* write, and the library has no way to know what the options mean.
|
|
553
|
+
*/
|
|
554
|
+
private reportDegenerate(keys: string[], questions: Record<string, Question>): void {
|
|
555
|
+
for (const key of keys) {
|
|
556
|
+
const q = questions[key]
|
|
557
|
+
if (q.type !== "choice") continue
|
|
558
|
+
const reason = degenerateCriteria(q.criteria)
|
|
559
|
+
if (!reason || this.warned.has(key)) continue
|
|
560
|
+
this.warned.add(key)
|
|
561
|
+
this.emit({
|
|
562
|
+
event: "classifier.warning",
|
|
563
|
+
question: key,
|
|
564
|
+
warning:
|
|
565
|
+
`classifier: question "${key}" has degenerate criteria (${reason}) — every option reads ` +
|
|
566
|
+
"the same to the model and the answer ranks at chance; describe what picking each " +
|
|
567
|
+
"option would MEAN (SPEC.md §8B)",
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ---------------------------------------------------------------- backends
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Assembles the request: the canonical model + questions, plus state VERBATIM as the host
|
|
576
|
+
* supplied it, then the §8 Gap 1 pipeline in §8 order (base → requestParams merge →
|
|
577
|
+
* bodyTransform → marshal).
|
|
578
|
+
*/
|
|
579
|
+
private body(state: unknown, questions: Record<string, Question>): string {
|
|
580
|
+
const qs: Record<string, unknown> = {}
|
|
581
|
+
for (const [key, q] of Object.entries(questions)) qs[key] = questionWire(q)
|
|
582
|
+
let body: Record<string, any> = { model: this.model, questions: qs, state }
|
|
583
|
+
// A requestParams key WINS on collision.
|
|
584
|
+
if (this.opts.requestParams) body = { ...body, ...this.opts.requestParams }
|
|
585
|
+
if (this.opts.bodyTransform) body = this.opts.bodyTransform(body) || body
|
|
586
|
+
return canonicalJson(body)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Identifies a recorded decision by the canonical request AND the state. */
|
|
590
|
+
private staticKey(state: unknown, questions: Record<string, Question>): string {
|
|
591
|
+
return canonicalRequest(this.model, questions) + "\u0000" + canonicalJson(state)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private evaluateStatic(state: unknown, questions: Record<string, Question>): Decision {
|
|
595
|
+
const raw = this.corpus.get(this.staticKey(state, questions))
|
|
596
|
+
if (raw === undefined) throw new Error("classifier: static: no recorded decision for this request+state")
|
|
597
|
+
return decodeDecision(raw, this.model)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private async evaluateSystemOne(
|
|
601
|
+
state: unknown,
|
|
602
|
+
questions: Record<string, Question>,
|
|
603
|
+
signal?: AbortSignal,
|
|
604
|
+
): Promise<Decision> {
|
|
605
|
+
const text = await this.post(this.body(state, questions), signal)
|
|
606
|
+
let parsed: unknown
|
|
607
|
+
try {
|
|
608
|
+
parsed = JSON.parse(text)
|
|
609
|
+
} catch (e) {
|
|
610
|
+
throw new Error(`classifier: response was not JSON: ${(e as Error).message}`)
|
|
611
|
+
}
|
|
612
|
+
return decodeDecision(parsed, this.model)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* The one POST, with the §8 retry budget, reusing the client's `ErrorInfo`/`ErrorTier`
|
|
617
|
+
* classifier and the `Retry-After` delay-seconds rule verbatim.
|
|
618
|
+
*
|
|
619
|
+
* NO CREDENTIAL VALUE AND NO EXPANDED HEADER VALUE APPEARS ON ANY PATH OUT OF HERE: an
|
|
620
|
+
* authentication failure names the status and the endpoint, nothing else, and a 401/403 body
|
|
621
|
+
* is never echoed back (a gateway happily reflects a bad Authorization header into its own
|
|
622
|
+
* 401 text).
|
|
623
|
+
*/
|
|
624
|
+
private async post(payload: string, signal?: AbortSignal): Promise<string> {
|
|
625
|
+
const endpoint = this.baseUrl.replace(/\/+$/, "") + "/systemone"
|
|
626
|
+
const retries = this.opts.retries ?? 2
|
|
627
|
+
const base = this.opts.retryBaseMs ?? 500
|
|
628
|
+
const classify = this.opts.onError ?? ((info: ErrorInfo): ErrorTier => (info.retryable ? "retry" : "fail"))
|
|
629
|
+
const doFetch = this.opts.fetch ?? fetch
|
|
630
|
+
let lastErr: unknown
|
|
631
|
+
for (let attempt = 0; ; attempt++) {
|
|
632
|
+
const timer = new AbortController()
|
|
633
|
+
const t = setTimeout(() => timer.abort(new Error(`classifier: request timeout after ${this.timeoutMs}ms`)), this.timeoutMs)
|
|
634
|
+
;(t as any).unref?.()
|
|
635
|
+
const onOuter = () => timer.abort(signal!.reason)
|
|
636
|
+
if (signal) {
|
|
637
|
+
if (signal.aborted) {
|
|
638
|
+
clearTimeout(t)
|
|
639
|
+
throw signal.reason ?? new Error("aborted")
|
|
640
|
+
}
|
|
641
|
+
signal.addEventListener("abort", onOuter, { once: true })
|
|
642
|
+
}
|
|
643
|
+
let res: Response | undefined
|
|
644
|
+
let thrown: unknown
|
|
645
|
+
try {
|
|
646
|
+
res = await doFetch(endpoint, {
|
|
647
|
+
method: "POST",
|
|
648
|
+
headers: this.requestHeaders(),
|
|
649
|
+
body: payload,
|
|
650
|
+
signal: timer.signal,
|
|
651
|
+
})
|
|
652
|
+
} catch (e) {
|
|
653
|
+
thrown = e
|
|
654
|
+
} finally {
|
|
655
|
+
clearTimeout(t)
|
|
656
|
+
signal?.removeEventListener("abort", onOuter)
|
|
657
|
+
}
|
|
658
|
+
if (res && res.ok) return await res.text()
|
|
659
|
+
if (signal?.aborted) throw signal.reason ?? new Error("aborted") // caller cancellation is never retried
|
|
660
|
+
let retryAfter: string | null = null
|
|
661
|
+
if (res) {
|
|
662
|
+
const text = await res.text().catch(() => "")
|
|
663
|
+
retryAfter = res.headers.get("retry-after")
|
|
664
|
+
lastErr = new Error(`classifier: POST ${endpoint}: HTTP ${res.status}${cause(res.status, text)}`)
|
|
665
|
+
} else {
|
|
666
|
+
lastErr = new Error(`classifier: POST ${endpoint}: ${errText(thrown)}`)
|
|
667
|
+
}
|
|
668
|
+
const retryable = res ? res.status === 408 || isRetryableStatus(res.status, this.opts.retryableStatuses) : true
|
|
669
|
+
if (attempt >= retries) throw lastErr
|
|
670
|
+
const tier = classify(res ? { status: res.status, attempt, retryable } : { error: thrown, attempt, retryable })
|
|
671
|
+
if (tier !== "retry") throw lastErr
|
|
672
|
+
// `??`, not `||`: Retry-After: 0 means "retry now", not "no opinion".
|
|
673
|
+
await sleep(retryAfterMs(retryAfter) ?? base * 2 ** attempt)
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Credential and `${ENV_VAR}` headers resolve AT CALL TIME. Never logged, never returned. */
|
|
678
|
+
private requestHeaders(): Record<string, string> {
|
|
679
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
|
680
|
+
const key = process.env[this.apiKeyEnv]
|
|
681
|
+
if (key) headers["Authorization"] = `Bearer ${key}`
|
|
682
|
+
for (const [k, v] of Object.entries(this.opts.headers ?? {})) headers[k] = expandEnv(v)
|
|
683
|
+
return headers
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The three question types rendered as ONE structured-output call on any §8 Client — the
|
|
688
|
+
* vendor-neutral fallback, so a host with no System One credential runs the same questions on
|
|
689
|
+
* a cheap chat model. `calibrated` is FALSE: the numbers are the model's self-report, not
|
|
690
|
+
* token probabilities.
|
|
691
|
+
*/
|
|
692
|
+
private async evaluateLLM(
|
|
693
|
+
state: unknown,
|
|
694
|
+
questions: Record<string, Question>,
|
|
695
|
+
signal?: AbortSignal,
|
|
696
|
+
): Promise<Decision> {
|
|
697
|
+
const qs: Record<string, unknown> = {}
|
|
698
|
+
for (const [key, q] of Object.entries(questions)) qs[key] = questionWire(q)
|
|
699
|
+
const prompt =
|
|
700
|
+
"Answer every question about the state below. Questions are INDEPENDENT: one answer is " +
|
|
701
|
+
"never context for another.\n\n" +
|
|
702
|
+
`STATE:\n${canonicalJson(state)}\n\nQUESTIONS:\n${canonicalJson(qs)}\n\n` +
|
|
703
|
+
"Reply with JSON only, no prose and no code fence, shaped exactly:\n" +
|
|
704
|
+
'{"answers":{"<key>":{"type":"noul","noul":0.0}}}\n' +
|
|
705
|
+
'A "noul" answer is {"type":"noul","noul":<0..1>}. A "choice" answer is ' +
|
|
706
|
+
'{"type":"choice","choice":"<one offered option id>","probabilities":{"<every offered option id>":<0..1>},"confidence":<0..1>}. ' +
|
|
707
|
+
'A "score" answer is {"type":"score","score":<a number within the rubric bounds, fractional allowed>,' +
|
|
708
|
+
'"legend":{"0":"<level 0>",…},"probabilities":{"0":<0..1>,…},"confidence":<0..1>}.'
|
|
709
|
+
const run = await this.opts.client!.run(prompt, { toolkit: EMPTY_TOOLKIT, signal })
|
|
710
|
+
const payload = firstJsonObject(run.text)
|
|
711
|
+
let parsed: unknown
|
|
712
|
+
try {
|
|
713
|
+
parsed = JSON.parse(payload)
|
|
714
|
+
} catch (e) {
|
|
715
|
+
// Never repaired: an unparseable answer is no answer (ADR 0020).
|
|
716
|
+
throw new Error(`classifier: llm: ${(e as Error).message}`)
|
|
717
|
+
}
|
|
718
|
+
const d = decodeDecision(parsed, this.model)
|
|
719
|
+
return new Decision(
|
|
720
|
+
d.model || this.model,
|
|
721
|
+
d.answers,
|
|
722
|
+
{ inputTokens: run.usage.promptTokens, outputTokens: run.usage.completionTokens },
|
|
723
|
+
// The model reports no calibration and none is derived here (ADR 0020).
|
|
724
|
+
false,
|
|
725
|
+
)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ---------------------------------------------------------------- metrics
|
|
729
|
+
|
|
730
|
+
private emit(ev: MetricEvent): void {
|
|
731
|
+
this.opts.onMetric?.(ev)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private emitError(start: number, e: unknown): void {
|
|
735
|
+
this.emit({
|
|
736
|
+
event: "classifier.evaluate",
|
|
737
|
+
model: this.model,
|
|
738
|
+
status: "error",
|
|
739
|
+
ms: Date.now() - start,
|
|
740
|
+
promptTokens: 0,
|
|
741
|
+
completionTokens: 0,
|
|
742
|
+
error: errText(e),
|
|
743
|
+
})
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Builds a Classifier, applying the §8B defaults and rejecting a style whose required option
|
|
748
|
+
* is missing before any call is made. */
|
|
749
|
+
export function createClassifier(opts: ClassifierOptions = {}): Classifier {
|
|
750
|
+
return new Classifier(opts)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ---------------------------------------------------------------- helpers
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* The §8B predicate. Degenerate ⇔ ANY of:
|
|
757
|
+
* 1. every value is empty (empty string or absent), or
|
|
758
|
+
* 2. every value equals its own key, or
|
|
759
|
+
* 3. every value is identical to every other value (n >= 2).
|
|
760
|
+
*
|
|
761
|
+
* A single-option choice (n === 1) is NEVER reported: there is nothing to differentiate. Returns
|
|
762
|
+
* the reason, or "" when the criteria are fine.
|
|
763
|
+
*/
|
|
764
|
+
export function degenerateCriteria(criteria: Record<string, string>): string {
|
|
765
|
+
const entries = Object.entries(criteria ?? {})
|
|
766
|
+
if (entries.length < 2) return ""
|
|
767
|
+
if (entries.every(([, v]) => (v ?? "") === "")) return "every description is empty"
|
|
768
|
+
if (entries.every(([k, v]) => v === k)) return "every description is just its own option id"
|
|
769
|
+
const first = entries[0][1]
|
|
770
|
+
if (entries.every(([, v]) => v === first)) return "every description is identical"
|
|
771
|
+
return ""
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Surfaces a backend's reported cause intact — EXCEPT on an authentication status: a 401/403
|
|
776
|
+
* body routinely reflects the credential or the header that was sent, so it never reaches a log,
|
|
777
|
+
* a metric, an error or a return value.
|
|
778
|
+
*/
|
|
779
|
+
function cause(status: number, body: string): string {
|
|
780
|
+
if (status === 401 || status === 403) return ""
|
|
781
|
+
const s = body.trim()
|
|
782
|
+
if (!s) return ""
|
|
783
|
+
return ": " + (s.length > 200 ? s.slice(0, 200) + "…" : s)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** `${ENV_VAR}` expands at call time; an unset var expands to the empty string, as §2. */
|
|
787
|
+
function expandEnv(value: string): string {
|
|
788
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name: string) => process.env[name] ?? "")
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** The outermost JSON object of a model reply, which may arrive wrapped in a fence or prose. */
|
|
792
|
+
function firstJsonObject(s: string): string {
|
|
793
|
+
const start = s.indexOf("{")
|
|
794
|
+
const end = s.lastIndexOf("}")
|
|
795
|
+
if (start < 0 || end <= start) throw new Error("classifier: llm: no JSON object in the reply")
|
|
796
|
+
return s.slice(start, end + 1)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function errText(e: unknown): string {
|
|
800
|
+
return e instanceof Error ? e.message : String(e)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function sleep(ms: number): Promise<void> {
|
|
804
|
+
// NOT unref'd, unlike the timeout watchdog above. This timer is the retry
|
|
805
|
+
// backoff we are about to await, so it is real pending work: unref'ing it
|
|
806
|
+
// tells Node the process need not stay alive for it, and the loop can resolve
|
|
807
|
+
// before the retry ever happens. The §8 client's `delay` has never unref'd for
|
|
808
|
+
// the same reason. Caught on Node 22, where it cancelled every test after the
|
|
809
|
+
// first one to exercise a classifier retry ("Promise resolution is still
|
|
810
|
+
// pending but the event loop has already resolved"); Node 24 happened to hide it.
|
|
811
|
+
return new Promise((resolve) => {
|
|
812
|
+
setTimeout(resolve, ms)
|
|
813
|
+
})
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* The `llm` style declares no tools — a classifier has no loop and executes nothing. The §8
|
|
818
|
+
* `run` takes a Toolkit, so this is the empty one; the cast is the price of not exposing a
|
|
819
|
+
* public "no tools" Toolkit constructor just for this path.
|
|
820
|
+
*/
|
|
821
|
+
const EMPTY_TOOLKIT = {
|
|
822
|
+
skillsPrompt: () => "",
|
|
823
|
+
toOpenAI: () => [],
|
|
824
|
+
toAnthropic: () => [],
|
|
825
|
+
toGemini: () => [],
|
|
826
|
+
get: () => undefined,
|
|
827
|
+
list: () => [],
|
|
828
|
+
execute: async () => {
|
|
829
|
+
throw new Error("classifier: the llm style declares no tools")
|
|
830
|
+
},
|
|
831
|
+
close: async () => {},
|
|
832
|
+
} as unknown as Toolkit
|