@genesislcap/ai-assistant 14.494.0 → 14.496.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.
Files changed (46) hide show
  1. package/dist/ai-assistant.api.json +588 -214
  2. package/dist/ai-assistant.d.ts +157 -6
  3. package/dist/chat-driver.cjs +5608 -0
  4. package/dist/chat-driver.cjs.map +7 -0
  5. package/dist/chat-driver.mjs +5566 -0
  6. package/dist/chat-driver.mjs.map +7 -0
  7. package/dist/custom-elements.json +713 -333
  8. package/dist/dts/channel/ai-activity-bus.d.ts +36 -0
  9. package/dist/dts/channel/ai-activity-bus.d.ts.map +1 -1
  10. package/dist/dts/channel/ai-activity-channel.d.ts +10 -1
  11. package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
  12. package/dist/dts/chat-driver-node.d.ts +28 -0
  13. package/dist/dts/chat-driver-node.d.ts.map +1 -0
  14. package/dist/dts/components/chat-driver/chat-driver.d.ts +66 -5
  15. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  16. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
  17. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +3 -0
  18. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  19. package/dist/dts/config/config.d.ts +37 -2
  20. package/dist/dts/config/config.d.ts.map +1 -1
  21. package/dist/dts/index.d.ts +1 -0
  22. package/dist/dts/index.d.ts.map +1 -1
  23. package/dist/dts/main/main.d.ts.map +1 -1
  24. package/dist/dts/state/debug-event-log.d.ts +8 -12
  25. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  26. package/dist/esm/channel/ai-activity-bus.js +48 -12
  27. package/dist/esm/chat-driver-node.js +33 -0
  28. package/dist/esm/components/chat-driver/chat-driver.js +77 -25
  29. package/dist/esm/components/chat-driver/chat-driver.test.js +160 -36
  30. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +7 -1
  31. package/dist/esm/main/main.js +8 -1
  32. package/dist/esm/main/popout-interaction-gate.test.js +6 -10
  33. package/dist/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +29 -19
  35. package/scripts/build-chat-driver-node.mjs +42 -0
  36. package/src/channel/ai-activity-bus.ts +64 -10
  37. package/src/channel/ai-activity-channel.ts +9 -1
  38. package/src/chat-driver-node.ts +54 -0
  39. package/src/components/chat-driver/chat-driver.test.ts +237 -50
  40. package/src/components/chat-driver/chat-driver.ts +151 -39
  41. package/src/components/orchestrating-driver/orchestrating-driver.ts +10 -11
  42. package/src/config/config.ts +40 -1
  43. package/src/index.ts +1 -0
  44. package/src/main/main.ts +8 -11
  45. package/src/main/popout-interaction-gate.test.ts +6 -11
  46. package/src/state/debug-event-log.ts +9 -18
@@ -5,6 +5,7 @@ import type {
5
5
  CachePolicy,
6
6
  ChatAttachment,
7
7
  ChatDriverResult,
8
+ ChatFallback,
8
9
  ChatMessage,
9
10
  ChatRequestOptions,
10
11
  ChatToolCall,
@@ -17,17 +18,19 @@ import type {
17
18
  InteractionResult,
18
19
  SubAgentFailureReason,
19
20
  SubAgentRequestOptions,
21
+ TurnFailureReason,
20
22
  } from '@genesislcap/foundation-ai';
21
23
  import {
22
24
  isObservableAIProviderRegistry,
23
25
  MalformedFunctionCallError,
24
26
  ResponseTruncatedError,
25
27
  } from '@genesislcap/foundation-ai';
26
- import { agenticActivityBus } from '../../channel/ai-activity-bus';
28
+ import { type ActivityBus, NOOP_ACTIVITY_BUS } from '../../channel/ai-activity-bus';
27
29
  import type {
28
30
  AgentConfig,
29
31
  CachePolicyInput,
30
32
  ProviderInput,
33
+ ResponseSchemaInput,
31
34
  SystemPromptContext,
32
35
  SystemPromptInput,
33
36
  TailContextInput,
@@ -191,6 +194,39 @@ interface FoldStackFrame {
191
194
  previousHandlers: ChatToolHandlers;
192
195
  }
193
196
 
197
+ /**
198
+ * Construction-time configuration for {@link ChatDriver}. Everything except the provider
199
+ * registry is optional — most fields are also settable per-agent via `applyAgent`, so a
200
+ * bare `new ChatDriver(registry)` is valid. Mirrors the `(registry, options)` shape of
201
+ * `OrchestratingDriver`.
202
+ *
203
+ * @beta
204
+ */
205
+ export interface ChatDriverConfig {
206
+ /** Initial tool handlers (static map or per-turn factory). Default `{}`. */
207
+ toolHandlers?: ToolHandlersInput;
208
+ /** Initial tool definitions (static array or per-turn factory). Default `[]`. */
209
+ toolDefinitions?: ToolDefinitionsInput;
210
+ /** Initial system prompt (string or per-turn resolver). */
211
+ systemPrompt?: SystemPromptInput;
212
+ /** Primer history prepended to the conversation. */
213
+ primerHistory?: ChatMessage[];
214
+ /** Hard cap on tool-loop iterations. Default `50`. */
215
+ maxToolIterations?: number;
216
+ /** Hard cap on fold operations. Default `5`. */
217
+ maxFoldOperations?: number;
218
+ /** Ring-buffer size for per-turn snapshots. Default `400`. */
219
+ maxTurnSnapshots?: number;
220
+ /** Session identity used to file meta events onto the shared debug-log timeline. */
221
+ sessionKey?: string;
222
+ /**
223
+ * Activity bus for lifecycle/halo/tool-loop events. Injected by the browser host
224
+ * (the shared cross-tab singleton); omitted off-browser (Node, tests, headless), where
225
+ * it defaults to {@link NOOP_ACTIVITY_BUS} so no `BroadcastChannel` is ever opened.
226
+ */
227
+ activityBus?: ActivityBus;
228
+ }
229
+
194
230
  /**
195
231
  * Plain TS class that drives a multi-turn chat conversation, including the tool-call loop.
196
232
  * Owned by `FoundationAiAssistant` — created in `connectedCallback`, torn down in `disconnectedCallback`.
@@ -453,6 +489,16 @@ export class ChatDriver extends EventTarget implements AiDriver {
453
489
  * the resolved string in a `<system-reminder>` marker and injects it at the message tail.
454
490
  */
455
491
  private activeTailContextInput?: TailContextInput;
492
+ /**
493
+ * Active agent's structured-output schema selector (static value or per-turn resolver).
494
+ * When it resolves to a schema, the model's final answer is constrained to it this turn.
495
+ */
496
+ private activeResponseSchemaInput?: ResponseSchemaInput;
497
+ /**
498
+ * Active agent's refusal-fallback chain (static). Passed through to the provider so a refused
499
+ * turn (e.g. Fable 5) is re-run on the next model server-side.
500
+ */
501
+ private activeFallbacks?: ChatFallback[];
456
502
  /**
457
503
  * Active agent's unresolved-tool hook, captured from `applyAgent`. Consulted
458
504
  * only when a tool call cannot be dispatched (a stale or hallucinated name);
@@ -498,19 +544,32 @@ export class ChatDriver extends EventTarget implements AiDriver {
498
544
  */
499
545
  private unsubscribeRegistry?: () => void;
500
546
 
547
+ /** Hard cap on tool-loop iterations. */
548
+ private readonly maxToolIterations: number;
549
+ /** Session identity used to file meta events onto the shared debug-log timeline. */
550
+ private readonly sessionKey: string;
551
+ /** Injected activity bus; defaults to a no-op off-browser (Node/tests/headless). */
552
+ private readonly activityBus: ActivityBus;
553
+
501
554
  constructor(
502
555
  private readonly providerRegistry: AIProviderRegistry,
503
- toolHandlers: ToolHandlersInput = {},
504
- toolDefinitions: ToolDefinitionsInput = [],
505
- systemPrompt?: SystemPromptInput,
506
- primerHistory?: ChatMessage[],
507
- private readonly maxToolIterations: number = DEFAULT_MAX_TOOL_ITERATIONS,
508
- maxFoldOperations: number = DEFAULT_MAX_FOLD_OPERATIONS,
509
- maxTurnSnapshots: number = DEFAULT_MAX_TURN_SNAPSHOTS,
510
- /** Session identity used to file meta events onto the shared debug-log timeline. */
511
- private readonly sessionKey: string = '',
556
+ config: ChatDriverConfig = {},
512
557
  ) {
513
558
  super();
559
+ const {
560
+ toolHandlers = {},
561
+ toolDefinitions = [],
562
+ systemPrompt,
563
+ primerHistory,
564
+ maxToolIterations = DEFAULT_MAX_TOOL_ITERATIONS,
565
+ maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS,
566
+ maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS,
567
+ sessionKey = '',
568
+ activityBus = NOOP_ACTIVITY_BUS,
569
+ } = config;
570
+ this.maxToolIterations = maxToolIterations;
571
+ this.sessionKey = sessionKey;
572
+ this.activityBus = activityBus;
514
573
  if (typeof toolHandlers === 'function') {
515
574
  this.toolHandlersFactory = toolHandlers;
516
575
  this.toolHandlers = {};
@@ -631,6 +690,35 @@ export class ChatDriver extends EventTarget implements AiDriver {
631
690
  return { reason: 'done' };
632
691
  }
633
692
 
693
+ /**
694
+ * Build the `done` loop result, carrying the typed failure reason when the turn
695
+ * bailed (PTC-0). The discriminant stays `'done'` either way — the same value a
696
+ * clean turn returns — so consumers matching on `reason === 'done'` are unchanged;
697
+ * `failureReason` is simply present on a failure and absent on success. Omitted
698
+ * (rather than set to `undefined`) so a happy-path result stays byte-identical to
699
+ * the historical `{ reason: 'done' }`.
700
+ */
701
+ private turnDone(failureReason?: TurnFailureReason): ChatDriverResult {
702
+ return failureReason ? { reason: 'done', failureReason } : { reason: 'done' };
703
+ }
704
+
705
+ /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
706
+ private static failureReasonOf(result: ChatDriverResult): TurnFailureReason | undefined {
707
+ return result.reason === 'done' ? result.failureReason : undefined;
708
+ }
709
+
710
+ /**
711
+ * Build the `tool-loop-end` event detail for a turn's result. A failure carries a
712
+ * `{ failureReason }` detail; a clean turn emits `undefined` — the historical shape,
713
+ * kept byte-identical so subscribers see exactly what they always have.
714
+ */
715
+ private static loopEndDetail(
716
+ result: ChatDriverResult,
717
+ ): { failureReason: TurnFailureReason } | undefined {
718
+ const failureReason = ChatDriver.failureReasonOf(result);
719
+ return failureReason ? { failureReason } : undefined;
720
+ }
721
+
634
722
  /**
635
723
  * Swap in a new agent's configuration. Called by OrchestratingDriver before
636
724
  * each specialist turn so the shared driver runs with the right tools and prompt.
@@ -675,6 +763,8 @@ export class ChatDriver extends EventTarget implements AiDriver {
675
763
  this.activeToolChoiceInput = config.toolChoice;
676
764
  this.activeCachePolicyInput = config.cachePolicy;
677
765
  this.activeTailContextInput = config.tailContext;
766
+ this.activeResponseSchemaInput = config.responseSchema;
767
+ this.activeFallbacks = config.fallbacks;
678
768
  this.activeOnUnresolvedTool = config.onUnresolvedTool;
679
769
  this.resolvedProviderCache.clear();
680
770
  this.lastResolvedProviderName = undefined;
@@ -1267,7 +1357,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
1267
1357
  // "actively computing" from "parked awaiting the user" — the latter is a
1268
1358
  // safe window for actions disallowed mid-request (e.g. switching provider
1269
1359
  // during a long journey step). Paired with `interaction-resolved`.
1270
- agenticActivityBus.publish('interaction-requested', undefined);
1360
+ this.activityBus.publish('interaction-requested', undefined);
1271
1361
  if (chatInputDuringExecution) {
1272
1362
  this.dispatchEvent(
1273
1363
  new CustomEvent('interaction-start', {
@@ -1342,7 +1432,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
1342
1432
  // The park is ending and the loop is about to resume computing — paired
1343
1433
  // with `interaction-requested`. Fires for every resolution path (user
1344
1434
  // completion, timeout, cancellation), since all route through here.
1345
- agenticActivityBus.publish('interaction-resolved', undefined);
1435
+ this.activityBus.publish('interaction-resolved', undefined);
1346
1436
  interaction.resolve(result);
1347
1437
  this.pendingInteractions.delete(interactionId);
1348
1438
  // Tear down the live context on RESOLVE (not on element unmount) — this closes
@@ -1410,10 +1500,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
1410
1500
  phase: 'sendMessage',
1411
1501
  agent: this.activeAgentName,
1412
1502
  });
1413
- agenticActivityBus.publish('tool-loop-start', undefined);
1503
+ this.activityBus.publish('tool-loop-start', undefined);
1414
1504
 
1505
+ // Captured so the `finally` can carry the turn's outcome onto `tool-loop-end`.
1506
+ let result: ChatDriverResult = { reason: 'done' };
1415
1507
  try {
1416
- return await this.runToolLoop(userInput, attachments);
1508
+ result = await this.runToolLoop(userInput, attachments);
1509
+ return result;
1417
1510
  } catch (e) {
1418
1511
  logger.error('ChatDriver error:', e);
1419
1512
  recordTurnError(this.sessionKey, 'exception', {
@@ -1427,7 +1520,8 @@ export class ChatDriver extends EventTarget implements AiDriver {
1427
1520
  role: 'assistant',
1428
1521
  content: 'Sorry, something went wrong on my end. Please try again in a moment.',
1429
1522
  });
1430
- return { reason: 'done' };
1523
+ result = this.turnDone('exception');
1524
+ return result;
1431
1525
  } finally {
1432
1526
  recordMetaEvent(this.sessionKey, 'turn.end', {
1433
1527
  phase: 'sendMessage',
@@ -1436,7 +1530,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
1436
1530
  });
1437
1531
  this.busy = false;
1438
1532
  this.endTurn();
1439
- agenticActivityBus.publish('tool-loop-end', undefined);
1533
+ this.activityBus.publish('tool-loop-end', ChatDriver.loopEndDetail(result));
1440
1534
  }
1441
1535
  }
1442
1536
 
@@ -1602,17 +1696,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
1602
1696
  // be harvested into THIS session on completion and then discarded.
1603
1697
  const invocationId = crypto.randomUUID();
1604
1698
  const childSessionKey = `${this.sessionKey}::sub:${invocationId}`;
1605
- const child = new ChatDriver(
1606
- this.providerRegistry,
1607
- {},
1608
- [],
1609
- undefined,
1610
- undefined,
1611
- undefined,
1612
- undefined,
1613
- undefined,
1614
- childSessionKey,
1615
- );
1699
+ const child = new ChatDriver(this.providerRegistry, {
1700
+ sessionKey: childSessionKey,
1701
+ // Inherit the parent's bus so the sub-agent's tool-loop events still surface
1702
+ // (off-browser this is the shared no-op).
1703
+ activityBus: this.activityBus,
1704
+ });
1616
1705
  // Mark before the first turn so the child forces tool use and reports a
1617
1706
  // typed failure (rather than user-facing text) if it never completes.
1618
1707
  child.markAsSubAgent();
@@ -1781,9 +1870,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
1781
1870
  phase: 'continueFromHistory',
1782
1871
  agent: this.activeAgentName,
1783
1872
  });
1784
- agenticActivityBus.publish('tool-loop-start', undefined);
1873
+ this.activityBus.publish('tool-loop-start', undefined);
1874
+ // Captured so the `finally` can carry the turn's outcome onto `tool-loop-end`.
1875
+ let result: ChatDriverResult = { reason: 'done' };
1785
1876
  try {
1786
- return await this.runToolLoop('', undefined, transientPrimer);
1877
+ result = await this.runToolLoop('', undefined, transientPrimer);
1878
+ return result;
1787
1879
  } catch (e) {
1788
1880
  logger.error('ChatDriver error:', e);
1789
1881
  recordTurnError(this.sessionKey, 'exception', {
@@ -1797,7 +1889,8 @@ export class ChatDriver extends EventTarget implements AiDriver {
1797
1889
  role: 'assistant',
1798
1890
  content: 'Sorry, something went wrong on my end. Please try again in a moment.',
1799
1891
  });
1800
- return { reason: 'done' };
1892
+ result = this.turnDone('exception');
1893
+ return result;
1801
1894
  } finally {
1802
1895
  recordMetaEvent(this.sessionKey, 'turn.end', {
1803
1896
  phase: 'continueFromHistory',
@@ -1806,7 +1899,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
1806
1899
  });
1807
1900
  this.busy = false;
1808
1901
  this.endTurn();
1809
- agenticActivityBus.publish('tool-loop-end', undefined);
1902
+ this.activityBus.publish('tool-loop-end', ChatDriver.loopEndDetail(result));
1810
1903
  }
1811
1904
  }
1812
1905
 
@@ -2140,13 +2233,20 @@ export class ChatDriver extends EventTarget implements AiDriver {
2140
2233
  // provider is resolved — static value or a function of the turn context
2141
2234
  // (which carries the live state for stateful agents). Resolved before the
2142
2235
  // snapshot so the debug log records the exact request config the model saw.
2143
- const [resolvedTemperature, resolvedToolChoice, resolvedCachePolicy, resolvedTailContext] =
2236
+ const [
2237
+ resolvedTemperature,
2238
+ resolvedToolChoice,
2239
+ resolvedCachePolicy,
2240
+ resolvedTailContext,
2241
+ resolvedResponseSchema,
2242
+ ] =
2144
2243
  // oxlint-disable-next-line no-await-in-loop
2145
2244
  await Promise.all([
2146
2245
  this.resolveTurnInput<number>(this.activeTemperatureInput, promptCtx),
2147
2246
  this.resolveTurnInput<ChatToolChoice>(this.activeToolChoiceInput, promptCtx),
2148
2247
  this.resolveTurnInput<CachePolicy>(this.activeCachePolicyInput, promptCtx),
2149
2248
  this.resolveTurnInput<string>(this.activeTailContextInput, promptCtx),
2249
+ this.resolveTurnInput<object | undefined>(this.activeResponseSchemaInput, promptCtx),
2150
2250
  ]);
2151
2251
  // The system prompt is always just the agent's resolved prompt — byte-stable, so it can be
2152
2252
  // cached. The framework's volatile additions (fold suffix, retry nudge) and the agent's tail
@@ -2197,6 +2297,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
2197
2297
  cachePolicy: resolvedCachePolicy,
2198
2298
  // Framed volatile context injected at the message tail (never stored). Undefined → none.
2199
2299
  tailContext,
2300
+ // Structured-output schema for this turn (agent/state-resolved). When set, the transport
2301
+ // constrains the final answer to it natively where the model supports it. Undefined → free text.
2302
+ responseSchema: resolvedResponseSchema,
2303
+ // Refusal-fallback chain (e.g. Fable 5 → Opus 4.8). Passed to the provider; applied
2304
+ // server-side where supported. Undefined → no fallback.
2305
+ fallbacks: this.activeFallbacks,
2200
2306
  };
2201
2307
 
2202
2308
  // Resolve the active provider for this turn. Static names were validated
@@ -2244,7 +2350,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
2244
2350
  'While working on your request, I repeatedly called my tools incorrectly. This often works on a second try — would you like me to try again? If it happens again, try breaking your request into smaller steps.',
2245
2351
  });
2246
2352
  }
2247
- return { reason: 'done' };
2353
+ return this.turnDone('malformed-function-call');
2248
2354
  }
2249
2355
  // The response was truncated at the provider's output-token cap while it
2250
2356
  // still carried a tool call — its arguments are incomplete and unusable.
@@ -2273,7 +2379,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
2273
2379
  'My response was cut off because a single step reached the model output limit. This usually means one step tried to produce too much at once — try breaking your request into smaller steps.',
2274
2380
  });
2275
2381
  }
2276
- return { reason: 'done' };
2382
+ return this.turnDone('response-truncated');
2277
2383
  }
2278
2384
  // A request timeout from the transport (tagged `TimeoutError`) is not a
2279
2385
  // bug on our end — surface it distinctly instead of letting it fall
@@ -2299,7 +2405,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
2299
2405
  'The request timed out. You can ask me to try again, or break this into a smaller step.',
2300
2406
  });
2301
2407
  }
2302
- return { reason: 'done' };
2408
+ // Recorded as `exception` above (there is no separate `timeout` member of
2409
+ // TurnFailureReason for the main turn); surface the same reason here.
2410
+ return this.turnDone('exception');
2303
2411
  }
2304
2412
  // The request was aborted: either a user cancel (turnController) or a
2305
2413
  // driver dispose (lifecycleController, chained into the turn). A user
@@ -2380,7 +2488,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
2380
2488
  'While working on your request, I repeatedly generated a blank response. This often works on a second try — would you like me to try again? If it happens again, try breaking your request into smaller steps.',
2381
2489
  });
2382
2490
  }
2383
- return { reason: 'done' };
2491
+ return this.turnDone('empty-response');
2384
2492
  } else {
2385
2493
  // Split one model response into separate, individually-toggleable messages so each has its
2386
2494
  // own visibility toggle and debug-log category:
@@ -2744,7 +2852,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
2744
2852
  "I'm sorry, I repeatedly tried to use tools that aren't available to me, so I couldn't complete that. If a 'Download agent log' option appears in the Settings (cog) menu, you can download the log and share it with whoever set up this assistant to help fix the issue.",
2745
2853
  });
2746
2854
  }
2747
- return { reason: 'done' };
2855
+ return this.turnDone('unknown-tool-limit');
2748
2856
  }
2749
2857
 
2750
2858
  const firstContinuation = systemCalls[0];
@@ -2759,10 +2867,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
2759
2867
  // Sub-agent early exit — checked here so the exit point mirrors the
2760
2868
  // system-call pattern above. Set by completeSubAgent() in a tool handler.
2761
2869
  if (this.subAgentCompletion) {
2762
- return { reason: 'done' };
2870
+ return this.turnDone();
2763
2871
  }
2764
2872
  }
2765
2873
 
2874
+ // The loop fell through: either it hit the iteration cap (a failure) or it
2875
+ // broke on a clean final answer (success). Only the former carries a reason.
2876
+ let failureReason: TurnFailureReason | undefined;
2766
2877
  if (iterations >= this.maxToolIterations) {
2767
2878
  logger.warn('ChatDriver: reached max tool iterations, stopping');
2768
2879
  recordTurnError(this.sessionKey, 'max-iterations', {
@@ -2781,9 +2892,10 @@ export class ChatDriver extends EventTarget implements AiDriver {
2781
2892
  "I've reached my limit for this response. You can ask me to continue and I'll pick up where I left off.",
2782
2893
  });
2783
2894
  }
2895
+ failureReason = 'max-iterations';
2784
2896
  }
2785
2897
 
2786
- return { reason: 'done' };
2898
+ return this.turnDone(failureReason);
2787
2899
  }
2788
2900
 
2789
2901
  private appendToHistory(message: ChatMessage): void {
@@ -5,6 +5,7 @@ import type {
5
5
  ChatMessage,
6
6
  ChatRequestOptions,
7
7
  } from '@genesislcap/foundation-ai';
8
+ import type { ActivityBus } from '../../channel/ai-activity-bus';
8
9
  import type {
9
10
  AgentConfig,
10
11
  FallbackAgentConfig,
@@ -137,6 +138,8 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
137
138
  maxToolIterations?: number;
138
139
  maxFoldOperations?: number;
139
140
  maxTurnSnapshots?: number;
141
+ /** Activity bus passed through to the inner ChatDriver (browser host injects the singleton). */
142
+ activityBus?: ActivityBus;
140
143
  } = {},
141
144
  ) {
142
145
  super();
@@ -166,17 +169,13 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
166
169
  ? { ...rawFallback, systemPrompt: buildFallbackSystemPrompt(rawFallback, this.specialists) }
167
170
  : undefined;
168
171
 
169
- this.chatDriver = new ChatDriver(
170
- providerRegistry,
171
- {},
172
- [],
173
- undefined,
174
- undefined,
175
- options.maxToolIterations,
176
- options.maxFoldOperations,
177
- options.maxTurnSnapshots,
178
- this.sessionKey,
179
- );
172
+ this.chatDriver = new ChatDriver(providerRegistry, {
173
+ maxToolIterations: options.maxToolIterations,
174
+ maxFoldOperations: options.maxFoldOperations,
175
+ maxTurnSnapshots: options.maxTurnSnapshots,
176
+ sessionKey: this.sessionKey,
177
+ activityBus: options.activityBus,
178
+ });
180
179
 
181
180
  // Proxy events from the shared driver
182
181
  this.chatDriver.addEventListener('history-updated', (e: Event) => {
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  CachePolicy,
3
+ ChatFallback,
3
4
  ChatInputDuringExecutionMode,
4
5
  ChatMessage,
5
6
  ChatToolChoice,
@@ -7,7 +8,7 @@ import type {
7
8
  ChatToolHandlers,
8
9
  } from '@genesislcap/foundation-ai';
9
10
 
10
- export type { CachePolicy, ChatInputDuringExecutionMode, ChatToolChoice };
11
+ export type { CachePolicy, ChatFallback, ChatInputDuringExecutionMode, ChatToolChoice };
11
12
 
12
13
  /**
13
14
  * Context passed to `onActivate` / `onDeactivate` lifecycle hooks on an agent.
@@ -174,6 +175,25 @@ export type CachePolicyInput =
174
175
  */
175
176
  export type TailContextInput = string | ((ctx: SystemPromptContext) => string | Promise<string>);
176
177
 
178
+ /**
179
+ * Structured-output schema for an agent. When set, the model's final (non-tool) answer is
180
+ * constrained to this JSON Schema instead of free text. Either a static schema or a function
181
+ * resolved each tool-loop iteration — return the schema on the turn(s) it should apply and
182
+ * `undefined` otherwise, so "whole-loop" vs "finalize-turn" enforcement is just what the
183
+ * resolver returns (e.g. `({ state }) => state.machine.matches('finalizing') ? schema : undefined`).
184
+ *
185
+ * Orthogonal to {@link ToolChoiceInput}: an agent can carry both `tools` and a `responseSchema`.
186
+ * Each provider applies it natively where possible (Anthropic `output_config.format`, Gemini
187
+ * JSON mode) and degrades gracefully otherwise. The schema is a plain JSON Schema object; keep to
188
+ * the portable subset providers share (`additionalProperties: false`, explicit `required`, enums,
189
+ * `anyOf` for nullables — no numeric/string constraints or recursion).
190
+ *
191
+ * @beta
192
+ */
193
+ export type ResponseSchemaInput =
194
+ | object
195
+ | ((ctx: SystemPromptContext) => object | undefined | Promise<object | undefined>);
196
+
177
197
  /**
178
198
  * Context passed to an agent's `onUnresolvedTool` hook when the model calls a
179
199
  * tool the driver cannot dispatch.
@@ -337,6 +357,25 @@ interface BaseAgentConfig {
337
357
  * @beta
338
358
  */
339
359
  tailContext?: TailContextInput;
360
+ /**
361
+ * Structured-output schema for this agent. When resolved to a schema on a turn, the model's
362
+ * final (non-tool) answer is constrained to it instead of free text. Composes with `tools`;
363
+ * resolved per turn like {@link BaseAgentConfig.cachePolicy}, so returning `undefined` on
364
+ * working turns and the schema on the finalize turn gives finalize-turn enforcement for free.
365
+ * See {@link ResponseSchemaInput}.
366
+ *
367
+ * @beta
368
+ */
369
+ responseSchema?: ResponseSchemaInput;
370
+ /**
371
+ * Refusal-fallback chain for this agent (provider-neutral). If the model declines a turn
372
+ * (`stop_reason: 'refusal'` — e.g. Fable 5 safety classifiers), the provider re-runs it on the
373
+ * next listed model. Typical use: a Fable 5 agent with `[{ model: 'claude-opus-4-8' }]`. Static
374
+ * (not per-turn); providers that support it apply it server-side, others ignore it.
375
+ *
376
+ * @beta
377
+ */
378
+ fallbacks?: ChatFallback[];
340
379
  /**
341
380
  * Optional hook consulted when the model calls a tool the driver cannot
342
381
  * dispatch — either a *stale* tool (advertised earlier this activation but
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export * from './state/persistence';
16
16
  export * from './provider/ai-provider-switcher';
17
17
  export * from './provider/assistant-app-settings';
18
18
  export * from './utils/tool-fold';
19
+ export type { TurnFailureReason } from './state/debug-event-log';
19
20
  export type { TimelineMessage } from './utils/flatten-sub-agent-messages';
20
21
  export type { CostSessionModelEntry, CostSessionRecord } from './utils/cost-session-history';
21
22
  export type { ModelTagAppearance } from '@genesislcap/foundation-ai';
package/src/main/main.ts CHANGED
@@ -1249,20 +1249,17 @@ export class FoundationAiAssistant extends GenesisElement {
1249
1249
  maxToolIterations: agent.maxToolIterations,
1250
1250
  maxFoldOperations: agent.maxFoldOperations,
1251
1251
  maxTurnSnapshots: agent.maxTurnSnapshots,
1252
+ activityBus: agenticActivityBus,
1252
1253
  });
1253
1254
  }
1254
1255
 
1255
- return new ChatDriver(
1256
- this.providerRegistry,
1257
- {},
1258
- [],
1259
- undefined,
1260
- undefined,
1261
- agent.maxToolIterations,
1262
- agent.maxFoldOperations,
1263
- agent.maxTurnSnapshots,
1264
- this.getStateKey() ?? '',
1265
- );
1256
+ return new ChatDriver(this.providerRegistry, {
1257
+ maxToolIterations: agent.maxToolIterations,
1258
+ maxFoldOperations: agent.maxFoldOperations,
1259
+ maxTurnSnapshots: agent.maxTurnSnapshots,
1260
+ sessionKey: this.getStateKey() ?? '',
1261
+ activityBus: agenticActivityBus,
1262
+ });
1266
1263
  }
1267
1264
 
1268
1265
  /**
@@ -1,6 +1,5 @@
1
1
  import type { ChatMessage } from '@genesislcap/foundation-ai';
2
2
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
- import { agenticActivityBus } from '../channel/ai-activity-bus';
4
3
  import { FoundationAiAssistant } from './main';
5
4
 
6
5
  // Hold a reference so the custom-element registration isn't tree-shaken.
@@ -14,19 +13,15 @@ FoundationAiAssistant;
14
13
  // (`main.template.ts`). This suite pins the GATE LOGIC (`hasActivePendingInteraction`)
15
14
  // that binding depends on, so a change to when the gate opens/closes is caught.
16
15
  //
17
- // Note: this deliberately does NOT mount the assistant. Mounting runs
18
- // `connectedCallback`, which activates the `agenticActivityBus` `BroadcastChannel` and
19
- // leaves the runner's event loop open (the suite hangs on exit). A getter-level test
20
- // needs no mount and stays fast + reliable. `document.createElement` only upgrades the
21
- // element (constructor) it does not connect so no bus subscription is created;
22
- // importing the module still opens the module-load channel, so close it after the suite.
16
+ // Note: this deliberately does NOT mount the assistant. Mounting runs `connectedCallback`,
17
+ // which subscribes to `agenticActivityBus` — opening a cross-tab `BroadcastChannel` in a browser
18
+ // env and leaving the runner's event loop open. A getter-level test needs no mount and stays fast
19
+ // + reliable. `document.createElement` only upgrades the element (constructor) — it does not
20
+ // connectso nothing subscribes, and the bus opens its channel lazily on first use, so none is
21
+ // ever created here (no teardown needed).
23
22
 
24
23
  const Suite = createLogicSuite('FoundationAiAssistant popout interaction gate');
25
24
 
26
- Suite.after(() => {
27
- agenticActivityBus.close();
28
- });
29
-
30
25
  /** A fresh (unconnected) element with a fake session store wired in. */
31
26
  function elementWith(state: string, messages: ChatMessage[]): FoundationAiAssistant {
32
27
  const el = document.createElement('foundation-ai-assistant') as FoundationAiAssistant;
@@ -24,6 +24,8 @@
24
24
  * @internal
25
25
  */
26
26
 
27
+ import type { TurnFailureReason } from '@genesislcap/foundation-ai';
28
+
27
29
  /**
28
30
  * Catalogue of meta event names. This is the documented surface — extend it as
29
31
  * new events are wired in (Tier 2/3 lifecycle, interaction, provider events).
@@ -211,25 +213,14 @@ export function recordMetaEvent(
211
213
 
212
214
  /**
213
215
  * Why a turn failed or was retried — stamped as `detail.reason` on `turn.error`
214
- * and `turn.retry` events. Enumerated so the set stays in sync with the README
215
- * and call sites can't drift to ad-hoc strings.
216
- *
217
- * - `exception` — an uncaught error escaped the tool loop (catch-all).
218
- * - `malformed-function-call`— the provider returned an unparseable tool call.
219
- * - `empty-response` — the model returned no content and no tool calls.
220
- * - `unknown-tool-limit` — the model repeatedly called tools it couldn't dispatch,
221
- * whether hallucinated or stale (real earlier, retired now).
222
- * - `max-iterations` — the tool loop hit its iteration cap.
223
- * - `response-truncated` — a turn stopped at the provider's output-token cap with an
224
- * incomplete tool call; deterministic, so it bails without retry.
216
+ * and `turn.retry` events, and (since PTC-0) surfaced at the driver's loop
217
+ * boundary via {@link ChatDriverResult.failureReason}. Re-exported from
218
+ * `@genesislcap/foundation-ai` — where it lives alongside `ChatDriverResult` so
219
+ * the boundary field and this log surface can never drift apart — under its
220
+ * historical name so existing importers are unaffected. Enumerated so the set
221
+ * stays in sync with the README and call sites can't drift to ad-hoc strings.
225
222
  */
226
- export type TurnFailureReason =
227
- | 'exception'
228
- | 'malformed-function-call'
229
- | 'empty-response'
230
- | 'unknown-tool-limit'
231
- | 'max-iterations'
232
- | 'response-truncated';
223
+ export type { TurnFailureReason };
233
224
 
234
225
  /**
235
226
  * Record a turn-ending failure (`turn.error`, importance `high`). The `reason`