@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,16 +1,31 @@
1
1
  import type {
2
2
  AIProvider,
3
3
  ChatAttachment,
4
+ ChatDriverResult,
4
5
  ChatMessage,
5
6
  ChatRequestOptions,
7
+ ChatToolCall,
6
8
  ChatToolDefinition,
7
9
  ChatToolHandlers,
8
10
  } from '@genesislcap/foundation-ai';
9
11
  import { MalformedFunctionCallError } from '@genesislcap/foundation-ai';
12
+ import type { AgentConfig } from '../../config/config';
10
13
  import { logger } from '../../utils/logger';
14
+ import { TOOL_FOLD_SYMBOL, type ToolFold } from '../../utils/tool-fold';
15
+ import type { AiDriver, AllAgentSummary } from '../ai-driver/ai-driver';
11
16
 
12
17
  const DEFAULT_MAX_TOOL_ITERATIONS = 50;
18
+ const DEFAULT_MAX_FOLD_OPERATIONS = 5;
19
+ const DEFAULT_MAX_UNKNOWN_TOOL_CALLS = 5;
13
20
  const MAX_MALFORMED_RETRIES = 2;
21
+ const SUGGESTIONS_HISTORY_WINDOW = 8;
22
+
23
+ /** Name reserved for the cross-agent handoff tool — injected by OrchestratingDriver. */
24
+ export const REQUEST_CONTINUATION_TOOL = 'request_continuation';
25
+
26
+ /** Paired in history for each `request_continuation` so tool_calls stay balanced for the provider. */
27
+ const HANDOFF_TOOL_RESULT_PLACEHOLDER =
28
+ 'Handoff to another specialist — routing continues on the next turn.';
14
29
 
15
30
  /**
16
31
  * Event emitted when the chat history is updated (new message appended).
@@ -19,6 +34,12 @@ const MAX_MALFORMED_RETRIES = 2;
19
34
  */
20
35
  export type ChatHistoryUpdatedEvent = CustomEvent<ReadonlyArray<ChatMessage>>;
21
36
 
37
+ interface FoldStackFrame {
38
+ foldName: string;
39
+ previousDefinitions: ChatToolDefinition[];
40
+ previousHandlers: ChatToolHandlers;
41
+ }
42
+
22
43
  /**
23
44
  * Plain TS class that drives a multi-turn chat conversation, including the tool-call loop.
24
45
  * Owned by `FoundationAiAssistant` — created in `connectedCallback`, torn down in `disconnectedCallback`.
@@ -29,7 +50,7 @@ export type ChatHistoryUpdatedEvent = CustomEvent<ReadonlyArray<ChatMessage>>;
29
50
  *
30
51
  * @beta
31
52
  */
32
- export class ChatDriver extends EventTarget {
53
+ export class ChatDriver extends EventTarget implements AiDriver {
33
54
  private history: ChatMessage[] = [];
34
55
  private busy = false;
35
56
  private pendingInteractions = new Map<
@@ -37,26 +58,159 @@ export class ChatDriver extends EventTarget {
37
58
  { resolve: (value: any) => void; reject: (reason?: any) => void }
38
59
  >();
39
60
 
61
+ private systemPrompt?: string;
62
+ private toolDefinitions: ChatToolDefinition[];
63
+ private toolHandlers: ChatToolHandlers;
64
+ private primerHistory?: ChatMessage[];
65
+ private activeAgentName?: string;
66
+ /**
67
+ * When set (e.g. by OrchestratingDriver), applied only to the conversation slice
68
+ * sent to the model — stored `history` stays unchanged for UI and logging.
69
+ */
70
+ private providerHistoryTransform?: (history: ChatMessage[]) => ChatMessage[];
71
+
72
+ /** Stack of fold frames — grows when a fold opens, shrinks when it closes. */
73
+ private foldStack: FoldStackFrame[] = [];
74
+ /** Consecutive fold open/close ops without a real tool call. Reset on real tool execution. */
75
+ private consecutiveFoldOps = 0;
76
+ /** Consecutive unknown-tool calls without a real tool call. Reset on real tool execution. */
77
+ private consecutiveUnknownToolCalls = 0;
78
+ private readonly maxFoldOperations: number;
79
+
40
80
  constructor(
41
81
  private readonly aiProvider: AIProvider,
42
- private readonly toolHandlers: ChatToolHandlers = {},
43
- private readonly toolDefinitions: ChatToolDefinition[] = [],
44
- private readonly systemPrompt?: string,
45
- private readonly primerHistory?: ChatMessage[],
82
+ toolHandlers: ChatToolHandlers = {},
83
+ toolDefinitions: ChatToolDefinition[] = [],
84
+ systemPrompt?: string,
85
+ primerHistory?: ChatMessage[],
46
86
  private readonly maxToolIterations: number = DEFAULT_MAX_TOOL_ITERATIONS,
87
+ maxFoldOperations: number = DEFAULT_MAX_FOLD_OPERATIONS,
47
88
  ) {
48
89
  super();
49
- if (!systemPrompt) {
50
- logger.error(
51
- 'ChatDriver: no systemPrompt provided. The assistant will have no instructions — set the systemPrompt property on foundation-ai-assistant.',
52
- );
53
- }
90
+ this.toolHandlers = toolHandlers;
91
+ this.toolDefinitions = toolDefinitions;
92
+ this.systemPrompt = systemPrompt;
93
+ this.primerHistory = primerHistory;
94
+ this.maxFoldOperations = maxFoldOperations;
95
+ }
96
+
97
+ /**
98
+ * Swap in a new agent's configuration. Called by OrchestratingDriver before
99
+ * each specialist turn so the shared driver runs with the right tools and prompt.
100
+ */
101
+ applyAgent(config: AgentConfig): void {
102
+ this.systemPrompt = config.systemPrompt;
103
+ this.toolDefinitions = config.toolDefinitions ?? [];
104
+ this.toolHandlers = config.toolHandlers ?? {};
105
+ this.primerHistory = config.primerHistory;
106
+ this.activeAgentName = config.name;
107
+ // Reset fold state when agent changes — each specialist starts fresh
108
+ this.foldStack = [];
109
+ this.consecutiveFoldOps = 0;
110
+ }
111
+
112
+ /**
113
+ * Optional transform applied to conversation history immediately before each LLM request.
114
+ * Cleared when `undefined`. Does not alter stored history.
115
+ */
116
+ setProviderHistoryTransform(transform?: (history: ChatMessage[]) => ChatMessage[]): void {
117
+ this.providerHistoryTransform = transform;
54
118
  }
55
119
 
56
120
  getHistory(): ReadonlyArray<ChatMessage> {
57
121
  return this.history;
58
122
  }
59
123
 
124
+ getRawHistory(): readonly ChatMessage[] {
125
+ return this.history;
126
+ }
127
+
128
+ /** Returns the current fold stack names for debugging. */
129
+ getActiveFoldNames(): string[] {
130
+ return this.foldStack.map((f) => f.foldName);
131
+ }
132
+
133
+ async getSuggestions(
134
+ history: ChatMessage[],
135
+ prompt: string,
136
+ count: number,
137
+ allAgentInfo?: AllAgentSummary[],
138
+ ): Promise<string[]> {
139
+ if (!this.aiProvider.prompt) {
140
+ logger.warn('ChatDriver: AIProvider does not implement prompt()');
141
+ return [];
142
+ }
143
+
144
+ let agentContext = '';
145
+ let toolContext = '';
146
+
147
+ if (allAgentInfo?.length) {
148
+ const agentDescriptions = allAgentInfo
149
+ .map((agent) => {
150
+ const tools = agent.tools.map((t) => t.name).join(', ');
151
+ return `- ${agent.name} (${agent.description}): ${tools ? `(Tools: ${tools})` : 'No tools'}`;
152
+ })
153
+ .join('\n');
154
+ agentContext = `The assistant has the following capabilities:\n${agentDescriptions}`;
155
+
156
+ const allToolNames = allAgentInfo
157
+ .flatMap((agent) => agent.tools.map((t) => t.name))
158
+ .filter((value, index, self) => self.indexOf(value) === index)
159
+ .join(', ');
160
+ toolContext = allToolNames
161
+ ? `You have access to the following tools across all agents: ${allToolNames}.`
162
+ : '';
163
+ } else if (this.activeAgentName) {
164
+ const toolNames = this.toolDefinitions.map((tool) => tool.name).join(', ');
165
+ agentContext = `You are currently acting as the "${this.activeAgentName}" agent.`;
166
+ toolContext = toolNames ? `You have access to the following tools: ${toolNames}.` : '';
167
+ }
168
+
169
+ let systemPrompt: string;
170
+ if (history.length === 0) {
171
+ systemPrompt = `You are generating short, simple starter prompts to show a user what an AI assistant can do. Generate exactly ${count} brief suggestions phrased as the user would write them. Keep them short and generic — do not invent specific names, IDs, or data (e.g. prefer "Search for a trade" over "Find all trades with Client A").`;
172
+ if (agentContext || toolContext) {
173
+ systemPrompt += " Base suggestions only on the agent's actual capabilities.";
174
+ if (agentContext) systemPrompt += ` ${agentContext}`;
175
+ if (toolContext) systemPrompt += ` ${toolContext}`;
176
+ systemPrompt += ' Do not suggest anything outside these capabilities.';
177
+ }
178
+ } else {
179
+ systemPrompt = `You are generating suggested follow-up prompts that a user could send to an AI assistant. Generate exactly ${count} suggestions phrased as the user would write them — first-person requests or questions directed at the agent. The first ${Math.max(0, count - 1)} should be natural follow-ups to the last turn of conversation. Do not invent specific names, IDs, or data values that do not appear in the conversation history.`;
180
+ if (agentContext || toolContext) {
181
+ systemPrompt += ' Suggestions must only cover what the agent is capable of.';
182
+ if (agentContext) systemPrompt += ` ${agentContext}`;
183
+ if (toolContext) systemPrompt += ` ${toolContext}`;
184
+ systemPrompt += ' Do not suggest anything outside these capabilities.';
185
+ }
186
+ }
187
+ if (prompt) {
188
+ systemPrompt += ` Additional guidance: "${prompt}"`;
189
+ }
190
+ systemPrompt +=
191
+ ' Output only the suggestions — one per line, no numbering, no bullets, no commentary, no preamble. Do not describe or explain the suggestions.';
192
+
193
+ const conversationContext = history
194
+ .filter(
195
+ (m) =>
196
+ (m.role === 'user' || m.role === 'assistant') &&
197
+ !m.toolCalls?.length &&
198
+ !m.thinking &&
199
+ !!m.content?.trim(),
200
+ )
201
+ .slice(-SUGGESTIONS_HISTORY_WINDOW)
202
+ .map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`)
203
+ .join('\n');
204
+
205
+ const message = conversationContext || 'The conversation has not started yet.';
206
+
207
+ const text = await this.aiProvider.prompt!(message, { systemPrompt });
208
+ return text
209
+ .split('\n')
210
+ .map((s) => s.trim())
211
+ .filter(Boolean);
212
+ }
213
+
60
214
  isBusy(): boolean {
61
215
  return this.busy;
62
216
  }
@@ -87,11 +241,12 @@ export class ChatDriver extends EventTarget {
87
241
  public resolveInteraction(interactionId: string, result: any): void {
88
242
  const interaction = this.pendingInteractions.get(interactionId);
89
243
  if (interaction) {
90
- // Mark the message as resolved before resolving the promise so the next
91
- // history-updated event carries the updated flag.
92
- const msg = this.history.find((m) => m.interaction?.interactionId === interactionId);
93
- if (msg?.interaction) {
94
- msg.interaction.resolved = true;
244
+ const idx = this.history.findIndex((m) => m.interaction?.interactionId === interactionId);
245
+ if (idx !== -1) {
246
+ this.history[idx] = {
247
+ ...this.history[idx],
248
+ interaction: { ...this.history[idx].interaction!, resolved: result },
249
+ };
95
250
  this.dispatchEvent(
96
251
  new CustomEvent<ReadonlyArray<ChatMessage>>('history-updated', {
97
252
  detail: this.history,
@@ -111,63 +266,253 @@ export class ChatDriver extends EventTarget {
111
266
  */
112
267
  public loadHistory(messages: ChatMessage[]): void {
113
268
  this.history = [...messages];
269
+ this.dispatchEvent(
270
+ new CustomEvent<ReadonlyArray<ChatMessage>>('history-updated', {
271
+ detail: this.history,
272
+ }),
273
+ );
114
274
  }
115
275
 
116
- async sendMessage(userInput: string, attachments?: ChatAttachment[]): Promise<void> {
117
- if (this.busy || (!userInput.trim() && !attachments?.length)) return;
276
+ async sendMessage(userInput: string, attachments?: ChatAttachment[]): Promise<ChatDriverResult> {
277
+ if (this.busy || (!userInput.trim() && !attachments?.length)) return { reason: 'done' };
118
278
  if (!this.aiProvider.chat) {
119
279
  logger.warn('ChatDriver: AIProvider does not implement chat()');
120
- return;
280
+ return { reason: 'done' };
121
281
  }
122
282
 
123
283
  this.busy = true;
124
284
  this.appendToHistory({ role: 'user', content: userInput, attachments });
125
285
 
126
286
  try {
127
- await this.runToolLoop(userInput, attachments);
287
+ return await this.runToolLoop(userInput, attachments);
128
288
  } catch (e) {
129
289
  logger.error('ChatDriver error:', e);
130
290
  this.appendToHistory({ role: 'assistant', content: 'Sorry, something went wrong.' });
291
+ return { reason: 'done' };
131
292
  } finally {
132
293
  this.busy = false;
133
294
  }
134
295
  }
135
296
 
136
- private async runToolLoop(userInput: string, attachments?: ChatAttachment[]): Promise<void> {
137
- const baseOptions: ChatRequestOptions = {
138
- systemPrompt: this.systemPrompt,
139
- tools: this.toolDefinitions.length ? this.toolDefinitions : undefined,
140
- };
297
+ /**
298
+ * Continue the tool loop from current history without appending a new user message.
299
+ * Used by OrchestratingDriver after an agent handoff.
300
+ */
301
+ async continueFromHistory(transientPrimer?: ChatMessage[]): Promise<ChatDriverResult> {
302
+ if (this.busy) return { reason: 'done' };
303
+ if (!this.aiProvider.chat) {
304
+ logger.warn('ChatDriver: AIProvider does not implement chat()');
305
+ return { reason: 'done' };
306
+ }
307
+
308
+ this.busy = true;
309
+ try {
310
+ return await this.runToolLoop('', undefined, transientPrimer);
311
+ } catch (e) {
312
+ logger.error('ChatDriver error:', e);
313
+ this.appendToHistory({ role: 'assistant', content: 'Sorry, something went wrong.' });
314
+ return { reason: 'done' };
315
+ } finally {
316
+ this.busy = false;
317
+ }
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Fold mechanics
322
+ // ---------------------------------------------------------------------------
323
+
324
+ /** Extract ToolFold metadata from a handler, or undefined if it isn't a fold facade. */
325
+ private getFold(toolName: string): ToolFold | undefined {
326
+ const handler = this.toolHandlers[toolName];
327
+ return handler ? ((handler as any)[TOOL_FOLD_SYMBOL] as ToolFold | undefined) : undefined;
328
+ }
329
+
330
+ /**
331
+ * Search all currently registered fold facades (and their nested folds recursively)
332
+ * to find which fold contains a given tool name. Returns the immediate parent fold name.
333
+ */
334
+ private findFoldContaining(toolName: string, handlers?: ChatToolHandlers): string | null {
335
+ const source = handlers ?? this.toolHandlers;
336
+ for (const [, handler] of Object.entries(source)) {
337
+ const fold = (handler as any)[TOOL_FOLD_SYMBOL] as ToolFold | undefined;
338
+ if (!fold) continue;
339
+ // Direct inner tool match
340
+ if (fold.handlers[toolName]) return fold.name;
341
+ // Recurse into nested folds
342
+ const nested = this.findFoldContaining(toolName, fold.handlers);
343
+ if (nested) return fold.name;
344
+ }
345
+ return null;
346
+ }
347
+
348
+ /**
349
+ * Install the fold's inner tool set, replacing (exclusive) or extending (non-exclusive)
350
+ * the current tool set. Also injects the close tool. Does NOT touch the fold stack.
351
+ */
352
+ private applyFoldToolSet(fold: ToolFold, foldName: string): void {
353
+ const closeToolName = `close_${foldName}`;
354
+ const newDefs: ChatToolDefinition[] = [];
355
+ const newHandlers: ChatToolHandlers = {};
356
+
357
+ if (!fold.exclusive) {
358
+ // Non-exclusive: keep existing tools minus the facade we just opened
359
+ for (const def of this.toolDefinitions) {
360
+ if (def.name !== foldName) newDefs.push(def);
361
+ }
362
+ for (const [name, handler] of Object.entries(this.toolHandlers)) {
363
+ if (name !== foldName) newHandlers[name] = handler;
364
+ }
365
+ }
366
+
367
+ // Install inner tools from fold metadata
368
+ newDefs.push(...fold.tools);
369
+ Object.assign(newHandlers, fold.handlers);
370
+
371
+ // Inject the close tool
372
+ newDefs.push({
373
+ name: closeToolName,
374
+ description: `Close the ${foldName} fold and return to the previous set of tools.`,
375
+ parameters: { type: 'object', properties: {} },
376
+ });
377
+ newHandlers[closeToolName] = async (): Promise<string> => this.closeFold();
378
+
379
+ this.toolDefinitions = newDefs;
380
+ this.toolHandlers = newHandlers;
381
+ }
382
+
383
+ /** Open a fold: push a stack frame, swap the tool set, return the response message. */
384
+ private openFold(
385
+ foldName: string,
386
+ fold: ToolFold,
387
+ args: Record<string, unknown>,
388
+ ): Promise<string> {
389
+ // Shortcut dispatch: model passed inner tool args directly, e.g.
390
+ // trading_tools({ search_trades: { side: "BUY" } })
391
+ for (const key of Object.keys(args)) {
392
+ const innerHandler = fold.handlers[key];
393
+ if (innerHandler) {
394
+ logger.debug(`ChatDriver: fold shortcut dispatch "${foldName}" → "${key}"`);
395
+ // Open the fold first so the tool set is correct for subsequent calls
396
+ this.pushFoldFrame(foldName);
397
+ this.applyFoldToolSet(fold, foldName);
398
+ this.consecutiveFoldOps = 0; // shortcut dispatch counts as real work
399
+ const innerArgs =
400
+ typeof args[key] === 'object' && args[key] !== null
401
+ ? (args[key] as Record<string, unknown>)
402
+ : {};
403
+ return innerHandler(innerArgs, {
404
+ requestInteraction: (c, d) => this.requestInteraction(c, d),
405
+ }).then((r) => (typeof r === 'string' ? r : JSON.stringify(r)));
406
+ }
407
+ }
408
+
409
+ // Normal two-step open
410
+ this.pushFoldFrame(foldName);
411
+ this.applyFoldToolSet(fold, foldName);
412
+
413
+ const innerToolNames = fold.tools.map((t) => t.name);
414
+ const closeToolName = `close_${foldName}`;
415
+ let message = `Fold opened: ${foldName}. Tools now available: ${[...innerToolNames, closeToolName].join(', ')}.`;
416
+ if (fold.usageNotes) message += ` Notes: ${fold.usageNotes}`;
417
+ message += ` Call ${closeToolName} when done to return to the previous tools.`;
418
+ return Promise.resolve(message);
419
+ }
420
+
421
+ private pushFoldFrame(foldName: string): void {
422
+ this.foldStack.push({
423
+ foldName,
424
+ previousDefinitions: [...this.toolDefinitions],
425
+ previousHandlers: { ...this.toolHandlers },
426
+ });
427
+ }
428
+
429
+ /** Close the top fold: pop the stack frame, restore the previous tool set. */
430
+ private closeFold(): string {
431
+ const frame = this.foldStack.pop();
432
+ if (!frame) return 'No fold is currently open.';
433
+
434
+ this.toolDefinitions = frame.previousDefinitions;
435
+ this.toolHandlers = frame.previousHandlers;
436
+
437
+ const toolNames = this.toolDefinitions.map((t) => t.name);
438
+ return `Fold closed: ${frame.foldName}. Tools now available: ${toolNames.join(', ')}.`;
439
+ }
440
+
441
+ /** Build the fold-awareness suffix appended to the system prompt each LLM call. */
442
+ private buildFoldSystemPromptSuffix(): string {
443
+ // Collect fold facades from the current handler map
444
+ const activeFolds: ToolFold[] = [];
445
+ for (const handler of Object.values(this.toolHandlers)) {
446
+ const fold = (handler as any)[TOOL_FOLD_SYMBOL] as ToolFold | undefined;
447
+ if (fold) activeFolds.push(fold);
448
+ }
449
+
450
+ if (activeFolds.length === 0 && this.foldStack.length === 0) return '';
451
+
452
+ const parts: string[] = ['\n\n--- Tool Folds ---'];
453
+ parts.push(
454
+ 'Some tools are grouped into folds. You may see tool calls in the conversation history for tools that are not currently available — they are inside a fold. To access them, invoke the fold tool first.',
455
+ );
456
+
457
+ if (this.foldStack.length > 0) {
458
+ const current = this.foldStack[this.foldStack.length - 1];
459
+ parts.push(
460
+ `You are currently inside the "${current.foldName}" fold. Call close_${current.foldName} when you are done with these tools.`,
461
+ );
462
+ }
463
+
464
+ for (const fold of activeFolds) {
465
+ parts.push(`• ${fold.name}: ${fold.description}`);
466
+ }
467
+
468
+ return parts.join('\n');
469
+ }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // Tool loop
473
+ // ---------------------------------------------------------------------------
474
+
475
+ private async runToolLoop(
476
+ userInput: string,
477
+ attachments?: ChatAttachment[],
478
+ transientPrimer?: ChatMessage[],
479
+ ): Promise<ChatDriverResult> {
480
+ if (!this.systemPrompt) {
481
+ logger.warn(
482
+ 'ChatDriver: no systemPrompt set. The assistant will have no instructions — provide a systemPrompt via agents config or the foundation-ai-assistant property.',
483
+ );
484
+ }
141
485
 
142
- // History has the user message at the end — pass everything before it as history,
143
- // and the user input as the userMessage argument.
144
486
  let currentInput = userInput;
145
487
  let currentAttachments: ChatAttachment[] | undefined = attachments;
146
488
  let iterations = 0;
147
489
  let malformedAttempts = 0;
490
+ const startIteration = currentInput ? 1 : 0;
148
491
 
149
492
  while (iterations < this.maxToolIterations) {
150
493
  iterations += 1;
151
494
 
152
- // On the first iteration, the last item in history is the user message which is
153
- // passed separately as currentInput — exclude it. On subsequent iterations, the
154
- // full history (including tool results) should be sent and currentInput is empty.
155
- // primerHistory (if provided) is prepended to every call but never stored in
156
- // this.history and never shown in the UI.
157
- const primer = this.primerHistory ?? [];
158
- const historyForCall =
159
- iterations === 1 ? [...primer, ...this.history.slice(0, -1)] : [...primer, ...this.history];
160
-
161
- // On malformed-call retries, augment the system prompt to steer the model
162
- // away from generating Python-style batched function call syntax.
495
+ const foldSuffix = this.buildFoldSystemPromptSuffix();
496
+ const baseSystemPrompt = this.systemPrompt
497
+ ? `${this.systemPrompt}${foldSuffix}`
498
+ : foldSuffix || undefined;
499
+
500
+ const primer = [...(this.primerHistory ?? []), ...(transientPrimer ?? [])];
501
+ const baseHistory = iterations === startIteration ? this.history.slice(0, -1) : this.history;
502
+ const historyForProvider = this.providerHistoryTransform
503
+ ? this.providerHistoryTransform([...baseHistory])
504
+ : baseHistory;
505
+ const historyForCall = [...primer, ...historyForProvider];
506
+
163
507
  const systemPrompt =
164
508
  malformedAttempts > 0
165
- ? `${baseOptions.systemPrompt ?? ''}\n\nIMPORTANT: Use only the structured function-call API to invoke tools. Do not write Python code or use Python-style syntax to call tools.`
166
- : baseOptions.systemPrompt;
509
+ ? `${baseSystemPrompt ?? ''}\n\nIMPORTANT: Use only the structured function-call API to invoke tools. Do not write Python code or use Python-style syntax to call tools.`
510
+ : baseSystemPrompt;
167
511
 
168
512
  const options: ChatRequestOptions = {
169
- ...baseOptions,
170
513
  systemPrompt,
514
+ // Strip fold-only properties (foldEvent, foldPath) before sending to provider
515
+ tools: this.toolDefinitions.length ? this.toolDefinitions : undefined,
171
516
  attachments: currentAttachments,
172
517
  };
173
518
 
@@ -182,7 +527,7 @@ export class ChatDriver extends EventTarget {
182
527
  logger.warn(
183
528
  `ChatDriver: MALFORMED_FUNCTION_CALL, retrying (${malformedAttempts}/${MAX_MALFORMED_RETRIES})`,
184
529
  );
185
- iterations -= 1; // don't consume an iteration budget slot for a failed attempt
530
+ iterations -= 1;
186
531
  continue;
187
532
  }
188
533
  logger.error('ChatDriver: MALFORMED_FUNCTION_CALL, max retries reached');
@@ -191,20 +536,32 @@ export class ChatDriver extends EventTarget {
191
536
  content:
192
537
  "I'm sorry, I wasn't able to complete that request. Please try rephrasing or breaking it into smaller steps.",
193
538
  });
194
- return;
539
+ return { reason: 'done' };
195
540
  }
196
541
  throw e;
197
542
  }
198
543
 
199
- currentAttachments = undefined; // attachments only sent on first call
544
+ currentAttachments = undefined;
200
545
 
201
546
  const isThinkingStep = response.content && response.toolCalls?.length;
202
547
  const isEmptyResponse = !response.content?.trim() && !response.toolCalls?.length;
203
548
 
204
549
  if (isEmptyResponse) {
205
- // Do nothing, discard empty/whitespace-only responses
550
+ malformedAttempts += 1;
551
+ if (malformedAttempts < MAX_MALFORMED_RETRIES) {
552
+ logger.warn(
553
+ `ChatDriver: empty model response, retrying (${malformedAttempts}/${MAX_MALFORMED_RETRIES})`,
554
+ );
555
+ iterations -= 1;
556
+ continue;
557
+ }
558
+ logger.error('ChatDriver: empty model response after all retries');
559
+ this.appendToHistory({
560
+ role: 'assistant',
561
+ content: 'Remote agent returned no response.',
562
+ });
563
+ return { reason: 'done' };
206
564
  } else if (isThinkingStep) {
207
- // Separate thinking message and tool call message
208
565
  this.appendToHistory({ ...response, toolCalls: undefined, thinking: true });
209
566
  this.appendToHistory({ ...response, content: '' });
210
567
  } else {
@@ -212,52 +569,222 @@ export class ChatDriver extends EventTarget {
212
569
  }
213
570
 
214
571
  if (!response.toolCalls?.length) {
215
- // Terminal text response — done
216
572
  break;
217
573
  }
218
574
 
219
- // Execute all tool calls for this iteration concurrently, then append results in order
220
- // eslint-disable-next-line no-await-in-loop
221
- const toolResults = await Promise.all(
222
- response.toolCalls.map(async (tc) => {
223
- const handler = this.toolHandlers[tc.name];
224
- if (!handler) {
225
- logger.warn(`ChatDriver: no handler registered for tool "${tc.name}"`);
226
- return { toolCallId: tc.id, content: `Unknown tool: ${tc.name}` };
227
- }
228
- try {
229
- const result = await handler(tc.args, {
230
- requestInteraction: (componentName, data) =>
231
- this.requestInteraction(componentName, data),
232
- });
233
- const content = typeof result === 'string' ? result : JSON.stringify(result);
234
- return { toolCallId: tc.id, content };
235
- } catch (e) {
236
- logger.error(`ChatDriver tool "${tc.name}" failed:`, e);
237
- return { toolCallId: tc.id, content: `Tool error: ${(e as Error).message}` };
238
- }
239
- }),
575
+ const [toolCalls, systemCalls] = response.toolCalls.reduce<[ChatToolCall[], ChatToolCall[]]>(
576
+ (acc, tc) => {
577
+ if (tc.name === REQUEST_CONTINUATION_TOOL) acc[1].push(tc);
578
+ else acc[0].push(tc);
579
+ return acc;
580
+ },
581
+ [[], []],
240
582
  );
241
583
 
242
- for (const result of toolResults) {
584
+ const executedById = new Map<string, { toolCallId: string; content: string }>();
585
+ const unknownToolIds = new Set<string>();
586
+ let anyRealToolExecuted = false;
587
+ let hitUnknownToolLimit = false;
588
+
589
+ if (toolCalls.length > 0) {
590
+ // eslint-disable-next-line no-await-in-loop
591
+ await Promise.all(
592
+ toolCalls.map(async (tc) => {
593
+ // Check for fold facade
594
+ const fold = this.getFold(tc.name);
595
+ if (fold) {
596
+ this.consecutiveFoldOps += 1;
597
+ if (this.consecutiveFoldOps > this.maxFoldOperations) {
598
+ logger.warn(
599
+ `ChatDriver: fold operation limit (${this.maxFoldOperations}) reached — injecting guidance`,
600
+ );
601
+ executedById.set(tc.id, {
602
+ toolCallId: tc.id,
603
+ content: `You have opened and closed folds ${this.consecutiveFoldOps} times without calling any tools. Please call a specific tool to make progress, or respond to the user.`,
604
+ });
605
+ return;
606
+ }
607
+ const content = await this.openFold(tc.name, fold, tc.args);
608
+ executedById.set(tc.id, { toolCallId: tc.id, content });
609
+ // Fold open/close does NOT count as a real iteration — decrement to compensate
610
+ iterations -= 1;
611
+ return;
612
+ }
613
+
614
+ // Check for close-fold tool
615
+ if (tc.name.startsWith('close_') && this.foldStack.length > 0) {
616
+ const topFoldName = this.foldStack[this.foldStack.length - 1].foldName;
617
+ if (tc.name === `close_${topFoldName}`) {
618
+ this.consecutiveFoldOps += 1;
619
+ if (this.consecutiveFoldOps > this.maxFoldOperations) {
620
+ executedById.set(tc.id, {
621
+ toolCallId: tc.id,
622
+ content: `You have opened and closed folds ${this.consecutiveFoldOps} times without calling any tools. Please call a specific tool to make progress, or respond to the user.`,
623
+ });
624
+ return;
625
+ }
626
+ const content = this.closeFold();
627
+ executedById.set(tc.id, { toolCallId: tc.id, content });
628
+ iterations -= 1;
629
+ return;
630
+ }
631
+ }
632
+
633
+ // Regular tool — check if it's inside a fold and guide the model
634
+ const handler = this.toolHandlers[tc.name];
635
+ if (!handler) {
636
+ const containingFold = this.findFoldContaining(tc.name);
637
+ if (containingFold) {
638
+ logger.debug(
639
+ `ChatDriver: model called folded tool "${tc.name}" — guiding to open "${containingFold}"`,
640
+ );
641
+ executedById.set(tc.id, {
642
+ toolCallId: tc.id,
643
+ content: `"${tc.name}" is not directly available. It is inside the "${containingFold}" fold. Call ${containingFold} first to access it.`,
644
+ });
645
+ // Guidance does not count as a real iteration or fold op
646
+ iterations -= 1;
647
+ } else {
648
+ this.consecutiveUnknownToolCalls += 1;
649
+ logger.warn(
650
+ `ChatDriver: no handler registered for tool "${tc.name}" (${this.consecutiveUnknownToolCalls}/${DEFAULT_MAX_UNKNOWN_TOOL_CALLS}). Available tools: ${Object.keys(this.toolHandlers).join(', ') || '(none)'}`,
651
+ );
652
+ executedById.set(tc.id, { toolCallId: tc.id, content: `Unknown tool: ${tc.name}` });
653
+ unknownToolIds.add(tc.id);
654
+ if (this.consecutiveUnknownToolCalls >= DEFAULT_MAX_UNKNOWN_TOOL_CALLS) {
655
+ hitUnknownToolLimit = true;
656
+ }
657
+ }
658
+ return;
659
+ }
660
+
661
+ // Real tool execution
662
+ try {
663
+ const result = await handler(tc.args, {
664
+ requestInteraction: (componentName, data) =>
665
+ this.requestInteraction(componentName, data),
666
+ });
667
+ const content = typeof result === 'string' ? result : JSON.stringify(result);
668
+ executedById.set(tc.id, { toolCallId: tc.id, content });
669
+ anyRealToolExecuted = true;
670
+ } catch (e) {
671
+ logger.error(`ChatDriver tool "${tc.name}" failed:`, e);
672
+ executedById.set(tc.id, {
673
+ toolCallId: tc.id,
674
+ content: `Tool error: ${(e as Error).message}`,
675
+ });
676
+ anyRealToolExecuted = true; // treat errors as real work for fold op counting
677
+ }
678
+ }),
679
+ );
680
+ }
681
+
682
+ // Reset counters whenever a real tool executes
683
+ if (anyRealToolExecuted) {
684
+ this.consecutiveFoldOps = 0;
685
+ this.consecutiveUnknownToolCalls = 0;
686
+ }
687
+
688
+ // Tag tool calls with fold UI metadata before appending results
689
+ const foldPath = this.foldStack.map((f) => f.foldName);
690
+
691
+ for (const tc of response.toolCalls) {
692
+ if (tc.name === REQUEST_CONTINUATION_TOOL) {
693
+ this.appendToHistory({
694
+ role: 'tool',
695
+ content: '',
696
+ toolResult: { toolCallId: tc.id, content: HANDOFF_TOOL_RESULT_PLACEHOLDER },
697
+ });
698
+ } else {
699
+ const r = executedById.get(tc.id);
700
+ if (r) {
701
+ this.appendToHistory({ role: 'tool', content: '', toolResult: r });
702
+ }
703
+ }
704
+ }
705
+
706
+ // Back-patch foldEvent and foldPath onto the tool call message we just appended.
707
+ // The response was appended before execution — find it and annotate.
708
+ let tcMsgIdx = -1;
709
+ for (let i = this.history.length - 1; i >= 0; i -= 1) {
710
+ if (this.history[i].role === 'assistant' && this.history[i].toolCalls?.length) {
711
+ tcMsgIdx = i;
712
+ break;
713
+ }
714
+ }
715
+ if (tcMsgIdx !== -1) {
716
+ const tcMsg = this.history[tcMsgIdx];
717
+ const availableToolNames = Object.keys(this.toolHandlers);
718
+ const annotatedCalls = tcMsg.toolCalls!.map((tc) => {
719
+ const isFoldOpen =
720
+ !!this.getFold(tc.name) ||
721
+ // Was a fold facade at time of the call (now the tool set has changed)
722
+ // — detect by checking if the result message indicated a fold open
723
+ executedById.get(tc.id)?.content?.startsWith('Fold opened:');
724
+ const isFoldClose = executedById.get(tc.id)?.content?.startsWith('Fold closed:');
725
+ const isUnknown = unknownToolIds.has(tc.id);
726
+ return {
727
+ ...tc,
728
+ foldEvent: isFoldOpen
729
+ ? ('open' as const)
730
+ : isFoldClose
731
+ ? ('close' as const)
732
+ : undefined,
733
+ // Use the fold path that was active at the START of this iteration (before any opens/closes)
734
+ foldPath: !isFoldOpen && !isFoldClose && foldPath.length > 0 ? foldPath : undefined,
735
+ unknown: isUnknown || undefined,
736
+ availableTools: isUnknown ? availableToolNames : undefined,
737
+ };
738
+ });
739
+ this.history[tcMsgIdx] = { ...tcMsg, toolCalls: annotatedCalls };
740
+ this.dispatchEvent(
741
+ new CustomEvent<ReadonlyArray<ChatMessage>>('history-updated', {
742
+ detail: this.history,
743
+ }),
744
+ );
745
+ }
746
+
747
+ if (hitUnknownToolLimit) {
748
+ logger.error(
749
+ `ChatDriver: unknown-tool limit (${DEFAULT_MAX_UNKNOWN_TOOL_CALLS}) reached — stopping`,
750
+ );
243
751
  this.appendToHistory({
244
- role: 'tool',
245
- content: '',
246
- toolResult: result,
752
+ role: 'assistant',
753
+ content:
754
+ "I'm sorry, I repeatedly tried to use tools that don't exist. Please check your agent configuration or try rephrasing your request.",
247
755
  });
756
+ return { reason: 'done' };
757
+ }
758
+
759
+ const firstContinuation = systemCalls[0];
760
+ if (firstContinuation) {
761
+ const { summary, remaining_task: remainingTask } = firstContinuation.args as {
762
+ summary: string;
763
+ remaining_task: string;
764
+ };
765
+ return { reason: 'agent-handoff', summary, remainingTask };
248
766
  }
249
767
 
250
- // Next iteration sends an empty string — the tool results are in history
251
768
  currentInput = '';
252
769
  }
253
770
 
254
771
  if (iterations >= this.maxToolIterations) {
255
772
  logger.warn('ChatDriver: reached max tool iterations, stopping');
773
+ this.appendToHistory({
774
+ role: 'assistant',
775
+ content:
776
+ "I've reached my limit for this response. You can ask me to continue and I'll pick up where I left off.",
777
+ });
256
778
  }
779
+
780
+ return { reason: 'done' };
257
781
  }
258
782
 
259
783
  private appendToHistory(message: ChatMessage): void {
260
- this.history = [...this.history, message];
784
+ const tagged: ChatMessage = this.activeAgentName
785
+ ? { ...message, agentName: this.activeAgentName }
786
+ : message;
787
+ this.history = [...this.history, tagged];
261
788
  this.dispatchEvent(
262
789
  new CustomEvent<ReadonlyArray<ChatMessage>>('history-updated', {
263
790
  detail: this.history,