@genesislcap/ai-assistant 15.3.2 → 15.3.3-alpha-4899ddd.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 +603 -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 +236 -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 +384 -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 +143 -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 +10 -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 +913 -0
  37. package/dist/esm/main/main.js +680 -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 +128 -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 +1243 -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 +680 -10
  60. package/src/state/ai-assistant-slice.test.ts +215 -0
  61. package/src/state/ai-assistant-slice.ts +181 -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
@@ -1,5 +1,5 @@
1
1
  import { __awaiter, __rest } from "tslib";
2
- import { isObservableAIProviderRegistry, MalformedFunctionCallError, ResponseTruncatedError, } from '@genesislcap/foundation-ai';
2
+ import { BudgetExhaustedError, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, isObservableAIProviderRegistry, MalformedFunctionCallError, ResponseTruncatedError, vendorTypeOfLabel, } from '@genesislcap/foundation-ai';
3
3
  import { NOOP_ACTIVITY_BUS } from '../../channel/ai-activity-bus';
4
4
  import { resolveChatProvider } from '../../config/validate-providers';
5
5
  import { clearSession, getMetaEvents, mergeMetaEvents, recordMetaEvent, recordTurnError, recordTurnRetry, } from '../../state/debug-event-log';
@@ -8,6 +8,40 @@ import { applyCondensation } from '../../utils/condense-history';
8
8
  import { applyHistoryCap, buildCompactionSummaryPrompt, findCompactionCut, normalizeForProvider, } from '../../utils/history-transform';
9
9
  import { logger } from '../../utils/logger';
10
10
  import { TOOL_FOLD_SYMBOL } from '../../utils/tool-fold';
11
+ /**
12
+ * Lift the reportable facts off a {@link BudgetExhaustedError}, or `undefined`
13
+ * when it carries none.
14
+ *
15
+ * "None" means **no figures AND no attributable vendor** — not merely no
16
+ * figures. Dropping the whole object on a figure-less 402 also dropped
17
+ * `vendorLabel`, which is the authoritative attribution source, and left the
18
+ * element's latch falling back to the driver's last-resolved provider, i.e. the
19
+ * PREVIOUS turn's vendor. A turn-1 Gemini call followed by a turn-2 classifier
20
+ * refused by Anthropic with a figure-less 402 then walled *Gemini* and advised
21
+ * switching to the vendor that had actually run out. Three live shapes reach
22
+ * here with no figures — a bare gateway 402, a code-only body, and the framed
23
+ * err frame whose figures are `null` (which the proxy now prefers over a
24
+ * misleading `0`, so this path is getting more common, not less).
25
+ *
26
+ * The figures alone still decide whether banner copy is composed — see
27
+ * `formatBlockedReason`, which returns `undefined` for a figure-less budget so a
28
+ * host-set explanation survives the latch.
29
+ */
30
+ const budgetDetailOf = (e) => {
31
+ var _a;
32
+ // The typed vendor is derived from the LABEL, not from `lastResolvedProvider`:
33
+ // the label comes from the transport that was actually refused, whereas the
34
+ // last-resolved provider is stale on the classification seam (an orchestrated
35
+ // turn classifies against the registry default, which this driver may never
36
+ // have resolved). The proxy's own `vendor` is the fallback for a transport
37
+ // whose static label no vendor claims (a multiplexing or white-labelled
38
+ // gateway). Omitted rather than set to `undefined` when neither resolves, so
39
+ // "we know the vendor" stays testable by presence alone.
40
+ const vendor = (_a = vendorTypeOfLabel(e.vendorLabel)) !== null && _a !== void 0 ? _a : vendorTypeOfLabel(e.serverVendor);
41
+ if (e.budgetUsd == null && e.spentUsd == null && !vendor)
42
+ return undefined;
43
+ return Object.assign(Object.assign({ budgetUsd: e.budgetUsd, spentUsd: e.spentUsd, vendorLabel: e.vendorLabel }, (vendor ? { vendor } : {})), (e.otherVendorAvailable != null ? { otherVendorAvailable: e.otherVendorAvailable } : {}));
44
+ };
11
45
  const DEFAULT_MAX_TOOL_ITERATIONS = 50;
12
46
  const DEFAULT_MAX_FOLD_OPERATIONS = 5;
13
47
  // TODO: dedup system prompts in-memory to allow raising this cap much higher.
@@ -229,10 +263,27 @@ export class ChatDriver extends EventTarget {
229
263
  * picked up on the next turn.
230
264
  */
231
265
  this.resolvedStatusCache = new Map();
232
- const { toolHandlers = {}, toolDefinitions = [], systemPrompt, primerHistory, maxToolIterations = DEFAULT_MAX_TOOL_ITERATIONS, maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS, maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS, sessionKey = '', activityBus = NOOP_ACTIVITY_BUS, } = config;
266
+ /**
267
+ * Set the moment a budget wall is observed anywhere in this turn — this
268
+ * driver's own 402, or a sub-agent's (which surfaces here only as a
269
+ * `'budget_exhausted'` tool result). Read at the top of the tool loop to end
270
+ * the turn before issuing another model call that would hit the same wall.
271
+ * Reset per turn alongside the other per-turn counters.
272
+ */
273
+ this.budgetExhaustedThisTurn = false;
274
+ /**
275
+ * Whether this turn's budget wall came from a SUB-AGENT rather than this
276
+ * driver's own request. Decides whether `lastResolvedProvider` is a valid
277
+ * attribution fallback: for an own wall it is the refusing vendor, for a
278
+ * child's wall it is the parent's vendor — the one known NOT to have refused.
279
+ * Reset per turn alongside `budgetWallDetail`.
280
+ */
281
+ this.budgetWallViaSubAgent = false;
282
+ const { toolHandlers = {}, toolDefinitions = [], systemPrompt, primerHistory, maxToolIterations = DEFAULT_MAX_TOOL_ITERATIONS, maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS, maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS, sessionKey = '', activityBus = NOOP_ACTIVITY_BUS, budgetExhaustedMessage = DEFAULT_BUDGET_EXHAUSTED_MESSAGE, } = config;
233
283
  this.maxToolIterations = maxToolIterations;
234
284
  this.sessionKey = sessionKey;
235
285
  this.activityBus = activityBus;
286
+ this.budgetExhaustedMessage = budgetExhaustedMessage;
236
287
  if (typeof toolHandlers === 'function') {
237
288
  this.toolHandlersFactory = toolHandlers;
238
289
  this.toolHandlers = {};
@@ -362,8 +413,61 @@ export class ChatDriver extends EventTarget {
362
413
  * (rather than set to `undefined`) so a happy-path result stays byte-identical to
363
414
  * the historical `{ reason: 'done' }`.
364
415
  */
365
- turnDone(failureReason) {
366
- return failureReason ? { reason: 'done', failureReason } : { reason: 'done' };
416
+ turnDone(failureReason, budget) {
417
+ if (!failureReason)
418
+ return { reason: 'done' };
419
+ // `budget` is likewise omitted rather than set to `undefined`, so a non-budget
420
+ // failure's shape is unchanged for a consumer that structurally compares it.
421
+ return budget ? { reason: 'done', failureReason, budget } : { reason: 'done', failureReason };
422
+ }
423
+ /**
424
+ * Terminal budget outcome for a wall hit **outside** the tool loop — today,
425
+ * `OrchestratingDriver`'s classification phase, which calls the provider
426
+ * directly and so never enters `runToolLoop`.
427
+ *
428
+ * Does **not** publish `tool-loop-end`: no `tool-loop-start` was published for
429
+ * the classify phase, and an unbalanced end would break start/end pairing for
430
+ * subscribers that rely on it. The driver **return value** is what reports this
431
+ * case — see `FoundationAiAssistant`'s latch, which reads both seams for
432
+ * exactly this reason.
433
+ *
434
+ * The non-sub-agent tail of the in-loop `BudgetExhaustedError` branch lives
435
+ * here so there is one copy of the log line, the debug-log entry, the
436
+ * transcript bubble and the result shape rather than two that can drift.
437
+ *
438
+ * @param pendingUserMessage - a user message that has NOT yet been appended,
439
+ * appended first so the answer does not end up replying to nothing. Only the
440
+ * classification seam passes it: `OrchestratingDriver` dispatches the user's
441
+ * text as an optimistic `history-updated` detail and leaves the real append
442
+ * to `chatDriver.sendMessage`, which never runs when `classify()` throws — so
443
+ * the bubble below would re-dispatch a history the user's own message was
444
+ * never in, and it would vanish from the transcript on the next render. The
445
+ * in-loop caller has already appended it and passes nothing.
446
+ *
447
+ * @internal
448
+ */
449
+ reportBudgetExhausted(e, pendingUserMessage) {
450
+ var _a, _b;
451
+ if (pendingUserMessage)
452
+ this.appendToHistory(pendingUserMessage);
453
+ this.budgetExhaustedThisTurn = true;
454
+ logger.error('ChatDriver: AI budget exhausted', e);
455
+ recordTurnError(this.sessionKey, 'budget-exhausted', {
456
+ agent: this.activeAgentName,
457
+ provider: this.lastResolvedProviderName,
458
+ // The registry ALIAS (e.g. 'high') is what `provider` records; the vendor
459
+ // is the thing a per-vendor budget is actually scoped to, and it was known
460
+ // at both ends and discarded in the middle until now. Taken from the
461
+ // refusing transport's label first — this method also serves the
462
+ // classification seam, where `lastResolvedProvider` is the PREVIOUS turn's
463
+ // vendor (or nothing), because classify runs against the registry default.
464
+ vendor: (_b = (_a = vendorTypeOfLabel(e.vendorLabel)) !== null && _a !== void 0 ? _a : vendorTypeOfLabel(e.serverVendor)) !== null && _b !== void 0 ? _b : this.lastResolvedProvider,
465
+ budgetUsd: e.budgetUsd,
466
+ spentUsd: e.spentUsd,
467
+ isSubAgent: this.isSubAgent,
468
+ });
469
+ this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedMessage });
470
+ return this.turnDone('budget-exhausted', budgetDetailOf(e));
367
471
  }
368
472
  /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
369
473
  static failureReasonOf(result) {
@@ -373,10 +477,39 @@ export class ChatDriver extends EventTarget {
373
477
  * Build the `tool-loop-end` event detail for a turn's result. A failure carries a
374
478
  * `{ failureReason }` detail; a clean turn emits `undefined` — the historical shape,
375
479
  * kept byte-identical so subscribers see exactly what they always have.
480
+ *
481
+ * A budget failure additionally carries `vendor` — the concrete vendor
482
+ * (`'anthropic'`/`'gemini'`) the walled turn resolved to, which the driver knows
483
+ * and used to discard. Optional and additive: a subscriber reading only
484
+ * `failureReason` is unaffected, a non-budget failure still emits the historical
485
+ * `{ failureReason }` with no `vendor` key, and the value is a plain string so
486
+ * the detail stays structured-cloneable for the cross-tab hop. It is the field a
487
+ * per-vendor budget model needs and the one that would be awkward to retrofit.
376
488
  */
377
- static loopEndDetail(result) {
489
+ loopEndDetail(result) {
490
+ var _a;
378
491
  const failureReason = ChatDriver.failureReasonOf(result);
379
- return failureReason ? { failureReason } : undefined;
492
+ if (!failureReason)
493
+ return undefined;
494
+ if (failureReason !== 'budget-exhausted') {
495
+ return { failureReason };
496
+ }
497
+ // The figures ride the event, not just the return value: this publish happens in
498
+ // sendMessage's `finally`, so for an in-loop wall it reaches the host's latch FIRST
499
+ // and the return-value seam is then a no-op (the latch is idempotent). Omitted
500
+ // entirely — never set to undefined — when the proxy sent no figures.
501
+ const budget = result.reason === 'done' ? result.budget : undefined;
502
+ // Prefer the refusing transport's own attribution over the driver's
503
+ // last-resolved provider, for the same staleness reason as `budgetDetailOf`.
504
+ //
505
+ // The fallback is legitimate ONLY for this driver's own wall — its resolved
506
+ // provider IS the refuser then. When the wall came from a SUB-AGENT
507
+ // (`budgetWallViaSubAgent`), the refuser is the child's vendor, and on a
508
+ // mixed registry `lastResolvedProvider` is the one vendor known NOT to have
509
+ // refused; an unattributable child wall degrades to the vendor-agnostic
510
+ // event instead, which the host's latch handles fail-safe.
511
+ const vendor = (_a = budget === null || budget === void 0 ? void 0 : budget.vendor) !== null && _a !== void 0 ? _a : (this.budgetWallViaSubAgent ? undefined : this.lastResolvedProvider);
512
+ return Object.assign(Object.assign({ failureReason }, (vendor ? { vendor } : {})), (budget ? { budget } : {}));
380
513
  }
381
514
  /**
382
515
  * Swap in a new agent's configuration. Called by OrchestratingDriver before
@@ -601,10 +734,12 @@ export class ChatDriver extends EventTarget {
601
734
  * under a separate session key, so recording here would orphan the event off
602
735
  * the user-visible debug-log timeline.)
603
736
  */
604
- failSubAgent(reason) {
737
+ failSubAgent(reason, budget) {
605
738
  if (!this.isSubAgent || this.subAgentFailure)
606
739
  return;
607
- this.subAgentFailure = { reason };
740
+ // Omitted rather than set to `undefined` so a non-budget failure's shape is
741
+ // unchanged for a structural comparison, matching `turnDone`.
742
+ this.subAgentFailure = budget ? { reason, budget } : { reason };
608
743
  }
609
744
  /**
610
745
  * Returns true if `releaseAgent` was called during the most recent turn.
@@ -1184,6 +1319,9 @@ export class ChatDriver extends EventTarget {
1184
1319
  this.subAgentCompletion = undefined;
1185
1320
  this.subAgentFailure = undefined;
1186
1321
  this.agentReleaseRequested = false;
1322
+ this.budgetExhaustedThisTurn = false;
1323
+ this.budgetWallDetail = undefined;
1324
+ this.budgetWallViaSubAgent = false;
1187
1325
  this.appendToHistory({ role: 'user', content: userInput, attachments });
1188
1326
  this.turnStartedAt = Date.now();
1189
1327
  recordMetaEvent(this.sessionKey, 'turn.start', {
@@ -1221,7 +1359,7 @@ export class ChatDriver extends EventTarget {
1221
1359
  });
1222
1360
  this.busy = false;
1223
1361
  this.endTurn();
1224
- this.activityBus.publish('tool-loop-end', ChatDriver.loopEndDetail(result));
1362
+ this.activityBus.publish('tool-loop-end', this.loopEndDetail(result));
1225
1363
  }
1226
1364
  });
1227
1365
  }
@@ -1484,12 +1622,35 @@ export class ChatDriver extends EventTarget {
1484
1622
  // provider ignored forced tool use and returned text). The previous
1485
1623
  // final-text fallback is intentionally gone — sub-agents return a
1486
1624
  // structured outcome only, and the parent handler decides how to recover.
1487
- const reason = (_e = (_d = child.getSubAgentFailure()) === null || _d === void 0 ? void 0 : _d.reason) !== null && _e !== void 0 ? _e : 'max_iterations';
1625
+ const failure = child.getSubAgentFailure();
1626
+ const reason = (_d = failure === null || failure === void 0 ? void 0 : failure.reason) !== null && _d !== void 0 ? _d : 'max_iterations';
1488
1627
  // Record under THIS (parent) driver's session so the failure lands on the
1489
1628
  // user-visible debug-log timeline — the child ran under its own session key.
1490
1629
  // This is also the only telemetry for the defensive default above, where the
1491
1630
  // child's loop ended without recording an explicit failure reason.
1492
1631
  recordMetaEvent(this.sessionKey, 'subagent.failed', { agent: name, reason });
1632
+ // A child that hit the budget wall walls this driver too — the cap is shared,
1633
+ // and the parent's very next model call would 402 as well. Flagged (not
1634
+ // thrown) because the handler's return value is still appended as a tool
1635
+ // result: the loop reads the flag before the next provider call and ends the
1636
+ // turn there, which is what makes the `'budget_exhausted'` doc's "terminal
1637
+ // for the parent too" actually true.
1638
+ //
1639
+ // The child's attribution is inherited with it: the vendor that refused is
1640
+ // the child's, which under a mixed registry is not this driver's. See
1641
+ // `budgetWallDetail` for what mis-attributing it costs.
1642
+ //
1643
+ // FIRST attribution wins (`??=`). With batched delegations, child A walling
1644
+ // WITH attribution can be followed by child B walling WITHOUT one (a
1645
+ // figure-less 402 behind a transport label no vendor claims); a plain
1646
+ // assignment reset the field to `undefined` and the short-circuit's
1647
+ // fallback then named the parent's own vendor — the one known NOT to have
1648
+ // refused.
1649
+ if (reason === 'budget_exhausted') {
1650
+ this.budgetExhaustedThisTurn = true;
1651
+ this.budgetWallViaSubAgent = true;
1652
+ (_e = this.budgetWallDetail) !== null && _e !== void 0 ? _e : (this.budgetWallDetail = failure === null || failure === void 0 ? void 0 : failure.budget);
1653
+ }
1493
1654
  return { outcome: { ok: false, reason }, trace };
1494
1655
  });
1495
1656
  }
@@ -1505,6 +1666,9 @@ export class ChatDriver extends EventTarget {
1505
1666
  this.beginTurn();
1506
1667
  this.subAgentCompletion = undefined;
1507
1668
  this.subAgentFailure = undefined;
1669
+ this.budgetExhaustedThisTurn = false;
1670
+ this.budgetWallDetail = undefined;
1671
+ this.budgetWallViaSubAgent = false;
1508
1672
  this.turnStartedAt = Date.now();
1509
1673
  recordMetaEvent(this.sessionKey, 'turn.start', {
1510
1674
  phase: 'continueFromHistory',
@@ -1541,7 +1705,7 @@ export class ChatDriver extends EventTarget {
1541
1705
  });
1542
1706
  this.busy = false;
1543
1707
  this.endTurn();
1544
- this.activityBus.publish('tool-loop-end', ChatDriver.loopEndDetail(result));
1708
+ this.activityBus.publish('tool-loop-end', this.loopEndDetail(result));
1545
1709
  }
1546
1710
  });
1547
1711
  }
@@ -1695,7 +1859,7 @@ export class ChatDriver extends EventTarget {
1695
1859
  // oxlint-disable-next-line complexity
1696
1860
  runToolLoop(userInput, attachments, transientPrimer) {
1697
1861
  return __awaiter(this, void 0, void 0, function* () {
1698
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
1862
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
1699
1863
  if (!this.systemPrompt) {
1700
1864
  logger.warn('ChatDriver: no systemPrompt set. The assistant will have no instructions — provide a systemPrompt via agents config or the foundation-ai-assistant property.');
1701
1865
  }
@@ -1725,8 +1889,48 @@ export class ChatDriver extends EventTarget {
1725
1889
  if (this.turnController.signal.aborted) {
1726
1890
  return this.completeAbortedTurn();
1727
1891
  }
1892
+ // A budget wall observed earlier this turn ends it HERE, before another
1893
+ // model call. Reachable only via a sub-agent (this driver's own 402
1894
+ // returns straight out of the catch below) — `invokeSubAgent` sets the
1895
+ // flag, its `{ ok: false, reason: 'budget_exhausted' }` outcome is
1896
+ // appended as a tool result, and without this the loop would call the
1897
+ // provider again into the same wall: N batched sub-agent calls otherwise
1898
+ // cost N doomed children AND a doomed parent call.
1899
+ //
1900
+ // Scoped strictly to the budget reason: every other SubAgentFailureReason
1901
+ // is something the parent can legitimately recover from, so those still
1902
+ // let the loop continue.
1903
+ if (this.budgetExhaustedThisTurn) {
1904
+ logger.error('ChatDriver: ending the turn — a sub-agent hit the AI budget wall');
1905
+ recordTurnError(this.sessionKey, 'budget-exhausted', {
1906
+ agent: this.activeAgentName,
1907
+ provider: this.lastResolvedProviderName,
1908
+ // The CHILD's vendor when it knew one, and NOTHING otherwise. On this
1909
+ // path the wall is definitionally the child's, and on a mixed registry
1910
+ // `lastResolvedProvider` is this driver's own vendor — the one known
1911
+ // NOT to have refused. An unattributable child wall must degrade to
1912
+ // the vendor-agnostic block (which `latchBlockedFrom` handles
1913
+ // fail-safe), never to a vendor that is known to be wrong: naming the
1914
+ // parent's vendor here walled BOTH — the child's via its own
1915
+ // tool-loop-end, the parent's via this event — and derived `blocked`
1916
+ // over headroom that still existed.
1917
+ vendor: (_a = this.budgetWallDetail) === null || _a === void 0 ? void 0 : _a.vendor,
1918
+ via: 'sub-agent',
1919
+ isSubAgent: this.isSubAgent,
1920
+ });
1921
+ if (this.isSubAgent) {
1922
+ this.failSubAgent('budget_exhausted', this.budgetWallDetail);
1923
+ }
1924
+ else {
1925
+ this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedMessage });
1926
+ }
1927
+ // Carried onto the result so `loopEndDetail` publishes the refusing
1928
+ // vendor rather than falling through to `lastResolvedProvider`, and the
1929
+ // host's latch walls the vendor that actually ran out.
1930
+ return this.turnDone('budget-exhausted', this.budgetWallDetail);
1931
+ }
1728
1932
  const promptCtx = {
1729
- agentName: (_a = this.activeAgentName) !== null && _a !== void 0 ? _a : '',
1933
+ agentName: (_b = this.activeAgentName) !== null && _b !== void 0 ? _b : '',
1730
1934
  history: this.history,
1731
1935
  turnIndex: iterations - 1,
1732
1936
  signal: this.turnController.signal,
@@ -1799,7 +2003,7 @@ export class ChatDriver extends EventTarget {
1799
2003
  // loses all summarized context. `normalizeForProvider` only touches
1800
2004
  // `compacted-summary`, so it's a safe pass-through for everything else.
1801
2005
  const primer = normalizeForProvider([
1802
- ...((_b = this.primerHistory) !== null && _b !== void 0 ? _b : []),
2006
+ ...((_c = this.primerHistory) !== null && _c !== void 0 ? _c : []),
1803
2007
  ...(transientPrimer !== null && transientPrimer !== void 0 ? transientPrimer : []),
1804
2008
  ]);
1805
2009
  const baseHistory = firstLlmCall ? this.history.slice(0, -1) : this.history;
@@ -1964,6 +2168,44 @@ export class ChatDriver extends EventTarget {
1964
2168
  }
1965
2169
  return this.turnDone('response-truncated');
1966
2170
  }
2171
+ // The AI-spend budget is gone and the proxy refused the request (HTTP
2172
+ // 402). Terminal in the strongest sense available to us: unlike a
2173
+ // truncation — which a smaller request would get past — *no* request
2174
+ // succeeds until someone raises the budget out of band. So there is no
2175
+ // retry here and no "try again" in the copy; the turn ends and the host
2176
+ // locks the composer off the `'budget-exhausted'` failure reason (see
2177
+ // `FoundationAiAssistant.blocked`).
2178
+ if (e instanceof BudgetExhaustedError) {
2179
+ // Flagged as well as returned: a sub-agent's wall reaches the PARENT
2180
+ // only as a tool result, and the parent must not issue another model
2181
+ // call after it (see the short-circuit at the top of this loop).
2182
+ this.budgetExhaustedThisTurn = true;
2183
+ this.budgetWallDetail = budgetDetailOf(e);
2184
+ // Paired with the detail everywhere it is written: this is the
2185
+ // driver's OWN wall, so the sub-agent marker must not survive from an
2186
+ // earlier child and suppress the `lastResolvedProvider` fallback that
2187
+ // is legitimate here. Unreachable today (the short-circuit fires
2188
+ // before a second wall can land in one turn) — kept structural so the
2189
+ // pairing does not depend on that ordering staying true.
2190
+ this.budgetWallViaSubAgent = false;
2191
+ if (this.isSubAgent) {
2192
+ logger.error('ChatDriver: AI budget exhausted', e);
2193
+ recordTurnError(this.sessionKey, 'budget-exhausted', {
2194
+ agent: this.activeAgentName,
2195
+ provider: this.lastResolvedProviderName,
2196
+ vendor: (_e = (_d = vendorTypeOfLabel(e.vendorLabel)) !== null && _d !== void 0 ? _d : vendorTypeOfLabel(e.serverVendor)) !== null && _e !== void 0 ? _e : this.lastResolvedProvider,
2197
+ budgetUsd: e.budgetUsd,
2198
+ spentUsd: e.spentUsd,
2199
+ isSubAgent: true,
2200
+ });
2201
+ // Bubble a typed failure to the parent instead of speaking to the
2202
+ // user, carrying the attribution so the parent walls the vendor that
2203
+ // actually refused rather than its own.
2204
+ this.failSubAgent('budget_exhausted', this.budgetWallDetail);
2205
+ return this.turnDone('budget-exhausted', this.budgetWallDetail);
2206
+ }
2207
+ return this.reportBudgetExhausted(e);
2208
+ }
1967
2209
  // A request timeout from the transport (tagged `TimeoutError`) is not a
1968
2210
  // bug on our end — surface it distinctly instead of letting it fall
1969
2211
  // through to the generic "something went wrong" catch. No auto-retry:
@@ -2029,15 +2271,15 @@ export class ChatDriver extends EventTarget {
2029
2271
  if (this.lastResolvedProviderName !== undefined) {
2030
2272
  response.providerName = this.lastResolvedProviderName;
2031
2273
  }
2032
- const isThinkingStep = response.content && ((_c = response.toolCalls) === null || _c === void 0 ? void 0 : _c.length);
2033
- const isEmptyResponse = !((_d = response.content) === null || _d === void 0 ? void 0 : _d.trim()) && !((_e = response.toolCalls) === null || _e === void 0 ? void 0 : _e.length);
2274
+ const isThinkingStep = response.content && ((_f = response.toolCalls) === null || _f === void 0 ? void 0 : _f.length);
2275
+ const isEmptyResponse = !((_g = response.content) === null || _g === void 0 ? void 0 : _g.trim()) && !((_h = response.toolCalls) === null || _h === void 0 ? void 0 : _h.length);
2034
2276
  // A pre-output refusal (safety-classifier decline, e.g. Fable 5 `stop_reason: 'refusal'`)
2035
2277
  // comes back with empty content, so it looks like a blank response — but it is deterministic:
2036
2278
  // retrying re-sends the identical request and refuses again, burning up to
2037
2279
  // MAX_EMPTY_RESPONSE_RETRIES turns on the most expensive models for the same outcome, ending
2038
2280
  // in the misleading "blank response" message. Treat it as a terminal, non-retried failure with
2039
2281
  // its own reason and message. (GENC-1461)
2040
- const isRefusal = ((_f = response.responseMeta) === null || _f === void 0 ? void 0 : _f.finishReason) === 'refusal';
2282
+ const isRefusal = ((_j = response.responseMeta) === null || _j === void 0 ? void 0 : _j.finishReason) === 'refusal';
2041
2283
  if (isEmptyResponse) {
2042
2284
  emptyResponseAttempts += 1;
2043
2285
  if (!isRefusal && emptyResponseAttempts < MAX_EMPTY_RESPONSE_RETRIES) {
@@ -2096,7 +2338,7 @@ export class ChatDriver extends EventTarget {
2096
2338
  emptyResponseAttempts = 0;
2097
2339
  malformedAttempts = 0;
2098
2340
  setupTransportAttempts = 0;
2099
- if (!((_g = response.toolCalls) === null || _g === void 0 ? void 0 : _g.length)) {
2341
+ if (!((_k = response.toolCalls) === null || _k === void 0 ? void 0 : _k.length)) {
2100
2342
  break;
2101
2343
  }
2102
2344
  const [toolCalls, systemCalls] = response.toolCalls.reduce((acc, tc) => {
@@ -2302,7 +2544,7 @@ export class ChatDriver extends EventTarget {
2302
2544
  // The response was appended before execution — find it and annotate.
2303
2545
  let tcMsgIdx = -1;
2304
2546
  for (let i = this.history.length - 1; i >= 0; i -= 1) {
2305
- if (this.history[i].role === 'assistant' && ((_h = this.history[i].toolCalls) === null || _h === void 0 ? void 0 : _h.length)) {
2547
+ if (this.history[i].role === 'assistant' && ((_l = this.history[i].toolCalls) === null || _l === void 0 ? void 0 : _l.length)) {
2306
2548
  tcMsgIdx = i;
2307
2549
  break;
2308
2550
  }
@@ -2340,7 +2582,7 @@ export class ChatDriver extends EventTarget {
2340
2582
  const unknownTools = [
2341
2583
  ...new Set([
2342
2584
  ...this.recentUnknownToolNames,
2343
- ...((_j = response.toolCalls) !== null && _j !== void 0 ? _j : [])
2585
+ ...((_m = response.toolCalls) !== null && _m !== void 0 ? _m : [])
2344
2586
  .filter((tc) => unknownToolIds.has(tc.id))
2345
2587
  .map((tc) => tc.name),
2346
2588
  ]),
@@ -2352,7 +2594,7 @@ export class ChatDriver extends EventTarget {
2352
2594
  const staleTools = [
2353
2595
  ...new Set([
2354
2596
  ...this.recentStaleToolNames,
2355
- ...((_k = response.toolCalls) !== null && _k !== void 0 ? _k : [])
2597
+ ...((_o = response.toolCalls) !== null && _o !== void 0 ? _o : [])
2356
2598
  .filter((tc) => staleToolIds.has(tc.id))
2357
2599
  .map((tc) => tc.name),
2358
2600
  ]),