@genesislcap/ai-assistant 14.420.0 → 14.421.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 (105) hide show
  1. package/dist/ai-assistant.api.json +4061 -1416
  2. package/dist/ai-assistant.d.ts +594 -81
  3. package/dist/dts/channel/ai-activity-channel.d.ts +4 -22
  4. package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
  5. package/dist/dts/components/ai-driver/ai-driver.d.ts +52 -0
  6. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -0
  7. package/dist/dts/components/ai-driver/index.d.ts +2 -0
  8. package/dist/dts/components/ai-driver/index.d.ts.map +1 -0
  9. package/dist/dts/components/chat-driver/chat-driver.d.ts +63 -8
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  11. package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.d.ts +3 -3
  12. package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.d.ts.map +1 -1
  13. package/dist/dts/components/chat-markdown/chat-markdown.d.ts +1 -1
  14. package/dist/dts/components/chat-markdown/chat-markdown.d.ts.map +1 -1
  15. package/dist/dts/components/halo-overlay.d.ts +13 -1
  16. package/dist/dts/components/halo-overlay.d.ts.map +1 -1
  17. package/dist/dts/components/orchestrating-driver/index.d.ts +2 -0
  18. package/dist/dts/components/orchestrating-driver/index.d.ts.map +1 -0
  19. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +39 -0
  20. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -0
  21. package/dist/dts/components/popout-manager/index.d.ts +2 -0
  22. package/dist/dts/components/popout-manager/index.d.ts.map +1 -0
  23. package/dist/dts/components/popout-manager/popout-manager.d.ts +72 -0
  24. package/dist/dts/components/popout-manager/popout-manager.d.ts.map +1 -0
  25. package/dist/dts/config/config.d.ts +43 -15
  26. package/dist/dts/config/config.d.ts.map +1 -1
  27. package/dist/dts/config/fallback-agents.d.ts +20 -0
  28. package/dist/dts/config/fallback-agents.d.ts.map +1 -0
  29. package/dist/dts/config/index.d.ts +1 -0
  30. package/dist/dts/config/index.d.ts.map +1 -1
  31. package/dist/dts/index.d.ts +6 -0
  32. package/dist/dts/index.d.ts.map +1 -1
  33. package/dist/dts/main/main.d.ts +122 -21
  34. package/dist/dts/main/main.d.ts.map +1 -1
  35. package/dist/dts/main/main.styles.d.ts.map +1 -1
  36. package/dist/dts/main/main.template.d.ts.map +1 -1
  37. package/dist/dts/main/main.types.d.ts +16 -0
  38. package/dist/dts/main/main.types.d.ts.map +1 -1
  39. package/dist/dts/state/ai-assistant-slice.d.ts +38 -0
  40. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -0
  41. package/dist/dts/state/driver-registry.d.ts +22 -0
  42. package/dist/dts/state/driver-registry.d.ts.map +1 -0
  43. package/dist/dts/state/session-store.d.ts +37 -0
  44. package/dist/dts/state/session-store.d.ts.map +1 -0
  45. package/dist/dts/suggestions/chat-suggestions.d.ts +7 -0
  46. package/dist/dts/suggestions/chat-suggestions.d.ts.map +1 -0
  47. package/dist/dts/types/ai-chat-widget.d.ts +3 -2
  48. package/dist/dts/types/ai-chat-widget.d.ts.map +1 -1
  49. package/dist/dts/utils/index.d.ts +1 -0
  50. package/dist/dts/utils/index.d.ts.map +1 -1
  51. package/dist/dts/utils/tool-fold.d.ts +133 -0
  52. package/dist/dts/utils/tool-fold.d.ts.map +1 -0
  53. package/dist/esm/components/ai-driver/ai-driver.js +1 -0
  54. package/dist/esm/components/ai-driver/index.js +1 -0
  55. package/dist/esm/components/chat-driver/chat-driver.js +499 -67
  56. package/dist/esm/components/chat-interaction-wrapper/chat-interaction-wrapper.js +2 -2
  57. package/dist/esm/components/chat-markdown/chat-markdown.js +1 -1
  58. package/dist/esm/components/halo-overlay.js +53 -7
  59. package/dist/esm/components/orchestrating-driver/index.js +1 -0
  60. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +247 -0
  61. package/dist/esm/components/popout-manager/index.js +1 -0
  62. package/dist/esm/components/popout-manager/popout-manager.js +126 -0
  63. package/dist/esm/config/fallback-agents.js +26 -0
  64. package/dist/esm/config/index.js +1 -0
  65. package/dist/esm/index.js +6 -0
  66. package/dist/esm/main/main.js +546 -112
  67. package/dist/esm/main/main.styles.js +200 -4
  68. package/dist/esm/main/main.template.js +163 -63
  69. package/dist/esm/state/ai-assistant-slice.js +54 -0
  70. package/dist/esm/state/driver-registry.js +46 -0
  71. package/dist/esm/state/session-store.js +39 -0
  72. package/dist/esm/suggestions/chat-suggestions.js +147 -0
  73. package/dist/esm/utils/index.js +1 -0
  74. package/dist/esm/utils/tool-fold.js +92 -0
  75. package/dist/tsconfig.tsbuildinfo +1 -1
  76. package/docs/migration-FUI-2495.md +339 -0
  77. package/docs/sub_agent.md +310 -0
  78. package/package.json +16 -15
  79. package/src/channel/ai-activity-channel.ts +4 -20
  80. package/src/components/ai-driver/ai-driver.ts +69 -0
  81. package/src/components/ai-driver/index.ts +1 -0
  82. package/src/components/chat-driver/chat-driver.ts +600 -73
  83. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts +3 -3
  84. package/src/components/chat-markdown/chat-markdown.ts +1 -1
  85. package/src/components/halo-overlay.ts +45 -7
  86. package/src/components/orchestrating-driver/index.ts +1 -0
  87. package/src/components/orchestrating-driver/orchestrating-driver.ts +328 -0
  88. package/src/components/popout-manager/index.ts +1 -0
  89. package/src/components/popout-manager/popout-manager.ts +147 -0
  90. package/src/config/config.ts +45 -15
  91. package/src/config/fallback-agents.ts +29 -0
  92. package/src/config/index.ts +1 -0
  93. package/src/index.ts +6 -0
  94. package/src/main/main.styles.ts +200 -4
  95. package/src/main/main.template.ts +200 -80
  96. package/src/main/main.ts +567 -94
  97. package/src/main/main.types.ts +11 -0
  98. package/src/state/ai-assistant-slice.ts +80 -0
  99. package/src/state/driver-registry.ts +51 -0
  100. package/src/state/session-store.ts +56 -0
  101. package/src/suggestions/chat-suggestions.ts +158 -0
  102. package/src/types/ai-chat-widget.ts +4 -2
  103. package/src/utils/index.ts +1 -0
  104. package/src/utils/tool-fold.ts +181 -0
  105. package/docs/multi-agent-architecture.md +0 -198
@@ -1,4 +1,4 @@
1
- import { InteractionResult } from '@genesislcap/foundation-ai';
1
+ import type { InteractionResult } from '@genesislcap/foundation-ai';
2
2
  import { customElement, GenesisElement, observable } from '@genesislcap/web-core';
3
3
  import { logger } from '../../utils/logger';
4
4
  import { AiChatInteractionWrapperStyles } from './chat-interaction-wrapper.styles';
@@ -26,8 +26,8 @@ export class AiChatInteractionWrapper extends GenesisElement {
26
26
  @observable data: any;
27
27
  /** @internal */
28
28
  @observable interactionId: string = '';
29
- /** When true, the interaction has already been resolved. Forwarded to the rendered component. @internal */
30
- @observable resolved: boolean = false;
29
+ /** The resolved result once the interaction has completed. Forwarded to the rendered component. @internal */
30
+ @observable resolved: InteractionResult<unknown> | undefined = undefined;
31
31
 
32
32
  /** @internal */
33
33
  container!: HTMLElement;
@@ -77,7 +77,7 @@ const styles = css`
77
77
  styles,
78
78
  })
79
79
  export class AiChatMarkdown extends GenesisElement {
80
- /** @internal */
80
+ /** The markdown string to render. */
81
81
  @observable content: string = '';
82
82
 
83
83
  /** @internal */
@@ -6,6 +6,11 @@ import {
6
6
  AI_COLOUR_VIOLET,
7
7
  } from '../styles/ai-colours';
8
8
 
9
+ const HALO_DEFAULT_SPEED = 1.5;
10
+ const HALO_DEFAULT_BORDER_SIZE = 3;
11
+ const HALO_DEFAULT_GLOW_OPACITY = 0.35;
12
+ const HALO_DEFAULT_GLOW_SPREAD = 70;
13
+
9
14
  /**
10
15
  * Animated halo overlay — rotating conic-gradient border with an inward glow.
11
16
  *
@@ -46,7 +51,7 @@ import {
46
51
  position: absolute;
47
52
  inset: 0;
48
53
  border-radius: inherit;
49
- padding: 3px;
54
+ padding: var(--halo-border-size, 3px);
50
55
  background: repeating-conic-gradient(
51
56
  from var(--halo-angle, 0deg),
52
57
  ${AI_COLOUR_AMBER},
@@ -79,14 +84,47 @@ import {
79
84
  ${AI_COLOUR_VIOLET},
80
85
  ${AI_COLOUR_AMBER}
81
86
  );
82
- opacity: 0.35;
83
- -webkit-mask-image: radial-gradient(ellipse at center, transparent 70%, black 100%);
84
- mask-image: radial-gradient(ellipse at center, transparent 70%, black 100%);
87
+ opacity: var(--halo-glow-opacity, 0.35);
88
+ -webkit-mask-image: radial-gradient(
89
+ ellipse at center,
90
+ transparent var(--halo-glow-spread, 70%),
91
+ black 100%
92
+ );
93
+ mask-image: radial-gradient(
94
+ ellipse at center,
95
+ transparent var(--halo-glow-spread, 70%),
96
+ black 100%
97
+ );
85
98
  }
86
99
  `,
87
100
  })
88
101
  export class AiHaloOverlay extends GenesisElement {
89
102
  @attr({ mode: 'boolean' }) active: boolean = false;
103
+ /** Rotation speed in degrees per frame. Default: 1.5 (≈ 4 s per full revolution at 60 fps). */
104
+ @attr({ converter: { fromView: Number, toView: String } }) speed: number = HALO_DEFAULT_SPEED;
105
+ /** Rotation direction. Default: 'cw' (clockwise). */
106
+ @attr direction: 'cw' | 'ccw' = 'cw';
107
+ /** Border thickness in px. Default: 3. */
108
+ @attr({ attribute: 'border-size', converter: { fromView: Number, toView: String } })
109
+ borderSize: number = HALO_DEFAULT_BORDER_SIZE;
110
+ /** Glow layer opacity (0–1). Default: 0.35. */
111
+ @attr({ attribute: 'glow-opacity', converter: { fromView: Number, toView: String } })
112
+ glowOpacity: number = HALO_DEFAULT_GLOW_OPACITY;
113
+ /** Transparent stop of the radial glow mask as a percentage (0–100). Higher = less spread. Default: 70. */
114
+ @attr({ attribute: 'glow-spread', converter: { fromView: Number, toView: String } })
115
+ glowSpread: number = HALO_DEFAULT_GLOW_SPREAD;
116
+
117
+ borderSizeChanged() {
118
+ this.style.setProperty('--halo-border-size', `${this.borderSize}px`);
119
+ }
120
+
121
+ glowOpacityChanged() {
122
+ this.style.setProperty('--halo-glow-opacity', String(this.glowOpacity));
123
+ }
124
+
125
+ glowSpreadChanged() {
126
+ this.style.setProperty('--halo-glow-spread', `${this.glowSpread}%`);
127
+ }
90
128
 
91
129
  // TODO: The rAF loop is fine for demos but has two drawbacks vs a pure CSS @property animation:
92
130
  // 1. It runs on the main thread, so heavy JS work can cause the animation to stutter.
@@ -94,8 +132,6 @@ export class AiHaloOverlay extends GenesisElement {
94
132
  // Once @property inside Shadow DOM has solid cross-browser support, consider switching back
95
133
  // to a CSS @keyframes approach and removing connectedCallback/disconnectedCallback/tick().
96
134
 
97
- // 1.5° per frame @ 60 fps ≈ 4 s per full revolution
98
- private static readonly DEG_PER_FRAME = 1.5;
99
135
  private static readonly FULL_ROTATION_DEG = 360;
100
136
 
101
137
  private angle = 0;
@@ -114,7 +150,9 @@ export class AiHaloOverlay extends GenesisElement {
114
150
  }
115
151
 
116
152
  private tick() {
117
- this.angle = (this.angle + AiHaloOverlay.DEG_PER_FRAME) % AiHaloOverlay.FULL_ROTATION_DEG;
153
+ const step = this.direction === 'ccw' ? -this.speed : this.speed;
154
+ this.angle =
155
+ (this.angle + step + AiHaloOverlay.FULL_ROTATION_DEG) % AiHaloOverlay.FULL_ROTATION_DEG;
118
156
  this.style.setProperty('--halo-angle', `${this.angle}deg`);
119
157
  this.animFrame = requestAnimationFrame(() => this.tick());
120
158
  }
@@ -0,0 +1 @@
1
+ export { OrchestratingDriver } from './orchestrating-driver';
@@ -0,0 +1,328 @@
1
+ import type {
2
+ AIProvider,
3
+ ChatAttachment,
4
+ ChatDriverResult,
5
+ ChatMessage,
6
+ ChatRequestOptions,
7
+ } from '@genesislcap/foundation-ai';
8
+ import type { AgentConfig, FallbackAgentConfig, SpecialistAgentConfig } from '../../config/config';
9
+ import { logger } from '../../utils/logger';
10
+ import type { AiDriver, AllAgentSummary } from '../ai-driver/ai-driver';
11
+ import { ChatDriver, REQUEST_CONTINUATION_TOOL } from '../chat-driver/chat-driver';
12
+
13
+ const DEFAULT_MAX_HANDOFFS = 3;
14
+ const DEFAULT_CLASSIFIER_HISTORY_LENGTH = 4;
15
+ const DEFAULT_CLASSIFIER_RETRIES = 2;
16
+
17
+ const REQUEST_CONTINUATION_DEFINITION = {
18
+ name: REQUEST_CONTINUATION_TOOL,
19
+ description:
20
+ "Call this when you have completed your part of the task but fulfilling the user's full request requires capabilities outside your domain. Do not call this if you can complete the request yourself. Pass a plain-language description of what still needs to be done — another specialist will be selected automatically.",
21
+ parameters: {
22
+ type: 'object',
23
+ required: ['summary', 'remaining_task'],
24
+ properties: {
25
+ summary: {
26
+ type: 'string',
27
+ description: 'What you found or did — passed as context to the next specialist.',
28
+ },
29
+ remaining_task: {
30
+ type: 'string',
31
+ description:
32
+ 'What still needs to be done, in plain language. Used to route to the right specialist.',
33
+ },
34
+ },
35
+ },
36
+ };
37
+
38
+ function isSpecialist(agent: AgentConfig): agent is SpecialistAgentConfig {
39
+ return !agent.fallback;
40
+ }
41
+
42
+ function isFallback(agent: AgentConfig): agent is FallbackAgentConfig {
43
+ return agent.fallback === true;
44
+ }
45
+
46
+ function buildFallbackSystemPrompt(
47
+ fallback: FallbackAgentConfig,
48
+ specialists: SpecialistAgentConfig[],
49
+ ): string {
50
+ const agentList = specialists.map((s) => `- ${s.name}: ${s.description}`).join('\n');
51
+ if (fallback.systemPrompt) {
52
+ return fallback.systemPrompt.replace('{{agents}}', agentList);
53
+ }
54
+ return `You are a helpful assistant. You cannot directly help with the user's request, but the following specialists are available:\n\n${agentList}\n\nPolitely let the user know what you can help with and invite them to rephrase their request.`;
55
+ }
56
+
57
+ /**
58
+ * Prepares history for the LLM only: masks tool call args and results from other
59
+ * agents so the active specialist is not confused by tools it does not have.
60
+ * Canonical history in `ChatDriver` stays unmasked for UI and logging.
61
+ */
62
+ function transformHistoryForAgent(history: ChatMessage[], agentName: string): ChatMessage[] {
63
+ return history.map((msg) => {
64
+ if (!msg.agentName || msg.agentName === agentName) return msg;
65
+ if (msg.toolCalls?.length) {
66
+ return { ...msg, toolCalls: msg.toolCalls.map((tc) => ({ ...tc, args: {} })) };
67
+ }
68
+ if (msg.toolResult) {
69
+ return {
70
+ ...msg,
71
+ toolResult: { ...msg.toolResult, content: "[other agent's tool result omitted]" },
72
+ };
73
+ }
74
+ return msg;
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Orchestrates multiple specialist agents. Sits between `FoundationAiAssistant`
80
+ * and `ChatDriver`, classifying each user message and routing it to the right
81
+ * specialist — each with its own focused system prompt, tools, and primer.
82
+ *
83
+ * @beta
84
+ */
85
+ export class OrchestratingDriver extends EventTarget implements AiDriver {
86
+ private readonly chatDriver: ChatDriver;
87
+ private readonly specialists: SpecialistAgentConfig[];
88
+ private readonly fallback?: FallbackAgentConfig;
89
+ private readonly maxHandoffs: number;
90
+ private readonly classifierHistoryLength: number;
91
+ private readonly classifierRetries: number;
92
+
93
+ activeAgent?: AgentConfig;
94
+
95
+ constructor(
96
+ private readonly aiProvider: AIProvider,
97
+ private readonly agents: AgentConfig[],
98
+ options: {
99
+ maxHandoffs?: number;
100
+ classifierHistoryLength?: number;
101
+ classifierRetries?: number;
102
+ maxToolIterations?: number;
103
+ maxFoldOperations?: number;
104
+ } = {},
105
+ ) {
106
+ super();
107
+ this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
108
+ this.classifierHistoryLength =
109
+ options.classifierHistoryLength ?? DEFAULT_CLASSIFIER_HISTORY_LENGTH;
110
+ this.classifierRetries = options.classifierRetries ?? DEFAULT_CLASSIFIER_RETRIES;
111
+
112
+ this.specialists = agents.filter(isSpecialist);
113
+ const fallbacks = agents.filter(isFallback);
114
+ if (fallbacks.length > 1) {
115
+ logger.warn(
116
+ 'OrchestratingDriver: multiple fallback agents found — only the first will be used.',
117
+ );
118
+ }
119
+
120
+ const rawFallback = fallbacks[0];
121
+ this.fallback = rawFallback
122
+ ? { ...rawFallback, systemPrompt: buildFallbackSystemPrompt(rawFallback, this.specialists) }
123
+ : undefined;
124
+
125
+ this.chatDriver = new ChatDriver(
126
+ aiProvider,
127
+ {},
128
+ [],
129
+ undefined,
130
+ undefined,
131
+ options.maxToolIterations,
132
+ options.maxFoldOperations,
133
+ );
134
+
135
+ // Proxy history-updated events from the shared driver
136
+ this.chatDriver.addEventListener('history-updated', (e: Event) => {
137
+ this.dispatchEvent(new CustomEvent('history-updated', { detail: (e as CustomEvent).detail }));
138
+ });
139
+ }
140
+
141
+ resolveInteraction(interactionId: string, result: unknown): void {
142
+ this.chatDriver.resolveInteraction(interactionId, result);
143
+ }
144
+
145
+ isBusy(): boolean {
146
+ return this.chatDriver.isBusy();
147
+ }
148
+
149
+ loadHistory(messages: ChatMessage[]): void {
150
+ this.chatDriver.loadHistory(messages);
151
+ }
152
+
153
+ getRawHistory(): readonly ChatMessage[] {
154
+ return this.chatDriver.getHistory();
155
+ }
156
+
157
+ async getSuggestions(
158
+ history: ChatMessage[],
159
+ prompt: string,
160
+ count: number,
161
+ allAgentInfo?: AllAgentSummary[],
162
+ ): Promise<string[]> {
163
+ const agentInfo = this.specialists.map((s) => ({
164
+ name: s.name,
165
+ description: s.description,
166
+ tools: s.toolDefinitions ?? [],
167
+ }));
168
+ return this.chatDriver.getSuggestions(history, prompt, count, agentInfo);
169
+ }
170
+
171
+ async sendMessage(input: string, attachments?: ChatAttachment[]): Promise<ChatDriverResult> {
172
+ const history = this.chatDriver.getHistory() as ChatMessage[];
173
+
174
+ // Emit the user message immediately so the UI reflects it during the classify
175
+ // round-trip — without this the chat appears frozen until classify returns.
176
+ this.dispatchEvent(
177
+ new CustomEvent('history-updated', {
178
+ detail: [...history, { role: 'user', content: input, attachments }],
179
+ }),
180
+ );
181
+
182
+ this.dispatchEvent(new CustomEvent('orchestrating-start'));
183
+ let currentAgent = await this.classify(input, history);
184
+ let isHandoff = false;
185
+ let handoffs = 0;
186
+ let handoffSummary = '';
187
+ let remainingTask = '';
188
+
189
+ while (true) {
190
+ this.applyAgent(currentAgent);
191
+
192
+ let result: ChatDriverResult;
193
+ if (isHandoff) {
194
+ const contextPrimer: ChatMessage[] = handoffSummary
195
+ ? [{ role: 'user', content: `[Context from previous agent]: ${handoffSummary}` }]
196
+ : [];
197
+ // eslint-disable-next-line no-await-in-loop
198
+ result = await this.chatDriver.continueFromHistory(contextPrimer);
199
+ } else {
200
+ // eslint-disable-next-line no-await-in-loop
201
+ result = await this.chatDriver.sendMessage(input, attachments);
202
+ }
203
+
204
+ if (result.reason !== 'agent-handoff' || isFallback(currentAgent)) {
205
+ break;
206
+ }
207
+
208
+ handoffs += 1;
209
+ if (handoffs > this.maxHandoffs) {
210
+ this.appendInlineMessage(
211
+ `I wasn't able to fully complete your request — the task required more hand-offs between specialists than allowed (max: ${this.maxHandoffs}). Please try breaking your request into smaller steps.`,
212
+ );
213
+ break;
214
+ }
215
+
216
+ handoffSummary = result.summary;
217
+ remainingTask = result.remainingTask;
218
+ isHandoff = true;
219
+
220
+ const updatedHistory = this.chatDriver.getHistory() as ChatMessage[];
221
+ this.dispatchEvent(new CustomEvent('orchestrating-start'));
222
+ // eslint-disable-next-line no-await-in-loop
223
+ currentAgent = await this.classify(remainingTask, updatedHistory);
224
+ }
225
+
226
+ return { reason: 'done' };
227
+ }
228
+
229
+ async continueFromHistory(transientPrimer?: ChatMessage[]): Promise<ChatDriverResult> {
230
+ return this.chatDriver.continueFromHistory(transientPrimer);
231
+ }
232
+
233
+ private applyAgent(agent: AgentConfig): void {
234
+ // Fallback agents are terminal and should not hand off to other specialists
235
+ const agentToApply = isFallback(agent)
236
+ ? agent
237
+ : {
238
+ ...agent,
239
+ toolDefinitions: [...(agent.toolDefinitions ?? []), REQUEST_CONTINUATION_DEFINITION],
240
+ };
241
+
242
+ const previousAgent = this.activeAgent;
243
+ if (previousAgent && previousAgent.name !== agent.name) {
244
+ const rawHistory = this.chatDriver.getHistory() as ChatMessage[];
245
+ this.chatDriver.loadHistory([...rawHistory, { role: 'system-event', content: agent.name }]);
246
+ }
247
+
248
+ this.chatDriver.setProviderHistoryTransform((h) => transformHistoryForAgent(h, agent.name));
249
+ this.chatDriver.applyAgent(agentToApply);
250
+ this.activeAgent = agent;
251
+ this.dispatchEvent(new CustomEvent('orchestrating-stop'));
252
+ this.dispatchEvent(new CustomEvent('agent-changed', { detail: agent }));
253
+ }
254
+
255
+ private async classify(input: string, history: ChatMessage[]): Promise<AgentConfig> {
256
+ if (this.specialists.length === 0) {
257
+ return this.fallback ?? { name: 'Assistant', fallback: true };
258
+ }
259
+
260
+ const agentList = this.specialists
261
+ .map((a, i) => `${i}: ${a.name} — ${a.description}`)
262
+ .join('\n');
263
+
264
+ const recentMessages = history
265
+ .filter((m) => m.role === 'user' || m.role === 'assistant')
266
+ .slice(-this.classifierHistoryLength)
267
+ .map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`)
268
+ .join('\n');
269
+
270
+ const classifierPrompt = `You are a routing classifier. Call select_agent with the index of the best matching agent for the user message. Use -1 only if the request is entirely unrelated to every agent listed — prefer the closest match when in doubt. If the request spans multiple agents, pick the one that should act first; I will re-classify and route again once that agent is done.\n\nAgents:\n${agentList}${recentMessages ? `\n\nRecent conversation:\n${recentMessages}` : ''}`;
271
+
272
+ const routingTool = {
273
+ name: 'select_agent',
274
+ description: 'Select the most appropriate agent for the user message.',
275
+ parameters: {
276
+ type: 'object',
277
+ required: ['agent_index'],
278
+ properties: {
279
+ agent_index: {
280
+ type: 'integer',
281
+ description: 'Zero-based index of the matching agent, or -1 if no agent matches.',
282
+ },
283
+ },
284
+ },
285
+ };
286
+
287
+ for (let attempt = 0; attempt <= this.classifierRetries; attempt += 1) {
288
+ try {
289
+ const options: ChatRequestOptions = {
290
+ systemPrompt: classifierPrompt,
291
+ tools: [routingTool],
292
+ };
293
+ // eslint-disable-next-line no-await-in-loop
294
+ const response = await this.aiProvider.chat!([], input, options);
295
+ const tc = response.toolCalls?.[0];
296
+ const index = tc?.name === 'select_agent' ? (tc.args.agent_index as number) : -1;
297
+
298
+ if (index >= 0 && index < this.specialists.length) {
299
+ return this.specialists[index];
300
+ }
301
+ // index === -1 — fall through to fallback
302
+ break;
303
+ } catch (e) {
304
+ logger.warn(`OrchestratingDriver: classifier attempt ${attempt + 1} failed:`, e);
305
+ if (attempt === this.classifierRetries) {
306
+ logger.error('OrchestratingDriver: classifier failed after all retries, using fallback');
307
+ }
308
+ }
309
+ }
310
+
311
+ if (this.fallback) return this.fallback;
312
+
313
+ // No fallback configured — emit an inline response and return first specialist as no-op
314
+ const specialistNames = this.specialists.map((s) => s.name).join(', ');
315
+ this.appendInlineMessage(
316
+ `I'm not sure how to help with that. I can assist with: ${specialistNames}.`,
317
+ );
318
+ return this.specialists[0];
319
+ }
320
+
321
+ private appendInlineMessage(content: string): void {
322
+ const history = this.chatDriver.getHistory() as ChatMessage[];
323
+ this.chatDriver.loadHistory([...history, { role: 'assistant', content }]);
324
+ this.dispatchEvent(
325
+ new CustomEvent('history-updated', { detail: this.chatDriver.getHistory() }),
326
+ );
327
+ }
328
+ }
@@ -0,0 +1 @@
1
+ export * from './popout-manager';
@@ -0,0 +1,147 @@
1
+ import { customElement, GenesisElement, html, observable } from '@genesislcap/web-core';
2
+ import { agenticActivityBus } from '../../channel/ai-activity-bus';
3
+
4
+ /**
5
+ * Contract for pages that support docking the AI assistant.
6
+ * Register an implementation with {@link getAiPopoutManager} on mount and deregister on unmount.
7
+ *
8
+ * @beta
9
+ */
10
+ export interface AiDockProvider {
11
+ /**
12
+ * Called when the user expands the bubble. The provider is responsible for inserting
13
+ * the element into its layout. State is held in the session store and restored
14
+ * automatically when the element connects.
15
+ */
16
+ onDock(element: HTMLElement): Promise<void>;
17
+ /** Called when the user collapses the panel or before navigation. */
18
+ onUndock(): Promise<void>;
19
+ /**
20
+ * Called by `collapseIfDocked` before navigation. The provider is responsible for
21
+ * finding the docked assistant element and calling `handlePopout()` on it, which
22
+ * publishes `chat-popin` and returns the assistant to the bubble.
23
+ */
24
+ initiateCollapse(): void;
25
+ }
26
+
27
+ /**
28
+ * App-shell component that owns the pop-out/pop-in lifecycle for the AI assistant bubble.
29
+ *
30
+ * @remarks
31
+ * Place this in the persistent app shell, wrapping a `foundation-ai-chat-bubble` that
32
+ * contains an AI assistant element in the `dialog-content` slot. The component will
33
+ * auto-create a matching collapse-mode element for docking into pages, and will
34
+ * control the expand button visibility based on whether a dock provider is registered.
35
+ *
36
+ * Pages that support docking call `aiPopoutManager.registerDockProvider()` on mount
37
+ * and `aiPopoutManager.deregisterDockProvider()` on unmount.
38
+ *
39
+ * @example
40
+ * ```html
41
+ * <foundation-ai-popout-manager>
42
+ * <foundation-ai-chat-bubble title="My Assistant">
43
+ * <my-assistant slot="dialog-content"></my-assistant>
44
+ * </foundation-ai-chat-bubble>
45
+ * </foundation-ai-popout-manager>
46
+ * ```
47
+ *
48
+ * @beta
49
+ */
50
+ @customElement({
51
+ name: 'foundation-ai-popout-manager',
52
+ template: html`
53
+ <slot></slot>
54
+ `,
55
+ })
56
+ export class FoundationAiPopoutManager extends GenesisElement {
57
+ /** True when a dock provider is registered — controls expand button visibility. */
58
+ @observable canDock = false;
59
+
60
+ private collapseEl: HTMLElement | null = null;
61
+ private dockProvider: AiDockProvider | null = null;
62
+ private isDocked = false;
63
+ private unsubBus?: () => void;
64
+
65
+ connectedCallback() {
66
+ super.connectedCallback();
67
+ _aiPopoutManager = this;
68
+
69
+ const bubble = this.querySelector('foundation-ai-chat-bubble');
70
+ const assistantEl = bubble?.querySelector('[slot="dialog-content"]');
71
+ if (assistantEl) {
72
+ this.collapseEl = document.createElement(assistantEl.tagName.toLowerCase());
73
+ this.collapseEl.setAttribute('popout-mode', 'collapse');
74
+ // Copy identity attributes so the collapse element shares the same session
75
+ // store and driver registry key as the expand-mode element in the bubble.
76
+ const headerTitle = assistantEl.getAttribute('header-title');
77
+ if (headerTitle) this.collapseEl.setAttribute('header-title', headerTitle);
78
+ if (assistantEl.id) this.collapseEl.id = assistantEl.id;
79
+ }
80
+
81
+ const unsubPopout = agenticActivityBus.subscribe('chat-popout', async () => {
82
+ if (this.isDocked || !this.dockProvider || !this.collapseEl) return;
83
+ this.isDocked = true;
84
+ await this.dockProvider.onDock(this.collapseEl);
85
+ });
86
+
87
+ const unsubPopin = agenticActivityBus.subscribe('chat-popin', () => {
88
+ this.isDocked = false;
89
+ this.dockProvider?.onUndock();
90
+ });
91
+
92
+ this.unsubBus = () => {
93
+ unsubPopout();
94
+ unsubPopin();
95
+ };
96
+ }
97
+
98
+ disconnectedCallback() {
99
+ super.disconnectedCallback();
100
+ this.unsubBus?.();
101
+ this.unsubBus = undefined;
102
+ _aiPopoutManager = undefined;
103
+ }
104
+
105
+ canDockChanged() {
106
+ const bubble = this.querySelector('foundation-ai-chat-bubble');
107
+ const assistantEl = bubble?.querySelector('[slot="dialog-content"]');
108
+ if (this.canDock) {
109
+ assistantEl?.setAttribute('popout-mode', 'expand');
110
+ } else {
111
+ assistantEl?.removeAttribute('popout-mode');
112
+ }
113
+ }
114
+
115
+ registerDockProvider(provider: AiDockProvider): void {
116
+ this.dockProvider = provider;
117
+ this.canDock = true;
118
+ }
119
+
120
+ deregisterDockProvider(): void {
121
+ this.isDocked = false;
122
+ this.dockProvider = null;
123
+ this.canDock = false;
124
+ }
125
+
126
+ /**
127
+ * If the assistant is currently docked, collapses it back into the bubble.
128
+ * Await this in `onBeforeNavButtonClick` to ensure cleanup before navigation.
129
+ */
130
+ collapseIfDocked(): Promise<void> {
131
+ if (!this.isDocked || !this.dockProvider) return Promise.resolve();
132
+ this.dockProvider.initiateCollapse();
133
+ return Promise.resolve();
134
+ }
135
+ }
136
+
137
+ let _aiPopoutManager: FoundationAiPopoutManager | undefined;
138
+
139
+ /**
140
+ * Returns the active `FoundationAiPopoutManager` instance, or `undefined` if none is mounted.
141
+ * Import this in pages to call `registerDockProvider` / `deregisterDockProvider`.
142
+ *
143
+ * @beta
144
+ */
145
+ export function getAiPopoutManager(): FoundationAiPopoutManager | undefined {
146
+ return _aiPopoutManager;
147
+ }
@@ -1,24 +1,10 @@
1
1
  import type { ChatMessage, ChatToolDefinition, ChatToolHandlers } from '@genesislcap/foundation-ai';
2
2
 
3
- /**
4
- * Configuration for a single specialist agent.
5
- *
6
- * When multiple agents are provided to `FoundationAiAssistant`, an orchestrating layer
7
- * will route each user message to the appropriate specialist based on intent. When only
8
- * one agent is configured, routing is skipped.
9
- *
10
- * @beta
11
- */
12
- export interface AgentConfig {
3
+ interface BaseAgentConfig {
13
4
  /**
14
5
  * Display name shown in the chat header when this agent is active.
15
6
  */
16
7
  name: string;
17
- /**
18
- * Plain-language description of what this agent handles.
19
- * Used by the classifier to auto-generate its routing prompt.
20
- */
21
- description: string;
22
8
  /**
23
9
  * System prompt injected into every conversation turn for this agent.
24
10
  */
@@ -37,3 +23,47 @@ export interface AgentConfig {
37
23
  */
38
24
  primerHistory?: ChatMessage[];
39
25
  }
26
+
27
+ /**
28
+ * Configuration for a specialist agent.
29
+ *
30
+ * Specialist agents are offered to the classifier for intent routing. The
31
+ * `description` is used to auto-generate the classifier prompt — no manual
32
+ * routing prompt authoring required.
33
+ *
34
+ * @beta
35
+ */
36
+ export interface SpecialistAgentConfig extends BaseAgentConfig {
37
+ /**
38
+ * Plain-language description of what this agent handles.
39
+ * Used by the classifier to auto-generate its routing prompt.
40
+ */
41
+ description: string;
42
+ fallback?: never;
43
+ }
44
+
45
+ /**
46
+ * Configuration for a fallback agent.
47
+ *
48
+ * Invoked when the classifier returns no match. Excluded from the classifier
49
+ * prompt. Only one fallback is permitted per agents array.
50
+ *
51
+ * @beta
52
+ */
53
+ export interface FallbackAgentConfig extends BaseAgentConfig {
54
+ /**
55
+ * Marks this agent as the fallback for unrecognised requests.
56
+ */
57
+ fallback: true;
58
+ description?: never;
59
+ }
60
+
61
+ /**
62
+ * Configuration for an agent passed to `FoundationAiAssistant`.
63
+ *
64
+ * Either a specialist (requires `description`) or a fallback (requires
65
+ * `fallback: true`). Passing both or neither is a compile error.
66
+ *
67
+ * @beta
68
+ */
69
+ export type AgentConfig = SpecialistAgentConfig | FallbackAgentConfig;