@genesislcap/ai-assistant 15.6.2 → 15.7.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 (76) hide show
  1. package/dist/ai-assistant.api.json +422 -6
  2. package/dist/ai-assistant.d.ts +654 -19
  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 +289 -32
  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/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  18. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  19. package/dist/dts/main/blocked-state.test.d.ts +2 -0
  20. package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
  21. package/dist/dts/main/main.d.ts +435 -19
  22. package/dist/dts/main/main.d.ts.map +1 -1
  23. package/dist/dts/main/main.styles.d.ts.map +1 -1
  24. package/dist/dts/main/main.styles.test.d.ts +2 -0
  25. package/dist/dts/main/main.styles.test.d.ts.map +1 -0
  26. package/dist/dts/main/main.template.d.ts +53 -0
  27. package/dist/dts/main/main.template.d.ts.map +1 -1
  28. package/dist/dts/state/ai-assistant-slice.d.ts +162 -6
  29. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  30. package/dist/dts/state/debug-event-log.d.ts +6 -1
  31. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  32. package/dist/dts/state/session-store.d.ts +11 -0
  33. package/dist/dts/state/session-store.d.ts.map +1 -1
  34. package/dist/dts/utils/banked-usage-baselines.d.ts +51 -0
  35. package/dist/dts/utils/banked-usage-baselines.d.ts.map +1 -0
  36. package/dist/esm/components/chat-driver/chat-driver.js +263 -21
  37. package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
  38. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
  39. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
  40. package/dist/esm/components/settings-modal/settings-modal.styles.js +63 -18
  41. package/dist/esm/components/settings-modal/settings-modal.template.js +36 -7
  42. package/dist/esm/main/blocked-state.test.js +969 -0
  43. package/dist/esm/main/cost-session-banking.test.js +238 -8
  44. package/dist/esm/main/main.js +826 -48
  45. package/dist/esm/main/main.styles.js +72 -0
  46. package/dist/esm/main/main.styles.test.js +99 -0
  47. package/dist/esm/main/main.template.js +121 -4
  48. package/dist/esm/state/ai-assistant-slice.js +145 -7
  49. package/dist/esm/state/ai-assistant-slice.test.js +138 -1
  50. package/dist/esm/state/debug-event-log.js +7 -2
  51. package/dist/esm/state/debug-event-log.test.js +49 -1
  52. package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
  53. package/dist/esm/utils/banked-usage-baselines.js +84 -0
  54. package/dist/tsconfig.tsbuildinfo +1 -1
  55. package/docs/migration-GENC-1464.md +562 -0
  56. package/docs/sub_agent.md +20 -3
  57. package/package.json +17 -17
  58. package/src/channel/ai-activity-channel.ts +56 -2
  59. package/src/components/chat-driver/chat-driver.test.ts +549 -0
  60. package/src/components/chat-driver/chat-driver.ts +324 -14
  61. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
  62. package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
  63. package/src/components/settings-modal/settings-modal.styles.ts +63 -18
  64. package/src/components/settings-modal/settings-modal.template.ts +38 -7
  65. package/src/main/blocked-state.test.ts +1316 -0
  66. package/src/main/cost-session-banking.test.ts +283 -11
  67. package/src/main/main.styles.test.ts +130 -0
  68. package/src/main/main.styles.ts +72 -0
  69. package/src/main/main.template.ts +131 -4
  70. package/src/main/main.ts +827 -42
  71. package/src/state/ai-assistant-slice.test.ts +215 -0
  72. package/src/state/ai-assistant-slice.ts +218 -8
  73. package/src/state/debug-event-log.test.ts +63 -0
  74. package/src/state/debug-event-log.ts +7 -2
  75. package/src/state/persistence/session-snapshot.test.ts +22 -0
  76. package/src/utils/banked-usage-baselines.ts +90 -0
@@ -0,0 +1,312 @@
1
+ import { __awaiter } from "tslib";
2
+ import { BudgetExhaustedError } from '@genesislcap/foundation-ai';
3
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
4
+ import { AgenticActivityBus } from '../../channel/ai-activity-bus';
5
+ // Side-effect import — MUST precede `./orchestrating-driver` so the driver
6
+ // subclasses jsdom's EventTarget (CustomEvent dispatch then works in node).
7
+ import '../chat-driver/align-event-globals';
8
+ import { OrchestratingDriver } from './orchestrating-driver';
9
+ // GENC-1464 — the orchestrated turn's OUTCOME.
10
+ //
11
+ // `createDriver` returns an `OrchestratingDriver` for every agents-configured
12
+ // host, and its `sendMessage` used to end with a hardcoded `return { reason:
13
+ // 'done' }`. That made every typed failure unreachable for those hosts —
14
+ // including `'budget-exhausted'`, which the assistant element latches its blocked
15
+ // state off. A 402 therefore produced an apology over a still-enabled composer
16
+ // for exactly the configurations this workstream exists to fix.
17
+ //
18
+ // The classifier compounds it: `classify()` is a SECOND retry ladder stacked on
19
+ // the transport's, so an untyped catch re-issued the 402 `classifierRetries + 1`
20
+ // times and then ran a fallback turn into the same wall.
21
+ const makeRegistry = (provider) => ({
22
+ get: () => provider,
23
+ default: () => provider,
24
+ defaultName: () => 'test',
25
+ names: () => ['test'],
26
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
27
+ listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
28
+ });
29
+ const agent = (overrides) => (Object.assign({ description: 'test agent' }, overrides));
30
+ /** A provider that ends every turn immediately with a plain-text reply (no tools). */
31
+ const okProvider = () => ({
32
+ chat: () => __awaiter(void 0, void 0, void 0, function* () { return ({ role: 'assistant', content: 'ok' }); }),
33
+ });
34
+ /** A provider that counts calls and always refuses for budget. */
35
+ const walledProvider = (vendorLabel = 'Anthropic') => {
36
+ let n = 0;
37
+ return {
38
+ calls: () => n,
39
+ provider: {
40
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
41
+ n += 1;
42
+ throw new BudgetExhaustedError(vendorLabel, 25, 25.4);
43
+ }),
44
+ },
45
+ };
46
+ };
47
+ const failureReasonOf = (r) => r.reason === 'done' ? r.failureReason : undefined;
48
+ const Suite = createLogicSuite('orchestrating-driver budget wall');
49
+ // ── 1. The showstopper: a walled AGENT turn must reach the caller ───────────
50
+ Suite('a budget wall on the agent turn surfaces as the orchestrated result', () => __awaiter(void 0, void 0, void 0, function* () {
51
+ // ONE specialist and no fallback, so `classify` short-circuits without calling
52
+ // the provider — the wall is hit by the AGENT turn, inside a real tool loop.
53
+ // Against the old hardcoded return this deep-equal fails.
54
+ const { provider } = walledProvider();
55
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
56
+ sessionKey: 'budget-1',
57
+ });
58
+ const result = yield driver.sendMessage('go');
59
+ assert.equal(result, {
60
+ reason: 'done',
61
+ failureReason: 'budget-exhausted',
62
+ budget: { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic', vendor: 'anthropic' },
63
+ });
64
+ }));
65
+ Suite("the vendor is the refusing transport's, not a hardcoded default", () => __awaiter(void 0, void 0, void 0, function* () {
66
+ // The label rides the contract; `vendor` is its normalised key. Without a
67
+ // second vendor exercised here, a constant would pass every other assertion in
68
+ // this file — and per-vendor blocking would wall the wrong vendor forever.
69
+ const { provider } = walledProvider('Gemini');
70
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
71
+ sessionKey: 'budget-gemini',
72
+ });
73
+ const result = yield driver.sendMessage('go');
74
+ assert.equal(result.reason === 'done' ? result.budget : undefined, {
75
+ budgetUsd: 25,
76
+ spentUsd: 25.4,
77
+ vendorLabel: 'Gemini',
78
+ vendor: 'gemini',
79
+ });
80
+ }));
81
+ Suite('an unrecognised vendor label carries no typed vendor at all', () => __awaiter(void 0, void 0, void 0, function* () {
82
+ // Attribution degrades to "unattributable" rather than to a wrong vendor —
83
+ // which makes the element fall back to its vendor-agnostic block. Safe.
84
+ const { provider } = walledProvider('Acme AI');
85
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
86
+ sessionKey: 'budget-unknown-vendor',
87
+ });
88
+ const result = yield driver.sendMessage('go');
89
+ assert.equal(result.reason === 'done' ? result.budget : undefined, {
90
+ budgetUsd: 25,
91
+ spentUsd: 25.4,
92
+ vendorLabel: 'Acme AI',
93
+ });
94
+ }));
95
+ Suite('the wall also reaches the tool-loop-end bus detail through the orchestrator', () => __awaiter(void 0, void 0, void 0, function* () {
96
+ var _a;
97
+ const bus = new AgenticActivityBus();
98
+ const seen = [];
99
+ const stop = bus.subscribe('tool-loop-end', (d) => seen.push(d));
100
+ const { provider } = walledProvider();
101
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
102
+ sessionKey: 'budget-2',
103
+ activityBus: bus,
104
+ });
105
+ yield driver.sendMessage('go');
106
+ assert.is(seen.length, 1, 'exactly one tool-loop-end for the one agent turn');
107
+ assert.is((_a = seen[0]) === null || _a === void 0 ? void 0 : _a.failureReason, 'budget-exhausted');
108
+ stop();
109
+ bus.close();
110
+ }));
111
+ Suite('the bus detail carries the proxy figures, not just the failure reason', () => __awaiter(void 0, void 0, void 0, function* () {
112
+ var _a, _b, _c;
113
+ // The element latches off THIS event for an in-loop wall — the publish sits in
114
+ // sendMessage's `finally`, so it lands before the return-value seam and the
115
+ // idempotent latch makes the later one a no-op. If the figures rode only the
116
+ // return value they would be unreachable in the common case and the banner would
117
+ // show the generic copy in exactly the path they were added for.
118
+ const bus = new AgenticActivityBus();
119
+ const seen = [];
120
+ const stop = bus.subscribe('tool-loop-end', (d) => seen.push(d));
121
+ const { provider } = walledProvider();
122
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
123
+ sessionKey: 'budget-figures',
124
+ activityBus: bus,
125
+ });
126
+ const result = yield driver.sendMessage('go');
127
+ assert.equal((_a = seen[0]) === null || _a === void 0 ? void 0 : _a.budget, { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic', vendor: 'anthropic' }, 'the event carries the same figures as the return value');
128
+ assert.is((_b = seen[0]) === null || _b === void 0 ? void 0 : _b.vendor, 'anthropic', 'and the top-level vendor agrees with the refusing transport, not with a stale last-resolved provider');
129
+ assert.equal(result.reason === 'done' ? result.budget : undefined, (_c = seen[0]) === null || _c === void 0 ? void 0 : _c.budget, 'event and return value agree — neither is the sole carrier');
130
+ stop();
131
+ bus.close();
132
+ }));
133
+ Suite('a clean turn publishes no budget key at all', () => __awaiter(void 0, void 0, void 0, function* () {
134
+ const bus = new AgenticActivityBus();
135
+ const seen = [];
136
+ const stop = bus.subscribe('tool-loop-end', (d) => seen.push(d));
137
+ const provider = {
138
+ chat: () => __awaiter(void 0, void 0, void 0, function* () { return ({ role: 'assistant', content: 'ok' }); }),
139
+ };
140
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
141
+ sessionKey: 'budget-clean',
142
+ activityBus: bus,
143
+ });
144
+ yield driver.sendMessage('go');
145
+ assert.ok(seen.every((d) => d === undefined || !('budget' in d)), 'no budget key on a non-budget turn — the historical detail shape is unchanged');
146
+ stop();
147
+ bus.close();
148
+ }));
149
+ // ── 2. No regression on the happy / handoff paths ───────────────────────────
150
+ Suite('a clean turn still returns the bare legacy shape', () => __awaiter(void 0, void 0, void 0, function* () {
151
+ const driver = new OrchestratingDriver(makeRegistry(okProvider()), [agent({ name: 'Solo' })], {
152
+ sessionKey: 'budget-3',
153
+ });
154
+ const result = yield driver.sendMessage('go');
155
+ assert.equal(result, { reason: 'done' }, 'no failureReason on a clean turn');
156
+ }));
157
+ Suite('a handoff-capped turn never leaks agent-handoff to the caller', () => __awaiter(void 0, void 0, void 0, function* () {
158
+ // `'agent-handoff'` is an internal routing protocol. The two breaks that hold a
159
+ // non-'done' inner result both carry it, so the collapse rule in `sendMessage`
160
+ // is what keeps "report the turn that ended the loop" from changing the
161
+ // discriminant a host matches on.
162
+ let n = 0;
163
+ const nextId = () => {
164
+ n += 1;
165
+ return n;
166
+ };
167
+ const handoffProvider = {
168
+ chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
169
+ var _a;
170
+ // The classifier call carries the routing tool; answer it with index 0.
171
+ if ((_a = options === null || options === void 0 ? void 0 : options.tools) === null || _a === void 0 ? void 0 : _a.some((t) => t.name === 'select_agent')) {
172
+ return {
173
+ role: 'assistant',
174
+ content: '',
175
+ toolCalls: [{ id: `c-${nextId()}`, name: 'select_agent', args: { agent_index: 0 } }],
176
+ };
177
+ }
178
+ // Every agent turn asks to hand off, so the handoff cap is what ends it.
179
+ return {
180
+ role: 'assistant',
181
+ content: '',
182
+ toolCalls: [
183
+ {
184
+ id: `h-${nextId()}`,
185
+ name: 'request_continuation',
186
+ args: { summary: 's', remaining_task: 'more' },
187
+ },
188
+ ],
189
+ };
190
+ }),
191
+ };
192
+ const driver = new OrchestratingDriver(makeRegistry(handoffProvider), [agent({ name: 'A' }), agent({ name: 'B' })], { sessionKey: 'budget-4', maxHandoffs: 1 });
193
+ const result = yield driver.sendMessage('go');
194
+ assert.is(result.reason, 'done', 'the internal handoff protocol never escapes');
195
+ }));
196
+ Suite('a wall on a post-handoff classify does not duplicate the user message', () => __awaiter(void 0, void 0, void 0, function* () {
197
+ // `classify()` runs at TWO seams. Before the first agent turn the user's
198
+ // message is unappended (only the optimistic `history-updated` echo exists),
199
+ // so the wall-catch must append it. At the END of a handoff iteration
200
+ // `chatDriver.sendMessage` has already appended it — passing it again
201
+ // produced ['user','assistant','tool','user','assistant'] with 'go' twice,
202
+ // persisting into the snapshot and into the history sent to the model once
203
+ // the budget was raised. The single-specialist narrowness guard below cannot
204
+ // catch this: with one specialist, classify short-circuits and the handoff
205
+ // seam never runs.
206
+ let n = 0;
207
+ const provider = {
208
+ chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
209
+ var _a;
210
+ n += 1;
211
+ if ((_a = options === null || options === void 0 ? void 0 : options.tools) === null || _a === void 0 ? void 0 : _a.some((t) => t.name === 'select_agent')) {
212
+ // First classify routes; the post-handoff classify hits the wall.
213
+ if (n > 2)
214
+ throw new BudgetExhaustedError('Anthropic', 25, 25.4);
215
+ return {
216
+ role: 'assistant',
217
+ content: '',
218
+ toolCalls: [{ id: `c-${n}`, name: 'select_agent', args: { agent_index: 0 } }],
219
+ };
220
+ }
221
+ // The agent turn asks to hand off, forcing the second classify.
222
+ return {
223
+ role: 'assistant',
224
+ content: '',
225
+ toolCalls: [
226
+ {
227
+ id: `h-${n}`,
228
+ name: 'request_continuation',
229
+ args: { summary: 's', remaining_task: 'more' },
230
+ },
231
+ ],
232
+ };
233
+ }),
234
+ };
235
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'A' }), agent({ name: 'B' })], { sessionKey: 'budget-4b' });
236
+ const result = yield driver.sendMessage('go');
237
+ assert.is(failureReasonOf(result), 'budget-exhausted');
238
+ const history = driver.getRawHistory();
239
+ const userMessages = history.filter((m) => m.role === 'user' && m.content === 'go');
240
+ assert.is(userMessages.length, 1, "the user's message appears exactly once");
241
+ }));
242
+ // ── 3. The classifier's own retry ladder ───────────────────────────────────
243
+ Suite('a wall during classification costs exactly one provider call', () => __awaiter(void 0, void 0, void 0, function* () {
244
+ // TWO specialists and no fallback, so `classify` really runs. Before the narrow
245
+ // rethrow this was FOUR calls: `classifierRetries + 1` classifier attempts, then
246
+ // a doomed agent turn.
247
+ const { calls, provider } = walledProvider();
248
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'A' }), agent({ name: 'B' })], { sessionKey: 'budget-5' });
249
+ const result = yield driver.sendMessage('go');
250
+ assert.is(calls(), 1, 'no ladder clears a budget wall — do not climb it');
251
+ assert.is(failureReasonOf(result), 'budget-exhausted');
252
+ }));
253
+ Suite('a classify-time wall lands on the transcript, without a routing apology', () => __awaiter(void 0, void 0, void 0, function* () {
254
+ // The old ordering emitted "I'm not sure how to help with that…" from the
255
+ // no-fallback exit and THEN routed anyway, so the user read a routing excuse
256
+ // the classifier never earned, immediately above the real error.
257
+ const { provider } = walledProvider();
258
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'A' }), agent({ name: 'B' })], { sessionKey: 'budget-6' });
259
+ yield driver.sendMessage('go');
260
+ const history = driver.getRawHistory();
261
+ const text = history.map((m) => m.content).join('\n');
262
+ assert.ok(text.includes('AI usage limit'), 'the real reason is what the user reads');
263
+ assert.not.ok(text.includes("I'm not sure how to help with that"));
264
+ // The user's own message must survive. `runOrchestratedTurn` dispatches it
265
+ // optimistically and leaves the real append to `chatDriver.sendMessage`, which
266
+ // a classify-time wall never reaches — so the driver's history held only the
267
+ // assistant bubble and the next render dropped what the user had typed.
268
+ assert.equal(history.map((m) => m.role), ['user', 'assistant'], 'the question stays above the answer');
269
+ assert.is(history[0].content, 'go');
270
+ }));
271
+ Suite('an in-loop wall does not double-append the user message', () => __awaiter(void 0, void 0, void 0, function* () {
272
+ // Narrowness guard for the append above: `reportBudgetExhausted` also serves
273
+ // the in-loop path, where `sendMessage` has ALREADY appended the user message.
274
+ // Only the classify seam passes one, so this path must be unchanged. ONE
275
+ // specialist and no fallback, so `classify` short-circuits and the wall is hit
276
+ // inside a real tool loop.
277
+ const { provider } = walledProvider();
278
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
279
+ sessionKey: 'budget-6b',
280
+ });
281
+ yield driver.sendMessage('go');
282
+ const history = driver.getRawHistory();
283
+ assert.is(history.filter((m) => m.role === 'user').length, 1, 'one user message, not two');
284
+ }));
285
+ Suite('a wall during classification never starts the fallback turn', () => __awaiter(void 0, void 0, void 0, function* () {
286
+ const { calls, provider } = walledProvider();
287
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'A' }), agent({ name: 'B' }), agent({ name: 'Catch-all', fallback: true })], { sessionKey: 'budget-7' });
288
+ const result = yield driver.sendMessage('go');
289
+ assert.is(calls(), 1, 'the fallback agent is not run into the same wall');
290
+ assert.is(failureReasonOf(result), 'budget-exhausted');
291
+ }));
292
+ Suite('a plain classifier failure still retries and falls back (narrowness guard)', () => __awaiter(void 0, void 0, void 0, function* () {
293
+ // Proves the rethrow did not make every classify failure terminal: only the
294
+ // budget wall short-circuits, because only the budget wall is unclearable.
295
+ let classifierCalls = 0;
296
+ const provider = {
297
+ chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
298
+ var _a;
299
+ if ((_a = options === null || options === void 0 ? void 0 : options.tools) === null || _a === void 0 ? void 0 : _a.some((t) => t.name === 'select_agent')) {
300
+ classifierCalls += 1;
301
+ throw new Error('classifier is having a moment');
302
+ }
303
+ return { role: 'assistant', content: 'fallback handled it' };
304
+ }),
305
+ };
306
+ const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'A' }), agent({ name: 'B' }), agent({ name: 'Catch-all', fallback: true })], { sessionKey: 'budget-8' });
307
+ const result = yield driver.sendMessage('go');
308
+ assert.is(classifierCalls, 3, 'classifierRetries (2) + the first attempt');
309
+ assert.is(result.reason, 'done');
310
+ assert.is(failureReasonOf(result), undefined, 'the fallback turn succeeded');
311
+ }));
312
+ Suite.run();
@@ -1,4 +1,5 @@
1
1
  import { __awaiter } from "tslib";
2
+ import { BudgetExhaustedError } from '@genesislcap/foundation-ai';
2
3
  import { validateStaticAgentProviders } from '../../config/validate-providers';
3
4
  import { recordMetaEvent } from '../../state/debug-event-log';
4
5
  import { transformHistoryForAgent } from '../../utils/history-transform';
@@ -68,6 +69,17 @@ export class OrchestratingDriver extends EventTarget {
68
69
  * user stops. Reset at the start of each `sendMessage`.
69
70
  */
70
71
  this.cancelled = false;
72
+ /**
73
+ * Whether the CURRENT turn's user message has reached the inner driver's
74
+ * history. False through the pre-first-turn classify (where the only echo of
75
+ * the message is an optimistic `history-updated` dispatch), true from the
76
+ * moment `chatDriver.sendMessage` is entered — including through every later
77
+ * handoff classify. Read by the budget-wall catch in `sendMessage` to decide
78
+ * whether `reportBudgetExhausted` must append the message itself: appending
79
+ * it when already appended duplicated it; not appending it when unappended
80
+ * made it vanish. Reset at the top of each `runOrchestratedTurn`.
81
+ */
82
+ this.userMessageAppended = false;
71
83
  /**
72
84
  * Sticky user pick from the picker (or the host's `setAgent` API). Only
73
85
  * changes on explicit user action. Survives flow completion: when a stateful
@@ -115,6 +127,7 @@ export class OrchestratingDriver extends EventTarget {
115
127
  maxTurnSnapshots: options.maxTurnSnapshots,
116
128
  sessionKey: this.sessionKey,
117
129
  activityBus: options.activityBus,
130
+ budgetExhaustedMessage: options.budgetExhaustedMessage,
118
131
  });
119
132
  // Proxy events from the shared driver
120
133
  this.chatDriver.addEventListener('history-updated', (e) => {
@@ -210,8 +223,39 @@ export class OrchestratingDriver extends EventTarget {
210
223
  });
211
224
  }
212
225
  sendMessage(input, attachments) {
226
+ return __awaiter(this, void 0, void 0, function* () {
227
+ try {
228
+ return yield this.runOrchestratedTurn(input, attachments);
229
+ }
230
+ catch (e) {
231
+ // The classifier calls the provider directly, outside any ChatDriver turn,
232
+ // so a budget wall raised there would escape as an unhandled rejection —
233
+ // `FoundationAiAssistant.send()` has a `finally` but no `catch`. Land it on
234
+ // the driver that owns the transcript, which appends the user-facing copy
235
+ // and returns the same terminal result an in-loop wall would.
236
+ //
237
+ // The user's message rides along ONLY while it is genuinely unappended.
238
+ // `classify()` runs at two seams: before the first agent turn — where the
239
+ // `history-updated` dispatch in `runOrchestratedTurn` is optimistic only
240
+ // and the real append lives inside `chatDriver.sendMessage`, which the
241
+ // wall prevents from ever running — and again at the END of every handoff
242
+ // iteration, by which point `chatDriver.sendMessage` HAS appended it.
243
+ // Passing the message unconditionally duplicated it on the handoff seam:
244
+ // `['user','assistant','tool','user','assistant']` in the transcript, the
245
+ // snapshot, and the history sent to the model once the budget is raised.
246
+ if (e instanceof BudgetExhaustedError) {
247
+ return this.chatDriver.reportBudgetExhausted(e, this.userMessageAppended
248
+ ? undefined
249
+ : Object.assign({ role: 'user', content: input }, (attachments ? { attachments } : {})));
250
+ }
251
+ throw e;
252
+ }
253
+ });
254
+ }
255
+ runOrchestratedTurn(input, attachments) {
213
256
  return __awaiter(this, void 0, void 0, function* () {
214
257
  this.cancelled = false;
258
+ this.userMessageAppended = false;
215
259
  const history = this.chatDriver.getHistory();
216
260
  // Emit the user message immediately so the UI reflects it during the classify
217
261
  // round-trip — without this the chat appears frozen until classify returns.
@@ -226,6 +270,8 @@ export class OrchestratingDriver extends EventTarget {
226
270
  let handoffs = 0;
227
271
  let handoffSummary = '';
228
272
  let remainingTask = '';
273
+ // The outcome of the agent turn that ENDS the loop — see the return below.
274
+ let lastResult;
229
275
  while (true) {
230
276
  // Cancelled before a (next) agent turn started — e.g. during classify.
231
277
  // The chat driver appends its own "Stopped." marker when a turn it was
@@ -243,9 +289,15 @@ export class OrchestratingDriver extends EventTarget {
243
289
  result = yield this.chatDriver.continueFromHistory(contextPrimer);
244
290
  }
245
291
  else {
292
+ // From here the inner driver owns the user message — it appends it as
293
+ // its first act, before any provider call can throw. Set BEFORE the
294
+ // await so a wall raised anywhere inside the turn (or on a later
295
+ // handoff classify) sees the append as done.
296
+ this.userMessageAppended = true;
246
297
  // oxlint-disable-next-line no-await-in-loop
247
298
  result = yield this.chatDriver.sendMessage(input, attachments);
248
299
  }
300
+ lastResult = result;
249
301
  // Release check: a stateful agent called `releaseAgent` from a terminal
250
302
  // tool handler. Fire onDeactivate, clear the pin, drop the user back to
251
303
  // classifier-mode. The LLM has already emitted its final wrap-up message
@@ -273,7 +325,20 @@ export class OrchestratingDriver extends EventTarget {
273
325
  // oxlint-disable-next-line no-await-in-loop
274
326
  currentAgent = yield this.classify(remainingTask, updatedHistory);
275
327
  }
276
- return { reason: 'done' };
328
+ // The orchestrated turn reports the outcome of the agent turn that ENDED it.
329
+ // The hardcoded `{ reason: 'done' }` that used to live here made every
330
+ // typed failure unreachable for an agents-configured host — including
331
+ // `'budget-exhausted'`, which the element latches its blocked state off.
332
+ //
333
+ // "Last wins" cannot hide an earlier failure behind a later success: a
334
+ // failing inner turn always ends the loop, because the loop only continues
335
+ // on `reason === 'agent-handoff'`. The two breaks that hold a non-'done'
336
+ // result (the handoff cap, and the pinned/fallback break) both carry
337
+ // `'agent-handoff'`, which is an internal routing protocol that must never
338
+ // escape to the host — the collapse below strips it. A cancel before the
339
+ // first turn leaves `lastResult` undefined; the release break returns the
340
+ // inner turn's real result, which is correct, because that turn ran.
341
+ return (lastResult === null || lastResult === void 0 ? void 0 : lastResult.reason) === 'done' ? lastResult : { reason: 'done' };
277
342
  });
278
343
  }
279
344
  continueFromHistory(transientPrimer) {
@@ -514,6 +579,11 @@ export class OrchestratingDriver extends EventTarget {
514
579
  },
515
580
  },
516
581
  };
582
+ // True only when the classifier RAN and answered "no match" (index -1). The
583
+ // no-fallback apology below is gated on it: an exhausted-retries exit never
584
+ // earned that apology, and emitting it there put a routing excuse ahead of
585
+ // the real error the routed turn is about to produce.
586
+ let routedNoMatch = false;
517
587
  for (let attempt = 0; attempt <= this.classifierRetries; attempt += 1) {
518
588
  try {
519
589
  const options = {
@@ -535,9 +605,18 @@ export class OrchestratingDriver extends EventTarget {
535
605
  return this.specialists[index];
536
606
  }
537
607
  // index === -1 — fall through to fallback
608
+ routedNoMatch = true;
538
609
  break;
539
610
  }
540
611
  catch (e) {
612
+ // Terminal: no ladder clears a budget wall. Short-circuit the
613
+ // classifier's own retries the same way 402 short-circuits the
614
+ // transport's — this loop is a SECOND retry ladder stacked on that one,
615
+ // so without this a single wall costs `classifierRetries + 1` doomed
616
+ // classifier calls and then a doomed fallback turn on top. Deliberately
617
+ // narrow: a generic classifier failure must still retry and fall back.
618
+ if (e instanceof BudgetExhaustedError)
619
+ throw e;
541
620
  logger.warn(`OrchestratingDriver: classifier attempt ${attempt + 1} failed:`, e);
542
621
  if (attempt === this.classifierRetries) {
543
622
  logger.error('OrchestratingDriver: classifier failed after all retries, using fallback');
@@ -546,9 +625,15 @@ export class OrchestratingDriver extends EventTarget {
546
625
  }
547
626
  if (this.fallback)
548
627
  return this.fallback;
549
- // No fallback configured emit an inline response and return first specialist as no-op
550
- const specialistNames = this.specialists.map((s) => s.name).join(', ');
551
- this.appendInlineMessage(`I'm not sure how to help with that. I can assist with: ${specialistNames}.`);
628
+ // No fallback configured. Route to the first specialist as a no-op — but
629
+ // only APOLOGISE when the classifier actually said "none of these". On an
630
+ // exhausted-retries exit the classifier never expressed an opinion, so the
631
+ // apology would be a routing excuse the user reads immediately above the
632
+ // real error that turn is about to produce.
633
+ if (routedNoMatch) {
634
+ const specialistNames = this.specialists.map((s) => s.name).join(', ');
635
+ this.appendInlineMessage(`I'm not sure how to help with that. I can assist with: ${specialistNames}.`);
636
+ }
552
637
  return this.specialists[0];
553
638
  });
554
639
  }
@@ -304,9 +304,9 @@ export const settingsModalStyles = css `
304
304
  }
305
305
 
306
306
  /*
307
- * Deliberately quieter than the live block: no pulsing badge and no accent, so the
308
- * eye still lands on what is being built now. It leads only because it is the wider
309
- * figure, not because it is the more urgent one.
307
+ * Deliberately quieter than the live block: no pulsing badge and no accent, so the eye lands
308
+ * on what is being built now which is why that block leads the tab and this one follows,
309
+ * under the history heading whose rows it totals.
310
310
  */
311
311
  .settings-lifetime-usage-top {
312
312
  display: flex;
@@ -489,7 +489,14 @@ export const settingsModalStyles = css `
489
489
 
490
490
  .settings-token-bucket {
491
491
  display: flex;
492
- min-width: 0;
492
+
493
+ /*
494
+ * min-content (not 0) so a bucket cannot be shrunk narrower than its own unbreakable label.
495
+ * With the label set to nowrap, a zero minimum would overflow it into the next column instead
496
+ * of wrapping — so the container's flex-wrap takes over and moves a bucket to a second row,
497
+ * which is the degradation we want when the modal is dragged narrow.
498
+ */
499
+ min-width: min-content;
493
500
  flex-direction: column;
494
501
  gap: 2px;
495
502
  }
@@ -500,6 +507,15 @@ export const settingsModalStyles = css `
500
507
  text-transform: uppercase;
501
508
  letter-spacing: 0.06em;
502
509
  color: var(--neutral-foreground-hint);
510
+
511
+ /*
512
+ * One line, always. The rate marker is a separate inline box after collapsible whitespace, so
513
+ * a wrappable label gives it a break opportunity of its own — in a narrow column it orphaned
514
+ * onto line 2, which also pushed that bucket's value a line below the other three and broke
515
+ * the row of figures. nowrap removes every break opportunity in the label, the space before
516
+ * the marker included, so no glue character is needed.
517
+ */
518
+ white-space: nowrap;
503
519
  }
504
520
 
505
521
  .settings-token-bucket-value {
@@ -509,13 +525,24 @@ export const settingsModalStyles = css `
509
525
  }
510
526
 
511
527
  /*
512
- * Cache reads bill at a fraction of the uncached input rate, and in an agentic
513
- * session they are usually the largest bucket by a wide margin. Muted so the biggest
514
- * number on the row doesn't read as the dominant cost driver when it is the cheapest.
528
+ * The price-direction marker on a cache bucket's label down for reads (a fraction of the
529
+ * uncached input rate), up for writes (a premium over it). Three tiers, so a bucket with no
530
+ * marker is the reference rate rather than an unlabelled fourth case.
531
+ *
532
+ * Carried by the label rather than the value, which is what this used to do: cache reads are
533
+ * usually the largest bucket by a wide margin in an agentic session, so the value was muted to
534
+ * stop the biggest number reading as the dominant cost driver. That priced the treatment to the
535
+ * wrong thing — a dimmed 0 (every Gemini cache write) looked disabled next to a normal 0, and a
536
+ * rate is a property of the bucket, not of this turn's figure.
537
+ *
538
+ * Sized down and nudged up rather than tinted: the label is already the hint colour, so a
539
+ * dimmer marker would vanish and a brighter one would out-shout the word it qualifies.
515
540
  */
516
- .settings-token-bucket.is-discounted .settings-token-bucket-value {
517
- font-weight: 500;
518
- color: var(--neutral-foreground-hint);
541
+ .settings-token-bucket-rate {
542
+ font-size: 8px;
543
+ line-height: 1;
544
+ vertical-align: 0.15em;
545
+ letter-spacing: 0;
519
546
  }
520
547
 
521
548
  /*
@@ -537,9 +564,15 @@ export const settingsModalStyles = css `
537
564
  margin-bottom: 0;
538
565
  }
539
566
 
540
- /* Equal columns, so the four buckets line up as a grid rather than bunching left. */
567
+ /*
568
+ * Basis auto, not 0: a zero basis splits the row into four equal columns, so CACHE WRITE is
569
+ * squeezed exactly as hard as INPUT despite being twice the label. Sizing from content gives the
570
+ * long labels the room their text needs and lets the short ones concede it, which is what makes
571
+ * white-space: nowrap above safe — every column starts at least as wide as its own content,
572
+ * then shares the slack. Still grows and shrinks, so the four stay flush edge to edge.
573
+ */
541
574
  .settings-usage-row .settings-token-bucket {
542
- flex: 1 1 0;
575
+ flex: 1 1 auto;
543
576
  }
544
577
 
545
578
  .settings-model-tags {
@@ -579,6 +612,16 @@ export const settingsModalStyles = css `
579
612
  margin: calc(var(--design-unit) * 4px) 0 calc(var(--design-unit) * 2px);
580
613
  }
581
614
 
615
+ /*
616
+ * The heading labels the lifetime card as well as the rows, so the card gives up its own top
617
+ * margin and the gap becomes the heading's alone. Adjacent siblings collapse to the LARGER of
618
+ * the two margins, so leaving both would space the caps 3 units off the block they label while
619
+ * the rows below sit at 2 — reading as though the heading belonged to neither.
620
+ */
621
+ .settings-cost-history-title + .settings-lifetime-usage {
622
+ margin-top: 0;
623
+ }
624
+
582
625
  .settings-cost-history {
583
626
  display: flex;
584
627
  flex-direction: column;
@@ -615,14 +658,16 @@ export const settingsModalStyles = css `
615
658
  }
616
659
 
617
660
  /*
618
- * Both of these carry a ?hidden binding (showTokens), and both declare display: flex — which
619
- * silently beat the UA [hidden] { display: none } rule, because author-origin declarations
620
- * outrank user-agent origin whatever the specificity. Without this, showTokens: false still
621
- * rendered the full breakdown in the lifetime block, the live block and every history row.
622
- * Sibling elements that hide correctly do so only by declaring no display at all. Same
623
- * treatment as .settings-modal-tab-panel[hidden] near the top of this file.
661
+ * All of these carry a ?hidden binding, and all declare display: flex — which silently beat the
662
+ * UA [hidden] { display: none } rule, because author-origin declarations outrank user-agent
663
+ * origin whatever the specificity. Without this, showTokens: false still rendered the full
664
+ * breakdown in the lifetime block, the live block and every history row, and an empty row list
665
+ * still occupied its padding under the lifetime total. Sibling elements that hide correctly do
666
+ * so only by declaring no display at all. Same treatment as .settings-modal-tab-panel[hidden]
667
+ * near the top of this file.
624
668
  */
625
669
  .settings-usage-row-tokens[hidden],
670
+ .settings-cost-history[hidden],
626
671
  .settings-cost-history-row-content[hidden] {
627
672
  display: none !important;
628
673
  }