@genesislcap/ai-assistant 15.6.2 → 15.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/ai-assistant.api.json +391 -5
  2. package/dist/ai-assistant.d.ts +613 -6
  3. package/dist/chat-driver.cjs +285 -26
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +285 -26
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +254 -10
  8. package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
  9. package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
  13. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
  14. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
  15. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
  16. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  17. package/dist/dts/main/blocked-state.test.d.ts +2 -0
  18. package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
  19. package/dist/dts/main/main.d.ts +394 -6
  20. package/dist/dts/main/main.d.ts.map +1 -1
  21. package/dist/dts/main/main.styles.d.ts.map +1 -1
  22. package/dist/dts/main/main.styles.test.d.ts +2 -0
  23. package/dist/dts/main/main.styles.test.d.ts.map +1 -0
  24. package/dist/dts/main/main.template.d.ts +53 -0
  25. package/dist/dts/main/main.template.d.ts.map +1 -1
  26. package/dist/dts/state/ai-assistant-slice.d.ts +162 -6
  27. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  28. package/dist/dts/state/debug-event-log.d.ts +6 -1
  29. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  30. package/dist/dts/state/session-store.d.ts +11 -0
  31. package/dist/dts/state/session-store.d.ts.map +1 -1
  32. package/dist/esm/components/chat-driver/chat-driver.js +263 -21
  33. package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
  34. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
  35. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
  36. package/dist/esm/main/blocked-state.test.js +969 -0
  37. package/dist/esm/main/main.js +704 -16
  38. package/dist/esm/main/main.styles.js +47 -0
  39. package/dist/esm/main/main.styles.test.js +86 -0
  40. package/dist/esm/main/main.template.js +121 -4
  41. package/dist/esm/state/ai-assistant-slice.js +145 -7
  42. package/dist/esm/state/ai-assistant-slice.test.js +138 -1
  43. package/dist/esm/state/debug-event-log.js +7 -2
  44. package/dist/esm/state/debug-event-log.test.js +49 -1
  45. package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/docs/migration-GENC-1464.md +562 -0
  48. package/docs/sub_agent.md +20 -3
  49. package/package.json +17 -17
  50. package/src/channel/ai-activity-channel.ts +56 -2
  51. package/src/components/chat-driver/chat-driver.test.ts +549 -0
  52. package/src/components/chat-driver/chat-driver.ts +324 -14
  53. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
  54. package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
  55. package/src/main/blocked-state.test.ts +1316 -0
  56. package/src/main/main.styles.test.ts +103 -0
  57. package/src/main/main.styles.ts +47 -0
  58. package/src/main/main.template.ts +131 -4
  59. package/src/main/main.ts +704 -10
  60. package/src/state/ai-assistant-slice.test.ts +215 -0
  61. package/src/state/ai-assistant-slice.ts +218 -8
  62. package/src/state/debug-event-log.test.ts +63 -0
  63. package/src/state/debug-event-log.ts +7 -2
  64. package/src/state/persistence/session-snapshot.test.ts +22 -0
@@ -0,0 +1,438 @@
1
+ import type {
2
+ AIProvider,
3
+ AIProviderRegistry,
4
+ ChatDriverResult,
5
+ ChatMessage,
6
+ ChatRequestOptions,
7
+ } from '@genesislcap/foundation-ai';
8
+ import { BudgetExhaustedError } from '@genesislcap/foundation-ai';
9
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
10
+ import { AgenticActivityBus } from '../../channel/ai-activity-bus';
11
+ import type { AgentConfig } from '../../config/config';
12
+ // Side-effect import — MUST precede `./orchestrating-driver` so the driver
13
+ // subclasses jsdom's EventTarget (CustomEvent dispatch then works in node).
14
+ import '../chat-driver/align-event-globals';
15
+ import { OrchestratingDriver } from './orchestrating-driver';
16
+
17
+ // GENC-1464 — the orchestrated turn's OUTCOME.
18
+ //
19
+ // `createDriver` returns an `OrchestratingDriver` for every agents-configured
20
+ // host, and its `sendMessage` used to end with a hardcoded `return { reason:
21
+ // 'done' }`. That made every typed failure unreachable for those hosts —
22
+ // including `'budget-exhausted'`, which the assistant element latches its blocked
23
+ // state off. A 402 therefore produced an apology over a still-enabled composer
24
+ // for exactly the configurations this workstream exists to fix.
25
+ //
26
+ // The classifier compounds it: `classify()` is a SECOND retry ladder stacked on
27
+ // the transport's, so an untyped catch re-issued the 402 `classifierRetries + 1`
28
+ // times and then ran a fallback turn into the same wall.
29
+
30
+ const makeRegistry = (provider: AIProvider): AIProviderRegistry => ({
31
+ get: () => provider,
32
+ default: () => provider,
33
+ defaultName: () => 'test',
34
+ names: () => ['test'],
35
+ getStatus: async () => null,
36
+ listStatuses: async () => [],
37
+ });
38
+
39
+ const agent = (overrides: Partial<AgentConfig> & { name: string }): AgentConfig =>
40
+ ({ description: 'test agent', ...overrides }) as AgentConfig;
41
+
42
+ /** A provider that ends every turn immediately with a plain-text reply (no tools). */
43
+ const okProvider = (): AIProvider =>
44
+ ({
45
+ chat: async (): Promise<ChatMessage> => ({ role: 'assistant', content: 'ok' }),
46
+ }) as AIProvider;
47
+
48
+ /** A provider that counts calls and always refuses for budget. */
49
+ const walledProvider = (
50
+ vendorLabel = 'Anthropic',
51
+ ): { calls: () => number; provider: AIProvider } => {
52
+ let n = 0;
53
+ return {
54
+ calls: () => n,
55
+ provider: {
56
+ chat: async (): Promise<ChatMessage> => {
57
+ n += 1;
58
+ throw new BudgetExhaustedError(vendorLabel, 25, 25.4);
59
+ },
60
+ } as AIProvider,
61
+ };
62
+ };
63
+
64
+ const failureReasonOf = (r: ChatDriverResult) =>
65
+ r.reason === 'done' ? r.failureReason : undefined;
66
+
67
+ const Suite = createLogicSuite('orchestrating-driver budget wall');
68
+
69
+ // ── 1. The showstopper: a walled AGENT turn must reach the caller ───────────
70
+
71
+ Suite('a budget wall on the agent turn surfaces as the orchestrated result', async () => {
72
+ // ONE specialist and no fallback, so `classify` short-circuits without calling
73
+ // the provider — the wall is hit by the AGENT turn, inside a real tool loop.
74
+ // Against the old hardcoded return this deep-equal fails.
75
+ const { provider } = walledProvider();
76
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
77
+ sessionKey: 'budget-1',
78
+ });
79
+
80
+ const result = await driver.sendMessage('go');
81
+
82
+ assert.equal(result, {
83
+ reason: 'done',
84
+ failureReason: 'budget-exhausted',
85
+ budget: { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic', vendor: 'anthropic' },
86
+ });
87
+ });
88
+
89
+ Suite("the vendor is the refusing transport's, not a hardcoded default", async () => {
90
+ // The label rides the contract; `vendor` is its normalised key. Without a
91
+ // second vendor exercised here, a constant would pass every other assertion in
92
+ // this file — and per-vendor blocking would wall the wrong vendor forever.
93
+ const { provider } = walledProvider('Gemini');
94
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
95
+ sessionKey: 'budget-gemini',
96
+ });
97
+
98
+ const result = await driver.sendMessage('go');
99
+
100
+ assert.equal(result.reason === 'done' ? result.budget : undefined, {
101
+ budgetUsd: 25,
102
+ spentUsd: 25.4,
103
+ vendorLabel: 'Gemini',
104
+ vendor: 'gemini',
105
+ });
106
+ });
107
+
108
+ Suite('an unrecognised vendor label carries no typed vendor at all', async () => {
109
+ // Attribution degrades to "unattributable" rather than to a wrong vendor —
110
+ // which makes the element fall back to its vendor-agnostic block. Safe.
111
+ const { provider } = walledProvider('Acme AI');
112
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
113
+ sessionKey: 'budget-unknown-vendor',
114
+ });
115
+
116
+ const result = await driver.sendMessage('go');
117
+
118
+ assert.equal(result.reason === 'done' ? result.budget : undefined, {
119
+ budgetUsd: 25,
120
+ spentUsd: 25.4,
121
+ vendorLabel: 'Acme AI',
122
+ });
123
+ });
124
+
125
+ Suite('the wall also reaches the tool-loop-end bus detail through the orchestrator', async () => {
126
+ const bus = new AgenticActivityBus();
127
+ const seen: Array<{ failureReason?: string } | undefined> = [];
128
+ const stop = bus.subscribe('tool-loop-end', (d) => seen.push(d));
129
+ const { provider } = walledProvider();
130
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
131
+ sessionKey: 'budget-2',
132
+ activityBus: bus,
133
+ });
134
+
135
+ await driver.sendMessage('go');
136
+
137
+ assert.is(seen.length, 1, 'exactly one tool-loop-end for the one agent turn');
138
+ assert.is(seen[0]?.failureReason, 'budget-exhausted');
139
+ stop();
140
+ bus.close();
141
+ });
142
+
143
+ Suite('the bus detail carries the proxy figures, not just the failure reason', async () => {
144
+ // The element latches off THIS event for an in-loop wall — the publish sits in
145
+ // sendMessage's `finally`, so it lands before the return-value seam and the
146
+ // idempotent latch makes the later one a no-op. If the figures rode only the
147
+ // return value they would be unreachable in the common case and the banner would
148
+ // show the generic copy in exactly the path they were added for.
149
+ const bus = new AgenticActivityBus();
150
+ const seen: Array<
151
+ | {
152
+ failureReason?: string;
153
+ vendor?: string;
154
+ budget?: {
155
+ budgetUsd?: number;
156
+ spentUsd?: number;
157
+ vendorLabel: string;
158
+ vendor?: string;
159
+ };
160
+ }
161
+ | undefined
162
+ > = [];
163
+ const stop = bus.subscribe('tool-loop-end', (d) => seen.push(d));
164
+ const { provider } = walledProvider();
165
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
166
+ sessionKey: 'budget-figures',
167
+ activityBus: bus,
168
+ });
169
+
170
+ const result = await driver.sendMessage('go');
171
+
172
+ assert.equal(
173
+ seen[0]?.budget,
174
+ { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic', vendor: 'anthropic' },
175
+ 'the event carries the same figures as the return value',
176
+ );
177
+ assert.is(
178
+ seen[0]?.vendor,
179
+ 'anthropic',
180
+ 'and the top-level vendor agrees with the refusing transport, not with a stale last-resolved provider',
181
+ );
182
+ assert.equal(
183
+ result.reason === 'done' ? result.budget : undefined,
184
+ seen[0]?.budget,
185
+ 'event and return value agree — neither is the sole carrier',
186
+ );
187
+ stop();
188
+ bus.close();
189
+ });
190
+
191
+ Suite('a clean turn publishes no budget key at all', async () => {
192
+ const bus = new AgenticActivityBus();
193
+ const seen: Array<{ budget?: unknown } | undefined> = [];
194
+ const stop = bus.subscribe('tool-loop-end', (d) => seen.push(d));
195
+ const provider: AIProvider = {
196
+ chat: async () => ({ role: 'assistant', content: 'ok' }) as ChatMessage,
197
+ } as unknown as AIProvider;
198
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
199
+ sessionKey: 'budget-clean',
200
+ activityBus: bus,
201
+ });
202
+
203
+ await driver.sendMessage('go');
204
+
205
+ assert.ok(
206
+ seen.every((d) => d === undefined || !('budget' in d)),
207
+ 'no budget key on a non-budget turn — the historical detail shape is unchanged',
208
+ );
209
+ stop();
210
+ bus.close();
211
+ });
212
+
213
+ // ── 2. No regression on the happy / handoff paths ───────────────────────────
214
+
215
+ Suite('a clean turn still returns the bare legacy shape', async () => {
216
+ const driver = new OrchestratingDriver(makeRegistry(okProvider()), [agent({ name: 'Solo' })], {
217
+ sessionKey: 'budget-3',
218
+ });
219
+
220
+ const result = await driver.sendMessage('go');
221
+
222
+ assert.equal(result, { reason: 'done' }, 'no failureReason on a clean turn');
223
+ });
224
+
225
+ Suite('a handoff-capped turn never leaks agent-handoff to the caller', async () => {
226
+ // `'agent-handoff'` is an internal routing protocol. The two breaks that hold a
227
+ // non-'done' inner result both carry it, so the collapse rule in `sendMessage`
228
+ // is what keeps "report the turn that ended the loop" from changing the
229
+ // discriminant a host matches on.
230
+ let n = 0;
231
+ const nextId = () => {
232
+ n += 1;
233
+ return n;
234
+ };
235
+ const handoffProvider: AIProvider = {
236
+ chat: async (
237
+ _h: ChatMessage[],
238
+ _u: string,
239
+ options?: ChatRequestOptions,
240
+ ): Promise<ChatMessage> => {
241
+ // The classifier call carries the routing tool; answer it with index 0.
242
+ if (options?.tools?.some((t) => t.name === 'select_agent')) {
243
+ return {
244
+ role: 'assistant',
245
+ content: '',
246
+ toolCalls: [{ id: `c-${nextId()}`, name: 'select_agent', args: { agent_index: 0 } }],
247
+ };
248
+ }
249
+ // Every agent turn asks to hand off, so the handoff cap is what ends it.
250
+ return {
251
+ role: 'assistant',
252
+ content: '',
253
+ toolCalls: [
254
+ {
255
+ id: `h-${nextId()}`,
256
+ name: 'request_continuation',
257
+ args: { summary: 's', remaining_task: 'more' },
258
+ },
259
+ ],
260
+ };
261
+ },
262
+ };
263
+ const driver = new OrchestratingDriver(
264
+ makeRegistry(handoffProvider),
265
+ [agent({ name: 'A' }), agent({ name: 'B' })],
266
+ { sessionKey: 'budget-4', maxHandoffs: 1 },
267
+ );
268
+
269
+ const result = await driver.sendMessage('go');
270
+
271
+ assert.is(result.reason, 'done', 'the internal handoff protocol never escapes');
272
+ });
273
+
274
+ Suite('a wall on a post-handoff classify does not duplicate the user message', async () => {
275
+ // `classify()` runs at TWO seams. Before the first agent turn the user's
276
+ // message is unappended (only the optimistic `history-updated` echo exists),
277
+ // so the wall-catch must append it. At the END of a handoff iteration
278
+ // `chatDriver.sendMessage` has already appended it — passing it again
279
+ // produced ['user','assistant','tool','user','assistant'] with 'go' twice,
280
+ // persisting into the snapshot and into the history sent to the model once
281
+ // the budget was raised. The single-specialist narrowness guard below cannot
282
+ // catch this: with one specialist, classify short-circuits and the handoff
283
+ // seam never runs.
284
+ let n = 0;
285
+ const provider: AIProvider = {
286
+ chat: async (
287
+ _h: ChatMessage[],
288
+ _u: string,
289
+ options?: ChatRequestOptions,
290
+ ): Promise<ChatMessage> => {
291
+ n += 1;
292
+ if (options?.tools?.some((t) => t.name === 'select_agent')) {
293
+ // First classify routes; the post-handoff classify hits the wall.
294
+ if (n > 2) throw new BudgetExhaustedError('Anthropic', 25, 25.4);
295
+ return {
296
+ role: 'assistant',
297
+ content: '',
298
+ toolCalls: [{ id: `c-${n}`, name: 'select_agent', args: { agent_index: 0 } }],
299
+ };
300
+ }
301
+ // The agent turn asks to hand off, forcing the second classify.
302
+ return {
303
+ role: 'assistant',
304
+ content: '',
305
+ toolCalls: [
306
+ {
307
+ id: `h-${n}`,
308
+ name: 'request_continuation',
309
+ args: { summary: 's', remaining_task: 'more' },
310
+ },
311
+ ],
312
+ };
313
+ },
314
+ };
315
+ const driver = new OrchestratingDriver(
316
+ makeRegistry(provider),
317
+ [agent({ name: 'A' }), agent({ name: 'B' })],
318
+ { sessionKey: 'budget-4b' },
319
+ );
320
+
321
+ const result = await driver.sendMessage('go');
322
+
323
+ assert.is(failureReasonOf(result), 'budget-exhausted');
324
+ const history = driver.getRawHistory() as ChatMessage[];
325
+ const userMessages = history.filter((m) => m.role === 'user' && m.content === 'go');
326
+ assert.is(userMessages.length, 1, "the user's message appears exactly once");
327
+ });
328
+
329
+ // ── 3. The classifier's own retry ladder ───────────────────────────────────
330
+
331
+ Suite('a wall during classification costs exactly one provider call', async () => {
332
+ // TWO specialists and no fallback, so `classify` really runs. Before the narrow
333
+ // rethrow this was FOUR calls: `classifierRetries + 1` classifier attempts, then
334
+ // a doomed agent turn.
335
+ const { calls, provider } = walledProvider();
336
+ const driver = new OrchestratingDriver(
337
+ makeRegistry(provider),
338
+ [agent({ name: 'A' }), agent({ name: 'B' })],
339
+ { sessionKey: 'budget-5' },
340
+ );
341
+
342
+ const result = await driver.sendMessage('go');
343
+
344
+ assert.is(calls(), 1, 'no ladder clears a budget wall — do not climb it');
345
+ assert.is(failureReasonOf(result), 'budget-exhausted');
346
+ });
347
+
348
+ Suite('a classify-time wall lands on the transcript, without a routing apology', async () => {
349
+ // The old ordering emitted "I'm not sure how to help with that…" from the
350
+ // no-fallback exit and THEN routed anyway, so the user read a routing excuse
351
+ // the classifier never earned, immediately above the real error.
352
+ const { provider } = walledProvider();
353
+ const driver = new OrchestratingDriver(
354
+ makeRegistry(provider),
355
+ [agent({ name: 'A' }), agent({ name: 'B' })],
356
+ { sessionKey: 'budget-6' },
357
+ );
358
+
359
+ await driver.sendMessage('go');
360
+
361
+ const history = driver.getRawHistory() as ChatMessage[];
362
+ const text = history.map((m) => m.content).join('\n');
363
+ assert.ok(text.includes('AI usage limit'), 'the real reason is what the user reads');
364
+ assert.not.ok(text.includes("I'm not sure how to help with that"));
365
+ // The user's own message must survive. `runOrchestratedTurn` dispatches it
366
+ // optimistically and leaves the real append to `chatDriver.sendMessage`, which
367
+ // a classify-time wall never reaches — so the driver's history held only the
368
+ // assistant bubble and the next render dropped what the user had typed.
369
+ assert.equal(
370
+ history.map((m) => m.role),
371
+ ['user', 'assistant'],
372
+ 'the question stays above the answer',
373
+ );
374
+ assert.is(history[0].content, 'go');
375
+ });
376
+
377
+ Suite('an in-loop wall does not double-append the user message', async () => {
378
+ // Narrowness guard for the append above: `reportBudgetExhausted` also serves
379
+ // the in-loop path, where `sendMessage` has ALREADY appended the user message.
380
+ // Only the classify seam passes one, so this path must be unchanged. ONE
381
+ // specialist and no fallback, so `classify` short-circuits and the wall is hit
382
+ // inside a real tool loop.
383
+ const { provider } = walledProvider();
384
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
385
+ sessionKey: 'budget-6b',
386
+ });
387
+
388
+ await driver.sendMessage('go');
389
+
390
+ const history = driver.getRawHistory() as ChatMessage[];
391
+ assert.is(history.filter((m) => m.role === 'user').length, 1, 'one user message, not two');
392
+ });
393
+
394
+ Suite('a wall during classification never starts the fallback turn', async () => {
395
+ const { calls, provider } = walledProvider();
396
+ const driver = new OrchestratingDriver(
397
+ makeRegistry(provider),
398
+ [agent({ name: 'A' }), agent({ name: 'B' }), agent({ name: 'Catch-all', fallback: true })],
399
+ { sessionKey: 'budget-7' },
400
+ );
401
+
402
+ const result = await driver.sendMessage('go');
403
+
404
+ assert.is(calls(), 1, 'the fallback agent is not run into the same wall');
405
+ assert.is(failureReasonOf(result), 'budget-exhausted');
406
+ });
407
+
408
+ Suite('a plain classifier failure still retries and falls back (narrowness guard)', async () => {
409
+ // Proves the rethrow did not make every classify failure terminal: only the
410
+ // budget wall short-circuits, because only the budget wall is unclearable.
411
+ let classifierCalls = 0;
412
+ const provider: AIProvider = {
413
+ chat: async (
414
+ _h: ChatMessage[],
415
+ _u: string,
416
+ options?: ChatRequestOptions,
417
+ ): Promise<ChatMessage> => {
418
+ if (options?.tools?.some((t) => t.name === 'select_agent')) {
419
+ classifierCalls += 1;
420
+ throw new Error('classifier is having a moment');
421
+ }
422
+ return { role: 'assistant', content: 'fallback handled it' };
423
+ },
424
+ };
425
+ const driver = new OrchestratingDriver(
426
+ makeRegistry(provider),
427
+ [agent({ name: 'A' }), agent({ name: 'B' }), agent({ name: 'Catch-all', fallback: true })],
428
+ { sessionKey: 'budget-8' },
429
+ );
430
+
431
+ const result = await driver.sendMessage('go');
432
+
433
+ assert.is(classifierCalls, 3, 'classifierRetries (2) + the first attempt');
434
+ assert.is(result.reason, 'done');
435
+ assert.is(failureReasonOf(result), undefined, 'the fallback turn succeeded');
436
+ });
437
+
438
+ Suite.run();
@@ -5,6 +5,7 @@ import type {
5
5
  ChatMessage,
6
6
  ChatRequestOptions,
7
7
  } from '@genesislcap/foundation-ai';
8
+ import { BudgetExhaustedError } from '@genesislcap/foundation-ai';
8
9
  import type { ActivityBus } from '../../channel/ai-activity-bus';
9
10
  import type {
10
11
  AgentConfig,
@@ -102,6 +103,17 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
102
103
  * user stops. Reset at the start of each `sendMessage`.
103
104
  */
104
105
  private cancelled = false;
106
+ /**
107
+ * Whether the CURRENT turn's user message has reached the inner driver's
108
+ * history. False through the pre-first-turn classify (where the only echo of
109
+ * the message is an optimistic `history-updated` dispatch), true from the
110
+ * moment `chatDriver.sendMessage` is entered — including through every later
111
+ * handoff classify. Read by the budget-wall catch in `sendMessage` to decide
112
+ * whether `reportBudgetExhausted` must append the message itself: appending
113
+ * it when already appended duplicated it; not appending it when unappended
114
+ * made it vanish. Reset at the top of each `runOrchestratedTurn`.
115
+ */
116
+ private userMessageAppended = false;
105
117
  /**
106
118
  * Sticky user pick from the picker (or the host's `setAgent` API). Only
107
119
  * changes on explicit user action. Survives flow completion: when a stateful
@@ -141,6 +153,8 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
141
153
  maxTurnSnapshots?: number;
142
154
  /** Activity bus passed through to the inner ChatDriver (browser host injects the singleton). */
143
155
  activityBus?: ActivityBus;
156
+ /** Budget-wall transcript copy, passed through to the inner ChatDriver (GENC-1464). */
157
+ budgetExhaustedMessage?: string;
144
158
  } = {},
145
159
  ) {
146
160
  super();
@@ -176,6 +190,7 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
176
190
  maxTurnSnapshots: options.maxTurnSnapshots,
177
191
  sessionKey: this.sessionKey,
178
192
  activityBus: options.activityBus,
193
+ budgetExhaustedMessage: options.budgetExhaustedMessage,
179
194
  });
180
195
 
181
196
  // Proxy events from the shared driver
@@ -297,7 +312,46 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
297
312
  }
298
313
 
299
314
  async sendMessage(input: string, attachments?: ChatAttachment[]): Promise<ChatDriverResult> {
315
+ try {
316
+ return await this.runOrchestratedTurn(input, attachments);
317
+ } catch (e) {
318
+ // The classifier calls the provider directly, outside any ChatDriver turn,
319
+ // so a budget wall raised there would escape as an unhandled rejection —
320
+ // `FoundationAiAssistant.send()` has a `finally` but no `catch`. Land it on
321
+ // the driver that owns the transcript, which appends the user-facing copy
322
+ // and returns the same terminal result an in-loop wall would.
323
+ //
324
+ // The user's message rides along ONLY while it is genuinely unappended.
325
+ // `classify()` runs at two seams: before the first agent turn — where the
326
+ // `history-updated` dispatch in `runOrchestratedTurn` is optimistic only
327
+ // and the real append lives inside `chatDriver.sendMessage`, which the
328
+ // wall prevents from ever running — and again at the END of every handoff
329
+ // iteration, by which point `chatDriver.sendMessage` HAS appended it.
330
+ // Passing the message unconditionally duplicated it on the handoff seam:
331
+ // `['user','assistant','tool','user','assistant']` in the transcript, the
332
+ // snapshot, and the history sent to the model once the budget is raised.
333
+ if (e instanceof BudgetExhaustedError) {
334
+ return this.chatDriver.reportBudgetExhausted(
335
+ e,
336
+ this.userMessageAppended
337
+ ? undefined
338
+ : {
339
+ role: 'user',
340
+ content: input,
341
+ ...(attachments ? { attachments } : {}),
342
+ },
343
+ );
344
+ }
345
+ throw e;
346
+ }
347
+ }
348
+
349
+ private async runOrchestratedTurn(
350
+ input: string,
351
+ attachments?: ChatAttachment[],
352
+ ): Promise<ChatDriverResult> {
300
353
  this.cancelled = false;
354
+ this.userMessageAppended = false;
301
355
  const history = this.chatDriver.getHistory() as ChatMessage[];
302
356
 
303
357
  // Emit the user message immediately so the UI reflects it during the classify
@@ -315,6 +369,8 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
315
369
  let handoffs = 0;
316
370
  let handoffSummary = '';
317
371
  let remainingTask = '';
372
+ // The outcome of the agent turn that ENDS the loop — see the return below.
373
+ let lastResult: ChatDriverResult | undefined;
318
374
 
319
375
  while (true) {
320
376
  // Cancelled before a (next) agent turn started — e.g. during classify.
@@ -333,9 +389,15 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
333
389
  // oxlint-disable-next-line no-await-in-loop
334
390
  result = await this.chatDriver.continueFromHistory(contextPrimer);
335
391
  } else {
392
+ // From here the inner driver owns the user message — it appends it as
393
+ // its first act, before any provider call can throw. Set BEFORE the
394
+ // await so a wall raised anywhere inside the turn (or on a later
395
+ // handoff classify) sees the append as done.
396
+ this.userMessageAppended = true;
336
397
  // oxlint-disable-next-line no-await-in-loop
337
398
  result = await this.chatDriver.sendMessage(input, attachments);
338
399
  }
400
+ lastResult = result;
339
401
 
340
402
  // Release check: a stateful agent called `releaseAgent` from a terminal
341
403
  // tool handler. Fire onDeactivate, clear the pin, drop the user back to
@@ -371,7 +433,20 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
371
433
  currentAgent = await this.classify(remainingTask, updatedHistory);
372
434
  }
373
435
 
374
- return { reason: 'done' };
436
+ // The orchestrated turn reports the outcome of the agent turn that ENDED it.
437
+ // The hardcoded `{ reason: 'done' }` that used to live here made every
438
+ // typed failure unreachable for an agents-configured host — including
439
+ // `'budget-exhausted'`, which the element latches its blocked state off.
440
+ //
441
+ // "Last wins" cannot hide an earlier failure behind a later success: a
442
+ // failing inner turn always ends the loop, because the loop only continues
443
+ // on `reason === 'agent-handoff'`. The two breaks that hold a non-'done'
444
+ // result (the handoff cap, and the pinned/fallback break) both carry
445
+ // `'agent-handoff'`, which is an internal routing protocol that must never
446
+ // escape to the host — the collapse below strips it. A cancel before the
447
+ // first turn leaves `lastResult` undefined; the release break returns the
448
+ // inner turn's real result, which is correct, because that turn ran.
449
+ return lastResult?.reason === 'done' ? lastResult : { reason: 'done' };
375
450
  }
376
451
 
377
452
  async continueFromHistory(transientPrimer?: ChatMessage[]): Promise<ChatDriverResult> {
@@ -624,6 +699,12 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
624
699
  },
625
700
  };
626
701
 
702
+ // True only when the classifier RAN and answered "no match" (index -1). The
703
+ // no-fallback apology below is gated on it: an exhausted-retries exit never
704
+ // earned that apology, and emitting it there put a routing excuse ahead of
705
+ // the real error the routed turn is about to produce.
706
+ let routedNoMatch = false;
707
+
627
708
  for (let attempt = 0; attempt <= this.classifierRetries; attempt += 1) {
628
709
  try {
629
710
  const options: ChatRequestOptions = {
@@ -648,8 +729,16 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
648
729
  return this.specialists[index];
649
730
  }
650
731
  // index === -1 — fall through to fallback
732
+ routedNoMatch = true;
651
733
  break;
652
734
  } catch (e) {
735
+ // Terminal: no ladder clears a budget wall. Short-circuit the
736
+ // classifier's own retries the same way 402 short-circuits the
737
+ // transport's — this loop is a SECOND retry ladder stacked on that one,
738
+ // so without this a single wall costs `classifierRetries + 1` doomed
739
+ // classifier calls and then a doomed fallback turn on top. Deliberately
740
+ // narrow: a generic classifier failure must still retry and fall back.
741
+ if (e instanceof BudgetExhaustedError) throw e;
653
742
  logger.warn(`OrchestratingDriver: classifier attempt ${attempt + 1} failed:`, e);
654
743
  if (attempt === this.classifierRetries) {
655
744
  logger.error('OrchestratingDriver: classifier failed after all retries, using fallback');
@@ -659,11 +748,17 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
659
748
 
660
749
  if (this.fallback) return this.fallback;
661
750
 
662
- // No fallback configured emit an inline response and return first specialist as no-op
663
- const specialistNames = this.specialists.map((s) => s.name).join(', ');
664
- this.appendInlineMessage(
665
- `I'm not sure how to help with that. I can assist with: ${specialistNames}.`,
666
- );
751
+ // No fallback configured. Route to the first specialist as a no-op — but
752
+ // only APOLOGISE when the classifier actually said "none of these". On an
753
+ // exhausted-retries exit the classifier never expressed an opinion, so the
754
+ // apology would be a routing excuse the user reads immediately above the
755
+ // real error that turn is about to produce.
756
+ if (routedNoMatch) {
757
+ const specialistNames = this.specialists.map((s) => s.name).join(', ');
758
+ this.appendInlineMessage(
759
+ `I'm not sure how to help with that. I can assist with: ${specialistNames}.`,
760
+ );
761
+ }
667
762
  return this.specialists[0];
668
763
  }
669
764