@gobing-ai/ts-ai-decision 0.5.29 → 0.5.32
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 +208 -42
- package/dist/agent-driver.d.ts +59 -0
- package/dist/agent-driver.d.ts.map +1 -0
- package/dist/agent-driver.js +104 -0
- package/dist/catalog.js +1 -0
- package/dist/hub.d.ts +72 -10
- package/dist/hub.d.ts.map +1 -1
- package/dist/hub.js +163 -28
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/registry.d.ts +5 -3
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +24 -3
- package/dist/schema.d.ts +12 -10
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +20 -2
- package/dist/types.d.ts +11 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/typesafe-driver.d.ts +24 -0
- package/dist/typesafe-driver.d.ts.map +1 -0
- package/dist/typesafe-driver.js +160 -0
- package/package.json +7 -5
- package/schemas/decision-catalog.schema.json +26 -2
- package/src/agent-driver.ts +164 -0
- package/src/catalog.ts +1 -0
- package/src/hub.ts +293 -45
- package/src/index.ts +2 -0
- package/src/registry.ts +40 -6
- package/src/schema.ts +21 -2
- package/src/types.ts +12 -3
- package/src/typesafe-driver.ts +212 -0
package/src/hub.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
DecisionType,
|
|
22
22
|
JevEntry,
|
|
23
23
|
JevQuestion,
|
|
24
|
+
MakerRef,
|
|
24
25
|
} from './types';
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -40,6 +41,56 @@ export const DEFAULT_MIN_CONFIDENCE = 0.7;
|
|
|
40
41
|
/** Hub default when neither the per-call options, the decision nor the catalog names a maker. */
|
|
41
42
|
export const DEFAULT_MAKER = 'typesafe';
|
|
42
43
|
|
|
44
|
+
/** Upper bound on a carried error message, in characters (ADR-040). */
|
|
45
|
+
export const DECISION_ERROR_MESSAGE_MAX = 500;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The caught failure, carried on a fallback result so a consumer can persist it (Spur stores
|
|
49
|
+
* `decision_logs.error`). Only `name` and `message`: a stack, a `cause` chain or the failing
|
|
50
|
+
* request would leak prompt text and internals into logs.
|
|
51
|
+
*/
|
|
52
|
+
export interface DecisionErrorInfo {
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly message: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Reduce a thrown value to {@link DecisionErrorInfo}. Subclasses of `DecisionError` set their own
|
|
59
|
+
* `name`; a subclass that does not is named by its constructor rather than by `'Error'`.
|
|
60
|
+
*/
|
|
61
|
+
function toErrorInfo(error: unknown): DecisionErrorInfo {
|
|
62
|
+
if (!(error instanceof Error)) {
|
|
63
|
+
return { name: 'NonError', message: String(error).slice(0, DECISION_ERROR_MESSAGE_MAX) };
|
|
64
|
+
}
|
|
65
|
+
const named = error.name !== 'Error' ? error.name : (error.constructor?.name ?? error.name);
|
|
66
|
+
return { name: named, message: error.message.slice(0, DECISION_ERROR_MESSAGE_MAX) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One maker's attempt inside a chain: what it was, how it ended, and how long it took. Present only
|
|
71
|
+
* when more than one maker was tried, so a single-name decision's result is unchanged.
|
|
72
|
+
*/
|
|
73
|
+
export interface DecisionAttempt {
|
|
74
|
+
readonly maker: string;
|
|
75
|
+
readonly reason: DecisionResultBase['reason'];
|
|
76
|
+
readonly durationMs: number;
|
|
77
|
+
readonly error?: DecisionErrorInfo;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What a shadow maker did with the same request. Observed only: it rides beside the served answer and
|
|
82
|
+
* never changes it (ADR-040). `value` is the answer the shadow itself produced, so it is absent when
|
|
83
|
+
* the shadow fell back.
|
|
84
|
+
*/
|
|
85
|
+
export interface ShadowOutcome {
|
|
86
|
+
readonly maker: string;
|
|
87
|
+
readonly value?: string | number | boolean;
|
|
88
|
+
readonly confidence: number | null;
|
|
89
|
+
readonly reason: DecisionResultBase['reason'];
|
|
90
|
+
readonly durationMs: number;
|
|
91
|
+
readonly error?: DecisionErrorInfo;
|
|
92
|
+
}
|
|
93
|
+
|
|
43
94
|
/** Shared envelope of every {@link DecisionResult} variant. */
|
|
44
95
|
export interface DecisionResultBase {
|
|
45
96
|
readonly id: string;
|
|
@@ -51,6 +102,12 @@ export interface DecisionResultBase {
|
|
|
51
102
|
/** Registry name of the maker that was selected. */
|
|
52
103
|
readonly maker: string;
|
|
53
104
|
readonly durationMs: number;
|
|
105
|
+
/** The caught failure. Present only for `no-backend`, `timeout` and `error`. */
|
|
106
|
+
readonly error?: DecisionErrorInfo;
|
|
107
|
+
/** Every maker tried, in order. Present only when a chain tried more than one. */
|
|
108
|
+
readonly attempts?: readonly DecisionAttempt[];
|
|
109
|
+
/** The shadow's outcome. Present only when the decision or the call named a shadow. */
|
|
110
|
+
readonly shadow?: ShadowOutcome;
|
|
54
111
|
}
|
|
55
112
|
|
|
56
113
|
/** The fallback-guaranteed result of one `decide` call, shaped by the decision type. */
|
|
@@ -79,8 +136,12 @@ export interface DecisionDescriptor {
|
|
|
79
136
|
readonly fallback: string | number | boolean;
|
|
80
137
|
/** decision → catalog defaults → {@link DEFAULT_MIN_CONFIDENCE}. */
|
|
81
138
|
readonly minConfidence: number;
|
|
82
|
-
/** decision → catalog defaults → hub defaultMaker. */
|
|
139
|
+
/** First name of the effective chain (decision → catalog defaults → hub defaultMaker). */
|
|
83
140
|
readonly maker: string;
|
|
141
|
+
/** The full effective chain; a single name yields a one-element list. */
|
|
142
|
+
readonly makers: readonly string[];
|
|
143
|
+
/** The decision's configured shadow; a per-call shadow is not visible to `describe`. */
|
|
144
|
+
readonly shadow?: string;
|
|
84
145
|
/** decision → catalog defaults; absent when neither declares one. */
|
|
85
146
|
readonly model?: string;
|
|
86
147
|
}
|
|
@@ -97,8 +158,49 @@ export interface DecisionHubOptions {
|
|
|
97
158
|
|
|
98
159
|
/** Per-call options for {@link DecisionHub.decide}. */
|
|
99
160
|
export interface DecideOptions {
|
|
100
|
-
/**
|
|
101
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Per-call maker name or ordered fallback chain (ADR-040); wins over the decision's, the
|
|
163
|
+
* catalog's and the hub default. Validated before any backend call.
|
|
164
|
+
*/
|
|
165
|
+
readonly maker?: MakerRef;
|
|
166
|
+
/** Per-call shadow maker (ADR-040); wins over the decision's. Validated before any backend call. */
|
|
167
|
+
readonly shadow?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* One maker's outcome: its result, whether the chain should advance past it, and the answer it
|
|
172
|
+
* actually produced (absent when it fell back), which a shadow reports as its own value.
|
|
173
|
+
*/
|
|
174
|
+
interface AskOutcome {
|
|
175
|
+
result: DecisionResult;
|
|
176
|
+
failed: boolean;
|
|
177
|
+
answered?: string | number | boolean;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The one-question request handed to a maker — the shape of `DecisionMaker.ask`. */
|
|
181
|
+
interface AskRequest {
|
|
182
|
+
state: DecisionState;
|
|
183
|
+
questions: Record<string, Question>;
|
|
184
|
+
model?: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Normalize a {@link MakerRef} to an ordered chain. Empty and duplicate lists are caller errors
|
|
189
|
+
* here, not at load: the loader's schema already rejects them in a catalog.
|
|
190
|
+
*/
|
|
191
|
+
function toChain(ref: MakerRef): readonly string[] {
|
|
192
|
+
if (typeof ref === 'string') return [ref];
|
|
193
|
+
if (ref.length === 0) {
|
|
194
|
+
throw new UnknownDecisionMakerError('A maker chain must name at least one maker', '');
|
|
195
|
+
}
|
|
196
|
+
const seen = new Set<string>();
|
|
197
|
+
for (const name of ref) {
|
|
198
|
+
if (seen.has(name)) {
|
|
199
|
+
throw new UnknownDecisionMakerError(`Maker chain repeats '${name}'`, name);
|
|
200
|
+
}
|
|
201
|
+
seen.add(name);
|
|
202
|
+
}
|
|
203
|
+
return [...ref];
|
|
102
204
|
}
|
|
103
205
|
|
|
104
206
|
/** One registered decision plus the catalog defaults it loaded under. */
|
|
@@ -176,20 +278,24 @@ export class DecisionHub {
|
|
|
176
278
|
|
|
177
279
|
/**
|
|
178
280
|
* Register every decision of an already-loaded catalog. All-or-nothing: a
|
|
179
|
-
* duplicate decision id or an unregistered maker name (
|
|
180
|
-
* `defaults.maker
|
|
281
|
+
* duplicate decision id or an unregistered maker name (any chain name of a
|
|
282
|
+
* decision or `defaults.maker`, or a decision `shadow`) throws
|
|
283
|
+
* {@link DecisionCatalogError} and registers
|
|
181
284
|
* nothing from the offending catalog.
|
|
182
285
|
*/
|
|
183
286
|
load(catalog: DecisionCatalog): void {
|
|
184
287
|
const defaultsMaker = catalog.defaults.maker;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
288
|
+
const defaultsChain = defaultsMaker === undefined ? undefined : toChain(defaultsMaker);
|
|
289
|
+
for (const name of defaultsChain ?? []) {
|
|
290
|
+
if (!this.registry.has(name)) {
|
|
291
|
+
throw new DecisionCatalogError(
|
|
292
|
+
`Catalog "${catalog.source}" defaults.maker '${name}' is not registered in the hub registry ` +
|
|
293
|
+
`(registered: ${this.registry.names().join(', ') || 'none'})`,
|
|
294
|
+
catalog.source,
|
|
295
|
+
undefined,
|
|
296
|
+
'maker',
|
|
297
|
+
);
|
|
298
|
+
}
|
|
193
299
|
}
|
|
194
300
|
// Stage first, commit only when every decision passed the cross-catalog checks.
|
|
195
301
|
const staged: RegisteredDecision[] = [];
|
|
@@ -204,13 +310,24 @@ export class DecisionHub {
|
|
|
204
310
|
'id',
|
|
205
311
|
);
|
|
206
312
|
}
|
|
207
|
-
|
|
313
|
+
for (const name of definition.maker === undefined ? [] : toChain(definition.maker)) {
|
|
314
|
+
if (!this.registry.has(name)) {
|
|
315
|
+
throw new DecisionCatalogError(
|
|
316
|
+
`Decision "${id}" in catalog "${catalog.source}" names maker '${name}' that is not ` +
|
|
317
|
+
`registered in the hub registry (registered: ${this.registry.names().join(', ') || 'none'})`,
|
|
318
|
+
catalog.source,
|
|
319
|
+
id,
|
|
320
|
+
'maker',
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (definition.shadow !== undefined && !this.registry.has(definition.shadow)) {
|
|
208
325
|
throw new DecisionCatalogError(
|
|
209
|
-
`Decision "${id}" in catalog "${catalog.source}" names
|
|
326
|
+
`Decision "${id}" in catalog "${catalog.source}" names shadow '${definition.shadow}' that is not ` +
|
|
210
327
|
`registered in the hub registry (registered: ${this.registry.names().join(', ') || 'none'})`,
|
|
211
328
|
catalog.source,
|
|
212
329
|
id,
|
|
213
|
-
'
|
|
330
|
+
'shadow',
|
|
214
331
|
);
|
|
215
332
|
}
|
|
216
333
|
staged.push({ definition, defaults: catalog.defaults });
|
|
@@ -236,6 +353,7 @@ export class DecisionHub {
|
|
|
236
353
|
/** Pure read of one decision's served contract; throws UnknownDecisionError for an unknown id. */
|
|
237
354
|
describe(id: string): DecisionDescriptor {
|
|
238
355
|
const { definition, defaults } = this.mustGet(id);
|
|
356
|
+
const chain = this.effectiveChain(definition, defaults);
|
|
239
357
|
const model = definition.model ?? defaults.model;
|
|
240
358
|
return {
|
|
241
359
|
id: definition.id,
|
|
@@ -246,7 +364,9 @@ export class DecisionHub {
|
|
|
246
364
|
criteria: definition.criteria,
|
|
247
365
|
fallback: definition.fallback,
|
|
248
366
|
minConfidence: effectiveMinConfidence(definition, defaults),
|
|
249
|
-
maker:
|
|
367
|
+
maker: chain[0] ?? DEFAULT_MAKER,
|
|
368
|
+
makers: chain,
|
|
369
|
+
...(definition.shadow !== undefined ? { shadow: definition.shadow } : {}),
|
|
250
370
|
...(model !== undefined ? { model } : {}),
|
|
251
371
|
};
|
|
252
372
|
}
|
|
@@ -254,8 +374,9 @@ export class DecisionHub {
|
|
|
254
374
|
/**
|
|
255
375
|
* Serve one decision with a fallback-guaranteed answer. Resolves the input,
|
|
256
376
|
* renders exactly one Jev question keyed by the decision id, selects the
|
|
257
|
-
* maker
|
|
258
|
-
*
|
|
377
|
+
* maker chain (per call → decision → catalog defaults → hub default) and
|
|
378
|
+
* asks its makers in order until one answers, with an optional shadow asked
|
|
379
|
+
* beside it (ADR-040). Backend construction, transport, timeout,
|
|
259
380
|
* invalid answers and low confidence all resolve to the declared fallback;
|
|
260
381
|
* only caller mistakes throw, before any backend call.
|
|
261
382
|
*/
|
|
@@ -264,14 +385,26 @@ export class DecisionHub {
|
|
|
264
385
|
const { definition, defaults } = this.mustGet(id); // UnknownDecisionError — caller error
|
|
265
386
|
const params = resolveDecisionInput(definition, input); // DecisionInputError — caller error
|
|
266
387
|
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
// Reachable only for a per-call
|
|
270
|
-
// load/construct.
|
|
388
|
+
const chain = options.maker === undefined ? this.effectiveChain(definition, defaults) : toChain(options.maker);
|
|
389
|
+
for (const name of chain) {
|
|
390
|
+
// Reachable only for a per-call chain: catalog and hub default names are checked at
|
|
391
|
+
// load/construct. A caller error, thrown before any backend interaction.
|
|
392
|
+
if (!this.registry.has(name)) {
|
|
393
|
+
throw new UnknownDecisionMakerError(
|
|
394
|
+
`Decision "${id}" was asked on maker '${name}' that is not registered in the hub registry ` +
|
|
395
|
+
`(registered: ${this.registry.names().join(', ') || 'none'})`,
|
|
396
|
+
name,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const shadowName = options.shadow ?? definition.shadow;
|
|
402
|
+
if (shadowName !== undefined && !this.registry.has(shadowName)) {
|
|
403
|
+
// Same caller-error rule as a per-call maker name: before any backend interaction.
|
|
271
404
|
throw new UnknownDecisionMakerError(
|
|
272
|
-
`Decision "${id}" was asked
|
|
405
|
+
`Decision "${id}" was asked with shadow '${shadowName}' that is not registered in the hub registry ` +
|
|
273
406
|
`(registered: ${this.registry.names().join(', ') || 'none'})`,
|
|
274
|
-
|
|
407
|
+
shadowName,
|
|
275
408
|
);
|
|
276
409
|
}
|
|
277
410
|
|
|
@@ -283,6 +416,97 @@ export class DecisionHub {
|
|
|
283
416
|
...(model !== undefined ? { model } : {}),
|
|
284
417
|
};
|
|
285
418
|
|
|
419
|
+
// Shadow rule (ADR-040): start the shadow on the SAME request object, without awaiting it, so
|
|
420
|
+
// both paths run concurrently and latency is the slower of the two. It never rejects `decide`
|
|
421
|
+
// and never touches a served field — a rejected or failed ask maps to its own outcome.
|
|
422
|
+
const shadowStart = this.now();
|
|
423
|
+
const shadowTask: Promise<AskOutcome> | undefined =
|
|
424
|
+
shadowName === undefined
|
|
425
|
+
? undefined
|
|
426
|
+
: this.askOne(shadowName, definition, defaults, request, shadowStart).then(
|
|
427
|
+
(settled): AskOutcome => settled,
|
|
428
|
+
(error: unknown): AskOutcome => ({
|
|
429
|
+
result: this.fallbackResult(
|
|
430
|
+
definition,
|
|
431
|
+
shadowName,
|
|
432
|
+
'error',
|
|
433
|
+
null,
|
|
434
|
+
shadowStart,
|
|
435
|
+
undefined,
|
|
436
|
+
toErrorInfo(error),
|
|
437
|
+
),
|
|
438
|
+
failed: true,
|
|
439
|
+
}),
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
// Chain rule (ADR-040): ask makers in order and stop at the first that did not fail as a
|
|
443
|
+
// backend — `accepted` and `low-confidence` both answer. Every maker after the answering one
|
|
444
|
+
// stays unresolved, so its lazy factory never runs.
|
|
445
|
+
const attempts: DecisionAttempt[] = [];
|
|
446
|
+
let last: { result: DecisionResult; failed: boolean } | undefined;
|
|
447
|
+
for (const makerName of chain) {
|
|
448
|
+
const attemptStart = this.now();
|
|
449
|
+
last = await this.askOne(makerName, definition, defaults, request, attemptStart);
|
|
450
|
+
attempts.push({
|
|
451
|
+
maker: makerName,
|
|
452
|
+
reason: last.result.reason,
|
|
453
|
+
durationMs: this.now() - attemptStart,
|
|
454
|
+
...(last.result.error !== undefined ? { error: last.result.error } : {}),
|
|
455
|
+
});
|
|
456
|
+
if (!last.failed) break;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// `toChain` rejects an empty list, so the loop always ran at least once.
|
|
460
|
+
const final = last?.result;
|
|
461
|
+
if (final === undefined) {
|
|
462
|
+
throw new UnknownDecisionMakerError(`Decision "${id}" has no maker to ask`, id);
|
|
463
|
+
}
|
|
464
|
+
// `durationMs` covers the whole call, shadow wait included; a single-maker call without a
|
|
465
|
+
// shadow keeps its exact previous shape.
|
|
466
|
+
const shadowOutcome = shadowTask === undefined ? undefined : this.toShadowOutcome(await shadowTask);
|
|
467
|
+
return {
|
|
468
|
+
...final,
|
|
469
|
+
durationMs: this.now() - start,
|
|
470
|
+
...(attempts.length > 1 ? { attempts } : {}),
|
|
471
|
+
...(shadowOutcome !== undefined ? { shadow: shadowOutcome } : {}),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Project a shadow's own result onto {@link ShadowOutcome}. `value` is carried only when the shadow
|
|
477
|
+
* actually answered — a served-model answer or a low-confidence one — never its declared fallback.
|
|
478
|
+
*/
|
|
479
|
+
private toShadowOutcome(outcome: AskOutcome): ShadowOutcome {
|
|
480
|
+
const { result } = outcome;
|
|
481
|
+
const answered = result.source === 'model' || result.reason === 'low-confidence';
|
|
482
|
+
return {
|
|
483
|
+
maker: result.maker,
|
|
484
|
+
...(answered && outcome.answered !== undefined ? { value: outcome.answered } : {}),
|
|
485
|
+
confidence: result.confidence,
|
|
486
|
+
reason: result.reason,
|
|
487
|
+
durationMs: result.durationMs,
|
|
488
|
+
...(result.error !== undefined ? { error: result.error } : {}),
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Ask one maker and classify its outcome. `failed` is true exactly for `no-backend`, `timeout`
|
|
494
|
+
* and `error` — the reasons a chain advances past (ADR-040); `accepted` and `low-confidence`
|
|
495
|
+
* answer, so the chain stops there.
|
|
496
|
+
*/
|
|
497
|
+
private async askOne(
|
|
498
|
+
makerName: string,
|
|
499
|
+
definition: DecisionDefinition,
|
|
500
|
+
defaults: CatalogDefaults,
|
|
501
|
+
request: AskRequest,
|
|
502
|
+
start: number,
|
|
503
|
+
): Promise<AskOutcome> {
|
|
504
|
+
const outcome = (result: DecisionResult, answered?: string | number | boolean): AskOutcome => ({
|
|
505
|
+
result,
|
|
506
|
+
failed: result.reason === 'no-backend' || result.reason === 'timeout' || result.reason === 'error',
|
|
507
|
+
...(answered !== undefined ? { answered } : {}),
|
|
508
|
+
});
|
|
509
|
+
|
|
286
510
|
// The one auditable try/catch for steps 4–5: a resolve failure is a construction
|
|
287
511
|
// failure (no-backend); ask failures map per the error taxonomy. Neither rejects.
|
|
288
512
|
let inAsk = false;
|
|
@@ -296,17 +520,33 @@ export class DecisionHub {
|
|
|
296
520
|
validateAnswers(request.questions, answers);
|
|
297
521
|
const answer: Answer | undefined = answers[definition.id];
|
|
298
522
|
if (answer === undefined || answer.kind !== definition.type) {
|
|
299
|
-
|
|
523
|
+
// Nothing was thrown here, so the failure is synthesized: every `error` reason
|
|
524
|
+
// must carry a cause a consumer can log (ADR-040).
|
|
525
|
+
return outcome(
|
|
526
|
+
this.fallbackResult(definition, makerName, 'error', null, start, undefined, {
|
|
527
|
+
name: 'DecisionBackendError',
|
|
528
|
+
message: `Maker returned no answer of type ${definition.type} for "${definition.id}"`,
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
300
531
|
}
|
|
301
532
|
const observed = observedAnswer(answer);
|
|
533
|
+
const answered =
|
|
534
|
+
answer.kind === 'choice'
|
|
535
|
+
? answer.label
|
|
536
|
+
: answer.kind === 'score'
|
|
537
|
+
? answer.score
|
|
538
|
+
: answer.probability >= 0.5;
|
|
302
539
|
if (observed.confidence < effectiveMinConfidence(definition, defaults)) {
|
|
303
|
-
return
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
540
|
+
return outcome(
|
|
541
|
+
this.fallbackResult(
|
|
542
|
+
definition,
|
|
543
|
+
makerName,
|
|
544
|
+
'low-confidence',
|
|
545
|
+
observed.confidence,
|
|
546
|
+
start,
|
|
547
|
+
observed.probability,
|
|
548
|
+
),
|
|
549
|
+
answered,
|
|
310
550
|
);
|
|
311
551
|
}
|
|
312
552
|
const base = {
|
|
@@ -319,16 +559,19 @@ export class DecisionHub {
|
|
|
319
559
|
};
|
|
320
560
|
switch (answer.kind) {
|
|
321
561
|
case 'choice':
|
|
322
|
-
return { ...base, type: 'choice', value: answer.label };
|
|
562
|
+
return outcome({ ...base, type: 'choice', value: answer.label }, answered);
|
|
323
563
|
case 'score':
|
|
324
|
-
return { ...base, type: 'score', value: answer.score };
|
|
564
|
+
return outcome({ ...base, type: 'score', value: answer.score }, answered);
|
|
325
565
|
case 'noul':
|
|
326
|
-
return
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
566
|
+
return outcome(
|
|
567
|
+
{
|
|
568
|
+
...base,
|
|
569
|
+
type: 'noul',
|
|
570
|
+
value: answer.probability >= 0.5,
|
|
571
|
+
probability: answer.probability,
|
|
572
|
+
},
|
|
573
|
+
answered,
|
|
574
|
+
);
|
|
332
575
|
}
|
|
333
576
|
} catch (error) {
|
|
334
577
|
const reason =
|
|
@@ -337,13 +580,16 @@ export class DecisionHub {
|
|
|
337
580
|
: error instanceof DecisionTimeoutError
|
|
338
581
|
? 'timeout'
|
|
339
582
|
: 'error';
|
|
340
|
-
return
|
|
583
|
+
return outcome(
|
|
584
|
+
this.fallbackResult(definition, makerName, reason, null, start, undefined, toErrorInfo(error)),
|
|
585
|
+
);
|
|
341
586
|
}
|
|
342
587
|
}
|
|
343
588
|
|
|
344
|
-
/** Maker-
|
|
345
|
-
private
|
|
346
|
-
|
|
589
|
+
/** Maker-chain precedence for discovery results: decision → catalog defaults → hub default. */
|
|
590
|
+
private effectiveChain(definition: DecisionDefinition, defaults: CatalogDefaults): readonly string[] {
|
|
591
|
+
const ref = definition.maker ?? defaults.maker;
|
|
592
|
+
return ref === undefined ? [this.defaultMaker] : toChain(ref);
|
|
347
593
|
}
|
|
348
594
|
|
|
349
595
|
private mustGet(id: string): RegisteredDecision {
|
|
@@ -370,6 +616,7 @@ export class DecisionHub {
|
|
|
370
616
|
confidence: number | null,
|
|
371
617
|
start: number,
|
|
372
618
|
probability?: number,
|
|
619
|
+
error?: DecisionErrorInfo,
|
|
373
620
|
): DecisionResult {
|
|
374
621
|
const base = {
|
|
375
622
|
id: definition.id,
|
|
@@ -378,6 +625,7 @@ export class DecisionHub {
|
|
|
378
625
|
reason,
|
|
379
626
|
maker: makerName,
|
|
380
627
|
durationMs: this.now() - start,
|
|
628
|
+
...(error !== undefined ? { error } : {}),
|
|
381
629
|
};
|
|
382
630
|
switch (definition.type) {
|
|
383
631
|
case 'choice':
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from './agent-driver';
|
|
1
2
|
export * from './catalog';
|
|
2
3
|
export * from './errors';
|
|
3
4
|
export * from './hub';
|
|
@@ -5,3 +6,4 @@ export * from './params';
|
|
|
5
6
|
export * from './registry';
|
|
6
7
|
export * from './schema';
|
|
7
8
|
export * from './types';
|
|
9
|
+
export * from './typesafe-driver';
|
package/src/registry.ts
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createDecisionMaker,
|
|
3
|
+
type DecisionMaker,
|
|
4
|
+
type DecisionMakerOptions,
|
|
5
|
+
resolveApiKey,
|
|
6
|
+
} from '@gobing-ai/ts-ai-runner';
|
|
2
7
|
import { createFmDriver, type FmDriverOptions } from '@gobing-ai/ts-decision-fm';
|
|
3
8
|
import { createLayaDriver, type LayaDriverOptions } from '@gobing-ai/ts-laya-mlx';
|
|
9
|
+
import { type AgentDriverOptions, createAgentDriver } from './agent-driver';
|
|
4
10
|
import { DecisionRegistryError, UnknownDecisionMakerError } from './errors';
|
|
11
|
+
import { createTypesafeDriver } from './typesafe-driver';
|
|
5
12
|
|
|
6
13
|
/** A maker, or a lazy factory for one (sync or async). Factories run on first `resolve` and are memoised on success. */
|
|
7
14
|
export type MakerSource = DecisionMaker | (() => DecisionMaker | Promise<DecisionMaker>);
|
|
8
15
|
|
|
9
|
-
/** Options for the
|
|
16
|
+
/** Options for the four built-in makers pre-registered by {@link DecisionMakerRegistry} (task 0091 R2, task 0115 R8). */
|
|
10
17
|
export interface BuiltinMakerOptions {
|
|
11
18
|
/** Forwarded to `createDecisionMaker` for every built-in (env, apiKey, baseURL, timeoutMs, ...). */
|
|
12
19
|
readonly makerOptions?: Omit<DecisionMakerOptions, 'driver' | 'backend' | 'model'>;
|
|
13
|
-
/** Driver options for the bundled
|
|
14
|
-
readonly driverOptions?: {
|
|
20
|
+
/** Driver options for the bundled drivers; the fm platform check runs at first resolve, not registration. */
|
|
21
|
+
readonly driverOptions?: {
|
|
22
|
+
readonly 'fm-local'?: FmDriverOptions;
|
|
23
|
+
readonly 'laya-local'?: LayaDriverOptions;
|
|
24
|
+
readonly 'llm-agent'?: AgentDriverOptions;
|
|
25
|
+
};
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
@@ -31,11 +42,29 @@ export class DecisionMakerRegistry {
|
|
|
31
42
|
{ source: MakerSource; maker?: DecisionMaker; pending?: Promise<DecisionMaker> }
|
|
32
43
|
>();
|
|
33
44
|
|
|
34
|
-
/** Pre-registers `typesafe`, `fm-local` and `
|
|
45
|
+
/** Pre-registers `typesafe`, `fm-local`, `laya-local` and `llm-agent` unless `builtins: false`. */
|
|
35
46
|
constructor(options?: { builtins?: BuiltinMakerOptions | false }) {
|
|
36
47
|
if (options?.builtins === false) return;
|
|
37
48
|
const { makerOptions = {}, driverOptions = {} } = options?.builtins ?? {};
|
|
38
|
-
|
|
49
|
+
// The driver lives here now (ADR-039), so the built-in no longer routes through the
|
|
50
|
+
// deprecated dynamic-import bridge. A missing key still throws inside this lazy factory,
|
|
51
|
+
// before any request, so the hub keeps reporting `reason: 'no-backend'`.
|
|
52
|
+
//
|
|
53
|
+
// `Omit<>` over an index-signature option bag collapses every declared field to `unknown`,
|
|
54
|
+
// so the bag is read back through its own type here — same object, same runtime values.
|
|
55
|
+
const transportOptions = makerOptions as DecisionMakerOptions;
|
|
56
|
+
this.register('typesafe', () =>
|
|
57
|
+
createDecisionMaker({
|
|
58
|
+
driver: createTypesafeDriver({
|
|
59
|
+
apiKey: resolveApiKey(transportOptions),
|
|
60
|
+
baseURL: transportOptions.baseURL,
|
|
61
|
+
timeoutMs: transportOptions.timeoutMs,
|
|
62
|
+
maxRetries: transportOptions.maxRetries,
|
|
63
|
+
fetch: transportOptions.fetch,
|
|
64
|
+
}),
|
|
65
|
+
...makerOptions,
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
39
68
|
this.register('fm-local', () =>
|
|
40
69
|
createDecisionMaker({ driver: createFmDriver(driverOptions['fm-local'] ?? {}), ...makerOptions }),
|
|
41
70
|
);
|
|
@@ -51,6 +80,11 @@ export class DecisionMakerRegistry {
|
|
|
51
80
|
...makerOptions,
|
|
52
81
|
});
|
|
53
82
|
});
|
|
83
|
+
// Lazy like the rest: `names()`/`has()` construct nothing, and agent detection
|
|
84
|
+
// runs inside the first `ask`, so an unavailable agent surfaces as a fallback.
|
|
85
|
+
this.register('llm-agent', () =>
|
|
86
|
+
createDecisionMaker({ driver: createAgentDriver(driverOptions['llm-agent'] ?? {}), ...makerOptions }),
|
|
87
|
+
);
|
|
54
88
|
}
|
|
55
89
|
|
|
56
90
|
/** Register a maker or factory under `name`; chains. Throws on a bad or already-registered name. */
|
package/src/schema.ts
CHANGED
|
@@ -15,6 +15,24 @@ export const PARAM_NAME_PATTERN = /^[a-z][a-zA-Z0-9_]*$/;
|
|
|
15
15
|
/** Maker registry names: lowercase kebab-case. */
|
|
16
16
|
export const MAKER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
17
17
|
|
|
18
|
+
/** One maker name, or an ordered fallback chain of distinct names (ADR-040). */
|
|
19
|
+
const MakerRefSchema = z.union([
|
|
20
|
+
z.string().regex(MAKER_NAME_PATTERN),
|
|
21
|
+
z
|
|
22
|
+
.array(z.string().regex(MAKER_NAME_PATTERN))
|
|
23
|
+
.min(1)
|
|
24
|
+
.superRefine((names, ctx) => {
|
|
25
|
+
const seen = new Set<string>();
|
|
26
|
+
for (const name of names) {
|
|
27
|
+
if (seen.has(name)) {
|
|
28
|
+
ctx.addIssue({ code: 'custom', message: `maker list repeats '${name}'` });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
seen.add(name);
|
|
32
|
+
}
|
|
33
|
+
}),
|
|
34
|
+
]);
|
|
35
|
+
|
|
18
36
|
const JsonValueSchema: z.ZodType<Json> = z.lazy(() =>
|
|
19
37
|
z.union([
|
|
20
38
|
z.string(),
|
|
@@ -60,7 +78,8 @@ export const DecisionSchema = z
|
|
|
60
78
|
criteria: z.union([NoulCriteriaSchema, ChoiceCriteriaSchema, ScoreCriteriaSchema]).optional(),
|
|
61
79
|
fallback: z.union([z.string(), z.number(), z.boolean()]),
|
|
62
80
|
minConfidence: z.number().min(0).max(1).optional(),
|
|
63
|
-
maker:
|
|
81
|
+
maker: MakerRefSchema.optional(),
|
|
82
|
+
shadow: z.string().regex(MAKER_NAME_PATTERN).optional(),
|
|
64
83
|
model: z.string().optional(),
|
|
65
84
|
})
|
|
66
85
|
.strict();
|
|
@@ -72,7 +91,7 @@ export const DecisionCatalogSchema = z
|
|
|
72
91
|
version: z.literal(1),
|
|
73
92
|
defaults: z
|
|
74
93
|
.object({
|
|
75
|
-
maker:
|
|
94
|
+
maker: MakerRefSchema.optional(),
|
|
76
95
|
model: z.string().optional(),
|
|
77
96
|
minConfidence: z.number().min(0).max(1).optional(),
|
|
78
97
|
})
|
package/src/types.ts
CHANGED
|
@@ -43,14 +43,23 @@ export interface DecisionDefinition {
|
|
|
43
43
|
readonly fallback: string | number | boolean;
|
|
44
44
|
/** Overrides `defaults.minConfidence` (0..1). */
|
|
45
45
|
readonly minConfidence?: number;
|
|
46
|
-
/** Registry name, checked at hub load, not by the
|
|
47
|
-
readonly maker?:
|
|
46
|
+
/** Registry name, or an ordered fallback chain of names (ADR-040); checked at hub load, not by the loader. */
|
|
47
|
+
readonly maker?: MakerRef;
|
|
48
|
+
/** One maker asked beside the primary on live traffic and reported, never served (ADR-040). */
|
|
49
|
+
readonly shadow?: string;
|
|
48
50
|
readonly model?: string;
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/**
|
|
54
|
+
* A maker reference: one registry name, or an ordered fallback chain of names (ADR-040). The
|
|
55
|
+
* authored form is preserved (`string` stays `string`), so a single-name catalog round-trips
|
|
56
|
+
* byte-identically; the hub resolves the chain per call.
|
|
57
|
+
*/
|
|
58
|
+
export type MakerRef = string | readonly string[];
|
|
59
|
+
|
|
51
60
|
/** Catalog-wide maker/model/minConfidence defaults; every key optional. */
|
|
52
61
|
export interface CatalogDefaults {
|
|
53
|
-
readonly maker?:
|
|
62
|
+
readonly maker?: MakerRef;
|
|
54
63
|
readonly model?: string;
|
|
55
64
|
readonly minConfidence?: number;
|
|
56
65
|
}
|