@genesislcap/ai-assistant 14.444.1 → 14.445.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 (47) hide show
  1. package/dist/ai-assistant.api.json +312 -41
  2. package/dist/ai-assistant.d.ts +105 -8
  3. package/dist/dts/components/ai-driver/ai-driver.d.ts +7 -0
  4. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
  5. package/dist/dts/components/chat-driver/chat-driver.d.ts +37 -3
  6. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  7. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +5 -3
  8. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  9. package/dist/dts/config/config.d.ts +31 -0
  10. package/dist/dts/config/config.d.ts.map +1 -1
  11. package/dist/dts/config/define-stateful-agent.d.ts +9 -0
  12. package/dist/dts/config/define-stateful-agent.d.ts.map +1 -1
  13. package/dist/dts/config/validate-providers.d.ts +25 -0
  14. package/dist/dts/config/validate-providers.d.ts.map +1 -0
  15. package/dist/dts/config/validate-providers.test.d.ts +2 -0
  16. package/dist/dts/config/validate-providers.test.d.ts.map +1 -0
  17. package/dist/dts/main/main.d.ts +17 -5
  18. package/dist/dts/main/main.d.ts.map +1 -1
  19. package/dist/dts/main/main.styles.d.ts.map +1 -1
  20. package/dist/dts/main/main.template.d.ts.map +1 -1
  21. package/dist/dts/state/ai-assistant-slice.d.ts +14 -1
  22. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  23. package/dist/dts/state/session-store.d.ts +2 -0
  24. package/dist/dts/state/session-store.d.ts.map +1 -1
  25. package/dist/esm/components/chat-driver/chat-driver.js +84 -15
  26. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +23 -4
  27. package/dist/esm/config/define-stateful-agent.js +12 -0
  28. package/dist/esm/config/validate-providers.js +47 -0
  29. package/dist/esm/config/validate-providers.test.js +100 -0
  30. package/dist/esm/main/main.js +57 -10
  31. package/dist/esm/main/main.styles.js +52 -0
  32. package/dist/esm/main/main.template.js +36 -1
  33. package/dist/esm/state/ai-assistant-slice.js +8 -0
  34. package/dist/tsconfig.tsbuildinfo +1 -1
  35. package/docs/migration-GENC-1262.md +219 -0
  36. package/package.json +16 -16
  37. package/src/components/ai-driver/ai-driver.ts +8 -0
  38. package/src/components/chat-driver/chat-driver.ts +96 -14
  39. package/src/components/orchestrating-driver/orchestrating-driver.ts +29 -4
  40. package/src/config/config.ts +32 -0
  41. package/src/config/define-stateful-agent.ts +28 -0
  42. package/src/config/validate-providers.test.ts +148 -0
  43. package/src/config/validate-providers.ts +58 -0
  44. package/src/main/main.styles.ts +52 -0
  45. package/src/main/main.template.ts +50 -2
  46. package/src/main/main.ts +51 -7
  47. package/src/state/ai-assistant-slice.ts +24 -1
@@ -251,6 +251,58 @@ export const styles = css `
251
251
  font-family: var(--monospace-font, ui-monospace, monospace);
252
252
  }
253
253
 
254
+ .provider-list {
255
+ animation: settings-slide-in 0.2s ease-out;
256
+ padding: calc(var(--design-unit) * 1.5px) calc(var(--design-unit) * 3px);
257
+ border-bottom: 1px solid var(--neutral-stroke-rest);
258
+ background-color: var(--neutral-layer-2);
259
+ display: flex;
260
+ flex-direction: column;
261
+ gap: calc(var(--design-unit) * 1px);
262
+ }
263
+
264
+ .provider-list-label {
265
+ font-size: 0.85em;
266
+ font-weight: 600;
267
+ color: var(--neutral-foreground-rest);
268
+ }
269
+
270
+ .provider-list-row {
271
+ display: flex;
272
+ align-items: baseline;
273
+ gap: calc(var(--design-unit) * 1.5px);
274
+ font-size: 0.8em;
275
+ color: var(--neutral-foreground-hint, var(--neutral-foreground-rest));
276
+ opacity: 80%;
277
+ }
278
+
279
+ .provider-list-row.is-current {
280
+ opacity: 100%;
281
+ color: var(--neutral-foreground-rest);
282
+ }
283
+
284
+ .provider-list-name {
285
+ font-weight: 600;
286
+ }
287
+
288
+ .provider-list-model {
289
+ font-family: var(--monospace-font, ui-monospace, monospace);
290
+ flex: 1;
291
+ overflow: hidden;
292
+ text-overflow: ellipsis;
293
+ white-space: nowrap;
294
+ }
295
+
296
+ .provider-list-badge {
297
+ font-size: 0.7em;
298
+ text-transform: uppercase;
299
+ letter-spacing: 0.05em;
300
+ padding: 0 calc(var(--design-unit) * 0.75px);
301
+ border-radius: calc(var(--design-unit) * 0.5px);
302
+ background-color: var(--neutral-layer-3);
303
+ color: var(--neutral-foreground-rest);
304
+ }
305
+
254
306
  .splash-wrapper {
255
307
  position: absolute;
256
308
  inset: 0;
@@ -376,12 +376,47 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
376
376
  ></${progressTag}>
377
377
  </div>
378
378
  `)}
379
- ${when((x) => { var _a; return x.settingsOpen && ((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showActiveModel) !== false && x.activeModel != null; }, html `
379
+ ${when((x) => {
380
+ var _a;
381
+ return x.settingsOpen &&
382
+ ((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showActiveModel) !== false &&
383
+ x.providerStatuses.length <= 1 &&
384
+ x.activeModel != null;
385
+ }, html `
380
386
  <div class="session-stat" part="active-model">
381
387
  <span class="session-stat-label">Model</span>
382
388
  <span class="session-stat-value">${(x) => x.activeModel}</span>
383
389
  </div>
384
390
  `)}
391
+ ${when((x) => {
392
+ var _a;
393
+ return x.settingsOpen &&
394
+ ((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showActiveModel) !== false &&
395
+ x.providerStatuses.length > 1;
396
+ }, html `
397
+ <div class="provider-list" part="provider-list">
398
+ <div class="provider-list-label">Providers</div>
399
+ ${repeat((x) => x.providerStatuses, html `
400
+ <div
401
+ class="provider-list-row ${(entry, c) => entry.name === c.parent.activeProviderName ? 'is-current' : ''}"
402
+ part="provider-list-row"
403
+ >
404
+ <span class="provider-list-name">${(entry) => entry.name}</span>
405
+ <span class="provider-list-model">${(entry) => { var _a, _b; return (_b = (_a = entry.status) === null || _a === void 0 ? void 0 : _a.model) !== null && _b !== void 0 ? _b : '—'; }}</span>
406
+ ${when((entry) => entry.isDefault, html `
407
+ <span class="provider-list-badge" part="provider-list-default-badge">
408
+ default
409
+ </span>
410
+ `)}
411
+ ${when((entry, c) => entry.name === c.parent.activeProviderName, html `
412
+ <span class="provider-list-badge" part="provider-list-current-badge">
413
+ current
414
+ </span>
415
+ `)}
416
+ </div>
417
+ `)}
418
+ </div>
419
+ `)}
385
420
  ${when((x) => { var _a; return x.settingsOpen && ((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showSessionCost) !== false && x.sessionCostUsd > 0; }, html `
386
421
  <div class="session-stat" part="session-cost">
387
422
  <span class="session-stat-label">Session cost</span>
@@ -11,6 +11,8 @@ export const defaultSessionState = {
11
11
  contextLimit: undefined,
12
12
  sessionCostUsd: 0,
13
13
  activeModel: undefined,
14
+ activeProviderName: undefined,
15
+ providerStatuses: [],
14
16
  activeAgent: undefined,
15
17
  pinnedAgentName: null,
16
18
  flowOwnerAgentName: null,
@@ -57,6 +59,12 @@ export const aiAssistantSlice = createSlice({
57
59
  setActiveModel(state, action) {
58
60
  state.activeModel = action.payload;
59
61
  },
62
+ setActiveProviderName(state, action) {
63
+ state.activeProviderName = action.payload;
64
+ },
65
+ setProviderStatuses(state, action) {
66
+ state.providerStatuses = action.payload;
67
+ },
60
68
  setActiveAgent(state, action) {
61
69
  state.activeAgent = action.payload;
62
70
  },
@@ -1 +1 @@
1
- {"root":["../src/index.ts","../src/channel/ai-activity-bus.ts","../src/channel/ai-activity-channel.ts","../src/components/halo-overlay.ts","../src/components/activity-halo/activity-halo.ts","../src/components/agent-picker/agent-picker.constants.ts","../src/components/agent-picker/agent-picker.styles.ts","../src/components/agent-picker/agent-picker.template.ts","../src/components/agent-picker/agent-picker.ts","../src/components/agent-picker/index.ts","../src/components/ai-driver/ai-driver.ts","../src/components/ai-driver/index.ts","../src/components/chat-bubble/chat-bubble.styles.ts","../src/components/chat-bubble/chat-bubble.template.ts","../src/components/chat-bubble/chat-bubble.ts","../src/components/chat-bubble/index.ts","../src/components/chat-driver/chat-driver.ts","../src/components/chat-driver/index.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts","../src/components/chat-interaction-wrapper/index.ts","../src/components/chat-markdown/chat-markdown.ts","../src/components/chat-markdown/index.ts","../src/components/orchestrating-driver/index.ts","../src/components/orchestrating-driver/orchestrating-driver.ts","../src/components/popout-manager/index.ts","../src/components/popout-manager/popout-manager.ts","../src/config/config.ts","../src/config/define-stateful-agent.ts","../src/config/fallback-agents.ts","../src/config/index.ts","../src/main/index.ts","../src/main/main.styles.ts","../src/main/main.template.ts","../src/main/main.ts","../src/main/main.types.ts","../src/state/ai-assistant-slice.ts","../src/state/driver-registry.ts","../src/state/session-store.ts","../src/styles/ai-colours.ts","../src/styles/index.ts","../src/styles/styles.ts","../src/suggestions/chat-suggestions.ts","../src/tags/index.ts","../src/types/ai-chat-widget.ts","../src/utils/animated-panel-toggle.ts","../src/utils/history-transform.ts","../src/utils/index.ts","../src/utils/logger.ts","../src/utils/tool-fold.ts"],"version":"5.9.2"}
1
+ {"root":["../src/index.ts","../src/channel/ai-activity-bus.ts","../src/channel/ai-activity-channel.ts","../src/components/halo-overlay.ts","../src/components/activity-halo/activity-halo.ts","../src/components/agent-picker/agent-picker.constants.ts","../src/components/agent-picker/agent-picker.styles.ts","../src/components/agent-picker/agent-picker.template.ts","../src/components/agent-picker/agent-picker.ts","../src/components/agent-picker/index.ts","../src/components/ai-driver/ai-driver.ts","../src/components/ai-driver/index.ts","../src/components/chat-bubble/chat-bubble.styles.ts","../src/components/chat-bubble/chat-bubble.template.ts","../src/components/chat-bubble/chat-bubble.ts","../src/components/chat-bubble/index.ts","../src/components/chat-driver/chat-driver.ts","../src/components/chat-driver/index.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts","../src/components/chat-interaction-wrapper/index.ts","../src/components/chat-markdown/chat-markdown.ts","../src/components/chat-markdown/index.ts","../src/components/orchestrating-driver/index.ts","../src/components/orchestrating-driver/orchestrating-driver.ts","../src/components/popout-manager/index.ts","../src/components/popout-manager/popout-manager.ts","../src/config/config.ts","../src/config/define-stateful-agent.ts","../src/config/fallback-agents.ts","../src/config/index.ts","../src/config/validate-providers.test.ts","../src/config/validate-providers.ts","../src/main/index.ts","../src/main/main.styles.ts","../src/main/main.template.ts","../src/main/main.ts","../src/main/main.types.ts","../src/state/ai-assistant-slice.ts","../src/state/driver-registry.ts","../src/state/session-store.ts","../src/styles/ai-colours.ts","../src/styles/index.ts","../src/styles/styles.ts","../src/suggestions/chat-suggestions.ts","../src/tags/index.ts","../src/types/ai-chat-widget.ts","../src/utils/animated-panel-toggle.ts","../src/utils/history-transform.ts","../src/utils/index.ts","../src/utils/logger.ts","../src/utils/tool-fold.ts"],"version":"5.9.2"}
@@ -0,0 +1,219 @@
1
+ # Migration Guide — GENC-1262 (Per-Agent AI Provider)
2
+
3
+ This document covers the breaking changes introduced by the `mw/GENC-1262-per-agent-model` branch and the new per-agent provider capability that motivated them.
4
+
5
+ The old single-provider DI token (`AIProvider`) is gone. Hosts now register a **named registry of providers** (`AIProviderRegistry`), and each agent can declare which named provider it wants.
6
+
7
+ ---
8
+
9
+ ## 1. Single-provider DI token replaced by a registry
10
+
11
+ The `AIProvider` DI token (a const exported by `@genesislcap/foundation-ai`) has been **removed**. So has the `getAIProvider()` helper. They're replaced by a new token `AIProviderRegistry` and a registration helper `registerAIProviders`.
12
+
13
+ The `AIProvider` **interface** still exists and is still exported (concrete provider classes implement it). Only the DI-token const and `getAIProvider` helper are gone.
14
+
15
+ ### Bootstrap: single-provider host
16
+
17
+ **Before**
18
+
19
+ ```ts
20
+ import { AIProvider, createAIProvider, resolveAIConfig } from '@genesislcap/foundation-ai';
21
+ import { Registration } from '@microsoft/fast-foundation';
22
+
23
+ const aiConfig = await resolveAIConfig({ provider: 'gemini', model: 'gemini-2.5-flash' });
24
+
25
+ this.container.register(
26
+ Registration.instance(AIProvider, createAIProvider(aiConfig)),
27
+ );
28
+ ```
29
+
30
+ **After**
31
+
32
+ ```ts
33
+ import { createAIProvider, registerAIProviders, resolveAIConfig } from '@genesislcap/foundation-ai';
34
+
35
+ const aiConfig = await resolveAIConfig({ provider: 'gemini', model: 'gemini-2.5-flash' });
36
+
37
+ registerAIProviders(this.container, { gemini: createAIProvider(aiConfig) });
38
+ ```
39
+
40
+ Notes:
41
+
42
+ - `registerAIProviders` is a function — call it directly with the container; **do not** wrap it in `container.register(...)`.
43
+ - With one provider in the map, the default is inferred automatically. The key (`'gemini'` here) becomes both the registered name and the default name.
44
+ - Pick a stable, descriptive key. It's how agents will reference this provider in their config, and it's what the settings panel will show.
45
+
46
+ ### Bootstrap: multi-provider host
47
+
48
+ ```ts
49
+ import {
50
+ AnthropicProvider,
51
+ AnthropicTransport,
52
+ GeminiProvider,
53
+ GeminiTransport,
54
+ registerAIProviders,
55
+ } from '@genesislcap/foundation-ai';
56
+
57
+ const fast = new GeminiProvider({}, new GeminiTransport({ model: 'gemini-2.5-flash-lite' }));
58
+ const deep = new AnthropicProvider({}, new AnthropicTransport({ model: 'claude-sonnet-4-6' }));
59
+
60
+ registerAIProviders(this.container, { fast, deep }, { default: 'fast' });
61
+ ```
62
+
63
+ - With **more than one** entry, `options.default` is **required**. Omitting it throws — there's no implicit ordering.
64
+ - Unknown `options.default` (a name not present in `providers`) throws.
65
+ - Empty `providers` map throws.
66
+
67
+ ### What happens if you register nothing?
68
+
69
+ A built-in empty registry resolves from the DI container. Every lookup returns a no-op provider; the assistant renders inert. This preserves today's "AI feature flag off / nothing configured" behaviour — no need to special-case it.
70
+
71
+ ---
72
+
73
+ ## 2. New `BaseAgentConfig.provider` — per-agent provider override
74
+
75
+ Every agent (specialist, fallback, or stateful) can now declare which registered provider it uses by name. Omit the field to use the registry's default.
76
+
77
+ ```ts
78
+ import { defineAgent } from '@genesislcap/ai-assistant';
79
+
80
+ const classifier = defineAgent({
81
+ name: 'classifier',
82
+ description: 'Quick triage and routing.',
83
+ provider: 'fast', // resolves against the AIProviderRegistry
84
+ });
85
+
86
+ const analyst = defineAgent({
87
+ name: 'analyst',
88
+ description: 'Deep analysis with long context.',
89
+ provider: 'deep',
90
+ });
91
+ ```
92
+
93
+ Type: `provider?: string | ((ctx: SystemPromptContext) => string | Promise<string>)`.
94
+
95
+ ### Function form (per-turn resolution)
96
+
97
+ The function form mirrors `systemPrompt`, `toolDefinitions`, `toolHandlers` — resolved each tool-loop iteration with the same `SystemPromptContext`. On a plain `defineAgent` config this looks like:
98
+
99
+ ```ts
100
+ const triage = defineAgent({
101
+ name: 'triage',
102
+ description: 'Routes intents.',
103
+ provider: (ctx) => (ctx.history.length > 8 ? 'deep' : 'fast'),
104
+ });
105
+ ```
106
+
107
+ The argument is just `SystemPromptContext` — `agentName`, `history`, `turnIndex`, `signal`. No `state` field, because plain agents don't own per-turn state.
108
+
109
+ ### Stateful agents (`defineStatefulAgent`)
110
+
111
+ For agents built with `defineStatefulAgent<S>`, the `provider` resolver receives `StatefulAgentContext<S>` — the same context plus the live `state` value. This lets the agent flip provider based on whatever it owns (a state machine, an accumulator, anything in `S`):
112
+
113
+ ```ts
114
+ const guidedBooking = defineStatefulAgent<{ machine: GuidedBookingMachine }>({
115
+ name: 'guided_booking',
116
+ description: 'Walks the user through a multi-step booking flow.',
117
+ init: ({ /* ... */ }) => ({ machine: createMachine(...) }),
118
+
119
+ provider: ({ state }) =>
120
+ state.machine.state === GuidedBookingStates.EnterDetails ? 'deep' : 'fast',
121
+
122
+ systemPrompt: ({ state }) => state.machine.meta?.systemPrompt ?? '...',
123
+ // ...
124
+ });
125
+ ```
126
+
127
+ `defineStatefulAgent` wraps the function so `state` is threaded in before each call — you don't write the wrapping yourself.
128
+
129
+ **Use typed constants for state names.** Comparing against a string literal like `'enterDetails'` is fragile — renames in the machine don't break the resolver. Export a typed const map from the machine file and reference it from the agent:
130
+
131
+ ```ts
132
+ // guided-booking.machine.ts
133
+ export const GuidedBookingStates = {
134
+ PickCounterparty: 'pickCounterparty',
135
+ PickInstrument: 'pickInstrument',
136
+ EnterDetails: 'enterDetails',
137
+ Confirm: 'confirm',
138
+ Done: 'done',
139
+ Cancelled: 'cancelled',
140
+ } as const satisfies Record<string, GuidedBookingState>;
141
+ ```
142
+
143
+ Renaming a state now requires updating the map, which surfaces every consumer in one place. The `satisfies Record<string, GuidedBookingState>` keeps the values constrained to the state-name union.
144
+
145
+ For dynamic stateful agents (no machine — see [`trade-operations.agent.ts`](../../../../../showcase/client-app/client/src/routes/ai/trade-operations/trade-operations.agent.ts)), the same pattern applies but the predicate is whatever derives "current phase" from the state shape — e.g. `provider: ({ state }) => isPlanning(state) ? 'deep' : 'fast'`.
146
+
147
+ ### Sub-agents
148
+
149
+ `BaseAgentConfig.provider` is inherited by sub-agents. A sub-agent declared in `subAgents: [...]` can have its own `provider` field; it's validated alongside its parent.
150
+
151
+ ---
152
+
153
+ ## 3. Validation — unknown names and missing capabilities hard-error
154
+
155
+ There are no silent fallbacks for misconfigured providers. The validation runs at two points:
156
+
157
+ - **Static names** (`provider: 'fast'`) — validated at `OrchestratingDriver` construction time. Walks every agent and every nested sub-agent. Unknown names throw with the list of registered names. Providers missing `chat()` throw too.
158
+ - **Function-form names** (`provider: ({ state }) => '...'`) — validated on first resolution per name (cached after that). Subsequent state transitions that return a different name validate again.
159
+
160
+ Sample errors:
161
+
162
+ ```
163
+ OrchestratingDriver: agent "analyst" references unknown provider "deep". Registered: fast
164
+ ChatDriver: agent "analyst" resolved to provider "deep" which does not implement chat() — required by ChatDriver.
165
+ ```
166
+
167
+ If you see one of these, the fix is in either the registration (`registerAIProviders(...)`) or the agent config (`provider: '...'`), not in this code.
168
+
169
+ ---
170
+
171
+ ## 4. Settings panel — multi-provider display
172
+
173
+ The settings cog inside the assistant displays providers in two modes:
174
+
175
+ - **1 provider** — same single "Model" row as before. No visible change for single-provider hosts.
176
+ - **2+ providers** — a list of every registered provider with the model id from `getStatus()`, plus `default` and `current` badges. `current` follows the active agent's resolved provider.
177
+
178
+ Status is fetched once on connect via `AIProviderRegistry.listStatuses()` and refreshed when the active provider changes.
179
+
180
+ Nothing about the multi-provider list leaks outside the cog — the header, halo, and message rows are unchanged.
181
+
182
+ ---
183
+
184
+ ## 5. Consumer behaviour outside the assistant
185
+
186
+ Other foundation-ui components that consume the AI layer use **the registry's default provider only** — they're not bound to any agent, so per-agent overrides don't apply:
187
+
188
+ - `ai-criteria-search` (the natural-language criteria filter, including when embedded in `foundation-entity-management`) — calls `interpretCriteria` on the default provider.
189
+ - `ai-indicator` (chrome status indicator) — reads status / triggers Chrome model download on the default provider.
190
+
191
+ If your host needs criteria search backed by a specific provider, make sure that provider is your default.
192
+
193
+ ---
194
+
195
+ ## 6. Things that always use the default provider
196
+
197
+ Inside the assistant itself, a few non-turn paths always run against the registry default rather than the active agent's provider:
198
+
199
+ - **Suggestion generation** (`ChatDriver.getSuggestions`) — UI helper not bound to any agent.
200
+ - **Classifier** (`OrchestratingDriver`'s intent routing) — orchestrator-level decision, runs before any specialist is picked.
201
+
202
+ This is intentional: keep those paths cheap and predictable, and pick a default provider that's cheap enough for them.
203
+
204
+ ---
205
+
206
+ ## 7. Known limitations
207
+
208
+ - **Context-window flip.** If an agent switches provider mid-conversation (e.g. a stateful agent moves from a long-window provider to a short-window one), history accumulated under the larger window can blow the smaller window. The driver does not auto-trim. Either keep providers compatible in context size, or design state transitions that reset / summarise history.
209
+ - **Capability check is `chat()` only.** `ChatDriver` validates that the resolved provider implements `chat()`. It does **not** validate `streamChat()` or `prompt()`. If an agent's flow specifically needs streaming, ensure the provider implements it; otherwise calls will fall through to non-streaming.
210
+
211
+ ---
212
+
213
+ ## 8. Checklist for migrating a host app
214
+
215
+ 1. Replace `Registration.instance(AIProvider, createAIProvider(...))` with `registerAIProviders(container, { '<name>': createAIProvider(...) })`. Call it **outside** `container.register(...)`.
216
+ 2. Drop `import { AIProvider }` value imports; keep `import type { AIProvider }` if you still need the interface.
217
+ 3. Drop any `getAIProvider()` calls — pull the registry from DI (`@AIProviderRegistry providerRegistry!: AIProviderRegistry`) and use `providerRegistry.default()` or `providerRegistry.get(name)`.
218
+ 4. If you want per-agent providers, register multiple under distinct names with an explicit `default`, then add `provider: '<name>'` to the relevant `AgentConfig` entries.
219
+ 5. Re-run typecheck. The breakage surface is narrow: only sites that used the removed const or helper. Type-only uses of the `AIProvider` interface continue to work unchanged.
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": "14.444.1",
4
+ "version": "14.445.0",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -64,24 +64,24 @@
64
64
  }
65
65
  },
66
66
  "devDependencies": {
67
- "@genesislcap/foundation-testing": "14.444.1",
68
- "@genesislcap/genx": "14.444.1",
69
- "@genesislcap/rollup-builder": "14.444.1",
70
- "@genesislcap/ts-builder": "14.444.1",
71
- "@genesislcap/uvu-playwright-builder": "14.444.1",
72
- "@genesislcap/vite-builder": "14.444.1",
73
- "@genesislcap/webpack-builder": "14.444.1",
67
+ "@genesislcap/foundation-testing": "14.445.0",
68
+ "@genesislcap/genx": "14.445.0",
69
+ "@genesislcap/rollup-builder": "14.445.0",
70
+ "@genesislcap/ts-builder": "14.445.0",
71
+ "@genesislcap/uvu-playwright-builder": "14.445.0",
72
+ "@genesislcap/vite-builder": "14.445.0",
73
+ "@genesislcap/webpack-builder": "14.445.0",
74
74
  "@types/dompurify": "^3.0.5",
75
75
  "@types/marked": "^5.0.2"
76
76
  },
77
77
  "dependencies": {
78
- "@genesislcap/foundation-ai": "14.444.1",
79
- "@genesislcap/foundation-logger": "14.444.1",
80
- "@genesislcap/foundation-redux": "14.444.1",
81
- "@genesislcap/foundation-ui": "14.444.1",
82
- "@genesislcap/foundation-utils": "14.444.1",
83
- "@genesislcap/rapid-design-system": "14.444.1",
84
- "@genesislcap/web-core": "14.444.1",
78
+ "@genesislcap/foundation-ai": "14.445.0",
79
+ "@genesislcap/foundation-logger": "14.445.0",
80
+ "@genesislcap/foundation-redux": "14.445.0",
81
+ "@genesislcap/foundation-ui": "14.445.0",
82
+ "@genesislcap/foundation-utils": "14.445.0",
83
+ "@genesislcap/rapid-design-system": "14.445.0",
84
+ "@genesislcap/web-core": "14.445.0",
85
85
  "dompurify": "^3.3.1",
86
86
  "marked": "^17.0.3"
87
87
  },
@@ -93,5 +93,5 @@
93
93
  "publishConfig": {
94
94
  "access": "public"
95
95
  },
96
- "gitHead": "837befe80b4972686cb48fe7d86334c5d819ced6"
96
+ "gitHead": "ab4f2ce242dedb41dcd82795abf7e0acaf14979c"
97
97
  }
@@ -75,4 +75,12 @@ export interface AiDriver extends EventTarget {
75
75
  * orchestrator just delegates to its inner `ChatDriver`.
76
76
  */
77
77
  getTurnSnapshots?(): ReadonlyArray<TurnSnapshot>;
78
+
79
+ /**
80
+ * Name of the AI provider used on the most recent turn. Falls back to the
81
+ * registry's default name when no turn has run yet.
82
+ *
83
+ * @beta
84
+ */
85
+ getActiveProviderName(): string;
78
86
  }
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AIProvider,
3
+ AIProviderRegistry,
3
4
  ChatAttachment,
4
5
  ChatDriverResult,
5
6
  ChatMessage,
@@ -14,11 +15,13 @@ import { MalformedFunctionCallError } from '@genesislcap/foundation-ai';
14
15
  import { agenticActivityBus } from '../../channel/ai-activity-bus';
15
16
  import type {
16
17
  AgentConfig,
18
+ ProviderInput,
17
19
  SystemPromptContext,
18
20
  SystemPromptInput,
19
21
  ToolDefinitionsInput,
20
22
  ToolHandlersInput,
21
23
  } from '../../config/config';
24
+ import { resolveChatProvider } from '../../config/validate-providers';
22
25
  import { applyHistoryCap } from '../../utils/history-transform';
23
26
  import { logger } from '../../utils/logger';
24
27
  import { TOOL_FOLD_SYMBOL, type ToolFold } from '../../utils/tool-fold';
@@ -205,8 +208,24 @@ export class ChatDriver extends EventTarget implements AiDriver {
205
208
  private debugSnapshotter?: () => unknown;
206
209
  private readonly maxTurnSnapshots: number;
207
210
 
211
+ /**
212
+ * Active agent's provider selector (static name or per-turn resolver).
213
+ * `undefined` means "use the registry default".
214
+ */
215
+ private activeProviderInput?: ProviderInput;
216
+ /**
217
+ * Caches validated provider lookups per name within the current agent. Cleared
218
+ * by `applyAgent` so each new agent's static/function-resolved names are
219
+ * validated fresh.
220
+ */
221
+ private resolvedProviderCache = new Map<string, AIProvider>();
222
+ /** Last successfully resolved provider name — drives `getActiveProviderName`. */
223
+ private lastResolvedProviderName?: string;
224
+ /** Last dispatched `provider-changed` name; avoids duplicate events on stable turns. */
225
+ private lastDispatchedProviderName?: string;
226
+
208
227
  constructor(
209
- private readonly aiProvider: AIProvider,
228
+ private readonly providerRegistry: AIProviderRegistry,
210
229
  toolHandlers: ToolHandlersInput = {},
211
230
  toolDefinitions: ToolDefinitionsInput = [],
212
231
  systemPrompt?: SystemPromptInput,
@@ -270,11 +289,72 @@ export class ChatDriver extends EventTarget implements AiDriver {
270
289
  typeof config.displayName === 'string' ? config.displayName : config.name;
271
290
  this.debugSnapshotter = config.getDebugSnapshot;
272
291
  this.subAgentsMap = new Map((config.subAgents ?? []).map((s) => [s.name, s]));
292
+ this.activeProviderInput = config.provider;
293
+ this.resolvedProviderCache.clear();
294
+ this.lastResolvedProviderName = undefined;
295
+ // Static validation: resolve the name now so unknown-provider and missing-
296
+ // capability errors fire at agent swap rather than on the first LLM call.
297
+ // Function-form `provider` is validated lazily inside `resolveProviderForTurn`.
298
+ if (typeof config.provider === 'string') {
299
+ this.resolveProviderByName(config.provider, config.name);
300
+ }
273
301
  // Reset fold state when agent changes — each specialist starts fresh
274
302
  this.foldStack = [];
275
303
  this.consecutiveFoldOps = 0;
276
304
  }
277
305
 
306
+ /**
307
+ * Returns the most recently resolved provider name. Falls back to the
308
+ * registry's default when no per-turn resolution has happened yet.
309
+ */
310
+ getActiveProviderName(): string {
311
+ return this.lastResolvedProviderName ?? this.providerRegistry.defaultName();
312
+ }
313
+
314
+ /**
315
+ * Resolve a named provider against the registry. Cached per-agent so
316
+ * repeated lookups during one agent's lifetime don't re-validate.
317
+ * Validation lives in `resolveChatProvider`; this wrapper just adds the
318
+ * cache.
319
+ */
320
+ private resolveProviderByName(name: string, agentName: string): AIProvider {
321
+ const cached = this.resolvedProviderCache.get(name);
322
+ if (cached) return cached;
323
+ const provider = resolveChatProvider(this.providerRegistry, name, agentName);
324
+ this.resolvedProviderCache.set(name, provider);
325
+ return provider;
326
+ }
327
+
328
+ /**
329
+ * Resolve the provider to use for the current turn. Walks the agent's
330
+ * `provider` selector (static or function form) or falls back to the
331
+ * registry default. Dispatches `provider-changed` when the resolved name
332
+ * differs from the last dispatched value.
333
+ */
334
+ private async resolveProviderForTurn(ctx: SystemPromptContext): Promise<AIProvider> {
335
+ let provider: AIProvider;
336
+ let resolvedName: string;
337
+ if (this.activeProviderInput === undefined) {
338
+ provider = this.providerRegistry.default();
339
+ resolvedName = this.providerRegistry.defaultName();
340
+ } else {
341
+ const name =
342
+ typeof this.activeProviderInput === 'function'
343
+ ? await this.activeProviderInput(ctx)
344
+ : this.activeProviderInput;
345
+ provider = this.resolveProviderByName(name, this.activeAgentName ?? '<unknown>');
346
+ resolvedName = name;
347
+ }
348
+ this.lastResolvedProviderName = resolvedName;
349
+ if (resolvedName !== this.lastDispatchedProviderName) {
350
+ this.lastDispatchedProviderName = resolvedName;
351
+ this.dispatchEvent(
352
+ new CustomEvent<{ name: string }>('provider-changed', { detail: { name: resolvedName } }),
353
+ );
354
+ }
355
+ return provider;
356
+ }
357
+
278
358
  /**
279
359
  * Returns the early-stop result set by `completeSubAgent`, if any.
280
360
  * Called by a parent `ChatDriver` after running this instance as a sub-agent.
@@ -362,8 +442,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
362
442
  count: number,
363
443
  allAgentInfo?: AllAgentSummary[],
364
444
  ): Promise<string[]> {
365
- if (!this.aiProvider.prompt) {
366
- logger.warn('ChatDriver: AIProvider does not implement prompt()');
445
+ // Suggestions are an out-of-turn UI helper, not bound to any single agent —
446
+ // always run against the registry default. Best-effort: a default with no
447
+ // `prompt()` just means no suggestions, not a hard error.
448
+ const defaultProvider = this.providerRegistry.default();
449
+ if (!defaultProvider.prompt) {
450
+ logger.warn('ChatDriver: default AI provider does not implement prompt()');
367
451
  return [];
368
452
  }
369
453
 
@@ -449,7 +533,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
449
533
  `- No preamble, headings, summary, or commentary before or after the list.`;
450
534
  }
451
535
 
452
- const text = await this.aiProvider.prompt!(userMessage, { systemPrompt });
536
+ const text = await defaultProvider.prompt!(userMessage, { systemPrompt });
453
537
 
454
538
  // Lenient parsing as a defensive backstop: even with the strict prompt,
455
539
  // models occasionally slip in numbering, bullets, or surrounding markdown.
@@ -585,10 +669,6 @@ export class ChatDriver extends EventTarget implements AiDriver {
585
669
 
586
670
  async sendMessage(userInput: string, attachments?: ChatAttachment[]): Promise<ChatDriverResult> {
587
671
  if (this.busy || (!userInput.trim() && !attachments?.length)) return { reason: 'done' };
588
- if (!this.aiProvider.chat) {
589
- logger.warn('ChatDriver: AIProvider does not implement chat()');
590
- return { reason: 'done' };
591
- }
592
672
 
593
673
  this.busy = true;
594
674
  this.subAgentCompletion = undefined;
@@ -697,7 +777,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
697
777
  ...(subConfig.primerHistory ?? []),
698
778
  ];
699
779
 
700
- const child = new ChatDriver(this.aiProvider);
780
+ const child = new ChatDriver(this.providerRegistry);
701
781
  child.applyAgent({ ...subConfig, primerHistory: effectivePrimer });
702
782
  // Route interactions back through this driver so widgets render in the
703
783
  // parent's (ultimately the root's) history and resolve via the same
@@ -750,10 +830,6 @@ export class ChatDriver extends EventTarget implements AiDriver {
750
830
  */
751
831
  async continueFromHistory(transientPrimer?: ChatMessage[]): Promise<ChatDriverResult> {
752
832
  if (this.busy) return { reason: 'done' };
753
- if (!this.aiProvider.chat) {
754
- logger.warn('ChatDriver: AIProvider does not implement chat()');
755
- return { reason: 'done' };
756
- }
757
833
 
758
834
  this.busy = true;
759
835
  this.subAgentCompletion = undefined;
@@ -1032,10 +1108,16 @@ export class ChatDriver extends EventTarget implements AiDriver {
1032
1108
  attachments: attachmentsForCall,
1033
1109
  };
1034
1110
 
1111
+ // Resolve the active provider for this turn. Static names were validated
1112
+ // in `applyAgent`; function-form names are validated on first resolution
1113
+ // here and cached for the agent's lifetime.
1114
+ // oxlint-disable-next-line no-await-in-loop
1115
+ const activeProvider = await this.resolveProviderForTurn(promptCtx);
1116
+
1035
1117
  let response: ChatMessage;
1036
1118
  try {
1037
1119
  // oxlint-disable-next-line no-await-in-loop
1038
- response = await this.aiProvider.chat!(historyForCall, userInputForCall, options);
1120
+ response = await activeProvider.chat!(historyForCall, userInputForCall, options);
1039
1121
  } catch (e) {
1040
1122
  if (e instanceof MalformedFunctionCallError) {
1041
1123
  malformedAttempts += 1;