@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
@@ -21,9 +21,12 @@ import type {
21
21
  TurnFailureReason,
22
22
  } from '@genesislcap/foundation-ai';
23
23
  import {
24
+ BudgetExhaustedError,
25
+ DEFAULT_BUDGET_EXHAUSTED_MESSAGE,
24
26
  isObservableAIProviderRegistry,
25
27
  MalformedFunctionCallError,
26
28
  ResponseTruncatedError,
29
+ vendorTypeOfLabel,
27
30
  } from '@genesislcap/foundation-ai';
28
31
  import { type ActivityBus, NOOP_ACTIVITY_BUS } from '../../channel/ai-activity-bus';
29
32
  import type {
@@ -69,6 +72,53 @@ import { sumUsage } from '../../utils/sum-usage';
69
72
  import { TOOL_FOLD_SYMBOL, type ToolFold } from '../../utils/tool-fold';
70
73
  import type { AiDriver, AllAgentSummary } from '../ai-driver/ai-driver';
71
74
 
75
+ /**
76
+ * The `budget` payload carried on a `'budget-exhausted'` {@link ChatDriverResult}.
77
+ * Derived from the type rather than restated so the two cannot drift.
78
+ */
79
+ type BudgetDetail = NonNullable<Extract<ChatDriverResult, { reason: 'done' }>['budget']>;
80
+
81
+ /**
82
+ * Lift the reportable facts off a {@link BudgetExhaustedError}, or `undefined`
83
+ * when it carries none.
84
+ *
85
+ * "None" means **no figures AND no attributable vendor** — not merely no
86
+ * figures. Dropping the whole object on a figure-less 402 also dropped
87
+ * `vendorLabel`, which is the authoritative attribution source, and left the
88
+ * element's latch falling back to the driver's last-resolved provider, i.e. the
89
+ * PREVIOUS turn's vendor. A turn-1 Gemini call followed by a turn-2 classifier
90
+ * refused by Anthropic with a figure-less 402 then walled *Gemini* and advised
91
+ * switching to the vendor that had actually run out. Three live shapes reach
92
+ * here with no figures — a bare gateway 402, a code-only body, and the framed
93
+ * err frame whose figures are `null` (which the proxy now prefers over a
94
+ * misleading `0`, so this path is getting more common, not less).
95
+ *
96
+ * The figures alone still decide whether banner copy is composed — see
97
+ * `formatBlockedReason`, which returns `undefined` for a figure-less budget so a
98
+ * host-set explanation survives the latch.
99
+ */
100
+ const budgetDetailOf = (e: BudgetExhaustedError): BudgetDetail | undefined => {
101
+ // The typed vendor is derived from the LABEL, not from `lastResolvedProvider`:
102
+ // the label comes from the transport that was actually refused, whereas the
103
+ // last-resolved provider is stale on the classification seam (an orchestrated
104
+ // turn classifies against the registry default, which this driver may never
105
+ // have resolved). The proxy's own `vendor` is the fallback for a transport
106
+ // whose static label no vendor claims (a multiplexing or white-labelled
107
+ // gateway). Omitted rather than set to `undefined` when neither resolves, so
108
+ // "we know the vendor" stays testable by presence alone.
109
+ const vendor = vendorTypeOfLabel(e.vendorLabel) ?? vendorTypeOfLabel(e.serverVendor);
110
+ if (e.budgetUsd == null && e.spentUsd == null && !vendor) return undefined;
111
+ return {
112
+ budgetUsd: e.budgetUsd,
113
+ spentUsd: e.spentUsd,
114
+ vendorLabel: e.vendorLabel,
115
+ ...(vendor ? { vendor } : {}),
116
+ // Server-known truth the client cannot re-derive, so it rides all the way to
117
+ // the banner rather than being re-guessed there from registry membership.
118
+ ...(e.otherVendorAvailable != null ? { otherVendorAvailable: e.otherVendorAvailable } : {}),
119
+ };
120
+ };
121
+
72
122
  const DEFAULT_MAX_TOOL_ITERATIONS = 50;
73
123
  const DEFAULT_MAX_FOLD_OPERATIONS = 5;
74
124
  // TODO: dedup system prompts in-memory to allow raising this cap much higher.
@@ -238,6 +288,22 @@ export interface ChatDriverConfig {
238
288
  * it defaults to {@link NOOP_ACTIVITY_BUS} so no `BroadcastChannel` is ever opened.
239
289
  */
240
290
  activityBus?: ActivityBus;
291
+ /**
292
+ * Transcript copy appended when the AI-spend budget wall is hit (GENC-1464).
293
+ * Defaults to `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`.
294
+ *
295
+ * The assistant element already lets a host override the blocked **banner**
296
+ * via `setBlocked(true, reason)`; without this the transcript **bubble** stayed
297
+ * on the default, so a white-labelled host got its own copy in the banner and
298
+ * the shipped default directly below it. Passing the same effective copy here
299
+ * keeps the two surfaces saying one thing.
300
+ *
301
+ * Deliberately a driver-config field rather than something read off a chat
302
+ * config: `ChatDriver` has no `chatConfig` and is used standalone (see
303
+ * `chat-driver-node`), so threading one in would be a much larger and less
304
+ * reversible change.
305
+ */
306
+ budgetExhaustedMessage?: string;
241
307
  }
242
308
 
243
309
  /**
@@ -430,8 +496,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
430
496
  * Set when a sub-agent's tool loop ends without `completeSubAgent` being
431
497
  * called. Read by the parent's `invokeSubAgent` to build the `{ ok: false }`
432
498
  * branch of `requestSubAgent`. Only ever set when `isSubAgent` is true.
499
+ *
500
+ * `budget` rides along on a `'budget_exhausted'` failure so the parent inherits
501
+ * the child's ATTRIBUTION, not just the fact of a wall — see
502
+ * `budgetWallDetail`.
433
503
  */
434
- private subAgentFailure: { reason: SubAgentFailureReason } | undefined;
504
+ private subAgentFailure: { reason: SubAgentFailureReason; budget?: BudgetDetail } | undefined;
435
505
  /**
436
506
  * Set by `releaseAgent` inside a top-level tool handler — typically a stateful
437
507
  * agent's terminal-state handler signalling that its flow is complete and the
@@ -582,6 +652,39 @@ export class ChatDriver extends EventTarget implements AiDriver {
582
652
  private readonly sessionKey: string;
583
653
  /** Injected activity bus; defaults to a no-op off-browser (Node/tests/headless). */
584
654
  private readonly activityBus: ActivityBus;
655
+ /** Transcript copy for a budget wall — see `ChatDriverConfig.budgetExhaustedMessage`. */
656
+ private readonly budgetExhaustedMessage: string;
657
+ /**
658
+ * Set the moment a budget wall is observed anywhere in this turn — this
659
+ * driver's own 402, or a sub-agent's (which surfaces here only as a
660
+ * `'budget_exhausted'` tool result). Read at the top of the tool loop to end
661
+ * the turn before issuing another model call that would hit the same wall.
662
+ * Reset per turn alongside the other per-turn counters.
663
+ */
664
+ private budgetExhaustedThisTurn = false;
665
+ /**
666
+ * The refusing vendor's own attribution for the wall `budgetExhaustedThisTurn`
667
+ * records, when it was knowable. Kept SEPARATE from the flag rather than
668
+ * replacing it: a figure-less 402 from a transport no vendor claims yields no
669
+ * detail at all (`budgetDetailOf` returns `undefined`), and folding the two
670
+ * would make that case stop ending the turn.
671
+ *
672
+ * It matters most for a sub-agent's wall. The child can sit on a different
673
+ * vendor from its parent — `applyAgent` reads `config.provider` — so without
674
+ * this the parent's short-circuit reports `lastResolvedProvider`, i.e. the one
675
+ * vendor that did NOT refuse. Under a mixed registry that walls Gemini because
676
+ * an Anthropic child 402'd, and if those are the only two reachable vendors the
677
+ * host then derives `blocked` and locks a composer that still had headroom.
678
+ */
679
+ private budgetWallDetail?: BudgetDetail;
680
+ /**
681
+ * Whether this turn's budget wall came from a SUB-AGENT rather than this
682
+ * driver's own request. Decides whether `lastResolvedProvider` is a valid
683
+ * attribution fallback: for an own wall it is the refusing vendor, for a
684
+ * child's wall it is the parent's vendor — the one known NOT to have refused.
685
+ * Reset per turn alongside `budgetWallDetail`.
686
+ */
687
+ private budgetWallViaSubAgent = false;
585
688
 
586
689
  constructor(
587
690
  private readonly providerRegistry: AIProviderRegistry,
@@ -599,11 +702,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
599
702
  maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS,
600
703
  sessionKey = '',
601
704
  activityBus = NOOP_ACTIVITY_BUS,
705
+ budgetExhaustedMessage = DEFAULT_BUDGET_EXHAUSTED_MESSAGE,
602
706
  } = config;
603
707
  this.maxToolIterations = maxToolIterations;
604
708
  this.condenseBatchCalls = condenseBatchCalls;
605
709
  this.sessionKey = sessionKey;
606
710
  this.activityBus = activityBus;
711
+ this.budgetExhaustedMessage = budgetExhaustedMessage;
607
712
  if (typeof toolHandlers === 'function') {
608
713
  this.toolHandlersFactory = toolHandlers;
609
714
  this.toolHandlers = {};
@@ -732,8 +837,65 @@ export class ChatDriver extends EventTarget implements AiDriver {
732
837
  * (rather than set to `undefined`) so a happy-path result stays byte-identical to
733
838
  * the historical `{ reason: 'done' }`.
734
839
  */
735
- private turnDone(failureReason?: TurnFailureReason): ChatDriverResult {
736
- return failureReason ? { reason: 'done', failureReason } : { reason: 'done' };
840
+ private turnDone(failureReason?: TurnFailureReason, budget?: BudgetDetail): ChatDriverResult {
841
+ if (!failureReason) return { reason: 'done' };
842
+ // `budget` is likewise omitted rather than set to `undefined`, so a non-budget
843
+ // failure's shape is unchanged for a consumer that structurally compares it.
844
+ return budget ? { reason: 'done', failureReason, budget } : { reason: 'done', failureReason };
845
+ }
846
+
847
+ /**
848
+ * Terminal budget outcome for a wall hit **outside** the tool loop — today,
849
+ * `OrchestratingDriver`'s classification phase, which calls the provider
850
+ * directly and so never enters `runToolLoop`.
851
+ *
852
+ * Does **not** publish `tool-loop-end`: no `tool-loop-start` was published for
853
+ * the classify phase, and an unbalanced end would break start/end pairing for
854
+ * subscribers that rely on it. The driver **return value** is what reports this
855
+ * case — see `FoundationAiAssistant`'s latch, which reads both seams for
856
+ * exactly this reason.
857
+ *
858
+ * The non-sub-agent tail of the in-loop `BudgetExhaustedError` branch lives
859
+ * here so there is one copy of the log line, the debug-log entry, the
860
+ * transcript bubble and the result shape rather than two that can drift.
861
+ *
862
+ * @param pendingUserMessage - a user message that has NOT yet been appended,
863
+ * appended first so the answer does not end up replying to nothing. Only the
864
+ * classification seam passes it: `OrchestratingDriver` dispatches the user's
865
+ * text as an optimistic `history-updated` detail and leaves the real append
866
+ * to `chatDriver.sendMessage`, which never runs when `classify()` throws — so
867
+ * the bubble below would re-dispatch a history the user's own message was
868
+ * never in, and it would vanish from the transcript on the next render. The
869
+ * in-loop caller has already appended it and passes nothing.
870
+ *
871
+ * @internal
872
+ */
873
+ reportBudgetExhausted(
874
+ e: BudgetExhaustedError,
875
+ pendingUserMessage?: ChatMessage,
876
+ ): ChatDriverResult {
877
+ if (pendingUserMessage) this.appendToHistory(pendingUserMessage);
878
+ this.budgetExhaustedThisTurn = true;
879
+ logger.error('ChatDriver: AI budget exhausted', e);
880
+ recordTurnError(this.sessionKey, 'budget-exhausted', {
881
+ agent: this.activeAgentName,
882
+ provider: this.lastResolvedProviderName,
883
+ // The registry ALIAS (e.g. 'high') is what `provider` records; the vendor
884
+ // is the thing a per-vendor budget is actually scoped to, and it was known
885
+ // at both ends and discarded in the middle until now. Taken from the
886
+ // refusing transport's label first — this method also serves the
887
+ // classification seam, where `lastResolvedProvider` is the PREVIOUS turn's
888
+ // vendor (or nothing), because classify runs against the registry default.
889
+ vendor:
890
+ vendorTypeOfLabel(e.vendorLabel) ??
891
+ vendorTypeOfLabel(e.serverVendor) ??
892
+ this.lastResolvedProvider,
893
+ budgetUsd: e.budgetUsd,
894
+ spentUsd: e.spentUsd,
895
+ isSubAgent: this.isSubAgent,
896
+ });
897
+ this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedMessage });
898
+ return this.turnDone('budget-exhausted', budgetDetailOf(e));
737
899
  }
738
900
 
739
901
  /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
@@ -745,12 +907,48 @@ export class ChatDriver extends EventTarget implements AiDriver {
745
907
  * Build the `tool-loop-end` event detail for a turn's result. A failure carries a
746
908
  * `{ failureReason }` detail; a clean turn emits `undefined` — the historical shape,
747
909
  * kept byte-identical so subscribers see exactly what they always have.
748
- */
749
- private static loopEndDetail(
750
- result: ChatDriverResult,
751
- ): { failureReason: TurnFailureReason } | undefined {
910
+ *
911
+ * A budget failure additionally carries `vendor` — the concrete vendor
912
+ * (`'anthropic'`/`'gemini'`) the walled turn resolved to, which the driver knows
913
+ * and used to discard. Optional and additive: a subscriber reading only
914
+ * `failureReason` is unaffected, a non-budget failure still emits the historical
915
+ * `{ failureReason }` with no `vendor` key, and the value is a plain string so
916
+ * the detail stays structured-cloneable for the cross-tab hop. It is the field a
917
+ * per-vendor budget model needs and the one that would be awkward to retrofit.
918
+ */
919
+ private loopEndDetail(result: ChatDriverResult):
920
+ | {
921
+ failureReason: TurnFailureReason;
922
+ vendor?: AIProviderType;
923
+ budget?: BudgetDetail;
924
+ }
925
+ | undefined {
752
926
  const failureReason = ChatDriver.failureReasonOf(result);
753
- return failureReason ? { failureReason } : undefined;
927
+ if (!failureReason) return undefined;
928
+ if (failureReason !== 'budget-exhausted') {
929
+ return { failureReason };
930
+ }
931
+ // The figures ride the event, not just the return value: this publish happens in
932
+ // sendMessage's `finally`, so for an in-loop wall it reaches the host's latch FIRST
933
+ // and the return-value seam is then a no-op (the latch is idempotent). Omitted
934
+ // entirely — never set to undefined — when the proxy sent no figures.
935
+ const budget = result.reason === 'done' ? result.budget : undefined;
936
+ // Prefer the refusing transport's own attribution over the driver's
937
+ // last-resolved provider, for the same staleness reason as `budgetDetailOf`.
938
+ //
939
+ // The fallback is legitimate ONLY for this driver's own wall — its resolved
940
+ // provider IS the refuser then. When the wall came from a SUB-AGENT
941
+ // (`budgetWallViaSubAgent`), the refuser is the child's vendor, and on a
942
+ // mixed registry `lastResolvedProvider` is the one vendor known NOT to have
943
+ // refused; an unattributable child wall degrades to the vendor-agnostic
944
+ // event instead, which the host's latch handles fail-safe.
945
+ const vendor =
946
+ budget?.vendor ?? (this.budgetWallViaSubAgent ? undefined : this.lastResolvedProvider);
947
+ return {
948
+ failureReason,
949
+ ...(vendor ? { vendor } : {}),
950
+ ...(budget ? { budget } : {}),
951
+ };
754
952
  }
755
953
 
756
954
  /**
@@ -969,7 +1167,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
969
1167
  * `completeSubAgent`, if any. Called by a parent `ChatDriver` after running
970
1168
  * this instance as a sub-agent.
971
1169
  */
972
- getSubAgentFailure(): { reason: SubAgentFailureReason } | undefined {
1170
+ getSubAgentFailure(): { reason: SubAgentFailureReason; budget?: BudgetDetail } | undefined {
973
1171
  return this.subAgentFailure;
974
1172
  }
975
1173
 
@@ -981,9 +1179,11 @@ export class ChatDriver extends EventTarget implements AiDriver {
981
1179
  * under a separate session key, so recording here would orphan the event off
982
1180
  * the user-visible debug-log timeline.)
983
1181
  */
984
- private failSubAgent(reason: SubAgentFailureReason): void {
1182
+ private failSubAgent(reason: SubAgentFailureReason, budget?: BudgetDetail): void {
985
1183
  if (!this.isSubAgent || this.subAgentFailure) return;
986
- this.subAgentFailure = { reason };
1184
+ // Omitted rather than set to `undefined` so a non-budget failure's shape is
1185
+ // unchanged for a structural comparison, matching `turnDone`.
1186
+ this.subAgentFailure = budget ? { reason, budget } : { reason };
987
1187
  }
988
1188
 
989
1189
  /**
@@ -1623,6 +1823,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
1623
1823
  this.subAgentCompletion = undefined;
1624
1824
  this.subAgentFailure = undefined;
1625
1825
  this.agentReleaseRequested = false;
1826
+ this.budgetExhaustedThisTurn = false;
1827
+ this.budgetWallDetail = undefined;
1828
+ this.budgetWallViaSubAgent = false;
1626
1829
  this.appendToHistory({ role: 'user', content: userInput, attachments });
1627
1830
  this.turnStartedAt = Date.now();
1628
1831
  recordMetaEvent(this.sessionKey, 'turn.start', {
@@ -1659,7 +1862,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
1659
1862
  });
1660
1863
  this.busy = false;
1661
1864
  this.endTurn();
1662
- this.activityBus.publish('tool-loop-end', ChatDriver.loopEndDetail(result));
1865
+ this.activityBus.publish('tool-loop-end', this.loopEndDetail(result));
1663
1866
  }
1664
1867
  }
1665
1868
 
@@ -1974,12 +2177,35 @@ export class ChatDriver extends EventTarget implements AiDriver {
1974
2177
  // provider ignored forced tool use and returned text). The previous
1975
2178
  // final-text fallback is intentionally gone — sub-agents return a
1976
2179
  // structured outcome only, and the parent handler decides how to recover.
1977
- const reason = child.getSubAgentFailure()?.reason ?? 'max_iterations';
2180
+ const failure = child.getSubAgentFailure();
2181
+ const reason = failure?.reason ?? 'max_iterations';
1978
2182
  // Record under THIS (parent) driver's session so the failure lands on the
1979
2183
  // user-visible debug-log timeline — the child ran under its own session key.
1980
2184
  // This is also the only telemetry for the defensive default above, where the
1981
2185
  // child's loop ended without recording an explicit failure reason.
1982
2186
  recordMetaEvent(this.sessionKey, 'subagent.failed', { agent: name, reason });
2187
+ // A child that hit the budget wall walls this driver too — the cap is shared,
2188
+ // and the parent's very next model call would 402 as well. Flagged (not
2189
+ // thrown) because the handler's return value is still appended as a tool
2190
+ // result: the loop reads the flag before the next provider call and ends the
2191
+ // turn there, which is what makes the `'budget_exhausted'` doc's "terminal
2192
+ // for the parent too" actually true.
2193
+ //
2194
+ // The child's attribution is inherited with it: the vendor that refused is
2195
+ // the child's, which under a mixed registry is not this driver's. See
2196
+ // `budgetWallDetail` for what mis-attributing it costs.
2197
+ //
2198
+ // FIRST attribution wins (`??=`). With batched delegations, child A walling
2199
+ // WITH attribution can be followed by child B walling WITHOUT one (a
2200
+ // figure-less 402 behind a transport label no vendor claims); a plain
2201
+ // assignment reset the field to `undefined` and the short-circuit's
2202
+ // fallback then named the parent's own vendor — the one known NOT to have
2203
+ // refused.
2204
+ if (reason === 'budget_exhausted') {
2205
+ this.budgetExhaustedThisTurn = true;
2206
+ this.budgetWallViaSubAgent = true;
2207
+ this.budgetWallDetail ??= failure?.budget;
2208
+ }
1983
2209
  return { outcome: { ok: false, reason }, trace };
1984
2210
  }
1985
2211
 
@@ -1994,6 +2220,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
1994
2220
  this.beginTurn();
1995
2221
  this.subAgentCompletion = undefined;
1996
2222
  this.subAgentFailure = undefined;
2223
+ this.budgetExhaustedThisTurn = false;
2224
+ this.budgetWallDetail = undefined;
2225
+ this.budgetWallViaSubAgent = false;
1997
2226
  this.turnStartedAt = Date.now();
1998
2227
  recordMetaEvent(this.sessionKey, 'turn.start', {
1999
2228
  phase: 'continueFromHistory',
@@ -2028,7 +2257,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
2028
2257
  });
2029
2258
  this.busy = false;
2030
2259
  this.endTurn();
2031
- this.activityBus.publish('tool-loop-end', ChatDriver.loopEndDetail(result));
2260
+ this.activityBus.publish('tool-loop-end', this.loopEndDetail(result));
2032
2261
  }
2033
2262
  }
2034
2263
 
@@ -2245,6 +2474,46 @@ export class ChatDriver extends EventTarget implements AiDriver {
2245
2474
  return this.completeAbortedTurn();
2246
2475
  }
2247
2476
 
2477
+ // A budget wall observed earlier this turn ends it HERE, before another
2478
+ // model call. Reachable only via a sub-agent (this driver's own 402
2479
+ // returns straight out of the catch below) — `invokeSubAgent` sets the
2480
+ // flag, its `{ ok: false, reason: 'budget_exhausted' }` outcome is
2481
+ // appended as a tool result, and without this the loop would call the
2482
+ // provider again into the same wall: N batched sub-agent calls otherwise
2483
+ // cost N doomed children AND a doomed parent call.
2484
+ //
2485
+ // Scoped strictly to the budget reason: every other SubAgentFailureReason
2486
+ // is something the parent can legitimately recover from, so those still
2487
+ // let the loop continue.
2488
+ if (this.budgetExhaustedThisTurn) {
2489
+ logger.error('ChatDriver: ending the turn — a sub-agent hit the AI budget wall');
2490
+ recordTurnError(this.sessionKey, 'budget-exhausted', {
2491
+ agent: this.activeAgentName,
2492
+ provider: this.lastResolvedProviderName,
2493
+ // The CHILD's vendor when it knew one, and NOTHING otherwise. On this
2494
+ // path the wall is definitionally the child's, and on a mixed registry
2495
+ // `lastResolvedProvider` is this driver's own vendor — the one known
2496
+ // NOT to have refused. An unattributable child wall must degrade to
2497
+ // the vendor-agnostic block (which `latchBlockedFrom` handles
2498
+ // fail-safe), never to a vendor that is known to be wrong: naming the
2499
+ // parent's vendor here walled BOTH — the child's via its own
2500
+ // tool-loop-end, the parent's via this event — and derived `blocked`
2501
+ // over headroom that still existed.
2502
+ vendor: this.budgetWallDetail?.vendor,
2503
+ via: 'sub-agent',
2504
+ isSubAgent: this.isSubAgent,
2505
+ });
2506
+ if (this.isSubAgent) {
2507
+ this.failSubAgent('budget_exhausted', this.budgetWallDetail);
2508
+ } else {
2509
+ this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedMessage });
2510
+ }
2511
+ // Carried onto the result so `loopEndDetail` publishes the refusing
2512
+ // vendor rather than falling through to `lastResolvedProvider`, and the
2513
+ // host's latch walls the vendor that actually ran out.
2514
+ return this.turnDone('budget-exhausted', this.budgetWallDetail);
2515
+ }
2516
+
2248
2517
  const promptCtx: SystemPromptContext = {
2249
2518
  agentName: this.activeAgentName ?? '',
2250
2519
  history: this.history,
@@ -2511,6 +2780,47 @@ export class ChatDriver extends EventTarget implements AiDriver {
2511
2780
  }
2512
2781
  return this.turnDone('response-truncated');
2513
2782
  }
2783
+ // The AI-spend budget is gone and the proxy refused the request (HTTP
2784
+ // 402). Terminal in the strongest sense available to us: unlike a
2785
+ // truncation — which a smaller request would get past — *no* request
2786
+ // succeeds until someone raises the budget out of band. So there is no
2787
+ // retry here and no "try again" in the copy; the turn ends and the host
2788
+ // locks the composer off the `'budget-exhausted'` failure reason (see
2789
+ // `FoundationAiAssistant.blocked`).
2790
+ if (e instanceof BudgetExhaustedError) {
2791
+ // Flagged as well as returned: a sub-agent's wall reaches the PARENT
2792
+ // only as a tool result, and the parent must not issue another model
2793
+ // call after it (see the short-circuit at the top of this loop).
2794
+ this.budgetExhaustedThisTurn = true;
2795
+ this.budgetWallDetail = budgetDetailOf(e);
2796
+ // Paired with the detail everywhere it is written: this is the
2797
+ // driver's OWN wall, so the sub-agent marker must not survive from an
2798
+ // earlier child and suppress the `lastResolvedProvider` fallback that
2799
+ // is legitimate here. Unreachable today (the short-circuit fires
2800
+ // before a second wall can land in one turn) — kept structural so the
2801
+ // pairing does not depend on that ordering staying true.
2802
+ this.budgetWallViaSubAgent = false;
2803
+ if (this.isSubAgent) {
2804
+ logger.error('ChatDriver: AI budget exhausted', e);
2805
+ recordTurnError(this.sessionKey, 'budget-exhausted', {
2806
+ agent: this.activeAgentName,
2807
+ provider: this.lastResolvedProviderName,
2808
+ vendor:
2809
+ vendorTypeOfLabel(e.vendorLabel) ??
2810
+ vendorTypeOfLabel(e.serverVendor) ??
2811
+ this.lastResolvedProvider,
2812
+ budgetUsd: e.budgetUsd,
2813
+ spentUsd: e.spentUsd,
2814
+ isSubAgent: true,
2815
+ });
2816
+ // Bubble a typed failure to the parent instead of speaking to the
2817
+ // user, carrying the attribution so the parent walls the vendor that
2818
+ // actually refused rather than its own.
2819
+ this.failSubAgent('budget_exhausted', this.budgetWallDetail);
2820
+ return this.turnDone('budget-exhausted', this.budgetWallDetail);
2821
+ }
2822
+ return this.reportBudgetExhausted(e);
2823
+ }
2514
2824
  // A request timeout from the transport (tagged `TimeoutError`) is not a
2515
2825
  // bug on our end — surface it distinctly instead of letting it fall
2516
2826
  // through to the generic "something went wrong" catch. No auto-retry: