@gobing-ai/ts-ai-decision 0.5.30 → 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/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
- /** Per-call maker name; wins over the decision's, the catalog's and the hub default. */
101
- readonly maker?: string;
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 (decision or
180
- * `defaults.maker`) throws {@link DecisionCatalogError} and registers
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
- if (defaultsMaker !== undefined && !this.registry.has(defaultsMaker)) {
186
- throw new DecisionCatalogError(
187
- `Catalog "${catalog.source}" defaults.maker '${defaultsMaker}' is not registered in the hub registry ` +
188
- `(registered: ${this.registry.names().join(', ') || 'none'})`,
189
- catalog.source,
190
- undefined,
191
- 'maker',
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
- if (definition.maker !== undefined && !this.registry.has(definition.maker)) {
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 maker '${definition.maker}' that is not ` +
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
- 'maker',
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: this.effectiveMakerName(definition, defaults),
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 by name (per call → decision → catalog defaults → hub default) and
258
- * issues a single `maker.ask`. Backend construction, transport, timeout,
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 makerName = options.maker ?? definition.maker ?? defaults.maker ?? this.defaultMaker;
268
- if (!this.registry.has(makerName)) {
269
- // Reachable only for a per-call name: catalog and hub default names are checked at
270
- // load/construct. It is a caller error, thrown before any backend interaction.
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 on maker '${makerName}' that is not registered in the hub registry ` +
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
- makerName,
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
- return this.fallbackResult(definition, makerName, 'error', null, start);
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 this.fallbackResult(
304
- definition,
305
- makerName,
306
- 'low-confidence',
307
- observed.confidence,
308
- start,
309
- observed.probability,
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
- ...base,
328
- type: 'noul',
329
- value: answer.probability >= 0.5,
330
- probability: answer.probability,
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 this.fallbackResult(definition, makerName, reason, null, start);
583
+ return outcome(
584
+ this.fallbackResult(definition, makerName, reason, null, start, undefined, toErrorInfo(error)),
585
+ );
341
586
  }
342
587
  }
343
588
 
344
- /** Maker-name precedence for discovery results: decision → catalog defaults → hub default. */
345
- private effectiveMakerName(definition: DecisionDefinition, defaults: CatalogDefaults): string {
346
- return definition.maker ?? defaults.maker ?? this.defaultMaker;
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 { createDecisionMaker, type DecisionMaker, type DecisionMakerOptions } from '@gobing-ai/ts-ai-runner';
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 three built-in makers pre-registered by {@link DecisionMakerRegistry} (task 0091 R2). */
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 local drivers; the fm platform check runs at first resolve, not registration. */
14
- readonly driverOptions?: { readonly 'fm-local'?: FmDriverOptions; readonly 'laya-local'?: LayaDriverOptions };
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 `laya-local` unless `builtins: false`. */
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
- this.register('typesafe', () => createDecisionMaker({ backend: 'typesafe', ...makerOptions }));
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: z.string().regex(MAKER_NAME_PATTERN).optional(),
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: z.string().regex(MAKER_NAME_PATTERN).optional(),
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 catalog loader. */
47
- readonly maker?: string;
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?: string;
62
+ readonly maker?: MakerRef;
54
63
  readonly model?: string;
55
64
  readonly minConfidence?: number;
56
65
  }