@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.
- package/dist/ai-assistant.api.json +391 -5
- package/dist/ai-assistant.d.ts +603 -6
- package/dist/chat-driver.cjs +285 -26
- package/dist/chat-driver.cjs.map +3 -3
- package/dist/chat-driver.mjs +285 -26
- package/dist/chat-driver.mjs.map +3 -3
- package/dist/custom-elements.json +236 -10
- package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
- package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/main/blocked-state.test.d.ts +2 -0
- package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +384 -6
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.styles.d.ts.map +1 -1
- package/dist/dts/main/main.styles.test.d.ts +2 -0
- package/dist/dts/main/main.styles.test.d.ts.map +1 -0
- package/dist/dts/main/main.template.d.ts +53 -0
- package/dist/dts/main/main.template.d.ts.map +1 -1
- package/dist/dts/state/ai-assistant-slice.d.ts +143 -6
- package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
- package/dist/dts/state/debug-event-log.d.ts +6 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/dts/state/session-store.d.ts +10 -0
- package/dist/dts/state/session-store.d.ts.map +1 -1
- package/dist/esm/components/chat-driver/chat-driver.js +263 -21
- package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
- package/dist/esm/main/blocked-state.test.js +913 -0
- package/dist/esm/main/main.js +680 -16
- package/dist/esm/main/main.styles.js +47 -0
- package/dist/esm/main/main.styles.test.js +86 -0
- package/dist/esm/main/main.template.js +121 -4
- package/dist/esm/state/ai-assistant-slice.js +128 -7
- package/dist/esm/state/ai-assistant-slice.test.js +138 -1
- package/dist/esm/state/debug-event-log.js +7 -2
- package/dist/esm/state/debug-event-log.test.js +49 -1
- package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/docs/migration-GENC-1464.md +562 -0
- package/docs/sub_agent.md +20 -3
- package/package.json +17 -17
- package/src/channel/ai-activity-channel.ts +56 -2
- package/src/components/chat-driver/chat-driver.test.ts +549 -0
- package/src/components/chat-driver/chat-driver.ts +324 -14
- package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
- package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
- package/src/main/blocked-state.test.ts +1243 -0
- package/src/main/main.styles.test.ts +103 -0
- package/src/main/main.styles.ts +47 -0
- package/src/main/main.template.ts +131 -4
- package/src/main/main.ts +680 -10
- package/src/state/ai-assistant-slice.test.ts +215 -0
- package/src/state/ai-assistant-slice.ts +181 -8
- package/src/state/debug-event-log.test.ts +63 -0
- package/src/state/debug-event-log.ts +7 -2
- package/src/state/persistence/session-snapshot.test.ts +22 -0
package/dist/esm/main/main.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// =============================================================================
|
|
23
23
|
var FoundationAiAssistant_1;
|
|
24
24
|
import { __awaiter, __decorate, __rest } from "tslib";
|
|
25
|
-
import { AIProviderRegistry, isObservableAIProviderRegistry } from '@genesislcap/foundation-ai';
|
|
25
|
+
import { AIProviderRegistry, BUDGETED_VENDORS, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, isObservableAIProviderRegistry, VENDOR_LABELS, vendorTypeOfLabel, } from '@genesislcap/foundation-ai';
|
|
26
26
|
import { showNotificationDialog } from '@genesislcap/foundation-notifications';
|
|
27
27
|
import { avoidTreeShaking } from '@genesislcap/foundation-utils';
|
|
28
28
|
import { customElement, DOM, html, GenesisElement, observable, volatile, attr, } from '@genesislcap/web-core';
|
|
@@ -104,6 +104,84 @@ const COMPOSER_MIN_HEIGHT_PX = 48;
|
|
|
104
104
|
const COMPOSER_MAX_HEIGHT_PX = 400;
|
|
105
105
|
/** Keep at least this much of the message list visible while growing the composer. */
|
|
106
106
|
const COMPOSER_MIN_MESSAGES_PX = 80;
|
|
107
|
+
/**
|
|
108
|
+
* Composer placeholder while the assistant is blocked (GENC-1464). Deliberately
|
|
109
|
+
* fixed and short, and NOT host-overridable — the banner directly above it
|
|
110
|
+
* carries the detail, and that one IS overridable via `setBlocked(true, reason)`.
|
|
111
|
+
* The banner's own default copy is `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`, read
|
|
112
|
+
* straight from `foundation-ai` so the banner and the driver's transcript bubble
|
|
113
|
+
* cannot drift apart.
|
|
114
|
+
*/
|
|
115
|
+
const BLOCKED_PLACEHOLDER = 'AI usage limit reached';
|
|
116
|
+
/**
|
|
117
|
+
* Closing sentence for a wall the user cannot route around. Singular/plural
|
|
118
|
+
* variants because the banner names one vendor or several.
|
|
119
|
+
*/
|
|
120
|
+
const RAISE_LIMIT_ACTION = 'Contact your administrator to raise it.';
|
|
121
|
+
const RAISE_LIMITS_ACTION = 'Contact your administrator to raise them.';
|
|
122
|
+
/**
|
|
123
|
+
* Banner copy built from the figures a 402 actually reported, or `undefined`
|
|
124
|
+
* when the proxy sent none.
|
|
125
|
+
*
|
|
126
|
+
* `undefined` is the meaningful return, not a fallback: paired with the
|
|
127
|
+
* slice's "a re-latch without a reason keeps the existing explanation" rule, it
|
|
128
|
+
* makes a figureless driver latch leave a host-supplied reason intact rather
|
|
129
|
+
* than blanking it back to the default at the exact moment the wall is hit.
|
|
130
|
+
*
|
|
131
|
+
* Exported for the unit test that pins the formatting; not part of the element
|
|
132
|
+
* API.
|
|
133
|
+
*
|
|
134
|
+
* @internal
|
|
135
|
+
*/
|
|
136
|
+
export function formatBlockedReason(budget, vendor) {
|
|
137
|
+
if (!budget || (budget.budgetUsd == null && budget.spentUsd == null))
|
|
138
|
+
return undefined;
|
|
139
|
+
const money = (v) => (v == null ? 'an unknown amount' : `$${v.toFixed(2)}`);
|
|
140
|
+
const figures = `(${money(budget.spentUsd)} of ${money(budget.budgetUsd)})`;
|
|
141
|
+
// A per-vendor statement carries NO action clause, because the right action is
|
|
142
|
+
// not knowable at latch time: whether "switch vendor" or "contact your
|
|
143
|
+
// administrator" is the honest advice depends on whether any OTHER vendor still
|
|
144
|
+
// has headroom, which only the composing getter can see. The vendor-agnostic
|
|
145
|
+
// form keeps its historical trailing sentence — nothing composes onto it.
|
|
146
|
+
return vendor
|
|
147
|
+
? `${vendorDisplayName(vendor)}'s AI usage limit is reached ${figures}.`
|
|
148
|
+
: `AI usage limit reached ${figures}. Contact your administrator to raise it.`;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Human-readable name for a vendor, falling back to its raw type.
|
|
152
|
+
*
|
|
153
|
+
* `'none'` needs no special case: it is the "no provider configured" sentinel,
|
|
154
|
+
* it is filtered out of {@link FoundationAiAssistant.reachableVendors}, the latch
|
|
155
|
+
* refuses to attribute a wall to it, and
|
|
156
|
+
* {@link FoundationAiAssistant.setVendorBlocked} rejects it at the boundary — so
|
|
157
|
+
* nothing can put it in front of this function. The `?? vendor` fallback is for a
|
|
158
|
+
* vendor type added to `AIProviderType` without a label, which would print the
|
|
159
|
+
* type rather than nothing.
|
|
160
|
+
*/
|
|
161
|
+
function vendorDisplayName(vendor) {
|
|
162
|
+
var _a;
|
|
163
|
+
return (_a = VENDOR_LABELS[vendor]) !== null && _a !== void 0 ? _a : vendor;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* `a`, `a and b`, `a, b and c`.
|
|
167
|
+
*
|
|
168
|
+
* The general joiner is kept rather than hardcoded to a pair because the list
|
|
169
|
+
* is `BUDGETED_VENDORS`-shaped, and that list is a cross-repo contract owned by
|
|
170
|
+
* the proxy (today `anthropic` and `gemini`; `openai` is refused up front with
|
|
171
|
+
* 400 UNSUPPORTED_PROVIDER and can never be walled) — a vendor added there must
|
|
172
|
+
* not require an edit here. Its upper bound is not an assertion in a comment — it is
|
|
173
|
+
* the key count of `VENDOR_LABELS`, since every element of every list passed here
|
|
174
|
+
* comes from `blockedVendors` or `reachableVendors`, both of which hold
|
|
175
|
+
* `AIProviderType` values, and the type has five members of which `'none'` is
|
|
176
|
+
* excluded from both.
|
|
177
|
+
*/
|
|
178
|
+
function formatVendorList(vendors) {
|
|
179
|
+
var _a;
|
|
180
|
+
const names = vendors.map(vendorDisplayName);
|
|
181
|
+
if (names.length <= 1)
|
|
182
|
+
return (_a = names[0]) !== null && _a !== void 0 ? _a : '';
|
|
183
|
+
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
184
|
+
}
|
|
107
185
|
// Register supporting components when the main component module is imported.
|
|
108
186
|
avoidTreeShaking(AiChatMarkdown, AiChatInteractionWrapper, AiHaloOverlay, AiWavesIndicator, AiFlowingWavesIndicator, AiPlasmaOrbIndicator, AiChatBubble, AiActivityHalo, ChatSuggestions, AgentPicker);
|
|
109
187
|
/**
|
|
@@ -367,14 +445,25 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
367
445
|
}
|
|
368
446
|
/**
|
|
369
447
|
* True when a new send must be refused: a turn is running (`busy`), a page-reload
|
|
370
|
-
* restore is loading history that would clobber it (`restoring`),
|
|
371
|
-
* compaction is rewriting history (`compacting`)
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
448
|
+
* restore is loading history that would clobber it (`restoring`), a manual
|
|
449
|
+
* compaction is rewriting history (`compacting`), or a backend condition has
|
|
450
|
+
* locked the assistant outright (`blocked` — e.g. an exhausted AI budget).
|
|
451
|
+
* Mirrors the composer's `?disabled` gate so a **programmatic**
|
|
452
|
+
* `send`/`submitMessage` (e.g. a host's custom input while the built-in composer
|
|
453
|
+
* is hidden) can't slip past it and have its message wiped when the
|
|
454
|
+
* restored/compacted history lands (GENC-1351 §6).
|
|
375
455
|
*/
|
|
376
456
|
get sendBlocked() {
|
|
377
|
-
return this.busy || this.restoring || this.compacting;
|
|
457
|
+
return this.busy || this.restoring || this.compacting || this.blocked;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Why a programmatic send was refused, for `submitMessage`'s `errors`. A
|
|
461
|
+
* backend block is called out distinctly because — unlike the transient
|
|
462
|
+
* "busy" cases — waiting and retrying will never clear it, and a caller
|
|
463
|
+
* looping on "Assistant is busy" would spin forever.
|
|
464
|
+
*/
|
|
465
|
+
sendRefusalReason() {
|
|
466
|
+
return this.blocked ? this.effectiveBlockedReason : 'Assistant is busy';
|
|
378
467
|
}
|
|
379
468
|
/**
|
|
380
469
|
* Re-runs `agentsChanged` if the live `agents` array no longer matches the
|
|
@@ -563,6 +652,514 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
563
652
|
var _a;
|
|
564
653
|
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setRestoring(value);
|
|
565
654
|
}
|
|
655
|
+
/**
|
|
656
|
+
* Whether the assistant is blocked by a backend condition and cannot send —
|
|
657
|
+
* today, an exhausted AI-spend budget (GENC-1464). While true the composer is
|
|
658
|
+
* disabled, `send`/`submitMessage` refuse, suggestions stop being fetched, and
|
|
659
|
+
* a persistent banner (`part="blocked-banner"`) sits above the composer
|
|
660
|
+
* explaining why. The transcript stays visible and scrollable throughout —
|
|
661
|
+
* unlike `compacting` and `restoring`, this does not replace the conversation.
|
|
662
|
+
*
|
|
663
|
+
* **Latched.** Nothing in the element clears it: not a new turn, not "Clear"
|
|
664
|
+
* / "New chat", not a pop-in/out. It stays set until the host writes
|
|
665
|
+
* `false` — because nothing the user can do inside the assistant refills a
|
|
666
|
+
* budget. Hosts typically set it from their own pre-flight budget check on
|
|
667
|
+
* mount, and clear it once a later check shows headroom again.
|
|
668
|
+
*
|
|
669
|
+
* The driver also latches it automatically when a turn ends with the
|
|
670
|
+
* `'budget-exhausted'` failure reason — off the `tool-loop-end` activity bus
|
|
671
|
+
* (which also covers a sub-agent wall, a turn this element did not start, and
|
|
672
|
+
* an element swapped in mid-turn) and off the driver's return value (which
|
|
673
|
+
* covers a wall hit during multi-agent classification, where no tool loop ran
|
|
674
|
+
* and so no bus event fires). So a host that does no pre-flight at all still
|
|
675
|
+
* gets a correct locked UI the moment the first 402 lands. The bus topic is
|
|
676
|
+
* tab-scoped, so a wall hit in a popped-out window latches the main window
|
|
677
|
+
* too — correct for a shared spend cap.
|
|
678
|
+
*
|
|
679
|
+
* **Lifetime — read this before relying on it as your source of truth.** The
|
|
680
|
+
* latch is in-memory and per-`stateKey`:
|
|
681
|
+
*
|
|
682
|
+
* - It does **not** survive `switchSession`. Switching away tears the outgoing
|
|
683
|
+
* session's store down entirely, so switching back yields a fresh, unblocked
|
|
684
|
+
* store.
|
|
685
|
+
* - It does **not** survive a page reload. It is deliberately absent from the
|
|
686
|
+
* persisted session snapshot: the snapshot is long-lived, so a persisted
|
|
687
|
+
* latch would outlive an out-of-band budget raise with no in-element way to
|
|
688
|
+
* clear it — a stale lock is worse than re-deriving the state.
|
|
689
|
+
*
|
|
690
|
+
* The durable source of truth is therefore the **host's pre-flight**, which
|
|
691
|
+
* should run on mount and on every session switch. See
|
|
692
|
+
* `docs/migration-GENC-1464.md` §"How to adopt", Option B.
|
|
693
|
+
*
|
|
694
|
+
* @beta
|
|
695
|
+
*/
|
|
696
|
+
get blocked() {
|
|
697
|
+
var _a;
|
|
698
|
+
const session = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant;
|
|
699
|
+
if (!session)
|
|
700
|
+
return false;
|
|
701
|
+
if (session.blocked)
|
|
702
|
+
return true;
|
|
703
|
+
// Byte-identical to the old stored read for every host that never adopts the
|
|
704
|
+
// per-vendor API — which is what makes this derivation non-breaking.
|
|
705
|
+
if (session.blockedVendors.length === 0)
|
|
706
|
+
return false;
|
|
707
|
+
// Unknown reachability (statuses not loaded yet, or providers that report no
|
|
708
|
+
// status) fails SAFE — any wall blocks everything, exactly today's semantics —
|
|
709
|
+
// and needs no clause of its own: `every` on the empty set is vacuously true.
|
|
710
|
+
return this.reachableVendors.every((v) => session.blockedVendors.includes(v));
|
|
711
|
+
}
|
|
712
|
+
set blocked(value) {
|
|
713
|
+
var _a;
|
|
714
|
+
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked: value });
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The distinct vendors the registry can currently reach, from the provider
|
|
718
|
+
* statuses this element already loads on connect and refreshes on every
|
|
719
|
+
* observable-registry change.
|
|
720
|
+
*
|
|
721
|
+
* This is the element's answer to "which vendor would the next turn use" — a
|
|
722
|
+
* question that has **no** correct answer and deliberately gets no API. The
|
|
723
|
+
* provider is resolved per turn AND per agent: `activeProviderInput` may be an
|
|
724
|
+
* async function of the turn's context, an orchestrated turn picks its agent
|
|
725
|
+
* with an LLM `classify()` call, and sub-agents resolve their own providers.
|
|
726
|
+
* Any pre-turn "peek" would therefore be a guess that is wrong precisely on the
|
|
727
|
+
* multi-agent hosts per-vendor budgets exist for.
|
|
728
|
+
*
|
|
729
|
+
* Asking instead which vendors are REACHABLE is answerable, synchronous, and
|
|
730
|
+
* free — and it is enough: the composer must stay live while any reachable
|
|
731
|
+
* vendor has headroom, and must lock when none does. It also makes vendor-switch
|
|
732
|
+
* recovery a derivation rather than a mutation: a host that swaps its registry
|
|
733
|
+
* to another vendor fires the observable, the statuses reload, the walled vendor
|
|
734
|
+
* drops out of this set, and `blocked` goes false with every latch left intact.
|
|
735
|
+
*
|
|
736
|
+
* @beta
|
|
737
|
+
*/
|
|
738
|
+
get reachableVendors() {
|
|
739
|
+
var _a, _b;
|
|
740
|
+
const seen = new Set();
|
|
741
|
+
for (const entry of this.providerStatuses) {
|
|
742
|
+
const provider = (_a = entry.status) === null || _a === void 0 ? void 0 : _a.provider;
|
|
743
|
+
if (!provider || provider === 'none')
|
|
744
|
+
continue;
|
|
745
|
+
// Reachable means "can serve the next turn", and `chat()` is OPTIONAL on
|
|
746
|
+
// `AIProvider` — the shipped `ChromeProvider` and `OpenAIProvider`
|
|
747
|
+
// implement `getStatus()` only. Counting a chat-less provider here made
|
|
748
|
+
// every consumer wrong at once: it was headroom for the `blocked`
|
|
749
|
+
// derivation (so `otherVendorAvailable: false` failed to lock the
|
|
750
|
+
// composer and each send burned another doomed 402), and it was a switch
|
|
751
|
+
// target for the banner ("Switch to Chrome to keep going" toward a
|
|
752
|
+
// provider that cannot chat).
|
|
753
|
+
//
|
|
754
|
+
// The gate is CAPABILITY, not vendor identity, so a host's custom
|
|
755
|
+
// chat-capable OpenAI provider still counts — the same distinction
|
|
756
|
+
// `validate-providers.ts` already enforces by throwing. And it drops a
|
|
757
|
+
// vendor only on positive evidence: statuses normally come from
|
|
758
|
+
// `providerRegistry.listStatuses()`, whose `name` always resolves, but a
|
|
759
|
+
// host (or test) that assigns `providerStatuses` directly may have no
|
|
760
|
+
// matching registry entry, and an unresolvable name must not silently
|
|
761
|
+
// erase a vendor the status layer vouched for.
|
|
762
|
+
const registered = (_b = this.providerRegistry) === null || _b === void 0 ? void 0 : _b.get(entry.name);
|
|
763
|
+
if (registered && typeof registered.chat !== 'function')
|
|
764
|
+
continue;
|
|
765
|
+
seen.add(provider);
|
|
766
|
+
}
|
|
767
|
+
return [...seen];
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Vendors currently walled by the AI-spend budget, in the order they were
|
|
771
|
+
* walled. Empty for a host that never adopts the per-vendor API.
|
|
772
|
+
*
|
|
773
|
+
* May include a vendor this registry cannot reach — either because the registry
|
|
774
|
+
* moved on after the wall was latched, or because a 402's
|
|
775
|
+
* `otherVendorAvailable: false` walls every vendor the proxy meters (which is
|
|
776
|
+
* what that verdict is a statement about; see
|
|
777
|
+
* {@link FoundationAiAssistant.latchBlockedFrom}). Neither costs anything
|
|
778
|
+
* internally — {@link FoundationAiAssistant.blocked} asks only about the
|
|
779
|
+
* reachable set, and the banner reads the reachability-filtered
|
|
780
|
+
* {@link FoundationAiAssistant.relevantBlockedVendors} — but a host rendering
|
|
781
|
+
* this list itself should filter it against its own registry.
|
|
782
|
+
*
|
|
783
|
+
* Read-only on purpose: a settable array would let a host write a partial list
|
|
784
|
+
* and silently orphan the per-vendor banner copy.
|
|
785
|
+
* {@link FoundationAiAssistant.setVendorBlocked} is the write path.
|
|
786
|
+
*
|
|
787
|
+
* @beta
|
|
788
|
+
*/
|
|
789
|
+
get blockedVendors() {
|
|
790
|
+
var _a, _b;
|
|
791
|
+
return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedVendors) !== null && _b !== void 0 ? _b : [];
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Whether this specific vendor's budget is walled — regardless of whether any
|
|
795
|
+
* other vendor still has headroom.
|
|
796
|
+
*
|
|
797
|
+
* @beta
|
|
798
|
+
*/
|
|
799
|
+
isVendorBlocked(vendor) {
|
|
800
|
+
return this.blockedVendors.includes(vendor);
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Wall (or release) one vendor's budget, optionally with banner copy for it.
|
|
804
|
+
* The per-vendor mirror of {@link FoundationAiAssistant.setBlocked}.
|
|
805
|
+
*
|
|
806
|
+
* Walling a vendor does **not** on its own disable the composer: while another
|
|
807
|
+
* reachable vendor has headroom the assistant stays usable and the banner tells
|
|
808
|
+
* the user to switch. Only when every reachable vendor is walled does `blocked`
|
|
809
|
+
* become true.
|
|
810
|
+
*
|
|
811
|
+
* `reason` is composed into the banner sentence for that vendor, **however many
|
|
812
|
+
* vendors are walled** — see {@link FoundationAiAssistant.effectiveBlockedReason}.
|
|
813
|
+
* It is per-vendor copy, not a whole-banner override; {@link FoundationAiAssistant.blockedReason}
|
|
814
|
+
* is the override. Omitting it keeps whatever explanation that vendor already
|
|
815
|
+
* carried, so a driver latch landing after a host one cannot blank it.
|
|
816
|
+
*
|
|
817
|
+
* `'none'` is rejected with a warning rather than accepted: it is the "no
|
|
818
|
+
* provider configured" sentinel, not a vendor. Latching it would put the raw
|
|
819
|
+
* sentinel in the banner ("none's AI usage limit is reached") and could never
|
|
820
|
+
* be undone by derivation, because `'none'` never appears in
|
|
821
|
+
* {@link FoundationAiAssistant.reachableVendors} — so `blocked` could never
|
|
822
|
+
* become derivable from it either. Use {@link FoundationAiAssistant.setBlocked}
|
|
823
|
+
* for a vendor-agnostic block.
|
|
824
|
+
*
|
|
825
|
+
* @beta
|
|
826
|
+
*/
|
|
827
|
+
setVendorBlocked(vendor, blocked, reason) {
|
|
828
|
+
var _a;
|
|
829
|
+
if (vendor === 'none') {
|
|
830
|
+
logger.warn("FoundationAiAssistant.setVendorBlocked: 'none' is the no-provider sentinel, not a vendor — " +
|
|
831
|
+
'ignoring. Use setBlocked() for a vendor-agnostic block.');
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setVendorBlocked({ vendor, blocked, reason });
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* The walled vendors the user can still be routed to — i.e.
|
|
838
|
+
* {@link FoundationAiAssistant.blockedVendors} narrowed to the reachable set,
|
|
839
|
+
* which is the only set the banner may name.
|
|
840
|
+
*
|
|
841
|
+
* A wall the registry can no longer reach is not news: it cannot be hit, and
|
|
842
|
+
* naming it puts a vendor in front of the user that is not theirs. The concrete
|
|
843
|
+
* failure this exists to stop: a host ships Anthropic-only, Anthropic walls, the
|
|
844
|
+
* host swaps its registry to Gemini, Gemini walls — and the banner reads "AI
|
|
845
|
+
* usage limits are reached for Anthropic and Gemini" to a user who has never
|
|
846
|
+
* had an Anthropic key. The spurious second name also flips the copy onto the
|
|
847
|
+
* plural branch, so even the closing sentence is wrong.
|
|
848
|
+
*
|
|
849
|
+
* Falls back to the unfiltered list when nothing is reachable, which is the
|
|
850
|
+
* same fail-safe {@link FoundationAiAssistant.blocked} takes there: unknown
|
|
851
|
+
* reachability means every wall counts, so every wall is also worth naming.
|
|
852
|
+
*
|
|
853
|
+
* @internal
|
|
854
|
+
*/
|
|
855
|
+
get relevantBlockedVendors() {
|
|
856
|
+
const walled = this.blockedVendors;
|
|
857
|
+
const reachable = this.reachableVendors;
|
|
858
|
+
if (reachable.length === 0)
|
|
859
|
+
return walled;
|
|
860
|
+
return walled.filter((v) => reachable.includes(v));
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Whether the blocked banner has anything to say — a vendor-agnostic block, OR
|
|
864
|
+
* at least one walled vendor the registry can still reach. Broader than
|
|
865
|
+
* {@link FoundationAiAssistant.blocked} on purpose: partial exhaustion leaves
|
|
866
|
+
* the composer live but still needs to be announced, because the next turn may
|
|
867
|
+
* route to the walled vendor and fail.
|
|
868
|
+
*
|
|
869
|
+
* Reads the reachability-filtered list for the same reason the copy does — a
|
|
870
|
+
* host that has swapped its registry away from the walled vendor has nothing
|
|
871
|
+
* left to announce, and would otherwise get a banner that falls through to the
|
|
872
|
+
* generic default copy over a perfectly usable composer.
|
|
873
|
+
*
|
|
874
|
+
* @internal
|
|
875
|
+
*/
|
|
876
|
+
get bannerVisible() {
|
|
877
|
+
return this.blocked || this.relevantBlockedVendors.length > 0;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Whether a suggestions fetch would hit a wall.
|
|
881
|
+
*
|
|
882
|
+
* Unlike a chat turn, this one CAN be resolved exactly: both suggestion paths
|
|
883
|
+
* go to the registry **default** and never to a per-agent override
|
|
884
|
+
* (`ChatDriver.getSuggestions` calls `providerRegistry.default()`, and
|
|
885
|
+
* `OrchestratingDriver.getSuggestions` just delegates to it). So the vendor a
|
|
886
|
+
* suggestions call would use is knowable, and this is the one place in the
|
|
887
|
+
* feature where that is true.
|
|
888
|
+
*
|
|
889
|
+
* Same rationale as the guard it replaces: a doomed suggestions call burns a
|
|
890
|
+
* `suggestions.failed` meta event and parks a raw transport error in
|
|
891
|
+
* `suggestionsState` for hosts to find.
|
|
892
|
+
*
|
|
893
|
+
* @internal
|
|
894
|
+
*/
|
|
895
|
+
get suggestionsBlocked() {
|
|
896
|
+
var _a, _b;
|
|
897
|
+
if (this.blocked)
|
|
898
|
+
return true;
|
|
899
|
+
const defaultVendor = (_b = (_a = this.providerStatuses.find((e) => e.isDefault)) === null || _a === void 0 ? void 0 : _a.status) === null || _b === void 0 ? void 0 : _b.provider;
|
|
900
|
+
return !!defaultVendor && defaultVendor !== 'none' && this.isVendorBlocked(defaultVendor);
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Explanation shown in the blocked banner, or `null` for the element's default
|
|
904
|
+
* copy. Only meaningful while {@link FoundationAiAssistant.blocked} is true;
|
|
905
|
+
* setting `blocked = false` clears it.
|
|
906
|
+
*
|
|
907
|
+
* Writable, and symmetric with every other store-backed accessor on this
|
|
908
|
+
* class: writing it re-latches with the CURRENT `blocked` value, so it changes
|
|
909
|
+
* the copy without disturbing the flag. Assigning `null` clears the
|
|
910
|
+
* explanation while staying blocked (the banner falls back to the default
|
|
911
|
+
* copy). {@link FoundationAiAssistant.setBlocked} remains the way to write
|
|
912
|
+
* both in one atomic action.
|
|
913
|
+
*
|
|
914
|
+
* @beta
|
|
915
|
+
*/
|
|
916
|
+
get blockedReason() {
|
|
917
|
+
var _a, _b;
|
|
918
|
+
return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedReason) !== null && _b !== void 0 ? _b : null;
|
|
919
|
+
}
|
|
920
|
+
set blockedReason(value) {
|
|
921
|
+
var _a;
|
|
922
|
+
// Deliberately NOT `setBlocked({ blocked: this.blocked, reason })`: `blocked`
|
|
923
|
+
// is derived now, so re-latching with it would pass `false` for a partially
|
|
924
|
+
// walled session and clear every per-vendor latch as a side effect of setting
|
|
925
|
+
// a string.
|
|
926
|
+
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlockedReason(value);
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Latch the backend block off a turn outcome. The single decision point for
|
|
930
|
+
* both latch sites (the `tool-loop-end` bus subscription and `send()`'s
|
|
931
|
+
* return-value check), so the rule cannot diverge between them.
|
|
932
|
+
*
|
|
933
|
+
* Idempotent and one-way **per vendor**: it never unblocks, and it never
|
|
934
|
+
* re-writes an existing wall. That guard is load-bearing, not just an
|
|
935
|
+
* optimisation — the bus fires from the driver's `finally`, i.e. BEFORE
|
|
936
|
+
* `sendMessage()` resolves, so a host subscribed to `tool-loop-end` (what the
|
|
937
|
+
* migration guide's Option C recommends) sets its own detailed reason and the
|
|
938
|
+
* return-value latch would otherwise land a moment later and blank it.
|
|
939
|
+
*
|
|
940
|
+
* The guard being per-vendor rather than global is the whole difference. A
|
|
941
|
+
* single global "already blocked, do nothing" would swallow a second vendor's
|
|
942
|
+
* wall — so a session walled on Anthropic could never record that Gemini went
|
|
943
|
+
* too, and under a mixed registry an Anthropic-only wall would lock a composer
|
|
944
|
+
* that Gemini could still serve.
|
|
945
|
+
*
|
|
946
|
+
* **Which field is authoritative**, in order:
|
|
947
|
+
*
|
|
948
|
+
* 1. `budget.vendorLabel` — stamped by the transport that was actually refused,
|
|
949
|
+
* so it can never be stale. But it is a static per-transport string, so a
|
|
950
|
+
* white-labelled or multiplexing gateway fronting several upstreams leaves
|
|
951
|
+
* it unclaimed by any vendor.
|
|
952
|
+
* 2. `budget.vendor` — which the driver resolved from the label where it could,
|
|
953
|
+
* and otherwise from the proxy's own `vendor` field on the 402. It is
|
|
954
|
+
* therefore NOT simply `vendorLabel` normalised, and the two can disagree;
|
|
955
|
+
* that fallback is the only attribution on offer for the gateway case above.
|
|
956
|
+
* 3. `vendorHint` — the bus detail's top-level vendor, i.e. the driver's
|
|
957
|
+
* last-resolved provider. Last resort because it CAN be stale: an
|
|
958
|
+
* orchestrated turn classifies against the registry default, a provider the
|
|
959
|
+
* chat driver may never have resolved, so its last-resolved provider there is
|
|
960
|
+
* the previous turn's vendor or nothing at all.
|
|
961
|
+
*
|
|
962
|
+
* A wall that names no recognised vendor falls back to the vendor-agnostic
|
|
963
|
+
* block — fail-safe, and identical to the pre-per-vendor behaviour.
|
|
964
|
+
*
|
|
965
|
+
* **`otherVendorAvailable: false` is server-known truth and outranks every
|
|
966
|
+
* inference made here.** The proxy meters the pots, so only it can say whether
|
|
967
|
+
* anything else has headroom; this element can only observe which vendors the
|
|
968
|
+
* registry can REACH, which says nothing about their remaining spend. So when
|
|
969
|
+
* the proxy says no, every other *budgeted* vendor is walled in the same pass.
|
|
970
|
+
* That is what makes `blocked` derive true — locking the composer on the first
|
|
971
|
+
* response instead of after a second doomed turn — and what empties the "free"
|
|
972
|
+
* set the banner would otherwise have advised switching to.
|
|
973
|
+
*
|
|
974
|
+
* The sweep runs over `BUDGETED_VENDORS`, deliberately **not** over
|
|
975
|
+
* {@link FoundationAiAssistant.reachableVendors}. Reachability is loaded
|
|
976
|
+
* asynchronously (`activateSession` kicks off `loadProviderStatuses()` and does
|
|
977
|
+
* not await it), so a wall landing before the statuses resolve would have swept
|
|
978
|
+
* an empty set — silently discarding the one fact on the 402 the client cannot
|
|
979
|
+
* re-derive, with nothing to re-run it when the statuses arrived. Worse, it let
|
|
980
|
+
* the verdict effectively un-latch: the single wall derived `blocked` only
|
|
981
|
+
* while the reachable set was empty, so the moment the statuses landed the
|
|
982
|
+
* composer came back to life against a budget the proxy had already said was
|
|
983
|
+
* gone. Sweeping the metered vendor list instead makes the verdict independent
|
|
984
|
+
* of load order and of registry membership, which is exactly what it is: a
|
|
985
|
+
* statement about the proxy's pots, not about this user's registry.
|
|
986
|
+
*
|
|
987
|
+
* Walling a vendor the registry cannot reach costs nothing: `blocked` only asks
|
|
988
|
+
* whether every REACHABLE vendor is walled, and the banner reads
|
|
989
|
+
* {@link FoundationAiAssistant.relevantBlockedVendors}, which filters the walls
|
|
990
|
+
* back down to the reachable set before naming any of them.
|
|
991
|
+
*
|
|
992
|
+
* One boundary remains on the sweep: it walls only vendors the proxy meters
|
|
993
|
+
* (`isBudgetedVendor`). Chrome runs on-device with no pot to exhaust, so "no
|
|
994
|
+
* other vendor has budget" is not a statement about it, and "Switch to Chrome
|
|
995
|
+
* to keep going." stays honest advice.
|
|
996
|
+
*
|
|
997
|
+
* The sweep runs last but sits OUTSIDE the per-vendor idempotence guard, and
|
|
998
|
+
* both halves of that matter. Last, so the vendor that actually refused this
|
|
999
|
+
* turn heads the wall order the multi-vendor banner reads. Outside the guard,
|
|
1000
|
+
* because the verdict can arrive on a LATER 402 for a vendor that is already
|
|
1001
|
+
* walled — turn 1 walls Anthropic while Gemini still has headroom, turn 2 walls
|
|
1002
|
+
* it again and reports that Gemini has since gone too — and an early return
|
|
1003
|
+
* would drop exactly the news the second turn was there to deliver.
|
|
1004
|
+
*
|
|
1005
|
+
* @param reason - the turn's failure reason; anything but `'budget-exhausted'` is ignored.
|
|
1006
|
+
* @param ref - session store to write through; defaults to the live one. `send()`
|
|
1007
|
+
* passes the ref it captured before its awaits, since a lifecycle event during the
|
|
1008
|
+
* turn may already have cleared `_sessionRef`.
|
|
1009
|
+
* @param budget - what the 402 reported: figures when the proxy sent any, the
|
|
1010
|
+
* refusing vendor, and its `otherVendorAvailable` verdict.
|
|
1011
|
+
* @param vendorHint - the `tool-loop-end` detail's top-level vendor, used only
|
|
1012
|
+
* when the budget payload names none.
|
|
1013
|
+
*
|
|
1014
|
+
* @internal
|
|
1015
|
+
*/
|
|
1016
|
+
latchBlockedFrom(reason, ref = this._sessionRef, budget, vendorHint) {
|
|
1017
|
+
var _a, _b;
|
|
1018
|
+
if (reason !== 'budget-exhausted')
|
|
1019
|
+
return;
|
|
1020
|
+
const vendor = (_b = (_a = vendorTypeOfLabel(budget === null || budget === void 0 ? void 0 : budget.vendorLabel)) !== null && _a !== void 0 ? _a : budget === null || budget === void 0 ? void 0 : budget.vendor) !== null && _b !== void 0 ? _b : vendorHint;
|
|
1021
|
+
if (!vendor || vendor === 'none') {
|
|
1022
|
+
if (!this.blocked) {
|
|
1023
|
+
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setBlocked({ blocked: true, reason: formatBlockedReason(budget) });
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
else if (!this.isVendorBlocked(vendor)) {
|
|
1027
|
+
if (this.reachableVendors.length === 0) {
|
|
1028
|
+
// Not fatal — `blocked` still derives to true, so the UI is correct — but
|
|
1029
|
+
// the host gets no per-vendor benefit and would otherwise have no signal.
|
|
1030
|
+
logger.warn(`FoundationAiAssistant: ${vendor} hit its AI budget, but no registered provider counts ` +
|
|
1031
|
+
'as reachable, so per-vendor blocking degrades to blocking everything. Two causes: no ' +
|
|
1032
|
+
'provider reports a status (implement AIProvider.getStatus()), or every provider that ' +
|
|
1033
|
+
'does is chat-less and so cannot serve a turn (implement chat() on at least one).');
|
|
1034
|
+
}
|
|
1035
|
+
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({
|
|
1036
|
+
vendor,
|
|
1037
|
+
blocked: true,
|
|
1038
|
+
reason: formatBlockedReason(budget, vendor),
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
// Last, so the vendor that actually refused this turn heads the wall order the
|
|
1042
|
+
// multi-vendor banner reads — and unconditionally, NOT under the per-vendor
|
|
1043
|
+
// idempotence guard above, because the verdict can arrive on a later 402 for a
|
|
1044
|
+
// vendor that is already walled.
|
|
1045
|
+
if ((budget === null || budget === void 0 ? void 0 : budget.otherVendorAvailable) !== false)
|
|
1046
|
+
return;
|
|
1047
|
+
// Over the metered vendor list, NOT the reachable set: the statuses load
|
|
1048
|
+
// asynchronously, so a reachability-scoped sweep dropped the verdict entirely
|
|
1049
|
+
// when the wall beat them home. `relevantBlockedVendors` filters the extra
|
|
1050
|
+
// walls back out before the banner names anyone.
|
|
1051
|
+
for (const other of BUDGETED_VENDORS) {
|
|
1052
|
+
if (other === vendor || this.isVendorBlocked(other))
|
|
1053
|
+
continue;
|
|
1054
|
+
// No reason: the slice's "an omitted reason keeps the existing explanation"
|
|
1055
|
+
// rule applies, and there are no figures for this vendor — only the proxy's
|
|
1056
|
+
// word that it has nothing left. The banner names it either way.
|
|
1057
|
+
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({ vendor: other, blocked: true });
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Block or unblock the assistant, optionally with custom banner copy.
|
|
1062
|
+
* Equivalent to the {@link FoundationAiAssistant.blocked} setter, plus the
|
|
1063
|
+
* reason in one atomic write.
|
|
1064
|
+
*
|
|
1065
|
+
* Blocking writes the **vendor-agnostic** block — what a host with one budget
|
|
1066
|
+
* pot means — and does not populate
|
|
1067
|
+
* {@link FoundationAiAssistant.blockedVendors}. Unblocking clears the
|
|
1068
|
+
* per-vendor walls as well, so a host asserting "the wall is gone" after its
|
|
1069
|
+
* own pre-flight cannot be silently overruled by a driver latch it never saw.
|
|
1070
|
+
*
|
|
1071
|
+
* @beta
|
|
1072
|
+
*/
|
|
1073
|
+
setBlocked(blocked, reason) {
|
|
1074
|
+
var _a;
|
|
1075
|
+
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked, reason });
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Copy shown in the blocked banner, in ascending specificity:
|
|
1079
|
+
*
|
|
1080
|
+
* 1. the host's `blockedReason` — a whole-banner override, unchanged;
|
|
1081
|
+
* 2. copy composed from the walled vendors (their per-vendor reasons when set,
|
|
1082
|
+
* otherwise their names) plus the action that is actually available;
|
|
1083
|
+
* 3. the default budget-exhaustion message.
|
|
1084
|
+
*
|
|
1085
|
+
* The action clause is computed here rather than stored, because whether
|
|
1086
|
+
* "switch vendor" or "contact your administrator" is honest depends on whether
|
|
1087
|
+
* another reachable vendor still has headroom — which changes when the registry
|
|
1088
|
+
* changes, long after the wall was latched. Telling a user to switch to a
|
|
1089
|
+
* vendor that is also exhausted is worse than saying nothing.
|
|
1090
|
+
*
|
|
1091
|
+
* The `free` set is a client-side inference — "reachable and not known to be
|
|
1092
|
+
* walled" — and it is only ever allowed to be one. Where the proxy has told us
|
|
1093
|
+
* otherwise (`otherVendorAvailable: false`) the latch has already walled those
|
|
1094
|
+
* vendors, so they are not in `free` here and no "switch to X" clause can be
|
|
1095
|
+
* composed from them. Both lists are filtered by reachability, so the copy
|
|
1096
|
+
* names only vendors this user's registry can actually route to.
|
|
1097
|
+
*
|
|
1098
|
+
* **Per-vendor copy is composed however many vendors are walled**, which is
|
|
1099
|
+
* what {@link FoundationAiAssistant.setVendorBlocked} and
|
|
1100
|
+
* `docs/migration-GENC-1464.md` both promise. It previously reached only the
|
|
1101
|
+
* single-vendor branch, so a host that had carefully set copy for each of its
|
|
1102
|
+
* vendors watched all of it vanish the moment a second one walled — replaced by
|
|
1103
|
+
* generic copy, at the exact moment the situation got worse.
|
|
1104
|
+
*
|
|
1105
|
+
* The compact "AI usage limits are reached for A and B." list is kept for the
|
|
1106
|
+
* case it was written for: NO walled vendor carries copy, so a statement per
|
|
1107
|
+
* vendor would just repeat one boilerplate sentence per name. As soon as any of
|
|
1108
|
+
* them does carry copy, the branch lists one statement per vendor instead —
|
|
1109
|
+
* falling back to that same boilerplate for the ones that have none, so the
|
|
1110
|
+
* sentence set stays complete rather than silently naming a subset.
|
|
1111
|
+
*
|
|
1112
|
+
* @internal
|
|
1113
|
+
*/
|
|
1114
|
+
get effectiveBlockedReason() {
|
|
1115
|
+
var _a;
|
|
1116
|
+
if (this.blockedReason)
|
|
1117
|
+
return this.blockedReason;
|
|
1118
|
+
const walled = this.relevantBlockedVendors;
|
|
1119
|
+
if (walled.length === 0)
|
|
1120
|
+
return DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
|
|
1121
|
+
const free = this.reachableVendors.filter((v) => !walled.includes(v));
|
|
1122
|
+
const action = free.length
|
|
1123
|
+
? `Switch to ${formatVendorList(free)} to keep going.`
|
|
1124
|
+
: walled.length > 1
|
|
1125
|
+
? RAISE_LIMITS_ACTION
|
|
1126
|
+
: RAISE_LIMIT_ACTION;
|
|
1127
|
+
const reasons = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedVendorReasons;
|
|
1128
|
+
const statementFor = (v) => { var _a; return (_a = reasons === null || reasons === void 0 ? void 0 : reasons[v]) !== null && _a !== void 0 ? _a : `${vendorDisplayName(v)}'s AI usage limit is reached.`; };
|
|
1129
|
+
if (walled.length === 1)
|
|
1130
|
+
return `${statementFor(walled[0])} ${action}`;
|
|
1131
|
+
// Several walled vendors carry several sets of figures, which do not fit one
|
|
1132
|
+
// sentence. With no per-vendor copy to preserve, name them in a list rather
|
|
1133
|
+
// than repeat the same boilerplate sentence per vendor.
|
|
1134
|
+
if (!walled.some((v) => reasons === null || reasons === void 0 ? void 0 : reasons[v])) {
|
|
1135
|
+
return `AI usage limits are reached for ${formatVendorList(walled)}. ${action}`;
|
|
1136
|
+
}
|
|
1137
|
+
return `${walled.map(statementFor).join(' ')} ${action}`;
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall.
|
|
1141
|
+
*
|
|
1142
|
+
* A host-set `blockedReason` still wins, so a white-labelled host does not read
|
|
1143
|
+
* its own explanation in the banner and the shipped default directly below it —
|
|
1144
|
+
* that is the whole reason the driver takes this at all.
|
|
1145
|
+
*
|
|
1146
|
+
* What it deliberately is NOT is
|
|
1147
|
+
* {@link FoundationAiAssistant.effectiveBlockedReason}. That getter
|
|
1148
|
+
* composes advice — `"…Switch to Gemini to keep going."` — which is true only
|
|
1149
|
+
* while that vendor has headroom, and the transcript is a permanent record. The
|
|
1150
|
+
* driver reads this once at CONSTRUCTION, and `getOrCreateDriver` keys on the
|
|
1151
|
+
* agents list, so an agents swap *after* a wall re-runs `createDriver` and would
|
|
1152
|
+
* freeze that sentence into every later turn's bubble. Reading the latch's own
|
|
1153
|
+
* copy instead is structurally incapable of carrying the advice: the only
|
|
1154
|
+
* writer of `blockedReason` is the vendor-agnostic branch of
|
|
1155
|
+
* `latchBlockedFrom`, whose `formatBlockedReason(budget)` form has no switch
|
|
1156
|
+
* clause. Per-vendor copy lives in `blockedVendorReasons` and reaches the
|
|
1157
|
+
* banner alone.
|
|
1158
|
+
*/
|
|
1159
|
+
get transcriptBudgetExhaustedMessage() {
|
|
1160
|
+
var _a;
|
|
1161
|
+
return (_a = this.blockedReason) !== null && _a !== void 0 ? _a : DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
|
|
1162
|
+
}
|
|
566
1163
|
/**
|
|
567
1164
|
* Name of the agent the user has pinned via the agent picker. `null` means
|
|
568
1165
|
* automatic routing (Auto). Persisted on the session store, so it survives
|
|
@@ -1057,6 +1654,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1057
1654
|
maxFoldOperations: agent.maxFoldOperations,
|
|
1058
1655
|
maxTurnSnapshots: agent.maxTurnSnapshots,
|
|
1059
1656
|
activityBus: agenticActivityBus,
|
|
1657
|
+
budgetExhaustedMessage: this.transcriptBudgetExhaustedMessage,
|
|
1060
1658
|
});
|
|
1061
1659
|
}
|
|
1062
1660
|
return new ChatDriver(this.providerRegistry, {
|
|
@@ -1065,6 +1663,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1065
1663
|
maxTurnSnapshots: agent.maxTurnSnapshots,
|
|
1066
1664
|
sessionKey: (_c = this.getStateKey()) !== null && _c !== void 0 ? _c : '',
|
|
1067
1665
|
activityBus: agenticActivityBus,
|
|
1666
|
+
budgetExhaustedMessage: this.transcriptBudgetExhaustedMessage,
|
|
1068
1667
|
});
|
|
1069
1668
|
}
|
|
1070
1669
|
/**
|
|
@@ -1373,7 +1972,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1373
1972
|
});
|
|
1374
1973
|
}
|
|
1375
1974
|
connectedCallback() {
|
|
1376
|
-
var _a;
|
|
1975
|
+
var _a, _b;
|
|
1377
1976
|
// Initialise the store reference BEFORE super.connectedCallback() so that
|
|
1378
1977
|
// the first FAST render has access to the store. The store Proxy calls
|
|
1379
1978
|
// Observable.track(observableStore, sliceName) whenever a slice is read,
|
|
@@ -1411,6 +2010,18 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1411
2010
|
});
|
|
1412
2011
|
}
|
|
1413
2012
|
this.wireAppSettingsProvider();
|
|
2013
|
+
// Latch the backend block off the activity bus (GENC-1464). This is the primary
|
|
2014
|
+
// seam: it fires for a turn this element did not start, for a sub-agent's
|
|
2015
|
+
// wall (the child inherits the parent's bus and publishes its own
|
|
2016
|
+
// `tool-loop-end`), and it writes through whichever element is connected NOW
|
|
2017
|
+
// rather than the one that happened to call `send()`. Re-subscribed per
|
|
2018
|
+
// connect (docking/popout remounts); balanced in `disconnectedCallback`.
|
|
2019
|
+
(_b = this._unsubBudgetLatch) === null || _b === void 0 ? void 0 : _b.call(this);
|
|
2020
|
+
this._unsubBudgetLatch = agenticActivityBus.subscribe('tool-loop-end', (d) =>
|
|
2021
|
+
// Pass the figures through: this seam wins the latch for an in-loop wall (the
|
|
2022
|
+
// common case), so dropping them here would render the generic banner copy in
|
|
2023
|
+
// exactly the path `ChatDriverResult.budget` was widened to serve.
|
|
2024
|
+
this.latchBlockedFrom(d === null || d === void 0 ? void 0 : d.failureReason, undefined, d === null || d === void 0 ? void 0 : d.budget, d === null || d === void 0 ? void 0 : d.vendor));
|
|
1414
2025
|
if (this.messagesEl) {
|
|
1415
2026
|
this._scrollListener = () => {
|
|
1416
2027
|
this._userScrolledAway =
|
|
@@ -1500,7 +2111,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1500
2111
|
this.switchSession(currentKey !== null && currentKey !== void 0 ? currentKey : targetKey, targetKey);
|
|
1501
2112
|
}
|
|
1502
2113
|
disconnectedCallback() {
|
|
1503
|
-
var _a, _b, _c, _d, _e, _f;
|
|
2114
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
1504
2115
|
window.removeEventListener('pagehide', this._handlePageHide);
|
|
1505
2116
|
super.disconnectedCallback();
|
|
1506
2117
|
this._sessionActive = false;
|
|
@@ -1515,10 +2126,12 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1515
2126
|
this.unwireDriver();
|
|
1516
2127
|
(_a = this.unsubBus) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
1517
2128
|
this.unsubBus = undefined;
|
|
1518
|
-
(_b = this.
|
|
2129
|
+
(_b = this._unsubBudgetLatch) === null || _b === void 0 ? void 0 : _b.call(this);
|
|
2130
|
+
this._unsubBudgetLatch = undefined;
|
|
2131
|
+
(_c = this.unsubProviderRegistry) === null || _c === void 0 ? void 0 : _c.call(this);
|
|
1519
2132
|
this.unsubProviderRegistry = undefined;
|
|
1520
2133
|
this.unwireAppSettingsProvider();
|
|
1521
|
-
(
|
|
2134
|
+
(_d = this._executionCompletionUnsub) === null || _d === void 0 ? void 0 : _d.call(this);
|
|
1522
2135
|
this._executionCompletionUnsub = undefined;
|
|
1523
2136
|
// Note: the restore-core autosave AND diagnostics-append timers live on the
|
|
1524
2137
|
// shared per-session persister (GENC-1351 §5.10), not here — they must survive a
|
|
@@ -1536,12 +2149,12 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1536
2149
|
// the panel re-mounts open.
|
|
1537
2150
|
this._agentPickerToggle.finalize();
|
|
1538
2151
|
if (this.settingsOpen) {
|
|
1539
|
-
(
|
|
2152
|
+
(_e = this.settingsModal) === null || _e === void 0 ? void 0 : _e.close();
|
|
1540
2153
|
this.settingsOpen = false;
|
|
1541
2154
|
}
|
|
1542
2155
|
this._sessionMenuToggle.finalize();
|
|
1543
2156
|
// Capture before clearing — `wasBusy` reads the driver, which is dropped below.
|
|
1544
|
-
this.logMeta('assistant.disconnected', { wasBusy: (
|
|
2157
|
+
this.logMeta('assistant.disconnected', { wasBusy: (_g = (_f = this.driver) === null || _f === void 0 ? void 0 : _f.isBusy()) !== null && _g !== void 0 ? _g : false });
|
|
1545
2158
|
// Clear local references only — driver and store stay in their registries.
|
|
1546
2159
|
this.driver = undefined;
|
|
1547
2160
|
this._sessionRef = undefined;
|
|
@@ -2362,6 +2975,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2362
2975
|
* message will go; otherwise falls back to the host-provided placeholder.
|
|
2363
2976
|
*/
|
|
2364
2977
|
get effectivePlaceholder() {
|
|
2978
|
+
// The block outranks the agent pin: while blocked the composer is disabled
|
|
2979
|
+
// anyway, so naming the pinned agent would only imply a send is possible.
|
|
2980
|
+
if (this.blocked)
|
|
2981
|
+
return BLOCKED_PLACEHOLDER;
|
|
2365
2982
|
if (this.pinnedAgentName)
|
|
2366
2983
|
return `Message ${this.pinnedAgentName}...`;
|
|
2367
2984
|
return this.placeholder;
|
|
@@ -2741,6 +3358,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2741
3358
|
(_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.cancel) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
2742
3359
|
}
|
|
2743
3360
|
handleSuggestionClick(suggestion) {
|
|
3361
|
+
// Belt-and-braces with the template gate that hides the chips while blocked:
|
|
3362
|
+
// `send()` refuses, but `inputValue` would already have been written, leaving
|
|
3363
|
+
// the chip's text stranded in a disabled textarea the user cannot clear.
|
|
3364
|
+
if (this.sendBlocked)
|
|
3365
|
+
return;
|
|
2744
3366
|
this.inputValue = suggestion;
|
|
2745
3367
|
this.send();
|
|
2746
3368
|
}
|
|
@@ -2769,7 +3391,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2769
3391
|
return __awaiter(this, void 0, void 0, function* () {
|
|
2770
3392
|
var _a, _b, _c;
|
|
2771
3393
|
if (this.sendBlocked) {
|
|
2772
|
-
return { ok: false, errors: [
|
|
3394
|
+
return { ok: false, errors: [this.sendRefusalReason()] };
|
|
2773
3395
|
}
|
|
2774
3396
|
let nextAttachments = [];
|
|
2775
3397
|
if ((_a = input.files) === null || _a === void 0 ? void 0 : _a.length) {
|
|
@@ -2782,7 +3404,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2782
3404
|
// (Cast widens through TS's narrowing of `this.state` from the earlier
|
|
2783
3405
|
// early-return; the getter can return a different value across an await.)
|
|
2784
3406
|
if (this.sendBlocked) {
|
|
2785
|
-
return { ok: false, errors: [
|
|
3407
|
+
return { ok: false, errors: [this.sendRefusalReason()] };
|
|
2786
3408
|
}
|
|
2787
3409
|
if (errors.length) {
|
|
2788
3410
|
this.attachmentErrors = errors;
|
|
@@ -2814,6 +3436,21 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2814
3436
|
if (!this.driver || !suggestionsConfig || suggestionsConfig.behavior === 'never') {
|
|
2815
3437
|
return;
|
|
2816
3438
|
}
|
|
3439
|
+
// A guaranteed 402. This runs from the post-turn `finally` (the turn that
|
|
3440
|
+
// just latched), from `connectedCallback` (a pop-in against a blocked store),
|
|
3441
|
+
// and from the `pinnedAgentName` setter — which resets `suggestionsState`
|
|
3442
|
+
// first, defeating the already-fetched guard below. So without this a blocked
|
|
3443
|
+
// session still fires one doomed request per turn, per pop-in and per pin
|
|
3444
|
+
// change, each one burning a `suggestions.failed` meta event and parking a
|
|
3445
|
+
// raw transport-error string in `suggestionsState.message`.
|
|
3446
|
+
//
|
|
3447
|
+
// Resolved against the registry DEFAULT, not the whole composer gate: both
|
|
3448
|
+
// suggestion paths call `providerRegistry.default()`, so under partial
|
|
3449
|
+
// exhaustion this is knowable exactly — and suggestions stay available when
|
|
3450
|
+
// the default vendor is the one with headroom.
|
|
3451
|
+
if (this.suggestionsBlocked) {
|
|
3452
|
+
return;
|
|
3453
|
+
}
|
|
2817
3454
|
if (suggestionsConfig.behavior === 'initial' && this.messages.length > 0) {
|
|
2818
3455
|
return;
|
|
2819
3456
|
}
|
|
@@ -2895,7 +3532,16 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2895
3532
|
? `${input}\n\n*Attached: ${pendingAttachments.map((a) => a.name).join(', ')}*`
|
|
2896
3533
|
: input;
|
|
2897
3534
|
try {
|
|
2898
|
-
yield ((_a = this.driver) === null || _a === void 0 ? void 0 : _a.sendMessage(displayInput, pendingAttachments));
|
|
3535
|
+
const result = yield ((_a = this.driver) === null || _a === void 0 ? void 0 : _a.sendMessage(displayInput, pendingAttachments));
|
|
3536
|
+
// Latch the backend block the moment the driver reports it, so the composer
|
|
3537
|
+
// locks without waiting on the host's next pre-flight. The bus subscription
|
|
3538
|
+
// in `connectedCallback` covers most walls; this covers the one it cannot —
|
|
3539
|
+
// a wall hit during multi-agent classification, where no tool loop ran and
|
|
3540
|
+
// so no `tool-loop-end` was published. `latchBlockedFrom` is idempotent, so
|
|
3541
|
+
// the two seams overlapping is harmless.
|
|
3542
|
+
if ((result === null || result === void 0 ? void 0 : result.reason) === 'done') {
|
|
3543
|
+
this.latchBlockedFrom(result.failureReason, capturedSessionRef, result.budget);
|
|
3544
|
+
}
|
|
2899
3545
|
}
|
|
2900
3546
|
finally {
|
|
2901
3547
|
this.stopLoadingTimer();
|
|
@@ -3075,6 +3721,24 @@ __decorate([
|
|
|
3075
3721
|
__decorate([
|
|
3076
3722
|
volatile
|
|
3077
3723
|
], FoundationAiAssistant.prototype, "busy", null);
|
|
3724
|
+
__decorate([
|
|
3725
|
+
volatile
|
|
3726
|
+
], FoundationAiAssistant.prototype, "blocked", null);
|
|
3727
|
+
__decorate([
|
|
3728
|
+
volatile
|
|
3729
|
+
], FoundationAiAssistant.prototype, "reachableVendors", null);
|
|
3730
|
+
__decorate([
|
|
3731
|
+
volatile
|
|
3732
|
+
], FoundationAiAssistant.prototype, "relevantBlockedVendors", null);
|
|
3733
|
+
__decorate([
|
|
3734
|
+
volatile
|
|
3735
|
+
], FoundationAiAssistant.prototype, "bannerVisible", null);
|
|
3736
|
+
__decorate([
|
|
3737
|
+
volatile
|
|
3738
|
+
], FoundationAiAssistant.prototype, "suggestionsBlocked", null);
|
|
3739
|
+
__decorate([
|
|
3740
|
+
volatile
|
|
3741
|
+
], FoundationAiAssistant.prototype, "effectiveBlockedReason", null);
|
|
3078
3742
|
__decorate([
|
|
3079
3743
|
volatile
|
|
3080
3744
|
], FoundationAiAssistant.prototype, "effectiveChatInputDuringExecution", null);
|