@genesislcap/ai-assistant 15.6.2 → 15.7.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 +613 -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 +254 -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 +394 -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 +162 -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 +11 -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 +969 -0
  37. package/dist/esm/main/main.js +704 -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 +145 -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 +1316 -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 +704 -10
  60. package/src/state/ai-assistant-slice.test.ts +215 -0
  61. package/src/state/ai-assistant-slice.ts +218 -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
@@ -0,0 +1,562 @@
1
+ # Migration Guide — GENC-1464 (AI budget exhausted: a first-class blocked state)
2
+
3
+ > **Additive — no migration required.** Nothing was removed, renamed, or given a new
4
+ > meaning. Every existing event name, public member, template `part`, and failure
5
+ > reason behaves exactly as before. Upgrade, and the only visible change is that a
6
+ > spend-cap rejection now produces a clear, locked UI instead of a generic apology. The one
7
+ > exception is copy: the default banner sentence now names the vendor that refused — see
8
+ > [Per-vendor budgets](#per-vendor-budgets).
9
+ >
10
+ > All surfaces below are `@beta`.
11
+
12
+ ---
13
+
14
+ ## Why
15
+
16
+ When the ai-service proxy refuses a request because the AI-spend budget is used up, it
17
+ answers `HTTP 402` with `code: 'BUDGET_EXCEEDED'` (workstream C). Before this change the
18
+ client had no idea what that meant. A `402` fell through the transport as an untyped
19
+ `Error('Anthropic request error 402: …')`, landed in the chat driver's **transient-retry**
20
+ catch, and was re-issued up to `MAX_SETUP_TRANSPORT_RETRIES` times — three doomed requests
21
+ against a wall that no retry can clear — before the user finally got:
22
+
23
+ > Sorry, something went wrong on my end. Please try again in a moment.
24
+
25
+ Which is wrong twice over: nothing went wrong *on our end*, and trying again in a moment
26
+ will not help. Worse, the composer stayed enabled, so the user's next message hit the same
27
+ wall.
28
+
29
+ Budget exhaustion is now modelled for what it is: a **terminal, out-of-band condition**,
30
+ in the same family as `ResponseTruncatedError` — deterministic, not retryable, and fixable
31
+ only by a human raising the cap.
32
+
33
+ ---
34
+
35
+ ## What was added
36
+
37
+ ### 1. `@genesislcap/foundation-ai`
38
+
39
+ | Addition | Kind | Notes |
40
+ | --- | --- | --- |
41
+ | `BudgetExhaustedError` | exported class | Fields: `vendorLabel`, `budgetUsd?`, `spentUsd?`, `detail?`, `otherVendorAvailable?`, `serverVendorLabel?` |
42
+ | `DEFAULT_BUDGET_EXHAUSTED_MESSAGE` | exported const | The one copy shown to the user, shared by the transcript bubble and the assistant's banner |
43
+ | `'budget-exhausted'` | new `TurnFailureReason` member | Rides `ChatDriverResult.failureReason` and the `tool-loop-end` activity-bus detail |
44
+ | `'budget_exhausted'` | new `SubAgentFailureReason` member | The `{ ok: false }` branch of `requestSubAgent`. **Terminal for the parent turn too** — see [`sub_agent.md`](./sub_agent.md) |
45
+ | `ChatDriverResult.budget` | new optional field on the `done` arm | `{ budgetUsd?, spentUsd?, vendorLabel, vendor?, otherVendorAvailable? }` — what the `402` reported, present on a budget failure that carried figures **or** an attributable vendor |
46
+ | `VENDOR_LABELS` / `vendorTypeOfLabel` | exported const + function | The vendor display names every transport stamps, and the reverse lookup that turns one back into an `AIProviderType` |
47
+ | `BUDGETED_VENDORS` / `isBudgetedVendor` | exported const + function | The vendors the proxy meters (`anthropic`, `gemini`) — i.e. the ones a wall can name, mirroring ai-service's `VENDORS`. `chrome` and `openai` are in `VENDOR_LABELS` for display only: Chrome runs on-device with no pot to exhaust, and the proxy refuses `openai` up front with `400 UNSUPPORTED_PROVIDER`, so neither is ever walled |
48
+
49
+ ### Transport coverage
50
+
51
+ The classification is keyed on **status OR code** — the contracted `402`, or
52
+ `code: 'BUDGET_EXCEEDED'` under any status — and in every wire mode it runs
53
+ *before* the retryable-status ladder. Both keys matter: `status` is what the
54
+ workstream-C contract guarantees and the only key a plain-text rejection carries,
55
+ while `code` is the cheap defence against that contract drifting. `429` is in both
56
+ transports' retryable sets, so a proxy that ever mapped the refusal onto it would
57
+ otherwise reproduce the exact bug this change removes — five doomed requests and
58
+ ~31s of backoff before an opaque failure.
59
+
60
+ - **Legacy JSON** — `402` (or the code under another status) → `BudgetExhaustedError`,
61
+ enriched from the body when it parses. An unparseable or plain-text `402` still
62
+ classifies, just without the extra fields.
63
+ - **NDJSON framed** — a terminal `{ t: 'err', status: 402, code: 'BUDGET_EXCEEDED', error,
64
+ details: { … } }` frame → the same typed throw, classified before the
65
+ ladder exactly like `UPSTREAM_STALLED`.
66
+
67
+ The fields read off the rejection, top-level in the legacy body and under `details` in
68
+ the framed one (`error` stays top-level in both). Each is narrowed independently, so a
69
+ `null` figure — which the proxy sends in preference to a misleading `0` — does not
70
+ suppress the fields beside it:
71
+
72
+ | field | used for |
73
+ | --- | --- |
74
+ | `budgetUsd` | the cap in the banner's figures |
75
+ | `spentUsd` | the spend in the banner's figures |
76
+ | `error` | the proxy's human-readable detail |
77
+ | `vendor` | attribution when the transport's own label is unclaimed |
78
+ | `otherVendorAvailable` | whether "switch vendor" is honest advice at all |
79
+
80
+ `402` is deliberately **absent** from every transport's `RETRYABLE_STATUSES`, and a
81
+ regression test in each transport's suite asserts `fetch` is called exactly once, so it can
82
+ never quietly join the ladder. (`429` still retries — also pinned by a test.) A separate
83
+ `post-with-retry.test.ts` pins the *ordering* directly, by injecting a retryable set that
84
+ does contain `402`.
85
+
86
+ **Which transports are covered, precisely** — the detection lives in the shared
87
+ `postWithRetry` envelope, but not every transport delegates to it:
88
+
89
+ | Transport | Covered | Notes |
90
+ | --- | --- | --- |
91
+ | `AnthropicTransport` | ✅ both `chat()` and `sendStructuredPrompt` | Goes through `postWithRetry` |
92
+ | `GeminiTransport` | ✅ both | Goes through `postWithRetry` |
93
+ | `ServerOpenAITransport` | ⚠️ typed, but invisible | Does its **own bare `fetch`**, so it classifies the `402` separately. It has no `chat()` path — it serves only `sendStructuredPrompt`, whose single caller `OpenAIProvider.interpretCriteria` catches everything and returns `null`. So a `402` there degrades smart-search **silently** and can **never** latch `blocked`, before or after this change. The typed throw improves the log line and helps a host that catches it directly. |
94
+ | `ChromePromptTransport`, `DefaultTransport` | n/a | Local / no-op; there is no proxy and nothing to classify |
95
+
96
+ Routing `ServerOpenAITransport` through `postWithRetry` wholesale was considered and
97
+ declined: it has no retry ladder, no stall timer and no framed mode, so that is a
98
+ transport rewrite rather than a fix.
99
+
100
+ ### 2. `@genesislcap/ai-assistant`
101
+
102
+
103
+ | Addition | Kind |
104
+ | --- | --- |
105
+ | `FoundationAiAssistant.blocked` | public getter/setter (`boolean`) |
106
+ | `FoundationAiAssistant.blockedReason` | public getter **and setter** (`string \| null`) |
107
+ | `FoundationAiAssistant.setBlocked(blocked, reason?)` | public method |
108
+ | `part="blocked-banner"` | new template part (always rendered — see [Accessibility](#accessibility)) |
109
+ | `blocked` / `blockedReason` + `setBlocked` | session-slice state and action |
110
+ | per-vendor blocking (`blockedVendors`, `setVendorBlocked`, …) | see [Per-vendor budgets](#per-vendor-budgets) |
111
+ | `ChatDriverConfig.budgetExhaustedMessage` | new optional field — overrides the transcript copy |
112
+
113
+ The chat driver gained a typed `BudgetExhaustedError` branch that sits **before** the
114
+ transient-retry catch. It does not retry; it appends `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`
115
+
116
+ > You've reached your AI usage limit. Contact your administrator to raise it.
117
+
118
+ to the transcript (or calls `failSubAgent('budget_exhausted')` when running as a sub-agent),
119
+ and ends the turn with `failureReason: 'budget-exhausted'`.
120
+
121
+ That copy is deliberately **vendor-neutral**, because the same bundle ships to
122
+ white-labelled deployments. Both surfaces are overridable, and you should override
123
+ both together or neither: the banner via `setBlocked(true, reason)`, the transcript
124
+ bubble via `ChatDriverConfig.budgetExhaustedMessage` (the assistant element passes
125
+ your `blockedReason` through automatically, falling back to the shipped default).
126
+ Overriding only the banner used to leave a host reading its own wording directly
127
+ above the shipped default.
128
+
129
+ ### `setBlocked` and the reason
130
+
131
+ `reason` distinguishes three cases, not two:
132
+
133
+ | Call | Effect |
134
+ | --- | --- |
135
+ | `setBlocked(true, 'text')` | Blocks and sets the explanation |
136
+ | `setBlocked(true)` — reason **omitted** | Blocks and **keeps** any existing explanation |
137
+ | `setBlocked(true, null)` | Blocks and clears the explanation (banner falls back to the default) |
138
+ | `setBlocked(false)` | Unblocks, always clears the explanation, **and clears every per-vendor latch** |
139
+
140
+ The "omitted keeps" rule is load-bearing. The block has two writers — you, and the
141
+ driver's automatic latch — and the driver's write can land *after* yours: the
142
+ `tool-loop-end` bus event is published from the driver's `finally`, i.e. before
143
+ `sendMessage()` resolves. Treating an omitted reason as "clear it" meant a host
144
+ that set detailed copy from the bus had it replaced with generic copy a moment
145
+ later, every time.
146
+
147
+ ---
148
+
149
+ ## The blocked state
150
+
151
+ `blocked` follows the existing `compacting` / `restoring` pattern — session-slice-backed, so
152
+ it stays in sync across pop-in/pop-out — with **two deliberate differences**:
153
+
154
+ 1. **It renders alongside the transcript, not instead of it.** `compacting` and `restoring`
155
+ replace the message list with a status view, which is right for a transient rewrite where
156
+ history is mid-flight. A block is indefinite, so hiding the conversation behind it would
157
+ strand the user's work behind a wall they cannot clear. Instead a persistent banner
158
+ (`part="blocked-banner"`) sits above the composer, with the transcript fully readable.
159
+ 2. **It is latched.** Nothing in the element clears it — not a new turn, not
160
+ **Clear** / **New chat** (`resetSession` explicitly preserves it: starting a new chat does
161
+ not refill the budget), not a pop-in/out. It stays set until the host writes
162
+ `blocked = false`.
163
+
164
+ While blocked: the composer textarea and send button are disabled, the attach button is
165
+ disabled, the suggestion chips are hidden, the placeholder swaps to
166
+ `AI usage limit reached`, no suggestion requests are issued (each one would be a
167
+ guaranteed `402`), and `send()` / `submitMessage()` refuse. `submitMessage` returns
168
+ `{ ok: false, errors: [<the blocked reason>] }` rather than the transient
169
+ `'Assistant is busy'`, so a caller looping on "busy" doesn't spin forever.
170
+
171
+ > **Unrelated fix riding along.** The attach button now shares the text area's full
172
+ > disable gate. It previously checked only `busy`, so `compacting`, `restoring` **and**
173
+ > `blocked` were all missing from it — meaning files could be attached during a
174
+ > compaction or a restore and then never sent, which was already a bug before this
175
+ > feature existed. If you relied on attaching mid-compaction, that stops working.
176
+
177
+ ### Scope and lifetime
178
+
179
+ `blocked` lives on the **session store**, so it is per-`stateKey` and **in memory**.
180
+ Two consequences that you must design around rather than discover:
181
+
182
+ - **It does not survive `switchSession`.** Switching away tears the outgoing
183
+ session's store down completely, so switching back yields a fresh, unblocked
184
+ store. The old session's value is *not* retained.
185
+ - **It does not survive a page reload.** It is deliberately **not** in the
186
+ persisted session snapshot. The snapshot is long-lived, so a persisted latch
187
+ would outlive an out-of-band budget raise with no in-element way to clear it —
188
+ the user would come back to a permanently dead composer against a budget that
189
+ has headroom. Re-deriving the state is strictly safer than remembering it.
190
+
191
+ So the durable source of truth is **your pre-flight** (Option B below), which
192
+ should run on mount *and* on every session switch. The driver latch is a safety
193
+ net for the first `402` within a live session, not a store.
194
+
195
+ One thing the latch *does* cross: the `tool-loop-end` topic it rides is forwarded
196
+ on the **tab-scoped** channel, so a wall hit in a popped-out window locks the main
197
+ window's composer immediately too. That is correct for a shared spend cap and
198
+ consistent with how the halo and loop events already propagate.
199
+
200
+ ---
201
+
202
+ ## Per-vendor budgets
203
+
204
+ The AI-spend budget is **per vendor**: each vendor gets its own cap (the
205
+ ai-service side counts `SpentUsdAnthropic` / `SpentUsdGemini` alongside the
206
+ untouched combined `SpentUsd`, which is now reporting-only). Internal
207
+ (`@genesis.global`) users and local development stay unlimited on every vendor.
208
+ So one vendor can be exhausted while another still has headroom, and the
209
+ assistant models exactly that.
210
+
211
+ **This is not breaking.** `blockedVendors` starts empty, and while it is empty
212
+ `blocked` reads exactly as it always did.
213
+
214
+ ### What was added
215
+
216
+ | Addition | Kind |
217
+ | --- | --- |
218
+ | `FoundationAiAssistant.blockedVendors` | public getter (`readonly AIProviderType[]`) — no setter |
219
+ | `FoundationAiAssistant.isVendorBlocked(vendor)` | public method |
220
+ | `FoundationAiAssistant.setVendorBlocked(vendor, blocked, reason?)` | public method — mirrors `setBlocked` |
221
+ | `FoundationAiAssistant.reachableVendors` | public getter (`readonly AIProviderType[]`) |
222
+ | `blockedVendors` / `blockedVendorReasons` + `setVendorBlocked` / `setBlockedReason` | session-slice state and actions |
223
+ | `ChatDriverResult.budget.vendor` | new optional field — the typed vendor beside the existing `vendorLabel` |
224
+ | `VENDOR_LABELS`, `vendorTypeOfLabel` | exported from `@genesislcap/foundation-ai` |
225
+ | `.is-visible` / `.is-partial` | banner classes — `is-visible` whenever the banner has copy, `is-partial` layered on top for the some-vendors-walled state |
226
+
227
+ ### `blocked` is now derived
228
+
229
+ It reads **"every vendor the registry can currently reach is walled"**, plus the
230
+ vendor-agnostic flag. Concretely:
231
+
232
+ - Reading `blocked` is unchanged for you.
233
+ - Writing `blocked = true` / `setBlocked(true, …)` still sets the **vendor-agnostic**
234
+ block — one pot, which is what a single-budget host means. It does not populate
235
+ `blockedVendors`.
236
+ - Writing `blocked = false` / `setBlocked(false)` now **also clears every
237
+ per-vendor latch**. That is deliberate: it is what Option B's pre-flight already
238
+ meant, so Option B code is unchanged. A host asserting "the wall is gone" must
239
+ not be overruled by a driver latch it cannot see.
240
+
241
+ ### There is deliberately no "which vendor will the next turn use?"
242
+
243
+ And there will not be one — no `peekVendorForNextTurn()`. The provider is
244
+ resolved **per turn and per agent**: `activeProviderInput` may be an async
245
+ function of the turn's context, `OrchestratingDriver` picks its agent with an LLM
246
+ `classify()` call, and sub-agents resolve their own. Any pre-turn answer would be
247
+ a guess that is wrong precisely on the multi-agent hosts per-vendor budgets exist
248
+ for. Resolving against the registry *default* alone was also rejected: with a
249
+ mixed registry it locks the composer in the wrong direction.
250
+
251
+ The element asks the answerable question instead — which vendors are
252
+ **reachable**, read from the provider statuses it already loads — and locks only
253
+ when all of them are walled.
254
+
255
+ The one place the exact vendor *is* knowable is suggestions: both suggestion
256
+ paths call `providerRegistry.default()`. Suggestion chips therefore hide (and no
257
+ request is issued) only when the **default** vendor is walled.
258
+
259
+ ### Partial exhaustion: the composer stays live
260
+
261
+ While at least one reachable vendor has headroom the composer stays enabled, the
262
+ placeholder stays the host's, and `send`/`submitMessage` keep working. The banner
263
+ appears and names the walled vendor:
264
+
265
+ > Anthropic's AI usage limit is reached ($500.00 of $500.00). Switch to Gemini to keep going.
266
+
267
+ A turn is therefore **allowed to run and fail** if it happens to route to the
268
+ walled vendor. That is intentional and honest — there is no auto-switching,
269
+ because silently moving the user to another vendor would silently downgrade model
270
+ quality. The banner advises; the user (or the host) switches.
271
+
272
+ When every reachable vendor is walled the banner asks for a raise instead:
273
+
274
+ > Anthropic's AI usage limit is reached ($25.40 of $25.00). Contact your administrator to raise it.
275
+
276
+ …and with several walled vendors carrying no copy of their own, the figures do not
277
+ fit one sentence, so it names them:
278
+
279
+ > AI usage limits are reached for Anthropic and Gemini. Contact your administrator to raise them.
280
+
281
+ A host `blockedReason` still outranks all of it, and `setVendorBlocked(v, true,
282
+ '…')` supplies per-vendor copy that is composed into the sentence — **however many
283
+ vendors are walled**. As soon as any walled vendor carries copy, the banner reads
284
+ one statement per vendor instead of the compact list above, so nothing you set is
285
+ dropped when a second vendor goes:
286
+
287
+ > Anthropic spend is capped for this desk. Gemini's AI usage limit is reached. Contact your administrator to raise them.
288
+
289
+ A walled vendor with no copy falls back to the boilerplate sentence in that form
290
+ (`Gemini's …` above), so the set of statements always covers every vendor named.
291
+
292
+ The **transcript bubble** stays vendor-neutral. The assistant element passes
293
+ `blockedReason ?? DEFAULT_BUDGET_EXHAUSTED_MESSAGE` as
294
+ `ChatDriverConfig.budgetExhaustedMessage` — deliberately NOT the composed banner
295
+ copy. Only the vendor-agnostic latch writes `blockedReason`, and its wording
296
+ carries no switch clause, so the composed "switch to Gemini" advice is
297
+ structurally incapable of reaching the transcript. Per-vendor copy lives in
298
+ `blockedVendorReasons` and reaches the banner alone.
299
+
300
+ Ordering alone would not be enough to guarantee this: the driver reads the value
301
+ once at construction, but `getOrCreateDriver` keys on the agents list, so an
302
+ agents swap *after* a wall rebuilds the driver and would re-snapshot whatever the
303
+ banner said at that moment. The split is deliberate — the transcript is a
304
+ permanent record of the turn, and advice that stops being true the moment the user
305
+ switches vendor belongs on the banner.
306
+
307
+ > **Copy change.** With a recognised vendor label on the `402`, the default
308
+ > full-exhaustion banner now names the vendor ("Anthropic's AI usage limit is
309
+ > reached (…)") where it previously said "AI usage limit reached (…)". The generic
310
+ > `DEFAULT_BUDGET_EXHAUSTED_MESSAGE` fallback (no figures, no vendor) is unchanged,
311
+ > as is `BLOCKED_PLACEHOLDER`.
312
+
313
+ ### Vendor-switch recovery is automatic — with one host obligation
314
+
315
+ Recovery is a **derivation**, not a mutation. Switch your registry to another
316
+ vendor and the observable fires, the provider statuses reload, the walled vendor
317
+ drops out of `reachableVendors`, and `blocked` goes false — with the latch left
318
+ intact, so switching back re-derives the block. No host code, and nothing can
319
+ unblock mid-turn.
320
+
321
+ **This requires an `ObservableAIProviderRegistry`** (i.e. `registerAIProviders` /
322
+ `MutableAIProviderRegistry`), which the element feature-detects. A host with an
323
+ immutable registry that changes vendors some other way must call
324
+ `setVendorBlocked(vendor, false)` itself. That is the single host obligation this
325
+ feature adds.
326
+
327
+ Also note the fail-safe: if your providers do not implement the optional
328
+ `AIProvider.getStatus()`, `reachableVendors` is empty and **any** wall blocks
329
+ everything — identical to the pre-per-vendor behaviour, but with no per-vendor
330
+ benefit. A `logger.warn` fires the first time that happens.
331
+
332
+ ### Attribution, and when it degrades
333
+
334
+ A wall is attributed by reverse-looking-up the `vendorLabel` the refused
335
+ transport stamped, via the shared `VENDOR_LABELS` map (which the transports now
336
+ read their labels from, so the two cannot drift). Where that label is unclaimed —
337
+ a white-labelled or multiplexing gateway fronting several upstreams behind one
338
+ transport — the `402`'s own `vendor` is tried next. Only if neither resolves does
339
+ the wall degrade to the **vendor-agnostic** block — safe, and exactly the old
340
+ behaviour.
341
+
342
+ Attribution does **not** depend on the proxy reporting figures. A figure-less
343
+ `402` (a bare gateway rejection, a code-only body, or a framed error whose figures
344
+ are `null`) still carries the vendor, so the wall lands on the transport that was
345
+ refused rather than on whatever the driver resolved on the previous turn.
346
+
347
+ ### `otherVendorAvailable`: the one thing only the server knows
348
+
349
+ The registry tells this element which vendors **exist**. Nothing client-side can
350
+ tell it which vendors still have **budget**. So when the `402` reports
351
+ `otherVendorAvailable: false`, that verdict overrules the inference: every other
352
+ *budgeted* vendor is walled in the same pass, the composer locks on the first
353
+ response instead of after a second doomed turn, and the banner stops advising a
354
+ switch to a vendor whose pot is equally spent.
355
+
356
+ The sweep runs over `BUDGETED_VENDORS`, **not** over `reachableVendors`. The
357
+ verdict is a statement about the proxy's own pots, so it must not be scoped to a
358
+ registry snapshot that loads asynchronously and may not have arrived yet — a wall
359
+ that beat the statuses home would otherwise discard the verdict entirely, with
360
+ nothing to re-run it afterwards.
361
+
362
+ One consequence for hosts: `blockedVendors` may therefore list a vendor your
363
+ registry cannot reach. That is deliberate and costs nothing —
364
+
365
+ - `blocked` only asks whether every **reachable** vendor is walled, and
366
+ - the banner reads the reachability-filtered list, so it still names only vendors
367
+ you can route to (see [The banner names only vendors you can
368
+ reach](#the-banner-names-only-vendors-you-can-reach)).
369
+
370
+ …but if you render `blockedVendors` yourself, filter it against your own registry
371
+ rather than showing it raw.
372
+
373
+ One boundary remains: only `BUDGETED_VENDORS` are swept. A Chrome provider is
374
+ on-device and unmetered, so `Switch to Chrome to keep going.` remains honest.
375
+
376
+ An **absent** `otherVendorAvailable` (an older proxy) is not a `false` — the
377
+ client is back to inferring, and behaviour is exactly as before.
378
+
379
+ ### The banner names only vendors you can reach
380
+
381
+ Both the walled list the banner names and the free list it offers are filtered by
382
+ `reachableVendors`. A wall the registry has since moved away from cannot be hit,
383
+ so it is neither named nor announced — a host that ships Anthropic-only, walls,
384
+ then swaps to Gemini does not get "limits are reached for Anthropic and Gemini"
385
+ in front of a user who never had an Anthropic key. When nothing is reachable the
386
+ filter falls back to the unfiltered list, matching the fail-safe `blocked` takes
387
+ there.
388
+
389
+ ### `'none'` is not a vendor
390
+
391
+ `setVendorBlocked('none', …)` is rejected with a `logger.warn` and does nothing.
392
+ `'none'` is the no-provider sentinel: latching it would render "none's AI usage
393
+ limit is reached" and could never be released by derivation, because `'none'`
394
+ never appears in `reachableVendors`. Use `setBlocked` for a vendor-agnostic block.
395
+
396
+ ### Scope and lifetime are unchanged
397
+
398
+ The per-vendor latches live where the boolean lives: **in memory**, on the
399
+ session store, per-`stateKey`. They do not survive `switchSession`, they do not
400
+ survive a page reload, and they are deliberately absent from the persisted
401
+ snapshot. Your pre-flight remains the durable source of truth.
402
+
403
+ ### Styling
404
+
405
+ `.blocked-banner.is-visible` carries every visual property and applies whenever
406
+ the banner has something to say — including while only **some** vendors are walled
407
+ (banner visible, composer live). It is named for visibility rather than for
408
+ `blocked` precisely because that partial state has `blocked === false`. Add
409
+ `.is-partial` if you want a distinct treatment for it; it is layered on top and
410
+ sets nothing but a softer border by default.
411
+
412
+ ---
413
+
414
+ ## How to adopt
415
+
416
+ ### Option A — do nothing
417
+
418
+ The assistant latches `blocked` itself when a turn ends with `'budget-exhausted'`, so a
419
+ host that does no budget checking at all gets a correct locked UI the moment the first
420
+ `402` lands, with no code change. It latches from **two** seams, because neither covers
421
+ everything:
422
+
423
+ - the `tool-loop-end` activity-bus detail — which also fires for a **sub-agent's** wall,
424
+ for a turn this element did not start, and for an element swapped in mid-turn
425
+ (pop-in/pop-out), and which crosses to popout windows on the tab-scoped channel;
426
+ - the driver's **return value** — which covers the one case the bus cannot: a wall hit
427
+ during multi-agent **classification**, where no tool loop ran and so no
428
+ `tool-loop-end` was published.
429
+
430
+ Both go through one internal decision point, so the latch is idempotent and never
431
+ unblocks.
432
+
433
+ **What the banner says.** With no host reason set, the banner shows whatever figures the
434
+ proxy reported and names the vendor that refused — `Anthropic's AI usage limit is reached
435
+ ($25.40 of $25.00). Contact your administrator to raise it.` — falling back to the generic
436
+ default copy when the `402` carried no figures and no recognisable vendor. When another
437
+ vendor still has headroom the closing sentence becomes `Switch to Gemini to keep going.`
438
+ instead; see [Per-vendor budgets](#per-vendor-budgets). If you want host-specific wording
439
+ (a link to your billing page, a named contact), that is Option B.
440
+
441
+ ### Option B — pre-flight (required if the block must survive a reload or session switch)
442
+
443
+ Because the latch is in-memory and per-`stateKey` (see [Scope and lifetime](#scope-and-lifetime)),
444
+ this is the only way to get a correct locked UI **on mount** and **after a session
445
+ switch**. Run it in both places. It also blocks up front, before the user composes a
446
+ message that is guaranteed to fail:
447
+
448
+ ```ts
449
+ async function applyBudgetGate(assistantEl: FoundationAiAssistant) {
450
+ const { withinBudget, budgetUsd, spentUsd } = await checkAiBudget();
451
+ assistantEl.setBlocked(
452
+ !withinBudget,
453
+ withinBudget
454
+ ? null
455
+ : `AI usage limit reached ($${spentUsd} of $${budgetUsd}). Contact Genesis to increase it.`,
456
+ );
457
+ }
458
+
459
+ // on mount…
460
+ await applyBudgetGate(assistantEl);
461
+ // …and in your own session-switch handler
462
+ await applyBudgetGate(assistantEl);
463
+ ```
464
+
465
+ Pass no `reason` to use the default copy:
466
+
467
+ ```ts
468
+ assistantEl.blocked = true;
469
+ ```
470
+
471
+ Release it once a later check shows headroom (unblocking always clears the reason, so a
472
+ stale explanation can never outlive the condition):
473
+
474
+ ```ts
475
+ assistantEl.blocked = false;
476
+ ```
477
+
478
+ ### Option C — branch on the typed failure
479
+
480
+ For custom UI, telemetry, or a host-owned composer:
481
+
482
+ ```ts
483
+ import { BudgetExhaustedError } from '@genesislcap/foundation-ai';
484
+
485
+ // …from a driver result — the figures now ride along, so you do not have to
486
+ // catch the transport error to get them:
487
+ if (result.reason === 'done' && result.failureReason === 'budget-exhausted') {
488
+ showUpgradePrompt({ budgetUsd: result.budget?.budgetUsd, spentUsd: result.budget?.spentUsd });
489
+ }
490
+
491
+ // …or from the `tool-loop-end` activity-bus detail, which also carries the vendor:
492
+ agenticActivityBus.subscribe('tool-loop-end', (d) => {
493
+ if (d?.failureReason === 'budget-exhausted') showUpgradePrompt({ vendor: d.vendor });
494
+ });
495
+
496
+ // …or directly around a transport/provider call:
497
+ try {
498
+ await provider.chat(history, input);
499
+ } catch (e) {
500
+ if (e instanceof BudgetExhaustedError) {
501
+ showUpgradePrompt({ budgetUsd: e.budgetUsd, spentUsd: e.spentUsd });
502
+ }
503
+ }
504
+ ```
505
+
506
+ > If you set `blocked` from the bus subscription, note it fires **before**
507
+ > `sendMessage()` resolves — the element's own latch runs afterwards and is a
508
+ > no-op while already blocked, so your copy is preserved.
509
+
510
+ Note that `TurnFailureReason` and `SubAgentFailureReason` are **open unions that grew**. If
511
+ you `switch` exhaustively over either with a `never` guard, TypeScript will now flag the new
512
+ member — that is the intended prompt to decide how your app should treat it, not a breaking
513
+ change to runtime behaviour.
514
+
515
+ ---
516
+
517
+ ## Styling the banner
518
+
519
+ The banner is themed from the design system's own tokens and is addressable as a part:
520
+
521
+ ```css
522
+ foundation-ai-assistant::part(blocked-banner) {
523
+ border-color: var(--my-warning-color);
524
+ background: var(--my-warning-surface);
525
+ }
526
+ ```
527
+
528
+ It carries `role="status"` — a standing condition the user can also read off the disabled
529
+ composer, not an interruption worth an `alert`.
530
+
531
+ ### Accessibility
532
+
533
+ The banner element is **always in the DOM**, and only its text changes when `blocked`
534
+ flips. This is deliberate and load-bearing: `role="status"` announces *mutations* to a
535
+ region the assistive technology is already observing, and a region inserted into the DOM
536
+ already carrying its text is widely missed across NVDA, JAWS and VoiceOver. Keeping it
537
+ mounted makes the text change the observable event.
538
+
539
+ Consequences for hosts:
540
+
541
+ - `::part(blocked-banner)` now matches at all times. While unblocked the element is empty
542
+ and carries no padding, border or background, so it occupies **zero** space — every
543
+ visible property hangs off an internal `is-visible` class. If you style the part, put
544
+ your visual overrides where they only apply to the populated state (e.g. keep using the
545
+ border/background properties, which the empty state does not set) rather than adding
546
+ `min-height` or `padding` unconditionally.
547
+ - The composer's text area additionally carries `aria-disabled="true"` and an `aria-label`
548
+ containing the blocked reason while blocked, so the control's own accessible name states
549
+ why it is unavailable — a disabled control leaves the tab order, so its swapped
550
+ placeholder is never voiced.
551
+ - The banner has an `id`, and the text area, **send** button and **attach** button carry
552
+ `aria-describedby` pointing at it whenever the banner is visible — including during
553
+ **partial** exhaustion, where the composer is deliberately still enabled. That state has
554
+ no other accessible signal: `aria-disabled`/`aria-label` are keyed on the full block, and
555
+ a live composer keeps your own placeholder, so a screen-reader user would otherwise reach
556
+ the textarea with nothing to say the next turn might be refused. `role="status"` announces
557
+ the banner text when it changes; the description is what makes the same explanation
558
+ reachable afterwards, on demand, from the control it is about. It is also what gives
559
+ send/attach any explanation at all — they carried none in either state.
560
+
561
+ The id is shadow-DOM-scoped, so it cannot collide with your page. If you re-template the
562
+ composer, keep the reference: `aria-describedby` resolves same-root only.
package/docs/sub_agent.md CHANGED
@@ -139,10 +139,22 @@ type SubAgentFailureReason =
139
139
  | 'max_iterations'
140
140
  | 'malformed_tool_call'
141
141
  | 'empty_response'
142
- | 'unknown_tool_limit';
142
+ | 'unknown_tool_limit'
143
+ | 'timeout'
144
+ | 'response_truncated'
145
+ | 'refusal'
146
+ | 'budget_exhausted';
143
147
  ```
144
148
 
145
- Branch on `ok` in the calling handler and decide how to recover typically by handing the issue back to the parent agent:
149
+ Branch on `ok` in the calling handler — but **the right recovery depends on which
150
+ reason you got**, and the two groups are opposites:
151
+
152
+ | Reason | Recovery |
153
+ | --- | --- |
154
+ | `max_iterations`, `malformed_tool_call`, `empty_response`, `unknown_tool_limit`, `timeout` | **Retry or ask the user.** These are transient or prompt-design problems; a second attempt, a smaller task, or a clarifying question often succeeds. |
155
+ | `response_truncated` | **Do not retry as-is** — re-issuing the same request hits the same output-token cap. Retry only with a smaller step, or raise the provider's `maxTokens`. |
156
+ | `refusal` | **Terminal.** A safety-classifier decline is deterministic; the same request will be declined again. Return a terse error. |
157
+ | `budget_exhausted` | **Terminal, and already handled for you.** The AI-spend budget is gone and the proxy is refusing every request. Retrying is another `402`, and "ask the user" is asking someone who cannot fix it. Return a terse error and let the turn end — the parent driver ends the turn as soon as this outcome comes back, so your handler will not be called again this turn. |
146
158
 
147
159
  ```ts
148
160
  const outcome = await context.requestSubAgent<ExtractedTrades>('trade_file_extractor', {
@@ -150,7 +162,12 @@ const outcome = await context.requestSubAgent<ExtractedTrades>('trade_file_extra
150
162
  });
151
163
 
152
164
  if (!outcome.ok) {
153
- // sub-agent didn't complete let this agent retry or ask the user
165
+ // Terminal reasons: report and stop. Retrying reproduces them, and for
166
+ // budget_exhausted the parent turn is ending anyway.
167
+ if (outcome.reason === 'budget_exhausted' || outcome.reason === 'refusal') {
168
+ return { error: `Extraction could not run (${outcome.reason}).` };
169
+ }
170
+ // Everything else is worth another attempt or a question to the user.
154
171
  return { error: `Extraction didn't complete (${outcome.reason}).` };
155
172
  }
156
173
  const { rows } = outcome.result; // fully typed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@genesislcap/ai-assistant",
3
3
  "description": "Genesis AI Assistant micro-frontend",
4
- "version": "15.6.2",
4
+ "version": "15.7.0",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -73,26 +73,26 @@
73
73
  }
74
74
  },
75
75
  "devDependencies": {
76
- "@genesislcap/foundation-testing": "15.6.2",
77
- "@genesislcap/genx": "15.6.2",
78
- "@genesislcap/rollup-builder": "15.6.2",
79
- "@genesislcap/ts-builder": "15.6.2",
80
- "@genesislcap/uvu-playwright-builder": "15.6.2",
81
- "@genesislcap/vite-builder": "15.6.2",
82
- "@genesislcap/webpack-builder": "15.6.2",
76
+ "@genesislcap/foundation-testing": "15.7.0",
77
+ "@genesislcap/genx": "15.7.0",
78
+ "@genesislcap/rollup-builder": "15.7.0",
79
+ "@genesislcap/ts-builder": "15.7.0",
80
+ "@genesislcap/uvu-playwright-builder": "15.7.0",
81
+ "@genesislcap/vite-builder": "15.7.0",
82
+ "@genesislcap/webpack-builder": "15.7.0",
83
83
  "@types/dompurify": "^3.0.5",
84
84
  "@types/marked": "^5.0.2",
85
85
  "esbuild": "0.25.12"
86
86
  },
87
87
  "dependencies": {
88
- "@genesislcap/foundation-ai": "15.6.2",
89
- "@genesislcap/foundation-logger": "15.6.2",
90
- "@genesislcap/foundation-notifications": "15.6.2",
91
- "@genesislcap/foundation-redux": "15.6.2",
92
- "@genesislcap/foundation-ui": "15.6.2",
93
- "@genesislcap/foundation-utils": "15.6.2",
94
- "@genesislcap/rapid-design-system": "15.6.2",
95
- "@genesislcap/web-core": "15.6.2",
88
+ "@genesislcap/foundation-ai": "15.7.0",
89
+ "@genesislcap/foundation-logger": "15.7.0",
90
+ "@genesislcap/foundation-notifications": "15.7.0",
91
+ "@genesislcap/foundation-redux": "15.7.0",
92
+ "@genesislcap/foundation-ui": "15.7.0",
93
+ "@genesislcap/foundation-utils": "15.7.0",
94
+ "@genesislcap/rapid-design-system": "15.7.0",
95
+ "@genesislcap/web-core": "15.7.0",
96
96
  "dompurify": "^3.3.1",
97
97
  "marked": "^17.0.3"
98
98
  },
@@ -105,5 +105,5 @@
105
105
  "access": "public"
106
106
  },
107
107
  "customElements": "dist/custom-elements.json",
108
- "gitHead": "a5781780cb56906755ddbfc4be75e221719ae4b4"
108
+ "gitHead": "485a2cc9d4b8695ea2e57a9cc66cad55effb2184"
109
109
  }