@genesislcap/ai-assistant 14.444.1 → 14.445.1

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 (55) 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/dts/utils/sum-costs.d.ts +13 -0
  26. package/dist/dts/utils/sum-costs.d.ts.map +1 -0
  27. package/dist/dts/utils/sum-costs.test.d.ts +2 -0
  28. package/dist/dts/utils/sum-costs.test.d.ts.map +1 -0
  29. package/dist/esm/components/chat-driver/chat-driver.js +93 -15
  30. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +23 -4
  31. package/dist/esm/config/define-stateful-agent.js +12 -0
  32. package/dist/esm/config/validate-providers.js +47 -0
  33. package/dist/esm/config/validate-providers.test.js +100 -0
  34. package/dist/esm/main/main.js +76 -21
  35. package/dist/esm/main/main.styles.js +52 -0
  36. package/dist/esm/main/main.template.js +36 -1
  37. package/dist/esm/state/ai-assistant-slice.js +8 -0
  38. package/dist/esm/utils/sum-costs.js +23 -0
  39. package/dist/esm/utils/sum-costs.test.js +88 -0
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/docs/migration-GENC-1262.md +219 -0
  42. package/package.json +16 -16
  43. package/src/components/ai-driver/ai-driver.ts +8 -0
  44. package/src/components/chat-driver/chat-driver.ts +107 -14
  45. package/src/components/orchestrating-driver/orchestrating-driver.ts +29 -4
  46. package/src/config/config.ts +32 -0
  47. package/src/config/define-stateful-agent.ts +28 -0
  48. package/src/config/validate-providers.test.ts +148 -0
  49. package/src/config/validate-providers.ts +58 -0
  50. package/src/main/main.styles.ts +52 -0
  51. package/src/main/main.template.ts +50 -2
  52. package/src/main/main.ts +69 -14
  53. package/src/state/ai-assistant-slice.ts +24 -1
  54. package/src/utils/sum-costs.test.ts +108 -0
  55. package/src/utils/sum-costs.ts +22 -0
@@ -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.1",
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.1",
68
+ "@genesislcap/genx": "14.445.1",
69
+ "@genesislcap/rollup-builder": "14.445.1",
70
+ "@genesislcap/ts-builder": "14.445.1",
71
+ "@genesislcap/uvu-playwright-builder": "14.445.1",
72
+ "@genesislcap/vite-builder": "14.445.1",
73
+ "@genesislcap/webpack-builder": "14.445.1",
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.1",
79
+ "@genesislcap/foundation-logger": "14.445.1",
80
+ "@genesislcap/foundation-redux": "14.445.1",
81
+ "@genesislcap/foundation-ui": "14.445.1",
82
+ "@genesislcap/foundation-utils": "14.445.1",
83
+ "@genesislcap/rapid-design-system": "14.445.1",
84
+ "@genesislcap/web-core": "14.445.1",
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": "893e8b2ffadacaedb39f1935c1979278191b6730"
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
@@ -715,7 +795,17 @@ export class ChatDriver extends EventTarget implements AiDriver {
715
795
  }),
716
796
  );
717
797
  };
798
+ // Re-dispatch the child's `provider-changed` so the UI reflects whichever
799
+ // provider is *actually* running right now (the sub-agent may use a
800
+ // different provider than the parent). Restoration of the parent's
801
+ // provider on `sub-agent-stop` is handled by the listener in main.ts.
802
+ const forwardProviderChanged = (e: Event) => {
803
+ this.dispatchEvent(
804
+ new CustomEvent('provider-changed', { detail: (e as CustomEvent).detail }),
805
+ );
806
+ };
718
807
  child.addEventListener('history-updated', forwardTrace);
808
+ child.addEventListener('provider-changed', forwardProviderChanged);
719
809
 
720
810
  // Unique per-invocation id so listeners can pair start/stop reliably even
721
811
  // when the same sub-agent runs multiple times in parallel.
@@ -728,6 +818,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
728
818
  await child.sendMessage(task ?? '');
729
819
  } finally {
730
820
  child.removeEventListener('history-updated', forwardTrace);
821
+ child.removeEventListener('provider-changed', forwardProviderChanged);
731
822
  this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: lifecycleDetail }));
732
823
  }
733
824
 
@@ -750,10 +841,6 @@ export class ChatDriver extends EventTarget implements AiDriver {
750
841
  */
751
842
  async continueFromHistory(transientPrimer?: ChatMessage[]): Promise<ChatDriverResult> {
752
843
  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
844
 
758
845
  this.busy = true;
759
846
  this.subAgentCompletion = undefined;
@@ -1032,10 +1119,16 @@ export class ChatDriver extends EventTarget implements AiDriver {
1032
1119
  attachments: attachmentsForCall,
1033
1120
  };
1034
1121
 
1122
+ // Resolve the active provider for this turn. Static names were validated
1123
+ // in `applyAgent`; function-form names are validated on first resolution
1124
+ // here and cached for the agent's lifetime.
1125
+ // oxlint-disable-next-line no-await-in-loop
1126
+ const activeProvider = await this.resolveProviderForTurn(promptCtx);
1127
+
1035
1128
  let response: ChatMessage;
1036
1129
  try {
1037
1130
  // oxlint-disable-next-line no-await-in-loop
1038
- response = await this.aiProvider.chat!(historyForCall, userInputForCall, options);
1131
+ response = await activeProvider.chat!(historyForCall, userInputForCall, options);
1039
1132
  } catch (e) {
1040
1133
  if (e instanceof MalformedFunctionCallError) {
1041
1134
  malformedAttempts += 1;
@@ -1,5 +1,5 @@
1
1
  import type {
2
- AIProvider,
2
+ AIProviderRegistry,
3
3
  ChatAttachment,
4
4
  ChatDriverResult,
5
5
  ChatMessage,
@@ -12,6 +12,7 @@ import type {
12
12
  SystemPromptContext,
13
13
  SystemPromptInput,
14
14
  } from '../../config/config';
15
+ import { validateStaticAgentProviders } from '../../config/validate-providers';
15
16
  import { transformHistoryForAgent } from '../../utils/history-transform';
16
17
  import { logger } from '../../utils/logger';
17
18
  import type { AiDriver, AllAgentSummary } from '../ai-driver/ai-driver';
@@ -110,7 +111,7 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
110
111
  activeAgent?: AgentConfig;
111
112
 
112
113
  constructor(
113
- private readonly aiProvider: AIProvider,
114
+ private readonly providerRegistry: AIProviderRegistry,
114
115
  private readonly agents: AgentConfig[],
115
116
  options: {
116
117
  sessionKey?: string;
@@ -129,6 +130,11 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
129
130
  options.classifierHistoryLength ?? DEFAULT_CLASSIFIER_HISTORY_LENGTH;
130
131
  this.classifierRetries = options.classifierRetries ?? DEFAULT_CLASSIFIER_RETRIES;
131
132
 
133
+ // Static-name validation: walk every agent (and nested sub-agents). Any
134
+ // `provider: '<name>'` that isn't registered, or resolves to a provider
135
+ // without `chat()`, throws now rather than at first turn.
136
+ validateStaticAgentProviders(agents, providerRegistry);
137
+
132
138
  // Specialists drive the classifier. `excludeFromClassifier` agents are still
133
139
  // resolvable by name (so manual pinning works) but never auto-routed.
134
140
  this.specialists = agents.filter(isSpecialist).filter((a) => !a.excludeFromClassifier);
@@ -145,7 +151,7 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
145
151
  : undefined;
146
152
 
147
153
  this.chatDriver = new ChatDriver(
148
- aiProvider,
154
+ providerRegistry,
149
155
  {},
150
156
  [],
151
157
  undefined,
@@ -180,6 +186,11 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
180
186
  new CustomEvent('interaction-stop', { detail: (e as CustomEvent).detail }),
181
187
  );
182
188
  });
189
+ this.chatDriver.addEventListener('provider-changed', (e: Event) => {
190
+ this.dispatchEvent(
191
+ new CustomEvent('provider-changed', { detail: (e as CustomEvent).detail }),
192
+ );
193
+ });
183
194
  }
184
195
 
185
196
  resolveInteraction(interactionId: string, result: unknown): void {
@@ -190,6 +201,11 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
190
201
  return this.chatDriver.isBusy();
191
202
  }
192
203
 
204
+ /** Currently active provider name from the underlying ChatDriver. */
205
+ getActiveProviderName(): string {
206
+ return this.chatDriver.getActiveProviderName();
207
+ }
208
+
193
209
  /**
194
210
  * Pins routing to a specific agent by name. While pinned, the classifier is
195
211
  * skipped and the continuation tool is hidden from the agent's tool list, so
@@ -530,8 +546,17 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
530
546
  systemPrompt: classifierPrompt,
531
547
  tools: [routingTool],
532
548
  };
549
+ // Classification is orchestrator-level, not bound to any single agent —
550
+ // always run against the registry default.
551
+ const classifierProvider = this.providerRegistry.default();
552
+ if (!classifierProvider.chat) {
553
+ logger.warn(
554
+ 'OrchestratingDriver: default AI provider does not implement chat() — cannot classify.',
555
+ );
556
+ break;
557
+ }
533
558
  // oxlint-disable-next-line no-await-in-loop
534
- const response = await this.aiProvider.chat!([], input, options);
559
+ const response = await classifierProvider.chat([], input, options);
535
560
  const tc = response.toolCalls?.[0];
536
561
  const index = tc?.name === 'select_agent' ? (tc.args.agent_index as number) : -1;
537
562
 
@@ -75,6 +75,20 @@ export type ToolHandlersInput =
75
75
  | ChatToolHandlers
76
76
  | ((ctx: SystemPromptContext) => ChatToolHandlers | Promise<ChatToolHandlers>);
77
77
 
78
+ /**
79
+ * Names which registered `AIProviderRegistry` entry an agent uses. Either
80
+ * a static string (resolved once at agent registration) or a function resolved
81
+ * each tool-loop iteration — pick the function form to vary the provider by
82
+ * current state (e.g. a stateful agent that switches providers between
83
+ * machine states).
84
+ *
85
+ * Unknown provider names hard-error: static names at agent registration time,
86
+ * function-resolved names on first resolution.
87
+ *
88
+ * @beta
89
+ */
90
+ export type ProviderInput = string | ((ctx: SystemPromptContext) => string | Promise<string>);
91
+
78
92
  /**
79
93
  * Opts an agent in to manual selection from the assistant's agent picker.
80
94
  *
@@ -138,6 +152,24 @@ interface BaseAgentConfig {
138
152
  * See {@link ToolHandlersInput}.
139
153
  */
140
154
  toolHandlers?: ToolHandlersInput;
155
+ /**
156
+ * Which registered AI provider this agent uses, by name. Either a static
157
+ * string (resolved once at agent registration) or a function resolved each
158
+ * tool-loop iteration — pick the function form to vary the provider by
159
+ * current state (e.g. a stateful agent that switches providers between
160
+ * machine states).
161
+ *
162
+ * Omit to use the registry's default provider. Unknown names hard-error:
163
+ * static names at registration time, function-resolved names on first
164
+ * resolution.
165
+ *
166
+ * Known limitation: switching provider mid-conversation can blow the
167
+ * smaller provider's context window if history accumulated under a
168
+ * larger one. No automatic trimming is performed.
169
+ *
170
+ * @beta
171
+ */
172
+ provider?: ProviderInput;
141
173
  /**
142
174
  * Optional primer history prepended to every call (not visible to the user).
143
175
  * Used to establish agent identity and behavioural rules.