@gobing-ai/ts-ai-decision 0.5.30 → 0.5.33

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,91 @@
1
- import { createDecisionMaker, type DecisionMaker, type DecisionMakerOptions } from '@gobing-ai/ts-ai-runner';
1
+ import {
2
+ createDecisionMaker,
3
+ DecisionConfigError,
4
+ type DecisionDriver,
5
+ type DecisionMaker,
6
+ type DecisionMakerOptions,
7
+ resolveApiKey,
8
+ } from '@gobing-ai/ts-ai-runner';
2
9
  import { createFmDriver, type FmDriverOptions } from '@gobing-ai/ts-decision-fm';
3
- import { createLayaDriver, type LayaDriverOptions } from '@gobing-ai/ts-laya-mlx';
10
+ import { type AgentDriverOptions, createAgentDriver } from './agent-driver';
4
11
  import { DecisionRegistryError, UnknownDecisionMakerError } from './errors';
12
+ import { createTypesafeDriver } from './typesafe-driver';
13
+
14
+ /**
15
+ * Options forwarded to `createLayaDriver`. Same fields as `LayaDriverOptions` in
16
+ * `@gobing-ai/ts-laya-mlx`. Declared here so published `.d.ts` files do not import
17
+ * that package: it is an optional, macOS-only dependency (ADR-041) and is absent
18
+ * on other platforms.
19
+ */
20
+ export interface LayaLocalDriverOptions {
21
+ pythonPath?: string;
22
+ module?: string;
23
+ workerScript?: string;
24
+ cwd?: string;
25
+ modelId?: string;
26
+ modelPath?: string;
27
+ revision?: string;
28
+ cacheDir?: string;
29
+ dtype?: 'float32' | 'float16' | 'bfloat16';
30
+ batchSize?: number;
31
+ startupTimeoutMs?: number;
32
+ requestTimeoutMs?: number;
33
+ env?: Record<string, string | undefined>;
34
+ platform?: string;
35
+ arch?: string;
36
+ core?: 'mlx-python' | 'mlx-native' | 'auto';
37
+ /** Same role as `LayaDriverOptions.client`. Typed wide so the optional module's class stays assignable. */
38
+ client?: unknown;
39
+ /** Same role as `LayaDriverOptions.nativeLoader`. */
40
+ nativeLoader?: unknown;
41
+ }
42
+
43
+ const LAYA_DRIVER_PACKAGE = '@gobing-ai/ts-laya-mlx';
44
+
45
+ /**
46
+ * Load the bundled Laya driver. The package is an `optionalDependency` with
47
+ * `"os": ["darwin"]`, so non-macOS installs do not contain it. Only the import
48
+ * failure is rewritten; factory errors propagate unchanged.
49
+ *
50
+ * @internal Tests pass `load` because a workspace link always resolves.
51
+ */
52
+ export async function resolveBundledLayaDriver(
53
+ options: LayaLocalDriverOptions = {},
54
+ load: () => Promise<unknown> = () => import('@gobing-ai/ts-laya-mlx'),
55
+ ): Promise<DecisionDriver> {
56
+ let loaded: unknown;
57
+ try {
58
+ loaded = await load();
59
+ } catch (cause) {
60
+ throw new DecisionConfigError(
61
+ `The 'laya-local' maker requires '${LAYA_DRIVER_PACKAGE}', an optional dependency installed only on macOS. On macOS, install it with 'bun add ${LAYA_DRIVER_PACKAGE}'.`,
62
+ 'LAYA_BACKEND',
63
+ { cause },
64
+ );
65
+ }
66
+ const record = loaded !== null && typeof loaded === 'object' ? (loaded as { createLayaDriver?: unknown }) : {};
67
+ if (typeof record.createLayaDriver !== 'function') {
68
+ throw new DecisionConfigError(
69
+ `Module '${LAYA_DRIVER_PACKAGE}' does not export createLayaDriver`,
70
+ 'LAYA_BACKEND',
71
+ );
72
+ }
73
+ return record.createLayaDriver(options);
74
+ }
5
75
 
6
76
  /** A maker, or a lazy factory for one (sync or async). Factories run on first `resolve` and are memoised on success. */
7
77
  export type MakerSource = DecisionMaker | (() => DecisionMaker | Promise<DecisionMaker>);
8
78
 
9
- /** Options for the three built-in makers pre-registered by {@link DecisionMakerRegistry} (task 0091 R2). */
79
+ /** Options for the four built-in makers pre-registered by {@link DecisionMakerRegistry} (task 0091 R2, task 0115 R8). */
10
80
  export interface BuiltinMakerOptions {
11
81
  /** Forwarded to `createDecisionMaker` for every built-in (env, apiKey, baseURL, timeoutMs, ...). */
12
82
  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 };
83
+ /** Driver options for the bundled drivers; the fm platform check runs at first resolve, not registration. */
84
+ readonly driverOptions?: {
85
+ readonly 'fm-local'?: FmDriverOptions;
86
+ readonly 'laya-local'?: LayaLocalDriverOptions;
87
+ readonly 'llm-agent'?: AgentDriverOptions;
88
+ };
15
89
  }
16
90
 
17
91
  const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
@@ -31,26 +105,53 @@ export class DecisionMakerRegistry {
31
105
  { source: MakerSource; maker?: DecisionMaker; pending?: Promise<DecisionMaker> }
32
106
  >();
33
107
 
34
- /** Pre-registers `typesafe`, `fm-local` and `laya-local` unless `builtins: false`. */
108
+ /** Pre-registers `typesafe`, `fm-local`, `laya-local` and `llm-agent` unless `builtins: false`. */
35
109
  constructor(options?: { builtins?: BuiltinMakerOptions | false }) {
36
110
  if (options?.builtins === false) return;
37
111
  const { makerOptions = {}, driverOptions = {} } = options?.builtins ?? {};
38
- this.register('typesafe', () => createDecisionMaker({ backend: 'typesafe', ...makerOptions }));
112
+ // The driver lives here now (ADR-039), so the built-in no longer routes through the
113
+ // deprecated dynamic-import bridge. A missing key still throws inside this lazy factory,
114
+ // before any request, so the hub keeps reporting `reason: 'no-backend'`.
115
+ //
116
+ // `Omit<>` over an index-signature option bag collapses every declared field to `unknown`,
117
+ // so the bag is read back through its own type here — same object, same runtime values.
118
+ const transportOptions = makerOptions as DecisionMakerOptions;
119
+ this.register('typesafe', () =>
120
+ createDecisionMaker({
121
+ driver: createTypesafeDriver({
122
+ apiKey: resolveApiKey(transportOptions),
123
+ baseURL: transportOptions.baseURL,
124
+ timeoutMs: transportOptions.timeoutMs,
125
+ maxRetries: transportOptions.maxRetries,
126
+ fetch: transportOptions.fetch,
127
+ }),
128
+ ...makerOptions,
129
+ }),
130
+ );
39
131
  this.register('fm-local', () =>
40
132
  createDecisionMaker({ driver: createFmDriver(driverOptions['fm-local'] ?? {}), ...makerOptions }),
41
133
  );
42
- this.register('laya-local', () => {
43
- const driverOpts: LayaDriverOptions = {
134
+ // Dynamic import: a top-level import would throw on hosts where the
135
+ // optional macOS package was not installed, and the rest of the hub
136
+ // would fail to load. A missing module becomes LAYA_BACKEND, which
137
+ // DecisionHub maps to reason 'no-backend'.
138
+ this.register('laya-local', async () => {
139
+ const driverOpts: LayaLocalDriverOptions = {
44
140
  ...driverOptions['laya-local'],
45
141
  };
46
142
  if (makerOptions.env && driverOpts.env === undefined) {
47
143
  driverOpts.env = makerOptions.env as Record<string, string | undefined>;
48
144
  }
49
145
  return createDecisionMaker({
50
- driver: createLayaDriver(driverOpts),
146
+ driver: await resolveBundledLayaDriver(driverOpts),
51
147
  ...makerOptions,
52
148
  });
53
149
  });
150
+ // Lazy like the rest: `names()`/`has()` construct nothing, and agent detection
151
+ // runs inside the first `ask`, so an unavailable agent surfaces as a fallback.
152
+ this.register('llm-agent', () =>
153
+ createDecisionMaker({ driver: createAgentDriver(driverOptions['llm-agent'] ?? {}), ...makerOptions }),
154
+ );
54
155
  }
55
156
 
56
157
  /** Register a maker or factory under `name`; chains. Throws on a bad or already-registered name. */