@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
@@ -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,
@@ -59,6 +60,7 @@ import {
59
60
  import { ChatSuggestions } from '../suggestions/chat-suggestions';
60
61
  import { AnimatedPanelToggle } from '../utils/animated-panel-toggle';
61
62
  import { logger } from '../utils/logger';
63
+ import { sumCosts } from '../utils/sum-costs';
62
64
  import { expandToolTree } from '../utils/tool-fold';
63
65
  import { styles } from './main.styles';
64
66
  import { FoundationAiAssistantTemplate } from './main.template';
@@ -107,9 +109,9 @@ avoidTreeShaking(
107
109
  * - `toolHandlers` (functions),
108
110
  * - `onActivate` / `onDeactivate` (lifecycle hooks, functions),
109
111
  * - `getDebugSnapshot` (function),
110
- * - function-form `systemPrompt` / `toolDefinitions` / `displayName` (downgraded
111
- * to `undefined` in the snapshot — the live config on the driver is still
112
- * the source of truth; the slice only stores a serializable projection).
112
+ * - function-form `systemPrompt` / `toolDefinitions` / `displayName` / `provider`
113
+ * (downgraded to `undefined` in the snapshot — the live config on the driver
114
+ * is still the source of truth; the slice only stores a serializable projection).
113
115
  */
114
116
  function stripHandlers(agent: AgentConfig): Omit<AgentConfig, 'toolHandlers'> {
115
117
  const {
@@ -121,6 +123,7 @@ function stripHandlers(agent: AgentConfig): Omit<AgentConfig, 'toolHandlers'> {
121
123
  systemPrompt,
122
124
  toolDefinitions,
123
125
  displayName,
126
+ provider,
124
127
  ...rest
125
128
  } = agent;
126
129
  const stripped = {
@@ -128,6 +131,7 @@ function stripHandlers(agent: AgentConfig): Omit<AgentConfig, 'toolHandlers'> {
128
131
  systemPrompt: typeof systemPrompt === 'function' ? undefined : systemPrompt,
129
132
  toolDefinitions: typeof toolDefinitions === 'function' ? undefined : toolDefinitions,
130
133
  displayName: typeof displayName === 'function' ? undefined : displayName,
134
+ provider: typeof provider === 'function' ? undefined : provider,
131
135
  };
132
136
  return subAgents?.length
133
137
  ? { ...stripped, subAgents: subAgents.map(stripHandlers) as AgentConfig[] }
@@ -138,8 +142,11 @@ function stripHandlers(agent: AgentConfig): Omit<AgentConfig, 'toolHandlers'> {
138
142
  * Foundation AI Assistant component.
139
143
  *
140
144
  * @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`
145
+ * Register one or more AI providers via `registerAIProviders` from
146
+ * `@genesislcap/foundation-ai`; this element resolves the `AIProviderRegistry`
147
+ * through the DI container. Pass agent configuration via the `agents` property —
148
+ * each agent can override which registered provider it uses via `provider:`.
149
+ * The component creates a `ChatDriver` (single agent) or `OrchestratingDriver`
143
150
  * (multiple agents) to manage the conversation loop.
144
151
  *
145
152
  * Popout/collapse coordination uses `agenticActivityBus` topics `chat-popout` and `chat-popin` — not DOM `CustomEvent`s on this element.
@@ -156,7 +163,7 @@ function stripHandlers(agent: AgentConfig): Omit<AgentConfig, 'toolHandlers'> {
156
163
  styles,
157
164
  })
158
165
  export class FoundationAiAssistant extends GenesisElement {
159
- @AIProvider aiProvider!: AIProvider;
166
+ @AIProviderRegistry providerRegistry!: AIProviderRegistry;
160
167
 
161
168
  @observable designSystemPrefix: string = 'rapid';
162
169
  @attr({ attribute: 'header-title' }) headerTitle: string = 'Genesis Assistant';
@@ -439,6 +446,22 @@ export class FoundationAiAssistant extends GenesisElement {
439
446
  this._sessionRef?.actions.aiAssistant.setActiveModel(value);
440
447
  }
441
448
 
449
+ /** Name of the AI provider used on the most recent turn. */
450
+ get activeProviderName(): string | undefined {
451
+ return this._sessionRef?.store.aiAssistant.activeProviderName;
452
+ }
453
+ set activeProviderName(value: string | undefined) {
454
+ this._sessionRef?.actions.aiAssistant.setActiveProviderName(value);
455
+ }
456
+
457
+ /** Per-provider status snapshot used by the settings panel. */
458
+ get providerStatuses(): AIProviderRegistryStatusEntry[] {
459
+ return this._sessionRef?.store.aiAssistant.providerStatuses ?? [];
460
+ }
461
+ set providerStatuses(value: AIProviderRegistryStatusEntry[]) {
462
+ this._sessionRef?.actions.aiAssistant.setProviderStatuses(value);
463
+ }
464
+
442
465
  // ---- Transient UI state (stays as @observable on the component) ----
443
466
 
444
467
  private _suggestionsGeneration = 0;
@@ -698,7 +721,7 @@ export class FoundationAiAssistant extends GenesisElement {
698
721
  // this via `agent-changed` once applyAgent fires on first sendMessage.
699
722
  this.activeAgent = agents[0];
700
723
  }
701
- return new OrchestratingDriver(this.aiProvider, agents, {
724
+ return new OrchestratingDriver(this.providerRegistry, agents, {
702
725
  sessionKey: this.getStateKey() ?? '',
703
726
  maxHandoffs: agent.maxHandoffs,
704
727
  classifierHistoryLength: agent.classifierHistoryLength,
@@ -710,7 +733,7 @@ export class FoundationAiAssistant extends GenesisElement {
710
733
  }
711
734
 
712
735
  return new ChatDriver(
713
- this.aiProvider,
736
+ this.providerRegistry,
714
737
  {},
715
738
  [],
716
739
  undefined,
@@ -763,6 +786,15 @@ export class FoundationAiAssistant extends GenesisElement {
763
786
  if (invocationId) {
764
787
  this._sessionRef?.actions.aiAssistant.removeInputOverride({ id: invocationId });
765
788
  }
789
+ // Sub-agents may have flipped the displayed provider while running;
790
+ // restore the parent agent's provider so the cog reflects the
791
+ // conversational driver again. Status (model / context-limit) is
792
+ // refreshed alongside so they don't disagree.
793
+ const restored = this.driver?.getActiveProviderName();
794
+ if (restored && restored !== this.activeProviderName) {
795
+ this.activeProviderName = restored;
796
+ void this.resolveContextLimit();
797
+ }
766
798
  };
767
799
  const onInteractionStart = (e: Event) => {
768
800
  const { interactionId, chatInputDuringExecution } = (e as CustomEvent).detail as {
@@ -781,11 +813,19 @@ export class FoundationAiAssistant extends GenesisElement {
781
813
  this._sessionRef?.actions.aiAssistant.removeInputOverride({ id: interactionId });
782
814
  }
783
815
  };
816
+ const onProviderChanged = (e: Event) => {
817
+ const { name } = (e as CustomEvent<{ name: string }>).detail;
818
+ this.activeProviderName = name;
819
+ // Status (model id / context limit) belongs to the active provider — refresh
820
+ // the displayed values so the header reflects the provider that just took over.
821
+ void this.resolveContextLimit();
822
+ };
784
823
  driver.addEventListener('sub-agent-history-updated', onSubAgentHistoryUpdated);
785
824
  driver.addEventListener('sub-agent-start', onSubAgentStart);
786
825
  driver.addEventListener('sub-agent-stop', onSubAgentStop);
787
826
  driver.addEventListener('interaction-start', onInteractionStart);
788
827
  driver.addEventListener('interaction-stop', onInteractionStop);
828
+ driver.addEventListener('provider-changed', onProviderChanged);
789
829
 
790
830
  const cleanups: (() => void)[] = [
791
831
  () => driver.removeEventListener('history-updated', onHistoryUpdated),
@@ -794,6 +834,7 @@ export class FoundationAiAssistant extends GenesisElement {
794
834
  () => driver.removeEventListener('sub-agent-stop', onSubAgentStop),
795
835
  () => driver.removeEventListener('interaction-start', onInteractionStart),
796
836
  () => driver.removeEventListener('interaction-stop', onInteractionStop),
837
+ () => driver.removeEventListener('provider-changed', onProviderChanged),
797
838
  ];
798
839
 
799
840
  if (driver instanceof OrchestratingDriver) {
@@ -935,6 +976,7 @@ export class FoundationAiAssistant extends GenesisElement {
935
976
  }
936
977
  this.fetchSuggestions();
937
978
  void this.resolveContextLimit();
979
+ void this.loadProviderStatuses();
938
980
  if (this.messagesEl) {
939
981
  this._scrollListener = () => {
940
982
  this._userScrolledAway =
@@ -983,7 +1025,13 @@ export class FoundationAiAssistant extends GenesisElement {
983
1025
 
984
1026
  private async resolveContextLimit(): Promise<void> {
985
1027
  try {
986
- const status = await this.aiProvider.getStatus?.();
1028
+ // Resolve status for the currently active provider when a driver is up
1029
+ // (so per-agent provider swaps update the displayed model/limit); fall
1030
+ // back to the registry default at startup before any turn has run.
1031
+ const activeName = this.driver?.getActiveProviderName();
1032
+ const status = activeName
1033
+ ? await this.providerRegistry.getStatus(activeName)
1034
+ : await this.providerRegistry.getStatus();
987
1035
  this.contextLimit = status?.contextLimit;
988
1036
  this.activeModel = status?.model;
989
1037
  } catch {
@@ -991,6 +1039,14 @@ export class FoundationAiAssistant extends GenesisElement {
991
1039
  }
992
1040
  }
993
1041
 
1042
+ private async loadProviderStatuses(): Promise<void> {
1043
+ try {
1044
+ this.providerStatuses = await this.providerRegistry.listStatuses();
1045
+ } catch {
1046
+ // Non-fatal — settings panel just won't list providers
1047
+ }
1048
+ }
1049
+
994
1050
  chatConfigChanged() {
995
1051
  this.syncShowingSplash();
996
1052
  }
@@ -1039,10 +1095,9 @@ export class FoundationAiAssistant extends GenesisElement {
1039
1095
  // Recompute aggregated session cost from per-message `cost` fields. Recomputing
1040
1096
  // (rather than incrementing on append) keeps the total correct under any
1041
1097
  // mutation of the message list — including future clear-chat / re-render flows.
1042
- let runningCost = 0;
1043
- for (const m of this.messages) {
1044
- if (m.cost != null) runningCost += m.cost;
1045
- }
1098
+ // `sumCosts` recurses into `toolCall.subAgentTrace` so cost incurred by
1099
+ // sub-agents (potentially on different providers) is included.
1100
+ const runningCost = sumCosts(this.messages);
1046
1101
  if (runningCost !== this.sessionCostUsd) {
1047
1102
  this.sessionCostUsd = runningCost;
1048
1103
  }
@@ -1,4 +1,8 @@
1
- import type { ChatInputDuringExecutionMode, ChatMessage } from '@genesislcap/foundation-ai';
1
+ import type {
2
+ AIProviderRegistryStatusEntry,
3
+ ChatInputDuringExecutionMode,
4
+ ChatMessage,
5
+ } from '@genesislcap/foundation-ai';
2
6
  import type { PayloadAction } from '@genesislcap/foundation-redux';
3
7
  import { createSlice } from '@genesislcap/foundation-redux';
4
8
  import type { AgentConfig } from '../config/config';
@@ -39,6 +43,17 @@ export interface AiAssistantSessionState {
39
43
  sessionCostUsd: number;
40
44
  /** Active model id (e.g. `claude-sonnet-4-6`), resolved on connect. */
41
45
  activeModel: string | undefined;
46
+ /**
47
+ * Name of the AI provider used on the most recent turn — drives "current"
48
+ * marker in the settings panel. `undefined` until the first turn runs.
49
+ */
50
+ activeProviderName: string | undefined;
51
+ /**
52
+ * Snapshot of every registered provider's status, in registration order.
53
+ * Populated on connect (or on settings-open if not yet loaded) and reused
54
+ * for the lifetime of the session — provider registration is static.
55
+ */
56
+ providerStatuses: AIProviderRegistryStatusEntry[];
42
57
  activeAgent: Omit<AgentConfig, 'toolHandlers'> | undefined;
43
58
  /**
44
59
  * Name of the agent the user has pinned via the picker. `null` means the
@@ -90,6 +105,8 @@ export const defaultSessionState: AiAssistantSessionState = {
90
105
  contextLimit: undefined,
91
106
  sessionCostUsd: 0,
92
107
  activeModel: undefined,
108
+ activeProviderName: undefined,
109
+ providerStatuses: [],
93
110
  activeAgent: undefined,
94
111
  pinnedAgentName: null,
95
112
  flowOwnerAgentName: null,
@@ -137,6 +154,12 @@ export const aiAssistantSlice = createSlice({
137
154
  setActiveModel(state, action: PayloadAction<string | undefined>) {
138
155
  state.activeModel = action.payload;
139
156
  },
157
+ setActiveProviderName(state, action: PayloadAction<string | undefined>) {
158
+ state.activeProviderName = action.payload;
159
+ },
160
+ setProviderStatuses(state, action: PayloadAction<AIProviderRegistryStatusEntry[]>) {
161
+ state.providerStatuses = action.payload;
162
+ },
140
163
  setActiveAgent(state, action: PayloadAction<Omit<AgentConfig, 'toolHandlers'> | undefined>) {
141
164
  state.activeAgent = action.payload;
142
165
  },