@genesislcap/ai-assistant 15.3.2 → 15.3.3-alpha-4899ddd.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 (64) hide show
  1. package/dist/ai-assistant.api.json +391 -5
  2. package/dist/ai-assistant.d.ts +603 -6
  3. package/dist/chat-driver.cjs +285 -26
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +285 -26
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +236 -10
  8. package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
  9. package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
  13. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
  14. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
  15. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
  16. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  17. package/dist/dts/main/blocked-state.test.d.ts +2 -0
  18. package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
  19. package/dist/dts/main/main.d.ts +384 -6
  20. package/dist/dts/main/main.d.ts.map +1 -1
  21. package/dist/dts/main/main.styles.d.ts.map +1 -1
  22. package/dist/dts/main/main.styles.test.d.ts +2 -0
  23. package/dist/dts/main/main.styles.test.d.ts.map +1 -0
  24. package/dist/dts/main/main.template.d.ts +53 -0
  25. package/dist/dts/main/main.template.d.ts.map +1 -1
  26. package/dist/dts/state/ai-assistant-slice.d.ts +143 -6
  27. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  28. package/dist/dts/state/debug-event-log.d.ts +6 -1
  29. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  30. package/dist/dts/state/session-store.d.ts +10 -0
  31. package/dist/dts/state/session-store.d.ts.map +1 -1
  32. package/dist/esm/components/chat-driver/chat-driver.js +263 -21
  33. package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
  34. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
  35. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
  36. package/dist/esm/main/blocked-state.test.js +913 -0
  37. package/dist/esm/main/main.js +680 -16
  38. package/dist/esm/main/main.styles.js +47 -0
  39. package/dist/esm/main/main.styles.test.js +86 -0
  40. package/dist/esm/main/main.template.js +121 -4
  41. package/dist/esm/state/ai-assistant-slice.js +128 -7
  42. package/dist/esm/state/ai-assistant-slice.test.js +138 -1
  43. package/dist/esm/state/debug-event-log.js +7 -2
  44. package/dist/esm/state/debug-event-log.test.js +49 -1
  45. package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/docs/migration-GENC-1464.md +562 -0
  48. package/docs/sub_agent.md +20 -3
  49. package/package.json +17 -17
  50. package/src/channel/ai-activity-channel.ts +56 -2
  51. package/src/components/chat-driver/chat-driver.test.ts +549 -0
  52. package/src/components/chat-driver/chat-driver.ts +324 -14
  53. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
  54. package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
  55. package/src/main/blocked-state.test.ts +1243 -0
  56. package/src/main/main.styles.test.ts +103 -0
  57. package/src/main/main.styles.ts +47 -0
  58. package/src/main/main.template.ts +131 -4
  59. package/src/main/main.ts +680 -10
  60. package/src/state/ai-assistant-slice.test.ts +215 -0
  61. package/src/state/ai-assistant-slice.ts +181 -8
  62. package/src/state/debug-event-log.test.ts +63 -0
  63. package/src/state/debug-event-log.ts +7 -2
  64. package/src/state/persistence/session-snapshot.test.ts +22 -0
@@ -2,6 +2,7 @@ import { AgentPickerMode } from '@genesislcap/foundation-ai';
2
2
  import { AIProviderRegistry } from '@genesislcap/foundation-ai';
3
3
  import type { AIProviderRegistryStatusEntry } from '@genesislcap/foundation-ai';
4
4
  import type { AIProviderType } from '@genesislcap/foundation-ai';
5
+ import { BudgetExhaustedError } from '@genesislcap/foundation-ai';
5
6
  import type { CachePolicy } from '@genesislcap/foundation-ai';
6
7
  import type { ChatAttachment } from '@genesislcap/foundation-ai';
7
8
  import type { ChatConfig } from '@genesislcap/foundation-ai';
@@ -217,9 +218,59 @@ export declare interface AgenticActivityEvents {
217
218
  * driver dispose, and an agent handoff the detail is `undefined` — the historical shape,
218
219
  * kept byte-identical so subscribers that only care about the boundary can keep ignoring
219
220
  * it. Structured-cloneable so it survives the cross-tab BroadcastChannel hop.
221
+ *
222
+ * A `'budget-exhausted'` failure additionally carries `vendor` — the concrete
223
+ * vendor the walled turn resolved to (GENC-1464), which is NOT the same thing as
224
+ * the registry alias recorded on the debug-log entry. Optional and additive: a
225
+ * subscriber reading only `failureReason` is unaffected, and every other
226
+ * failure still emits the historical `{ failureReason }` with no `vendor` key.
227
+ * It exists so a later per-vendor budget model is an additive change rather
228
+ * than a retrofit — the vendor is known at the transport and at the driver, and
229
+ * was previously discarded between them.
230
+ *
231
+ * Note this topic is forwarded on the tab-scoped channel, so a wall hit in a
232
+ * popped-out window reaches the main window's subscribers too — which is why
233
+ * the assistant's blocked latch fires there as well. Correct for a shared spend
234
+ * cap; see `docs/migration-GENC-1464.md` §Scope.
220
235
  */
221
236
  'tool-loop-end': {
222
237
  failureReason?: TurnFailureReason;
238
+ vendor?: AIProviderType;
239
+ /**
240
+ * Proxy-reported spend figures, present only on `budget-exhausted`. Carried
241
+ * here because this event — not the driver's return value — is what latches
242
+ * the blocked state for a wall hit INSIDE the tool loop (the common case):
243
+ * the publish happens in `sendMessage`'s `finally`, so it lands before the
244
+ * return-value seam and wins the latch. Without these the banner would fall
245
+ * back to the generic copy in exactly the path the figures were added for.
246
+ * Plain numbers + string keep the detail structured-cloneable for the
247
+ * cross-tab hop.
248
+ */
249
+ budget?: {
250
+ budgetUsd?: number;
251
+ spentUsd?: number;
252
+ vendorLabel: string;
253
+ /**
254
+ * The refusing vendor as a typed value: normalised from `vendorLabel`
255
+ * where a vendor claims that label, otherwise from the proxy's own
256
+ * `vendor` field on the 402 — so it can disagree with `vendorLabel`,
257
+ * which is what keeps attribution working behind a white-labelled or
258
+ * multiplexing gateway.
259
+ *
260
+ * Distinct from the detail's top-level `vendor` (the driver's
261
+ * last-resolved provider): this one comes from the transport that was
262
+ * actually refused, so it is the one a per-vendor latch trusts first.
263
+ */
264
+ vendor?: AIProviderType;
265
+ /**
266
+ * The proxy's verdict on whether any OTHER vendor it meters still has
267
+ * headroom. The one fact here a subscriber cannot work out for itself:
268
+ * the registry says which vendors EXIST, never which still have
269
+ * budget. A `false` is what stops the banner advising a switch to a
270
+ * vendor that is equally spent.
271
+ */
272
+ otherVendorAvailable?: boolean;
273
+ };
223
274
  } | undefined;
224
275
  /**
225
276
  * Fired when a tool handler hands a widget to the user mid-loop and parks awaiting it
@@ -997,6 +1048,45 @@ declare interface BaseAgentConfig {
997
1048
  notResumableMessage?: string;
998
1049
  }
999
1050
 
1051
+ /**
1052
+ * Id of the blocked banner, referenced by the composer controls'
1053
+ * `aria-describedby`. Shadow-DOM-scoped, so a fixed string cannot collide with
1054
+ * the host page — and IDREF resolution is same-root, which is exactly where both
1055
+ * ends of this reference live.
1056
+ *
1057
+ * @internal
1058
+ */
1059
+ export declare const BLOCKED_BANNER_ID = "blocked-banner";
1060
+
1061
+ /**
1062
+ * Class list for the banner below, joined rather than interpolated so an
1063
+ * inapplicable modifier contributes nothing. Two interpolations directly in the
1064
+ * attribute emitted `class="blocked-banner "` in the common (unblocked) case —
1065
+ * harmless to the browser, but it shows up in every DOM snapshot and every
1066
+ * innerHTML assertion a host writes against this element.
1067
+ *
1068
+ * The two modifiers name what they actually gate, which is why neither is
1069
+ * `is-blocked`: `is-visible` means the banner has something to say, and that
1070
+ * includes PARTIAL exhaustion — one vendor walled, composer still live, `blocked`
1071
+ * false. `is-partial` then softens the treatment for exactly that case. Naming
1072
+ * the first after `blocked` read as a contradiction beside the second, and made
1073
+ * the styles say `.blocked-banner.is-blocked` to mean "visible".
1074
+ *
1075
+ * Exported for the unit test that pins the attribute; not part of the element
1076
+ * API.
1077
+ *
1078
+ * @internal
1079
+ */
1080
+ export declare const blockedBannerClasses: (x: FoundationAiAssistant) => string;
1081
+
1082
+ /**
1083
+ * The `budget` payload carried on a `'budget-exhausted'` {@link ChatDriverResult}.
1084
+ * Derived from the type rather than restated so the two cannot drift.
1085
+ */
1086
+ declare type BudgetDetail = NonNullable<Extract<ChatDriverResult, {
1087
+ reason: 'done';
1088
+ }>['budget']>;
1089
+
1000
1090
  export { CachePolicy }
1001
1091
 
1002
1092
  /**
@@ -1179,6 +1269,10 @@ export declare class ChatDriver extends EventTarget implements AiDriver {
1179
1269
  * Set when a sub-agent's tool loop ends without `completeSubAgent` being
1180
1270
  * called. Read by the parent's `invokeSubAgent` to build the `{ ok: false }`
1181
1271
  * branch of `requestSubAgent`. Only ever set when `isSubAgent` is true.
1272
+ *
1273
+ * `budget` rides along on a `'budget_exhausted'` failure so the parent inherits
1274
+ * the child's ATTRIBUTION, not just the fact of a wall — see
1275
+ * `budgetWallDetail`.
1182
1276
  */
1183
1277
  private subAgentFailure;
1184
1278
  /**
@@ -1325,6 +1419,39 @@ export declare class ChatDriver extends EventTarget implements AiDriver {
1325
1419
  private readonly sessionKey;
1326
1420
  /** Injected activity bus; defaults to a no-op off-browser (Node/tests/headless). */
1327
1421
  private readonly activityBus;
1422
+ /** Transcript copy for a budget wall — see `ChatDriverConfig.budgetExhaustedMessage`. */
1423
+ private readonly budgetExhaustedMessage;
1424
+ /**
1425
+ * Set the moment a budget wall is observed anywhere in this turn — this
1426
+ * driver's own 402, or a sub-agent's (which surfaces here only as a
1427
+ * `'budget_exhausted'` tool result). Read at the top of the tool loop to end
1428
+ * the turn before issuing another model call that would hit the same wall.
1429
+ * Reset per turn alongside the other per-turn counters.
1430
+ */
1431
+ private budgetExhaustedThisTurn;
1432
+ /**
1433
+ * The refusing vendor's own attribution for the wall `budgetExhaustedThisTurn`
1434
+ * records, when it was knowable. Kept SEPARATE from the flag rather than
1435
+ * replacing it: a figure-less 402 from a transport no vendor claims yields no
1436
+ * detail at all (`budgetDetailOf` returns `undefined`), and folding the two
1437
+ * would make that case stop ending the turn.
1438
+ *
1439
+ * It matters most for a sub-agent's wall. The child can sit on a different
1440
+ * vendor from its parent — `applyAgent` reads `config.provider` — so without
1441
+ * this the parent's short-circuit reports `lastResolvedProvider`, i.e. the one
1442
+ * vendor that did NOT refuse. Under a mixed registry that walls Gemini because
1443
+ * an Anthropic child 402'd, and if those are the only two reachable vendors the
1444
+ * host then derives `blocked` and locks a composer that still had headroom.
1445
+ */
1446
+ private budgetWallDetail?;
1447
+ /**
1448
+ * Whether this turn's budget wall came from a SUB-AGENT rather than this
1449
+ * driver's own request. Decides whether `lastResolvedProvider` is a valid
1450
+ * attribution fallback: for an own wall it is the refusing vendor, for a
1451
+ * child's wall it is the parent's vendor — the one known NOT to have refused.
1452
+ * Reset per turn alongside `budgetWallDetail`.
1453
+ */
1454
+ private budgetWallViaSubAgent;
1328
1455
  constructor(providerRegistry: AIProviderRegistry, config?: ChatDriverConfig);
1329
1456
  /**
1330
1457
  * Tear down the driver: aborts the lifecycle signal so any in-flight provider
@@ -1371,14 +1498,49 @@ export declare class ChatDriver extends EventTarget implements AiDriver {
1371
1498
  * the historical `{ reason: 'done' }`.
1372
1499
  */
1373
1500
  private turnDone;
1501
+ /**
1502
+ * Terminal budget outcome for a wall hit **outside** the tool loop — today,
1503
+ * `OrchestratingDriver`'s classification phase, which calls the provider
1504
+ * directly and so never enters `runToolLoop`.
1505
+ *
1506
+ * Does **not** publish `tool-loop-end`: no `tool-loop-start` was published for
1507
+ * the classify phase, and an unbalanced end would break start/end pairing for
1508
+ * subscribers that rely on it. The driver **return value** is what reports this
1509
+ * case — see `FoundationAiAssistant`'s latch, which reads both seams for
1510
+ * exactly this reason.
1511
+ *
1512
+ * The non-sub-agent tail of the in-loop `BudgetExhaustedError` branch lives
1513
+ * here so there is one copy of the log line, the debug-log entry, the
1514
+ * transcript bubble and the result shape rather than two that can drift.
1515
+ *
1516
+ * @param pendingUserMessage - a user message that has NOT yet been appended,
1517
+ * appended first so the answer does not end up replying to nothing. Only the
1518
+ * classification seam passes it: `OrchestratingDriver` dispatches the user's
1519
+ * text as an optimistic `history-updated` detail and leaves the real append
1520
+ * to `chatDriver.sendMessage`, which never runs when `classify()` throws — so
1521
+ * the bubble below would re-dispatch a history the user's own message was
1522
+ * never in, and it would vanish from the transcript on the next render. The
1523
+ * in-loop caller has already appended it and passes nothing.
1524
+ *
1525
+ * @internal
1526
+ */
1527
+ reportBudgetExhausted(e: BudgetExhaustedError, pendingUserMessage?: ChatMessage): ChatDriverResult;
1374
1528
  /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
1375
1529
  private static failureReasonOf;
1376
1530
  /**
1377
1531
  * Build the `tool-loop-end` event detail for a turn's result. A failure carries a
1378
1532
  * `{ failureReason }` detail; a clean turn emits `undefined` — the historical shape,
1379
1533
  * kept byte-identical so subscribers see exactly what they always have.
1534
+ *
1535
+ * A budget failure additionally carries `vendor` — the concrete vendor
1536
+ * (`'anthropic'`/`'gemini'`) the walled turn resolved to, which the driver knows
1537
+ * and used to discard. Optional and additive: a subscriber reading only
1538
+ * `failureReason` is unaffected, a non-budget failure still emits the historical
1539
+ * `{ failureReason }` with no `vendor` key, and the value is a plain string so
1540
+ * the detail stays structured-cloneable for the cross-tab hop. It is the field a
1541
+ * per-vendor budget model needs and the one that would be awkward to retrofit.
1380
1542
  */
1381
- private static loopEndDetail;
1543
+ private loopEndDetail;
1382
1544
  /**
1383
1545
  * Swap in a new agent's configuration. Called by OrchestratingDriver before
1384
1546
  * each specialist turn so the shared driver runs with the right tools and prompt.
@@ -1445,6 +1607,7 @@ export declare class ChatDriver extends EventTarget implements AiDriver {
1445
1607
  */
1446
1608
  getSubAgentFailure(): {
1447
1609
  reason: SubAgentFailureReason;
1610
+ budget?: BudgetDetail;
1448
1611
  } | undefined;
1449
1612
  /**
1450
1613
  * Record a sub-agent failure reason (first one wins). No-op for top-level
@@ -1667,6 +1830,22 @@ export declare interface ChatDriverConfig {
1667
1830
  * it defaults to {@link NOOP_ACTIVITY_BUS} so no `BroadcastChannel` is ever opened.
1668
1831
  */
1669
1832
  activityBus?: ActivityBus;
1833
+ /**
1834
+ * Transcript copy appended when the AI-spend budget wall is hit (GENC-1464).
1835
+ * Defaults to `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`.
1836
+ *
1837
+ * The assistant element already lets a host override the blocked **banner**
1838
+ * via `setBlocked(true, reason)`; without this the transcript **bubble** stayed
1839
+ * on the default, so a white-labelled host got its own copy in the banner and
1840
+ * the shipped default directly below it. Passing the same effective copy here
1841
+ * keeps the two surfaces saying one thing.
1842
+ *
1843
+ * Deliberately a driver-config field rather than something read off a chat
1844
+ * config: `ChatDriver` has no `chatConfig` and is used standalone (see
1845
+ * `chat-driver-node`), so threading one in would be a much larger and less
1846
+ * reversible change.
1847
+ */
1848
+ budgetExhaustedMessage?: string;
1670
1849
  }
1671
1850
 
1672
1851
  export { ChatFallback }
@@ -1698,6 +1877,31 @@ declare type ChatInteractionEventsMap = {
1698
1877
 
1699
1878
  export { ChatToolChoice }
1700
1879
 
1880
+ /**
1881
+ * `aria-describedby` for the composer's textarea, send button and attach button:
1882
+ * the banner's id whenever the banner has something to say, otherwise `null` (so
1883
+ * the attribute is omitted rather than emitted empty).
1884
+ *
1885
+ * Keyed on `bannerVisible`, NOT on `blocked`, and that is the point. The
1886
+ * PARTIAL state — some vendor walled, composer still live — is the state this
1887
+ * feature exists to create, and it was the one state with no accessible
1888
+ * explanation at all: `aria-disabled` and `aria-label` bind only on `blocked`, and
1889
+ * a live composer keeps the host's own placeholder, so a screen-reader user
1890
+ * arriving at the textarea heard "Type a message" with no hint that the next turn
1891
+ * might be refused. The banner's `role="status"` announces the text when it
1892
+ * CHANGES; this is what makes the same explanation reachable afterwards, on
1893
+ * demand, from the control it is about.
1894
+ *
1895
+ * Applied in the fully blocked state too, where it is additive: the `aria-label`
1896
+ * there states the reason as the control's name, and this restates it as its
1897
+ * description for the send/attach buttons, which carry neither.
1898
+ *
1899
+ * Exported for the unit test that pins it; not part of the element API.
1900
+ *
1901
+ * @internal
1902
+ */
1903
+ export declare const composerDescribedBy: (x: FoundationAiAssistant) => string | null;
1904
+
1701
1905
  /** A model used during a finalized cost session. */
1702
1906
  export declare interface CostSessionModelEntry {
1703
1907
  model: string;
@@ -1892,6 +2096,24 @@ export declare interface FallbackAgentConfig extends BaseAgentConfig {
1892
2096
  description?: never;
1893
2097
  }
1894
2098
 
2099
+ /**
2100
+ * Banner copy built from the figures a 402 actually reported, or `undefined`
2101
+ * when the proxy sent none.
2102
+ *
2103
+ * `undefined` is the meaningful return, not a fallback: paired with the
2104
+ * slice's "a re-latch without a reason keeps the existing explanation" rule, it
2105
+ * makes a figureless driver latch leave a host-supplied reason intact rather
2106
+ * than blanking it back to the default at the exact moment the wall is hit.
2107
+ *
2108
+ * Exported for the unit test that pins the formatting; not part of the element
2109
+ * API.
2110
+ *
2111
+ * @internal
2112
+ */
2113
+ export declare function formatBlockedReason(budget?: Extract<ChatDriverResult, {
2114
+ reason: 'done';
2115
+ }>['budget'], vendor?: AIProviderType): string | undefined;
2116
+
1895
2117
  /**
1896
2118
  * Foundation AI Assistant component.
1897
2119
  *
@@ -2019,13 +2241,22 @@ export declare class FoundationAiAssistant extends GenesisElement {
2019
2241
  get busy(): boolean;
2020
2242
  /**
2021
2243
  * True when a new send must be refused: a turn is running (`busy`), a page-reload
2022
- * restore is loading history that would clobber it (`restoring`), or a manual
2023
- * compaction is rewriting history (`compacting`). Mirrors the composer's
2024
- * `?disabled` gate so a **programmatic** `send`/`submitMessage` (e.g. a host's
2025
- * custom input while the built-in composer is hidden) can't slip past it and have
2026
- * its message wiped when the restored/compacted history lands (GENC-1351 §6).
2244
+ * restore is loading history that would clobber it (`restoring`), a manual
2245
+ * compaction is rewriting history (`compacting`), or a backend condition has
2246
+ * locked the assistant outright (`blocked` — e.g. an exhausted AI budget).
2247
+ * Mirrors the composer's `?disabled` gate so a **programmatic**
2248
+ * `send`/`submitMessage` (e.g. a host's custom input while the built-in composer
2249
+ * is hidden) can't slip past it and have its message wiped when the
2250
+ * restored/compacted history lands (GENC-1351 §6).
2027
2251
  */
2028
2252
  private get sendBlocked();
2253
+ /**
2254
+ * Why a programmatic send was refused, for `submitMessage`'s `errors`. A
2255
+ * backend block is called out distinctly because — unlike the transient
2256
+ * "busy" cases — waiting and retrying will never clear it, and a caller
2257
+ * looping on "Assistant is busy" would spin forever.
2258
+ */
2259
+ private sendRefusalReason;
2029
2260
  /**
2030
2261
  * Re-runs `agentsChanged` if the live `agents` array no longer matches the
2031
2262
  * fingerprint of the currently installed driver. Used to apply swaps that
@@ -2096,6 +2327,356 @@ export declare class FoundationAiAssistant extends GenesisElement {
2096
2327
  */
2097
2328
  get restoring(): boolean;
2098
2329
  set restoring(value: boolean);
2330
+ /**
2331
+ * Whether the assistant is blocked by a backend condition and cannot send —
2332
+ * today, an exhausted AI-spend budget (GENC-1464). While true the composer is
2333
+ * disabled, `send`/`submitMessage` refuse, suggestions stop being fetched, and
2334
+ * a persistent banner (`part="blocked-banner"`) sits above the composer
2335
+ * explaining why. The transcript stays visible and scrollable throughout —
2336
+ * unlike `compacting` and `restoring`, this does not replace the conversation.
2337
+ *
2338
+ * **Latched.** Nothing in the element clears it: not a new turn, not "Clear"
2339
+ * / "New chat", not a pop-in/out. It stays set until the host writes
2340
+ * `false` — because nothing the user can do inside the assistant refills a
2341
+ * budget. Hosts typically set it from their own pre-flight budget check on
2342
+ * mount, and clear it once a later check shows headroom again.
2343
+ *
2344
+ * The driver also latches it automatically when a turn ends with the
2345
+ * `'budget-exhausted'` failure reason — off the `tool-loop-end` activity bus
2346
+ * (which also covers a sub-agent wall, a turn this element did not start, and
2347
+ * an element swapped in mid-turn) and off the driver's return value (which
2348
+ * covers a wall hit during multi-agent classification, where no tool loop ran
2349
+ * and so no bus event fires). So a host that does no pre-flight at all still
2350
+ * gets a correct locked UI the moment the first 402 lands. The bus topic is
2351
+ * tab-scoped, so a wall hit in a popped-out window latches the main window
2352
+ * too — correct for a shared spend cap.
2353
+ *
2354
+ * **Lifetime — read this before relying on it as your source of truth.** The
2355
+ * latch is in-memory and per-`stateKey`:
2356
+ *
2357
+ * - It does **not** survive `switchSession`. Switching away tears the outgoing
2358
+ * session's store down entirely, so switching back yields a fresh, unblocked
2359
+ * store.
2360
+ * - It does **not** survive a page reload. It is deliberately absent from the
2361
+ * persisted session snapshot: the snapshot is long-lived, so a persisted
2362
+ * latch would outlive an out-of-band budget raise with no in-element way to
2363
+ * clear it — a stale lock is worse than re-deriving the state.
2364
+ *
2365
+ * The durable source of truth is therefore the **host's pre-flight**, which
2366
+ * should run on mount and on every session switch. See
2367
+ * `docs/migration-GENC-1464.md` §"How to adopt", Option B.
2368
+ *
2369
+ * @beta
2370
+ */
2371
+ get blocked(): boolean;
2372
+ set blocked(value: boolean);
2373
+ /**
2374
+ * The distinct vendors the registry can currently reach, from the provider
2375
+ * statuses this element already loads on connect and refreshes on every
2376
+ * observable-registry change.
2377
+ *
2378
+ * This is the element's answer to "which vendor would the next turn use" — a
2379
+ * question that has **no** correct answer and deliberately gets no API. The
2380
+ * provider is resolved per turn AND per agent: `activeProviderInput` may be an
2381
+ * async function of the turn's context, an orchestrated turn picks its agent
2382
+ * with an LLM `classify()` call, and sub-agents resolve their own providers.
2383
+ * Any pre-turn "peek" would therefore be a guess that is wrong precisely on the
2384
+ * multi-agent hosts per-vendor budgets exist for.
2385
+ *
2386
+ * Asking instead which vendors are REACHABLE is answerable, synchronous, and
2387
+ * free — and it is enough: the composer must stay live while any reachable
2388
+ * vendor has headroom, and must lock when none does. It also makes vendor-switch
2389
+ * recovery a derivation rather than a mutation: a host that swaps its registry
2390
+ * to another vendor fires the observable, the statuses reload, the walled vendor
2391
+ * drops out of this set, and `blocked` goes false with every latch left intact.
2392
+ *
2393
+ * @beta
2394
+ */
2395
+ get reachableVendors(): readonly AIProviderType[];
2396
+ /**
2397
+ * Vendors currently walled by the AI-spend budget, in the order they were
2398
+ * walled. Empty for a host that never adopts the per-vendor API.
2399
+ *
2400
+ * May include a vendor this registry cannot reach — either because the registry
2401
+ * moved on after the wall was latched, or because a 402's
2402
+ * `otherVendorAvailable: false` walls every vendor the proxy meters (which is
2403
+ * what that verdict is a statement about; see
2404
+ * {@link FoundationAiAssistant.latchBlockedFrom}). Neither costs anything
2405
+ * internally — {@link FoundationAiAssistant.blocked} asks only about the
2406
+ * reachable set, and the banner reads the reachability-filtered
2407
+ * {@link FoundationAiAssistant.relevantBlockedVendors} — but a host rendering
2408
+ * this list itself should filter it against its own registry.
2409
+ *
2410
+ * Read-only on purpose: a settable array would let a host write a partial list
2411
+ * and silently orphan the per-vendor banner copy.
2412
+ * {@link FoundationAiAssistant.setVendorBlocked} is the write path.
2413
+ *
2414
+ * @beta
2415
+ */
2416
+ get blockedVendors(): readonly AIProviderType[];
2417
+ /**
2418
+ * Whether this specific vendor's budget is walled — regardless of whether any
2419
+ * other vendor still has headroom.
2420
+ *
2421
+ * @beta
2422
+ */
2423
+ isVendorBlocked(vendor: AIProviderType): boolean;
2424
+ /**
2425
+ * Wall (or release) one vendor's budget, optionally with banner copy for it.
2426
+ * The per-vendor mirror of {@link FoundationAiAssistant.setBlocked}.
2427
+ *
2428
+ * Walling a vendor does **not** on its own disable the composer: while another
2429
+ * reachable vendor has headroom the assistant stays usable and the banner tells
2430
+ * the user to switch. Only when every reachable vendor is walled does `blocked`
2431
+ * become true.
2432
+ *
2433
+ * `reason` is composed into the banner sentence for that vendor, **however many
2434
+ * vendors are walled** — see {@link FoundationAiAssistant.effectiveBlockedReason}.
2435
+ * It is per-vendor copy, not a whole-banner override; {@link FoundationAiAssistant.blockedReason}
2436
+ * is the override. Omitting it keeps whatever explanation that vendor already
2437
+ * carried, so a driver latch landing after a host one cannot blank it.
2438
+ *
2439
+ * `'none'` is rejected with a warning rather than accepted: it is the "no
2440
+ * provider configured" sentinel, not a vendor. Latching it would put the raw
2441
+ * sentinel in the banner ("none's AI usage limit is reached") and could never
2442
+ * be undone by derivation, because `'none'` never appears in
2443
+ * {@link FoundationAiAssistant.reachableVendors} — so `blocked` could never
2444
+ * become derivable from it either. Use {@link FoundationAiAssistant.setBlocked}
2445
+ * for a vendor-agnostic block.
2446
+ *
2447
+ * @beta
2448
+ */
2449
+ setVendorBlocked(vendor: AIProviderType, blocked: boolean, reason?: string | null): void;
2450
+ /**
2451
+ * The walled vendors the user can still be routed to — i.e.
2452
+ * {@link FoundationAiAssistant.blockedVendors} narrowed to the reachable set,
2453
+ * which is the only set the banner may name.
2454
+ *
2455
+ * A wall the registry can no longer reach is not news: it cannot be hit, and
2456
+ * naming it puts a vendor in front of the user that is not theirs. The concrete
2457
+ * failure this exists to stop: a host ships Anthropic-only, Anthropic walls, the
2458
+ * host swaps its registry to Gemini, Gemini walls — and the banner reads "AI
2459
+ * usage limits are reached for Anthropic and Gemini" to a user who has never
2460
+ * had an Anthropic key. The spurious second name also flips the copy onto the
2461
+ * plural branch, so even the closing sentence is wrong.
2462
+ *
2463
+ * Falls back to the unfiltered list when nothing is reachable, which is the
2464
+ * same fail-safe {@link FoundationAiAssistant.blocked} takes there: unknown
2465
+ * reachability means every wall counts, so every wall is also worth naming.
2466
+ *
2467
+ * @internal
2468
+ */
2469
+ private get relevantBlockedVendors();
2470
+ /**
2471
+ * Whether the blocked banner has anything to say — a vendor-agnostic block, OR
2472
+ * at least one walled vendor the registry can still reach. Broader than
2473
+ * {@link FoundationAiAssistant.blocked} on purpose: partial exhaustion leaves
2474
+ * the composer live but still needs to be announced, because the next turn may
2475
+ * route to the walled vendor and fail.
2476
+ *
2477
+ * Reads the reachability-filtered list for the same reason the copy does — a
2478
+ * host that has swapped its registry away from the walled vendor has nothing
2479
+ * left to announce, and would otherwise get a banner that falls through to the
2480
+ * generic default copy over a perfectly usable composer.
2481
+ *
2482
+ * @internal
2483
+ */
2484
+ get bannerVisible(): boolean;
2485
+ /**
2486
+ * Whether a suggestions fetch would hit a wall.
2487
+ *
2488
+ * Unlike a chat turn, this one CAN be resolved exactly: both suggestion paths
2489
+ * go to the registry **default** and never to a per-agent override
2490
+ * (`ChatDriver.getSuggestions` calls `providerRegistry.default()`, and
2491
+ * `OrchestratingDriver.getSuggestions` just delegates to it). So the vendor a
2492
+ * suggestions call would use is knowable, and this is the one place in the
2493
+ * feature where that is true.
2494
+ *
2495
+ * Same rationale as the guard it replaces: a doomed suggestions call burns a
2496
+ * `suggestions.failed` meta event and parks a raw transport error in
2497
+ * `suggestionsState` for hosts to find.
2498
+ *
2499
+ * @internal
2500
+ */
2501
+ get suggestionsBlocked(): boolean;
2502
+ /**
2503
+ * Explanation shown in the blocked banner, or `null` for the element's default
2504
+ * copy. Only meaningful while {@link FoundationAiAssistant.blocked} is true;
2505
+ * setting `blocked = false` clears it.
2506
+ *
2507
+ * Writable, and symmetric with every other store-backed accessor on this
2508
+ * class: writing it re-latches with the CURRENT `blocked` value, so it changes
2509
+ * the copy without disturbing the flag. Assigning `null` clears the
2510
+ * explanation while staying blocked (the banner falls back to the default
2511
+ * copy). {@link FoundationAiAssistant.setBlocked} remains the way to write
2512
+ * both in one atomic action.
2513
+ *
2514
+ * @beta
2515
+ */
2516
+ get blockedReason(): string | null;
2517
+ set blockedReason(value: string | null);
2518
+ /**
2519
+ * Latch the backend block off a turn outcome. The single decision point for
2520
+ * both latch sites (the `tool-loop-end` bus subscription and `send()`'s
2521
+ * return-value check), so the rule cannot diverge between them.
2522
+ *
2523
+ * Idempotent and one-way **per vendor**: it never unblocks, and it never
2524
+ * re-writes an existing wall. That guard is load-bearing, not just an
2525
+ * optimisation — the bus fires from the driver's `finally`, i.e. BEFORE
2526
+ * `sendMessage()` resolves, so a host subscribed to `tool-loop-end` (what the
2527
+ * migration guide's Option C recommends) sets its own detailed reason and the
2528
+ * return-value latch would otherwise land a moment later and blank it.
2529
+ *
2530
+ * The guard being per-vendor rather than global is the whole difference. A
2531
+ * single global "already blocked, do nothing" would swallow a second vendor's
2532
+ * wall — so a session walled on Anthropic could never record that Gemini went
2533
+ * too, and under a mixed registry an Anthropic-only wall would lock a composer
2534
+ * that Gemini could still serve.
2535
+ *
2536
+ * **Which field is authoritative**, in order:
2537
+ *
2538
+ * 1. `budget.vendorLabel` — stamped by the transport that was actually refused,
2539
+ * so it can never be stale. But it is a static per-transport string, so a
2540
+ * white-labelled or multiplexing gateway fronting several upstreams leaves
2541
+ * it unclaimed by any vendor.
2542
+ * 2. `budget.vendor` — which the driver resolved from the label where it could,
2543
+ * and otherwise from the proxy's own `vendor` field on the 402. It is
2544
+ * therefore NOT simply `vendorLabel` normalised, and the two can disagree;
2545
+ * that fallback is the only attribution on offer for the gateway case above.
2546
+ * 3. `vendorHint` — the bus detail's top-level vendor, i.e. the driver's
2547
+ * last-resolved provider. Last resort because it CAN be stale: an
2548
+ * orchestrated turn classifies against the registry default, a provider the
2549
+ * chat driver may never have resolved, so its last-resolved provider there is
2550
+ * the previous turn's vendor or nothing at all.
2551
+ *
2552
+ * A wall that names no recognised vendor falls back to the vendor-agnostic
2553
+ * block — fail-safe, and identical to the pre-per-vendor behaviour.
2554
+ *
2555
+ * **`otherVendorAvailable: false` is server-known truth and outranks every
2556
+ * inference made here.** The proxy meters the pots, so only it can say whether
2557
+ * anything else has headroom; this element can only observe which vendors the
2558
+ * registry can REACH, which says nothing about their remaining spend. So when
2559
+ * the proxy says no, every other *budgeted* vendor is walled in the same pass.
2560
+ * That is what makes `blocked` derive true — locking the composer on the first
2561
+ * response instead of after a second doomed turn — and what empties the "free"
2562
+ * set the banner would otherwise have advised switching to.
2563
+ *
2564
+ * The sweep runs over `BUDGETED_VENDORS`, deliberately **not** over
2565
+ * {@link FoundationAiAssistant.reachableVendors}. Reachability is loaded
2566
+ * asynchronously (`activateSession` kicks off `loadProviderStatuses()` and does
2567
+ * not await it), so a wall landing before the statuses resolve would have swept
2568
+ * an empty set — silently discarding the one fact on the 402 the client cannot
2569
+ * re-derive, with nothing to re-run it when the statuses arrived. Worse, it let
2570
+ * the verdict effectively un-latch: the single wall derived `blocked` only
2571
+ * while the reachable set was empty, so the moment the statuses landed the
2572
+ * composer came back to life against a budget the proxy had already said was
2573
+ * gone. Sweeping the metered vendor list instead makes the verdict independent
2574
+ * of load order and of registry membership, which is exactly what it is: a
2575
+ * statement about the proxy's pots, not about this user's registry.
2576
+ *
2577
+ * Walling a vendor the registry cannot reach costs nothing: `blocked` only asks
2578
+ * whether every REACHABLE vendor is walled, and the banner reads
2579
+ * {@link FoundationAiAssistant.relevantBlockedVendors}, which filters the walls
2580
+ * back down to the reachable set before naming any of them.
2581
+ *
2582
+ * One boundary remains on the sweep: it walls only vendors the proxy meters
2583
+ * (`isBudgetedVendor`). Chrome runs on-device with no pot to exhaust, so "no
2584
+ * other vendor has budget" is not a statement about it, and "Switch to Chrome
2585
+ * to keep going." stays honest advice.
2586
+ *
2587
+ * The sweep runs last but sits OUTSIDE the per-vendor idempotence guard, and
2588
+ * both halves of that matter. Last, so the vendor that actually refused this
2589
+ * turn heads the wall order the multi-vendor banner reads. Outside the guard,
2590
+ * because the verdict can arrive on a LATER 402 for a vendor that is already
2591
+ * walled — turn 1 walls Anthropic while Gemini still has headroom, turn 2 walls
2592
+ * it again and reports that Gemini has since gone too — and an early return
2593
+ * would drop exactly the news the second turn was there to deliver.
2594
+ *
2595
+ * @param reason - the turn's failure reason; anything but `'budget-exhausted'` is ignored.
2596
+ * @param ref - session store to write through; defaults to the live one. `send()`
2597
+ * passes the ref it captured before its awaits, since a lifecycle event during the
2598
+ * turn may already have cleared `_sessionRef`.
2599
+ * @param budget - what the 402 reported: figures when the proxy sent any, the
2600
+ * refusing vendor, and its `otherVendorAvailable` verdict.
2601
+ * @param vendorHint - the `tool-loop-end` detail's top-level vendor, used only
2602
+ * when the budget payload names none.
2603
+ *
2604
+ * @internal
2605
+ */
2606
+ private latchBlockedFrom;
2607
+ /**
2608
+ * Block or unblock the assistant, optionally with custom banner copy.
2609
+ * Equivalent to the {@link FoundationAiAssistant.blocked} setter, plus the
2610
+ * reason in one atomic write.
2611
+ *
2612
+ * Blocking writes the **vendor-agnostic** block — what a host with one budget
2613
+ * pot means — and does not populate
2614
+ * {@link FoundationAiAssistant.blockedVendors}. Unblocking clears the
2615
+ * per-vendor walls as well, so a host asserting "the wall is gone" after its
2616
+ * own pre-flight cannot be silently overruled by a driver latch it never saw.
2617
+ *
2618
+ * @beta
2619
+ */
2620
+ setBlocked(blocked: boolean, reason?: string | null): void;
2621
+ /**
2622
+ * Copy shown in the blocked banner, in ascending specificity:
2623
+ *
2624
+ * 1. the host's `blockedReason` — a whole-banner override, unchanged;
2625
+ * 2. copy composed from the walled vendors (their per-vendor reasons when set,
2626
+ * otherwise their names) plus the action that is actually available;
2627
+ * 3. the default budget-exhaustion message.
2628
+ *
2629
+ * The action clause is computed here rather than stored, because whether
2630
+ * "switch vendor" or "contact your administrator" is honest depends on whether
2631
+ * another reachable vendor still has headroom — which changes when the registry
2632
+ * changes, long after the wall was latched. Telling a user to switch to a
2633
+ * vendor that is also exhausted is worse than saying nothing.
2634
+ *
2635
+ * The `free` set is a client-side inference — "reachable and not known to be
2636
+ * walled" — and it is only ever allowed to be one. Where the proxy has told us
2637
+ * otherwise (`otherVendorAvailable: false`) the latch has already walled those
2638
+ * vendors, so they are not in `free` here and no "switch to X" clause can be
2639
+ * composed from them. Both lists are filtered by reachability, so the copy
2640
+ * names only vendors this user's registry can actually route to.
2641
+ *
2642
+ * **Per-vendor copy is composed however many vendors are walled**, which is
2643
+ * what {@link FoundationAiAssistant.setVendorBlocked} and
2644
+ * `docs/migration-GENC-1464.md` both promise. It previously reached only the
2645
+ * single-vendor branch, so a host that had carefully set copy for each of its
2646
+ * vendors watched all of it vanish the moment a second one walled — replaced by
2647
+ * generic copy, at the exact moment the situation got worse.
2648
+ *
2649
+ * The compact "AI usage limits are reached for A and B." list is kept for the
2650
+ * case it was written for: NO walled vendor carries copy, so a statement per
2651
+ * vendor would just repeat one boilerplate sentence per name. As soon as any of
2652
+ * them does carry copy, the branch lists one statement per vendor instead —
2653
+ * falling back to that same boilerplate for the ones that have none, so the
2654
+ * sentence set stays complete rather than silently naming a subset.
2655
+ *
2656
+ * @internal
2657
+ */
2658
+ get effectiveBlockedReason(): string;
2659
+ /**
2660
+ * Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall.
2661
+ *
2662
+ * A host-set `blockedReason` still wins, so a white-labelled host does not read
2663
+ * its own explanation in the banner and the shipped default directly below it —
2664
+ * that is the whole reason the driver takes this at all.
2665
+ *
2666
+ * What it deliberately is NOT is
2667
+ * {@link FoundationAiAssistant.effectiveBlockedReason}. That getter
2668
+ * composes advice — `"…Switch to Gemini to keep going."` — which is true only
2669
+ * while that vendor has headroom, and the transcript is a permanent record. The
2670
+ * driver reads this once at CONSTRUCTION, and `getOrCreateDriver` keys on the
2671
+ * agents list, so an agents swap *after* a wall re-runs `createDriver` and would
2672
+ * freeze that sentence into every later turn's bubble. Reading the latch's own
2673
+ * copy instead is structurally incapable of carrying the advice: the only
2674
+ * writer of `blockedReason` is the vendor-agnostic branch of
2675
+ * `latchBlockedFrom`, whose `formatBlockedReason(budget)` form has no switch
2676
+ * clause. Per-vendor copy lives in `blockedVendorReasons` and reaches the
2677
+ * banner alone.
2678
+ */
2679
+ private get transcriptBudgetExhaustedMessage();
2099
2680
  /**
2100
2681
  * Name of the agent the user has pinned via the agent picker. `null` means
2101
2682
  * automatic routing (Auto). Persisted on the session store, so it survives
@@ -2216,6 +2797,8 @@ export declare class FoundationAiAssistant extends GenesisElement {
2216
2797
  private driverCleanup?;
2217
2798
  private loadingTimer;
2218
2799
  private unsubBus?;
2800
+ /** Unsubscribe handle for the `tool-loop-end` budget latch (GENC-1464). */
2801
+ private _unsubBudgetLatch?;
2219
2802
  /** Unsubscribe handle for the provider-registry change listener (observable registries only). */
2220
2803
  private unsubProviderRegistry?;
2221
2804
  /** Unsubscribe handle for {@link (AssistantAppSettingsProvider:interface).subscribe}. */
@@ -3051,6 +3634,17 @@ export declare class OrchestratingDriver extends EventTarget implements AiDriver
3051
3634
  * user stops. Reset at the start of each `sendMessage`.
3052
3635
  */
3053
3636
  private cancelled;
3637
+ /**
3638
+ * Whether the CURRENT turn's user message has reached the inner driver's
3639
+ * history. False through the pre-first-turn classify (where the only echo of
3640
+ * the message is an optimistic `history-updated` dispatch), true from the
3641
+ * moment `chatDriver.sendMessage` is entered — including through every later
3642
+ * handoff classify. Read by the budget-wall catch in `sendMessage` to decide
3643
+ * whether `reportBudgetExhausted` must append the message itself: appending
3644
+ * it when already appended duplicated it; not appending it when unappended
3645
+ * made it vanish. Reset at the top of each `runOrchestratedTurn`.
3646
+ */
3647
+ private userMessageAppended;
3054
3648
  /**
3055
3649
  * Sticky user pick from the picker (or the host's `setAgent` API). Only
3056
3650
  * changes on explicit user action. Survives flow completion: when a stateful
@@ -3084,6 +3678,8 @@ export declare class OrchestratingDriver extends EventTarget implements AiDriver
3084
3678
  maxTurnSnapshots?: number;
3085
3679
  /** Activity bus passed through to the inner ChatDriver (browser host injects the singleton). */
3086
3680
  activityBus?: ActivityBus;
3681
+ /** Budget-wall transcript copy, passed through to the inner ChatDriver (GENC-1464). */
3682
+ budgetExhaustedMessage?: string;
3087
3683
  });
3088
3684
  resolveInteraction(interactionId: string, result: unknown): void;
3089
3685
  getInteractionContext(interactionId: string): InteractionContext | undefined;
@@ -3116,6 +3712,7 @@ export declare class OrchestratingDriver extends EventTarget implements AiDriver
3116
3712
  getExternalDiagnostics(): ReadonlyArray<DiagnosticEntry>;
3117
3713
  getSuggestions(history: ChatMessage[], prompt: string, count: number, allAgentInfo?: AllAgentSummary[]): Promise<string[]>;
3118
3714
  sendMessage(input: string, attachments?: ChatAttachment[]): Promise<ChatDriverResult>;
3715
+ private runOrchestratedTurn;
3119
3716
  continueFromHistory(transientPrimer?: ChatMessage[]): Promise<ChatDriverResult>;
3120
3717
  /** {@inheritDoc AiDriver.primeRestoredAgentState} */
3121
3718
  primeRestoredAgentState(snapshots: Record<string, unknown>): void;