@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
@@ -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.
@@ -5,6 +5,7 @@ import type {
5
5
  AgentLifecycleContext,
6
6
  ChatInputDuringExecutionMode,
7
7
  ManualSelectionConfig,
8
+ ProviderInput,
8
9
  SystemPromptContext,
9
10
  SystemPromptInput,
10
11
  ToolDefinitionsInput,
@@ -116,6 +117,16 @@ export interface StatefulAgentInit<S> {
116
117
  */
117
118
  toolHandlers?: (state: S) => ChatToolHandlers | Promise<ChatToolHandlers>;
118
119
 
120
+ /**
121
+ * Which registered AI provider this agent uses, by name. Either a static
122
+ * string or a function resolved each tool-loop iteration with the current
123
+ * `state` — pick the function form to vary provider per machine state
124
+ * (e.g. swap to a heavier model only for the planning step).
125
+ *
126
+ * Omit to use the registry's default provider. Unknown names hard-error.
127
+ */
128
+ provider?: string | ((ctx: StatefulAgentContext<S>) => string | Promise<string>);
129
+
119
130
  /**
120
131
  * Optional getter for the debug-log snapshot. Defaults to auto-snapshotting
121
132
  * any property on `state` that looks like a foundation-state-machine
@@ -246,6 +257,22 @@ export function defineStatefulAgent<S>(opts: StatefulAgentInit<S>): AgentConfig
246
257
  }
247
258
  : undefined;
248
259
 
260
+ // Function-form `provider` needs `state` injected the same way the other
261
+ // per-turn resolvers do; the static-string form is passed through unchanged
262
+ // and validated up-front by OrchestratingDriver.
263
+ const wrappedProvider: ProviderInput | undefined =
264
+ typeof opts.provider === 'function'
265
+ ? async (ctx: SystemPromptContext) => {
266
+ if (!state) {
267
+ throw new Error(`Stateful agent "${opts.name}" provider called before init`);
268
+ }
269
+ return (opts.provider as (ctx: StatefulAgentContext<S>) => string | Promise<string>)({
270
+ ...ctx,
271
+ state,
272
+ });
273
+ }
274
+ : opts.provider;
275
+
249
276
  const base = {
250
277
  name: opts.name,
251
278
  displayName: wrappedDisplayName,
@@ -255,6 +282,7 @@ export function defineStatefulAgent<S>(opts: StatefulAgentInit<S>): AgentConfig
255
282
  chatInputDuringExecution: opts.chatInputDuringExecution,
256
283
  toolDefinitions: wrappedTools,
257
284
  toolHandlers: wrappedHandlers,
285
+ provider: wrappedProvider,
258
286
 
259
287
  onActivate: async (ctx: AgentLifecycleContext) => {
260
288
  state = await opts.init(ctx);
@@ -0,0 +1,148 @@
1
+ import type { AIProvider, AIProviderRegistry } from '@genesislcap/foundation-ai';
2
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
+ import type { AgentConfig } from './config';
4
+ import { resolveChatProvider, validateStaticAgentProviders } from './validate-providers';
5
+
6
+ const chatProvider = (): AIProvider => ({
7
+ chat: async () => ({ role: 'assistant', content: '' }),
8
+ });
9
+
10
+ const promptOnlyProvider = (): AIProvider => ({
11
+ prompt: async () => '',
12
+ });
13
+
14
+ /**
15
+ * Minimal in-test registry — implements only what the validation helpers
16
+ * touch (`get`, `names`). Avoids depending on foundation-ai's concrete
17
+ * registry impl, and keeps these tests Node-runnable.
18
+ */
19
+ const makeRegistry = (providers: Record<string, AIProvider>): AIProviderRegistry => {
20
+ const map = new Map(Object.entries(providers));
21
+ return {
22
+ get: (name) => map.get(name),
23
+ default: () => map.values().next().value as AIProvider,
24
+ defaultName: () => map.keys().next().value as string,
25
+ names: () => [...map.keys()],
26
+ getStatus: async () => null,
27
+ listStatuses: async () => [],
28
+ };
29
+ };
30
+
31
+ const specialist = (overrides: Partial<AgentConfig> & { name: string }): AgentConfig =>
32
+ ({
33
+ name: overrides.name,
34
+ description: 'test specialist',
35
+ ...overrides,
36
+ }) as AgentConfig;
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // resolveChatProvider
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const resolve = createLogicSuite('resolveChatProvider');
43
+
44
+ resolve('returns the provider when the name is registered and has chat()', () => {
45
+ const fast = chatProvider();
46
+ const registry = makeRegistry({ fast });
47
+ assert.is(resolveChatProvider(registry, 'fast', 'A'), fast);
48
+ });
49
+
50
+ resolve('throws on an unknown provider name with the agent name in the message', () => {
51
+ const registry = makeRegistry({ fast: chatProvider() });
52
+ assert.throws(
53
+ () => resolveChatProvider(registry, 'deep', 'A'),
54
+ /Agent "A" references unknown provider "deep"/,
55
+ );
56
+ });
57
+
58
+ resolve('throws including the registered names so the misconfiguration is debuggable', () => {
59
+ const registry = makeRegistry({
60
+ fast: chatProvider(),
61
+ deep: chatProvider(),
62
+ cheap: chatProvider(),
63
+ });
64
+ assert.throws(() => resolveChatProvider(registry, 'typo', 'A'), /Registered: fast, deep, cheap/);
65
+ });
66
+
67
+ resolve('throws when the resolved provider does not implement chat()', () => {
68
+ const registry = makeRegistry({ chrome: promptOnlyProvider() });
69
+ assert.throws(
70
+ () => resolveChatProvider(registry, 'chrome', 'A'),
71
+ /Agent "A" resolved to provider "chrome" which does not implement chat\(\)/,
72
+ );
73
+ });
74
+
75
+ resolve.run();
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // validateStaticAgentProviders
79
+ // ---------------------------------------------------------------------------
80
+
81
+ const validate = createLogicSuite('validateStaticAgentProviders');
82
+
83
+ validate('accepts agents with no `provider` field (back-compat path)', () => {
84
+ const registry = makeRegistry({ default: chatProvider() });
85
+ assert.not.throws(() => validateStaticAgentProviders([specialist({ name: 'A' })], registry));
86
+ });
87
+
88
+ validate('accepts agents that name a registered chat-capable provider', () => {
89
+ const registry = makeRegistry({ fast: chatProvider(), deep: chatProvider() });
90
+ assert.not.throws(() =>
91
+ validateStaticAgentProviders([specialist({ name: 'A', provider: 'deep' })], registry),
92
+ );
93
+ });
94
+
95
+ validate('throws when a top-level agent references an unknown provider', () => {
96
+ const registry = makeRegistry({ fast: chatProvider() });
97
+ assert.throws(
98
+ () => validateStaticAgentProviders([specialist({ name: 'A', provider: 'deep' })], registry),
99
+ /Agent "A" references unknown provider "deep"/,
100
+ );
101
+ });
102
+
103
+ validate('recurses into subAgents — bad provider on a sub-agent throws', () => {
104
+ const registry = makeRegistry({ fast: chatProvider() });
105
+ const sub = specialist({ name: 'Sub', provider: 'missing' });
106
+ assert.throws(
107
+ () => validateStaticAgentProviders([specialist({ name: 'A', subAgents: [sub] })], registry),
108
+ /Agent "Sub" references unknown provider "missing"/,
109
+ );
110
+ });
111
+
112
+ validate(
113
+ 'recurses through multiple levels — bad provider on a deeply-nested sub-agent throws',
114
+ () => {
115
+ const registry = makeRegistry({ fast: chatProvider() });
116
+ const deepest = specialist({ name: 'Deepest', provider: 'missing' });
117
+ const middle = specialist({ name: 'Middle', subAgents: [deepest] });
118
+ assert.throws(
119
+ () =>
120
+ validateStaticAgentProviders([specialist({ name: 'Top', subAgents: [middle] })], registry),
121
+ /Agent "Deepest" references unknown provider "missing"/,
122
+ );
123
+ },
124
+ );
125
+
126
+ validate('defers function-form `provider` — it is not invoked at validation time', () => {
127
+ const registry = makeRegistry({ fast: chatProvider() });
128
+ const agent = specialist({
129
+ name: 'A',
130
+ // If validateStaticAgentProviders eagerly resolved this, it would throw
131
+ // here. The contract is that function-form is deferred to the driver's
132
+ // per-turn resolver.
133
+ provider: () => {
134
+ throw new Error('function-form provider should not be invoked at validation time');
135
+ },
136
+ });
137
+ assert.not.throws(() => validateStaticAgentProviders([agent], registry));
138
+ });
139
+
140
+ validate('shared sub-agent across two parents validates cleanly', () => {
141
+ const registry = makeRegistry({ fast: chatProvider() });
142
+ const shared = specialist({ name: 'Shared', provider: 'fast' });
143
+ const parentA = specialist({ name: 'ParentA', subAgents: [shared] });
144
+ const parentB = specialist({ name: 'ParentB', subAgents: [shared] });
145
+ assert.not.throws(() => validateStaticAgentProviders([parentA, parentB], registry));
146
+ });
147
+
148
+ validate.run();
@@ -0,0 +1,58 @@
1
+ import type { AIProvider, AIProviderRegistry } from '@genesislcap/foundation-ai';
2
+ import type { AgentConfig } from './config';
3
+
4
+ /**
5
+ * Resolve a provider name against the registry and assert it implements
6
+ * `chat()` — the capability the chat tool-loop depends on. Throws with a
7
+ * message naming the agent so misconfigured agents are easy to track down.
8
+ *
9
+ * Used by both the up-front static walk in `OrchestratingDriver` and by
10
+ * `ChatDriver`'s per-turn resolver for function-form `provider` values.
11
+ *
12
+ * @internal
13
+ */
14
+ export function resolveChatProvider(
15
+ registry: AIProviderRegistry,
16
+ providerName: string,
17
+ agentName: string,
18
+ ): AIProvider {
19
+ const provider = registry.get(providerName);
20
+ if (!provider) {
21
+ const available = registry.names().join(', ') || '(none)';
22
+ throw new Error(
23
+ `Agent "${agentName}" references unknown provider "${providerName}". Registered: ${available}`,
24
+ );
25
+ }
26
+ if (!provider.chat) {
27
+ throw new Error(
28
+ `Agent "${agentName}" resolved to provider "${providerName}" which does not implement chat().`,
29
+ );
30
+ }
31
+ return provider;
32
+ }
33
+
34
+ /**
35
+ * Walk an agent tree (including nested `subAgents`) and validate every static
36
+ * `provider: string` against the registry. Function-form `provider` values are
37
+ * deferred — they validate on first resolution inside the driver.
38
+ *
39
+ * Throws on the first failure, with the same message shape produced by
40
+ * {@link resolveChatProvider}.
41
+ *
42
+ * @internal
43
+ */
44
+ export function validateStaticAgentProviders(
45
+ agents: AgentConfig[],
46
+ registry: AIProviderRegistry,
47
+ ): void {
48
+ const visited = new Set<AgentConfig>();
49
+ const walk = (agent: AgentConfig): void => {
50
+ if (visited.has(agent)) return;
51
+ visited.add(agent);
52
+ if (typeof agent.provider === 'string') {
53
+ resolveChatProvider(registry, agent.provider, agent.name);
54
+ }
55
+ for (const sub of agent.subAgents ?? []) walk(sub);
56
+ };
57
+ for (const root of agents) walk(root);
58
+ }
@@ -257,6 +257,58 @@ export const styles = css`
257
257
  font-family: var(--monospace-font, ui-monospace, monospace);
258
258
  }
259
259
 
260
+ .provider-list {
261
+ animation: settings-slide-in 0.2s ease-out;
262
+ padding: calc(var(--design-unit) * 1.5px) calc(var(--design-unit) * 3px);
263
+ border-bottom: 1px solid var(--neutral-stroke-rest);
264
+ background-color: var(--neutral-layer-2);
265
+ display: flex;
266
+ flex-direction: column;
267
+ gap: calc(var(--design-unit) * 1px);
268
+ }
269
+
270
+ .provider-list-label {
271
+ font-size: 0.85em;
272
+ font-weight: 600;
273
+ color: var(--neutral-foreground-rest);
274
+ }
275
+
276
+ .provider-list-row {
277
+ display: flex;
278
+ align-items: baseline;
279
+ gap: calc(var(--design-unit) * 1.5px);
280
+ font-size: 0.8em;
281
+ color: var(--neutral-foreground-hint, var(--neutral-foreground-rest));
282
+ opacity: 80%;
283
+ }
284
+
285
+ .provider-list-row.is-current {
286
+ opacity: 100%;
287
+ color: var(--neutral-foreground-rest);
288
+ }
289
+
290
+ .provider-list-name {
291
+ font-weight: 600;
292
+ }
293
+
294
+ .provider-list-model {
295
+ font-family: var(--monospace-font, ui-monospace, monospace);
296
+ flex: 1;
297
+ overflow: hidden;
298
+ text-overflow: ellipsis;
299
+ white-space: nowrap;
300
+ }
301
+
302
+ .provider-list-badge {
303
+ font-size: 0.7em;
304
+ text-transform: uppercase;
305
+ letter-spacing: 0.05em;
306
+ padding: 0 calc(var(--design-unit) * 0.75px);
307
+ border-radius: calc(var(--design-unit) * 0.5px);
308
+ background-color: var(--neutral-layer-3);
309
+ color: var(--neutral-foreground-rest);
310
+ }
311
+
260
312
  .splash-wrapper {
261
313
  position: absolute;
262
314
  inset: 0;
@@ -15,7 +15,12 @@
15
15
  * lines let the formatter inject whitespace between them.
16
16
  */
17
17
 
18
- import type { ChatAttachment, ChatMessage, ChatToolCall } from '@genesislcap/foundation-ai';
18
+ import type {
19
+ AIProviderRegistryStatusEntry,
20
+ ChatAttachment,
21
+ ChatMessage,
22
+ ChatToolCall,
23
+ } from '@genesislcap/foundation-ai';
19
24
  import { isChatToolCallUnknown } from '@genesislcap/foundation-ai';
20
25
  import { html, ref, repeat, when, ViewTemplate } from '@genesislcap/web-core';
21
26
  import type { FoundationAiAssistant } from './main';
@@ -500,7 +505,10 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
500
505
  )}
501
506
  ${when(
502
507
  (x) =>
503
- x.settingsOpen && x.chatConfig.ui?.showActiveModel !== false && x.activeModel != null,
508
+ x.settingsOpen &&
509
+ x.chatConfig.ui?.showActiveModel !== false &&
510
+ x.providerStatuses.length <= 1 &&
511
+ x.activeModel != null,
504
512
  html<FoundationAiAssistant>`
505
513
  <div class="session-stat" part="active-model">
506
514
  <span class="session-stat-label">Model</span>
@@ -508,6 +516,46 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
508
516
  </div>
509
517
  `,
510
518
  )}
519
+ ${when(
520
+ (x) =>
521
+ x.settingsOpen &&
522
+ x.chatConfig.ui?.showActiveModel !== false &&
523
+ x.providerStatuses.length > 1,
524
+ html<FoundationAiAssistant>`
525
+ <div class="provider-list" part="provider-list">
526
+ <div class="provider-list-label">Providers</div>
527
+ ${repeat(
528
+ (x) => x.providerStatuses,
529
+ html<AIProviderRegistryStatusEntry, FoundationAiAssistant>`
530
+ <div
531
+ class="provider-list-row ${(entry, c) =>
532
+ entry.name === c.parent.activeProviderName ? 'is-current' : ''}"
533
+ part="provider-list-row"
534
+ >
535
+ <span class="provider-list-name">${(entry) => entry.name}</span>
536
+ <span class="provider-list-model">${(entry) => entry.status?.model ?? '—'}</span>
537
+ ${when(
538
+ (entry) => entry.isDefault,
539
+ html<AIProviderRegistryStatusEntry>`
540
+ <span class="provider-list-badge" part="provider-list-default-badge">
541
+ default
542
+ </span>
543
+ `,
544
+ )}
545
+ ${when(
546
+ (entry, c) => entry.name === c.parent.activeProviderName,
547
+ html<AIProviderRegistryStatusEntry>`
548
+ <span class="provider-list-badge" part="provider-list-current-badge">
549
+ current
550
+ </span>
551
+ `,
552
+ )}
553
+ </div>
554
+ `,
555
+ )}
556
+ </div>
557
+ `,
558
+ )}
511
559
  ${when(
512
560
  (x) => x.settingsOpen && x.chatConfig.ui?.showSessionCost !== false && x.sessionCostUsd > 0,
513
561
  html<FoundationAiAssistant>`
package/src/main/main.ts CHANGED
@@ -22,12 +22,13 @@
22
22
  // =============================================================================
23
23
 
24
24
  import type {
25
+ AIProviderRegistryStatusEntry,
25
26
  ChatAttachment,
26
27
  ChatConfig,
27
28
  ChatInputDuringExecutionMode,
28
29
  ChatMessage,
29
30
  } from '@genesislcap/foundation-ai';
30
- import { AIProvider } from '@genesislcap/foundation-ai';
31
+ import { AIProviderRegistry } from '@genesislcap/foundation-ai';
31
32
  import { avoidTreeShaking } from '@genesislcap/foundation-utils';
32
33
  import {
33
34
  customElement,
@@ -138,8 +139,11 @@ function stripHandlers(agent: AgentConfig): Omit<AgentConfig, 'toolHandlers'> {
138
139
  * Foundation AI Assistant component.
139
140
  *
140
141
  * @remarks
141
- * Inject an `AIProvider` through the DI container. Pass agent configuration via the `agents`
142
- * property. The component creates a `ChatDriver` (single agent) or `OrchestratingDriver`
142
+ * Register one or more AI providers via `registerAIProviders` from
143
+ * `@genesislcap/foundation-ai`; this element resolves the `AIProviderRegistry`
144
+ * through the DI container. Pass agent configuration via the `agents` property —
145
+ * each agent can override which registered provider it uses via `provider:`.
146
+ * The component creates a `ChatDriver` (single agent) or `OrchestratingDriver`
143
147
  * (multiple agents) to manage the conversation loop.
144
148
  *
145
149
  * Popout/collapse coordination uses `agenticActivityBus` topics `chat-popout` and `chat-popin` — not DOM `CustomEvent`s on this element.
@@ -156,7 +160,7 @@ function stripHandlers(agent: AgentConfig): Omit<AgentConfig, 'toolHandlers'> {
156
160
  styles,
157
161
  })
158
162
  export class FoundationAiAssistant extends GenesisElement {
159
- @AIProvider aiProvider!: AIProvider;
163
+ @AIProviderRegistry providerRegistry!: AIProviderRegistry;
160
164
 
161
165
  @observable designSystemPrefix: string = 'rapid';
162
166
  @attr({ attribute: 'header-title' }) headerTitle: string = 'Genesis Assistant';
@@ -439,6 +443,22 @@ export class FoundationAiAssistant extends GenesisElement {
439
443
  this._sessionRef?.actions.aiAssistant.setActiveModel(value);
440
444
  }
441
445
 
446
+ /** Name of the AI provider used on the most recent turn. */
447
+ get activeProviderName(): string | undefined {
448
+ return this._sessionRef?.store.aiAssistant.activeProviderName;
449
+ }
450
+ set activeProviderName(value: string | undefined) {
451
+ this._sessionRef?.actions.aiAssistant.setActiveProviderName(value);
452
+ }
453
+
454
+ /** Per-provider status snapshot used by the settings panel. */
455
+ get providerStatuses(): AIProviderRegistryStatusEntry[] {
456
+ return this._sessionRef?.store.aiAssistant.providerStatuses ?? [];
457
+ }
458
+ set providerStatuses(value: AIProviderRegistryStatusEntry[]) {
459
+ this._sessionRef?.actions.aiAssistant.setProviderStatuses(value);
460
+ }
461
+
442
462
  // ---- Transient UI state (stays as @observable on the component) ----
443
463
 
444
464
  private _suggestionsGeneration = 0;
@@ -698,7 +718,7 @@ export class FoundationAiAssistant extends GenesisElement {
698
718
  // this via `agent-changed` once applyAgent fires on first sendMessage.
699
719
  this.activeAgent = agents[0];
700
720
  }
701
- return new OrchestratingDriver(this.aiProvider, agents, {
721
+ return new OrchestratingDriver(this.providerRegistry, agents, {
702
722
  sessionKey: this.getStateKey() ?? '',
703
723
  maxHandoffs: agent.maxHandoffs,
704
724
  classifierHistoryLength: agent.classifierHistoryLength,
@@ -710,7 +730,7 @@ export class FoundationAiAssistant extends GenesisElement {
710
730
  }
711
731
 
712
732
  return new ChatDriver(
713
- this.aiProvider,
733
+ this.providerRegistry,
714
734
  {},
715
735
  [],
716
736
  undefined,
@@ -781,11 +801,19 @@ export class FoundationAiAssistant extends GenesisElement {
781
801
  this._sessionRef?.actions.aiAssistant.removeInputOverride({ id: interactionId });
782
802
  }
783
803
  };
804
+ const onProviderChanged = (e: Event) => {
805
+ const { name } = (e as CustomEvent<{ name: string }>).detail;
806
+ this.activeProviderName = name;
807
+ // Status (model id / context limit) belongs to the active provider — refresh
808
+ // the displayed values so the header reflects the provider that just took over.
809
+ void this.resolveContextLimit();
810
+ };
784
811
  driver.addEventListener('sub-agent-history-updated', onSubAgentHistoryUpdated);
785
812
  driver.addEventListener('sub-agent-start', onSubAgentStart);
786
813
  driver.addEventListener('sub-agent-stop', onSubAgentStop);
787
814
  driver.addEventListener('interaction-start', onInteractionStart);
788
815
  driver.addEventListener('interaction-stop', onInteractionStop);
816
+ driver.addEventListener('provider-changed', onProviderChanged);
789
817
 
790
818
  const cleanups: (() => void)[] = [
791
819
  () => driver.removeEventListener('history-updated', onHistoryUpdated),
@@ -794,6 +822,7 @@ export class FoundationAiAssistant extends GenesisElement {
794
822
  () => driver.removeEventListener('sub-agent-stop', onSubAgentStop),
795
823
  () => driver.removeEventListener('interaction-start', onInteractionStart),
796
824
  () => driver.removeEventListener('interaction-stop', onInteractionStop),
825
+ () => driver.removeEventListener('provider-changed', onProviderChanged),
797
826
  ];
798
827
 
799
828
  if (driver instanceof OrchestratingDriver) {
@@ -935,6 +964,7 @@ export class FoundationAiAssistant extends GenesisElement {
935
964
  }
936
965
  this.fetchSuggestions();
937
966
  void this.resolveContextLimit();
967
+ void this.loadProviderStatuses();
938
968
  if (this.messagesEl) {
939
969
  this._scrollListener = () => {
940
970
  this._userScrolledAway =
@@ -983,7 +1013,13 @@ export class FoundationAiAssistant extends GenesisElement {
983
1013
 
984
1014
  private async resolveContextLimit(): Promise<void> {
985
1015
  try {
986
- const status = await this.aiProvider.getStatus?.();
1016
+ // Resolve status for the currently active provider when a driver is up
1017
+ // (so per-agent provider swaps update the displayed model/limit); fall
1018
+ // back to the registry default at startup before any turn has run.
1019
+ const activeName = this.driver?.getActiveProviderName();
1020
+ const status = activeName
1021
+ ? await this.providerRegistry.getStatus(activeName)
1022
+ : await this.providerRegistry.getStatus();
987
1023
  this.contextLimit = status?.contextLimit;
988
1024
  this.activeModel = status?.model;
989
1025
  } catch {
@@ -991,6 +1027,14 @@ export class FoundationAiAssistant extends GenesisElement {
991
1027
  }
992
1028
  }
993
1029
 
1030
+ private async loadProviderStatuses(): Promise<void> {
1031
+ try {
1032
+ this.providerStatuses = await this.providerRegistry.listStatuses();
1033
+ } catch {
1034
+ // Non-fatal — settings panel just won't list providers
1035
+ }
1036
+ }
1037
+
994
1038
  chatConfigChanged() {
995
1039
  this.syncShowingSplash();
996
1040
  }