@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
@@ -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 {
@@ -68,6 +71,53 @@ import { logger } from '../../utils/logger';
68
71
  import { TOOL_FOLD_SYMBOL, type ToolFold } from '../../utils/tool-fold';
69
72
  import type { AiDriver, AllAgentSummary } from '../ai-driver/ai-driver';
70
73
 
74
+ /**
75
+ * The `budget` payload carried on a `'budget-exhausted'` {@link ChatDriverResult}.
76
+ * Derived from the type rather than restated so the two cannot drift.
77
+ */
78
+ type BudgetDetail = NonNullable<Extract<ChatDriverResult, { reason: 'done' }>['budget']>;
79
+
80
+ /**
81
+ * Lift the reportable facts off a {@link BudgetExhaustedError}, or `undefined`
82
+ * when it carries none.
83
+ *
84
+ * "None" means **no figures AND no attributable vendor** — not merely no
85
+ * figures. Dropping the whole object on a figure-less 402 also dropped
86
+ * `vendorLabel`, which is the authoritative attribution source, and left the
87
+ * element's latch falling back to the driver's last-resolved provider, i.e. the
88
+ * PREVIOUS turn's vendor. A turn-1 Gemini call followed by a turn-2 classifier
89
+ * refused by Anthropic with a figure-less 402 then walled *Gemini* and advised
90
+ * switching to the vendor that had actually run out. Three live shapes reach
91
+ * here with no figures — a bare gateway 402, a code-only body, and the framed
92
+ * err frame whose figures are `null` (which the proxy now prefers over a
93
+ * misleading `0`, so this path is getting more common, not less).
94
+ *
95
+ * The figures alone still decide whether banner copy is composed — see
96
+ * `formatBlockedReason`, which returns `undefined` for a figure-less budget so a
97
+ * host-set explanation survives the latch.
98
+ */
99
+ const budgetDetailOf = (e: BudgetExhaustedError): BudgetDetail | undefined => {
100
+ // The typed vendor is derived from the LABEL, not from `lastResolvedProvider`:
101
+ // the label comes from the transport that was actually refused, whereas the
102
+ // last-resolved provider is stale on the classification seam (an orchestrated
103
+ // turn classifies against the registry default, which this driver may never
104
+ // have resolved). The proxy's own `vendor` is the fallback for a transport
105
+ // whose static label no vendor claims (a multiplexing or white-labelled
106
+ // gateway). Omitted rather than set to `undefined` when neither resolves, so
107
+ // "we know the vendor" stays testable by presence alone.
108
+ const vendor = vendorTypeOfLabel(e.vendorLabel) ?? vendorTypeOfLabel(e.serverVendor);
109
+ if (e.budgetUsd == null && e.spentUsd == null && !vendor) return undefined;
110
+ return {
111
+ budgetUsd: e.budgetUsd,
112
+ spentUsd: e.spentUsd,
113
+ vendorLabel: e.vendorLabel,
114
+ ...(vendor ? { vendor } : {}),
115
+ // Server-known truth the client cannot re-derive, so it rides all the way to
116
+ // the banner rather than being re-guessed there from registry membership.
117
+ ...(e.otherVendorAvailable != null ? { otherVendorAvailable: e.otherVendorAvailable } : {}),
118
+ };
119
+ };
120
+
71
121
  const DEFAULT_MAX_TOOL_ITERATIONS = 50;
72
122
  const DEFAULT_MAX_FOLD_OPERATIONS = 5;
73
123
  // TODO: dedup system prompts in-memory to allow raising this cap much higher.
@@ -227,6 +277,22 @@ export interface ChatDriverConfig {
227
277
  * it defaults to {@link NOOP_ACTIVITY_BUS} so no `BroadcastChannel` is ever opened.
228
278
  */
229
279
  activityBus?: ActivityBus;
280
+ /**
281
+ * Transcript copy appended when the AI-spend budget wall is hit (GENC-1464).
282
+ * Defaults to `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`.
283
+ *
284
+ * The assistant element already lets a host override the blocked **banner**
285
+ * via `setBlocked(true, reason)`; without this the transcript **bubble** stayed
286
+ * on the default, so a white-labelled host got its own copy in the banner and
287
+ * the shipped default directly below it. Passing the same effective copy here
288
+ * keeps the two surfaces saying one thing.
289
+ *
290
+ * Deliberately a driver-config field rather than something read off a chat
291
+ * config: `ChatDriver` has no `chatConfig` and is used standalone (see
292
+ * `chat-driver-node`), so threading one in would be a much larger and less
293
+ * reversible change.
294
+ */
295
+ budgetExhaustedMessage?: string;
230
296
  }
231
297
 
232
298
  /**
@@ -419,8 +485,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
419
485
  * Set when a sub-agent's tool loop ends without `completeSubAgent` being
420
486
  * called. Read by the parent's `invokeSubAgent` to build the `{ ok: false }`
421
487
  * branch of `requestSubAgent`. Only ever set when `isSubAgent` is true.
488
+ *
489
+ * `budget` rides along on a `'budget_exhausted'` failure so the parent inherits
490
+ * the child's ATTRIBUTION, not just the fact of a wall — see
491
+ * `budgetWallDetail`.
422
492
  */
423
- private subAgentFailure: { reason: SubAgentFailureReason } | undefined;
493
+ private subAgentFailure: { reason: SubAgentFailureReason; budget?: BudgetDetail } | undefined;
424
494
  /**
425
495
  * Set by `releaseAgent` inside a top-level tool handler — typically a stateful
426
496
  * agent's terminal-state handler signalling that its flow is complete and the
@@ -569,6 +639,39 @@ export class ChatDriver extends EventTarget implements AiDriver {
569
639
  private readonly sessionKey: string;
570
640
  /** Injected activity bus; defaults to a no-op off-browser (Node/tests/headless). */
571
641
  private readonly activityBus: ActivityBus;
642
+ /** Transcript copy for a budget wall — see `ChatDriverConfig.budgetExhaustedMessage`. */
643
+ private readonly budgetExhaustedMessage: string;
644
+ /**
645
+ * Set the moment a budget wall is observed anywhere in this turn — this
646
+ * driver's own 402, or a sub-agent's (which surfaces here only as a
647
+ * `'budget_exhausted'` tool result). Read at the top of the tool loop to end
648
+ * the turn before issuing another model call that would hit the same wall.
649
+ * Reset per turn alongside the other per-turn counters.
650
+ */
651
+ private budgetExhaustedThisTurn = false;
652
+ /**
653
+ * The refusing vendor's own attribution for the wall `budgetExhaustedThisTurn`
654
+ * records, when it was knowable. Kept SEPARATE from the flag rather than
655
+ * replacing it: a figure-less 402 from a transport no vendor claims yields no
656
+ * detail at all (`budgetDetailOf` returns `undefined`), and folding the two
657
+ * would make that case stop ending the turn.
658
+ *
659
+ * It matters most for a sub-agent's wall. The child can sit on a different
660
+ * vendor from its parent — `applyAgent` reads `config.provider` — so without
661
+ * this the parent's short-circuit reports `lastResolvedProvider`, i.e. the one
662
+ * vendor that did NOT refuse. Under a mixed registry that walls Gemini because
663
+ * an Anthropic child 402'd, and if those are the only two reachable vendors the
664
+ * host then derives `blocked` and locks a composer that still had headroom.
665
+ */
666
+ private budgetWallDetail?: BudgetDetail;
667
+ /**
668
+ * Whether this turn's budget wall came from a SUB-AGENT rather than this
669
+ * driver's own request. Decides whether `lastResolvedProvider` is a valid
670
+ * attribution fallback: for an own wall it is the refusing vendor, for a
671
+ * child's wall it is the parent's vendor — the one known NOT to have refused.
672
+ * Reset per turn alongside `budgetWallDetail`.
673
+ */
674
+ private budgetWallViaSubAgent = false;
572
675
 
573
676
  constructor(
574
677
  private readonly providerRegistry: AIProviderRegistry,
@@ -585,10 +688,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
585
688
  maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS,
586
689
  sessionKey = '',
587
690
  activityBus = NOOP_ACTIVITY_BUS,
691
+ budgetExhaustedMessage = DEFAULT_BUDGET_EXHAUSTED_MESSAGE,
588
692
  } = config;
589
693
  this.maxToolIterations = maxToolIterations;
590
694
  this.sessionKey = sessionKey;
591
695
  this.activityBus = activityBus;
696
+ this.budgetExhaustedMessage = budgetExhaustedMessage;
592
697
  if (typeof toolHandlers === 'function') {
593
698
  this.toolHandlersFactory = toolHandlers;
594
699
  this.toolHandlers = {};
@@ -717,8 +822,65 @@ export class ChatDriver extends EventTarget implements AiDriver {
717
822
  * (rather than set to `undefined`) so a happy-path result stays byte-identical to
718
823
  * the historical `{ reason: 'done' }`.
719
824
  */
720
- private turnDone(failureReason?: TurnFailureReason): ChatDriverResult {
721
- return failureReason ? { reason: 'done', failureReason } : { reason: 'done' };
825
+ private turnDone(failureReason?: TurnFailureReason, budget?: BudgetDetail): ChatDriverResult {
826
+ if (!failureReason) return { reason: 'done' };
827
+ // `budget` is likewise omitted rather than set to `undefined`, so a non-budget
828
+ // failure's shape is unchanged for a consumer that structurally compares it.
829
+ return budget ? { reason: 'done', failureReason, budget } : { reason: 'done', failureReason };
830
+ }
831
+
832
+ /**
833
+ * Terminal budget outcome for a wall hit **outside** the tool loop — today,
834
+ * `OrchestratingDriver`'s classification phase, which calls the provider
835
+ * directly and so never enters `runToolLoop`.
836
+ *
837
+ * Does **not** publish `tool-loop-end`: no `tool-loop-start` was published for
838
+ * the classify phase, and an unbalanced end would break start/end pairing for
839
+ * subscribers that rely on it. The driver **return value** is what reports this
840
+ * case — see `FoundationAiAssistant`'s latch, which reads both seams for
841
+ * exactly this reason.
842
+ *
843
+ * The non-sub-agent tail of the in-loop `BudgetExhaustedError` branch lives
844
+ * here so there is one copy of the log line, the debug-log entry, the
845
+ * transcript bubble and the result shape rather than two that can drift.
846
+ *
847
+ * @param pendingUserMessage - a user message that has NOT yet been appended,
848
+ * appended first so the answer does not end up replying to nothing. Only the
849
+ * classification seam passes it: `OrchestratingDriver` dispatches the user's
850
+ * text as an optimistic `history-updated` detail and leaves the real append
851
+ * to `chatDriver.sendMessage`, which never runs when `classify()` throws — so
852
+ * the bubble below would re-dispatch a history the user's own message was
853
+ * never in, and it would vanish from the transcript on the next render. The
854
+ * in-loop caller has already appended it and passes nothing.
855
+ *
856
+ * @internal
857
+ */
858
+ reportBudgetExhausted(
859
+ e: BudgetExhaustedError,
860
+ pendingUserMessage?: ChatMessage,
861
+ ): ChatDriverResult {
862
+ if (pendingUserMessage) this.appendToHistory(pendingUserMessage);
863
+ this.budgetExhaustedThisTurn = true;
864
+ logger.error('ChatDriver: AI budget exhausted', e);
865
+ recordTurnError(this.sessionKey, 'budget-exhausted', {
866
+ agent: this.activeAgentName,
867
+ provider: this.lastResolvedProviderName,
868
+ // The registry ALIAS (e.g. 'high') is what `provider` records; the vendor
869
+ // is the thing a per-vendor budget is actually scoped to, and it was known
870
+ // at both ends and discarded in the middle until now. Taken from the
871
+ // refusing transport's label first — this method also serves the
872
+ // classification seam, where `lastResolvedProvider` is the PREVIOUS turn's
873
+ // vendor (or nothing), because classify runs against the registry default.
874
+ vendor:
875
+ vendorTypeOfLabel(e.vendorLabel) ??
876
+ vendorTypeOfLabel(e.serverVendor) ??
877
+ this.lastResolvedProvider,
878
+ budgetUsd: e.budgetUsd,
879
+ spentUsd: e.spentUsd,
880
+ isSubAgent: this.isSubAgent,
881
+ });
882
+ this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedMessage });
883
+ return this.turnDone('budget-exhausted', budgetDetailOf(e));
722
884
  }
723
885
 
724
886
  /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
@@ -730,12 +892,48 @@ export class ChatDriver extends EventTarget implements AiDriver {
730
892
  * Build the `tool-loop-end` event detail for a turn's result. A failure carries a
731
893
  * `{ failureReason }` detail; a clean turn emits `undefined` — the historical shape,
732
894
  * kept byte-identical so subscribers see exactly what they always have.
733
- */
734
- private static loopEndDetail(
735
- result: ChatDriverResult,
736
- ): { failureReason: TurnFailureReason } | undefined {
895
+ *
896
+ * A budget failure additionally carries `vendor` — the concrete vendor
897
+ * (`'anthropic'`/`'gemini'`) the walled turn resolved to, which the driver knows
898
+ * and used to discard. Optional and additive: a subscriber reading only
899
+ * `failureReason` is unaffected, a non-budget failure still emits the historical
900
+ * `{ failureReason }` with no `vendor` key, and the value is a plain string so
901
+ * the detail stays structured-cloneable for the cross-tab hop. It is the field a
902
+ * per-vendor budget model needs and the one that would be awkward to retrofit.
903
+ */
904
+ private loopEndDetail(result: ChatDriverResult):
905
+ | {
906
+ failureReason: TurnFailureReason;
907
+ vendor?: AIProviderType;
908
+ budget?: BudgetDetail;
909
+ }
910
+ | undefined {
737
911
  const failureReason = ChatDriver.failureReasonOf(result);
738
- return failureReason ? { failureReason } : undefined;
912
+ if (!failureReason) return undefined;
913
+ if (failureReason !== 'budget-exhausted') {
914
+ return { failureReason };
915
+ }
916
+ // The figures ride the event, not just the return value: this publish happens in
917
+ // sendMessage's `finally`, so for an in-loop wall it reaches the host's latch FIRST
918
+ // and the return-value seam is then a no-op (the latch is idempotent). Omitted
919
+ // entirely — never set to undefined — when the proxy sent no figures.
920
+ const budget = result.reason === 'done' ? result.budget : undefined;
921
+ // Prefer the refusing transport's own attribution over the driver's
922
+ // last-resolved provider, for the same staleness reason as `budgetDetailOf`.
923
+ //
924
+ // The fallback is legitimate ONLY for this driver's own wall — its resolved
925
+ // provider IS the refuser then. When the wall came from a SUB-AGENT
926
+ // (`budgetWallViaSubAgent`), the refuser is the child's vendor, and on a
927
+ // mixed registry `lastResolvedProvider` is the one vendor known NOT to have
928
+ // refused; an unattributable child wall degrades to the vendor-agnostic
929
+ // event instead, which the host's latch handles fail-safe.
930
+ const vendor =
931
+ budget?.vendor ?? (this.budgetWallViaSubAgent ? undefined : this.lastResolvedProvider);
932
+ return {
933
+ failureReason,
934
+ ...(vendor ? { vendor } : {}),
935
+ ...(budget ? { budget } : {}),
936
+ };
739
937
  }
740
938
 
741
939
  /**
@@ -954,7 +1152,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
954
1152
  * `completeSubAgent`, if any. Called by a parent `ChatDriver` after running
955
1153
  * this instance as a sub-agent.
956
1154
  */
957
- getSubAgentFailure(): { reason: SubAgentFailureReason } | undefined {
1155
+ getSubAgentFailure(): { reason: SubAgentFailureReason; budget?: BudgetDetail } | undefined {
958
1156
  return this.subAgentFailure;
959
1157
  }
960
1158
 
@@ -966,9 +1164,11 @@ export class ChatDriver extends EventTarget implements AiDriver {
966
1164
  * under a separate session key, so recording here would orphan the event off
967
1165
  * the user-visible debug-log timeline.)
968
1166
  */
969
- private failSubAgent(reason: SubAgentFailureReason): void {
1167
+ private failSubAgent(reason: SubAgentFailureReason, budget?: BudgetDetail): void {
970
1168
  if (!this.isSubAgent || this.subAgentFailure) return;
971
- this.subAgentFailure = { reason };
1169
+ // Omitted rather than set to `undefined` so a non-budget failure's shape is
1170
+ // unchanged for a structural comparison, matching `turnDone`.
1171
+ this.subAgentFailure = budget ? { reason, budget } : { reason };
972
1172
  }
973
1173
 
974
1174
  /**
@@ -1603,6 +1803,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
1603
1803
  this.subAgentCompletion = undefined;
1604
1804
  this.subAgentFailure = undefined;
1605
1805
  this.agentReleaseRequested = false;
1806
+ this.budgetExhaustedThisTurn = false;
1807
+ this.budgetWallDetail = undefined;
1808
+ this.budgetWallViaSubAgent = false;
1606
1809
  this.appendToHistory({ role: 'user', content: userInput, attachments });
1607
1810
  this.turnStartedAt = Date.now();
1608
1811
  recordMetaEvent(this.sessionKey, 'turn.start', {
@@ -1639,7 +1842,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
1639
1842
  });
1640
1843
  this.busy = false;
1641
1844
  this.endTurn();
1642
- this.activityBus.publish('tool-loop-end', ChatDriver.loopEndDetail(result));
1845
+ this.activityBus.publish('tool-loop-end', this.loopEndDetail(result));
1643
1846
  }
1644
1847
  }
1645
1848
 
@@ -1954,12 +2157,35 @@ export class ChatDriver extends EventTarget implements AiDriver {
1954
2157
  // provider ignored forced tool use and returned text). The previous
1955
2158
  // final-text fallback is intentionally gone — sub-agents return a
1956
2159
  // structured outcome only, and the parent handler decides how to recover.
1957
- const reason = child.getSubAgentFailure()?.reason ?? 'max_iterations';
2160
+ const failure = child.getSubAgentFailure();
2161
+ const reason = failure?.reason ?? 'max_iterations';
1958
2162
  // Record under THIS (parent) driver's session so the failure lands on the
1959
2163
  // user-visible debug-log timeline — the child ran under its own session key.
1960
2164
  // This is also the only telemetry for the defensive default above, where the
1961
2165
  // child's loop ended without recording an explicit failure reason.
1962
2166
  recordMetaEvent(this.sessionKey, 'subagent.failed', { agent: name, reason });
2167
+ // A child that hit the budget wall walls this driver too — the cap is shared,
2168
+ // and the parent's very next model call would 402 as well. Flagged (not
2169
+ // thrown) because the handler's return value is still appended as a tool
2170
+ // result: the loop reads the flag before the next provider call and ends the
2171
+ // turn there, which is what makes the `'budget_exhausted'` doc's "terminal
2172
+ // for the parent too" actually true.
2173
+ //
2174
+ // The child's attribution is inherited with it: the vendor that refused is
2175
+ // the child's, which under a mixed registry is not this driver's. See
2176
+ // `budgetWallDetail` for what mis-attributing it costs.
2177
+ //
2178
+ // FIRST attribution wins (`??=`). With batched delegations, child A walling
2179
+ // WITH attribution can be followed by child B walling WITHOUT one (a
2180
+ // figure-less 402 behind a transport label no vendor claims); a plain
2181
+ // assignment reset the field to `undefined` and the short-circuit's
2182
+ // fallback then named the parent's own vendor — the one known NOT to have
2183
+ // refused.
2184
+ if (reason === 'budget_exhausted') {
2185
+ this.budgetExhaustedThisTurn = true;
2186
+ this.budgetWallViaSubAgent = true;
2187
+ this.budgetWallDetail ??= failure?.budget;
2188
+ }
1963
2189
  return { outcome: { ok: false, reason }, trace };
1964
2190
  }
1965
2191
 
@@ -1974,6 +2200,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
1974
2200
  this.beginTurn();
1975
2201
  this.subAgentCompletion = undefined;
1976
2202
  this.subAgentFailure = undefined;
2203
+ this.budgetExhaustedThisTurn = false;
2204
+ this.budgetWallDetail = undefined;
2205
+ this.budgetWallViaSubAgent = false;
1977
2206
  this.turnStartedAt = Date.now();
1978
2207
  recordMetaEvent(this.sessionKey, 'turn.start', {
1979
2208
  phase: 'continueFromHistory',
@@ -2008,7 +2237,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
2008
2237
  });
2009
2238
  this.busy = false;
2010
2239
  this.endTurn();
2011
- this.activityBus.publish('tool-loop-end', ChatDriver.loopEndDetail(result));
2240
+ this.activityBus.publish('tool-loop-end', this.loopEndDetail(result));
2012
2241
  }
2013
2242
  }
2014
2243
 
@@ -2225,6 +2454,46 @@ export class ChatDriver extends EventTarget implements AiDriver {
2225
2454
  return this.completeAbortedTurn();
2226
2455
  }
2227
2456
 
2457
+ // A budget wall observed earlier this turn ends it HERE, before another
2458
+ // model call. Reachable only via a sub-agent (this driver's own 402
2459
+ // returns straight out of the catch below) — `invokeSubAgent` sets the
2460
+ // flag, its `{ ok: false, reason: 'budget_exhausted' }` outcome is
2461
+ // appended as a tool result, and without this the loop would call the
2462
+ // provider again into the same wall: N batched sub-agent calls otherwise
2463
+ // cost N doomed children AND a doomed parent call.
2464
+ //
2465
+ // Scoped strictly to the budget reason: every other SubAgentFailureReason
2466
+ // is something the parent can legitimately recover from, so those still
2467
+ // let the loop continue.
2468
+ if (this.budgetExhaustedThisTurn) {
2469
+ logger.error('ChatDriver: ending the turn — a sub-agent hit the AI budget wall');
2470
+ recordTurnError(this.sessionKey, 'budget-exhausted', {
2471
+ agent: this.activeAgentName,
2472
+ provider: this.lastResolvedProviderName,
2473
+ // The CHILD's vendor when it knew one, and NOTHING otherwise. On this
2474
+ // path the wall is definitionally the child's, and on a mixed registry
2475
+ // `lastResolvedProvider` is this driver's own vendor — the one known
2476
+ // NOT to have refused. An unattributable child wall must degrade to
2477
+ // the vendor-agnostic block (which `latchBlockedFrom` handles
2478
+ // fail-safe), never to a vendor that is known to be wrong: naming the
2479
+ // parent's vendor here walled BOTH — the child's via its own
2480
+ // tool-loop-end, the parent's via this event — and derived `blocked`
2481
+ // over headroom that still existed.
2482
+ vendor: this.budgetWallDetail?.vendor,
2483
+ via: 'sub-agent',
2484
+ isSubAgent: this.isSubAgent,
2485
+ });
2486
+ if (this.isSubAgent) {
2487
+ this.failSubAgent('budget_exhausted', this.budgetWallDetail);
2488
+ } else {
2489
+ this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedMessage });
2490
+ }
2491
+ // Carried onto the result so `loopEndDetail` publishes the refusing
2492
+ // vendor rather than falling through to `lastResolvedProvider`, and the
2493
+ // host's latch walls the vendor that actually ran out.
2494
+ return this.turnDone('budget-exhausted', this.budgetWallDetail);
2495
+ }
2496
+
2228
2497
  const promptCtx: SystemPromptContext = {
2229
2498
  agentName: this.activeAgentName ?? '',
2230
2499
  history: this.history,
@@ -2490,6 +2759,47 @@ export class ChatDriver extends EventTarget implements AiDriver {
2490
2759
  }
2491
2760
  return this.turnDone('response-truncated');
2492
2761
  }
2762
+ // The AI-spend budget is gone and the proxy refused the request (HTTP
2763
+ // 402). Terminal in the strongest sense available to us: unlike a
2764
+ // truncation — which a smaller request would get past — *no* request
2765
+ // succeeds until someone raises the budget out of band. So there is no
2766
+ // retry here and no "try again" in the copy; the turn ends and the host
2767
+ // locks the composer off the `'budget-exhausted'` failure reason (see
2768
+ // `FoundationAiAssistant.blocked`).
2769
+ if (e instanceof BudgetExhaustedError) {
2770
+ // Flagged as well as returned: a sub-agent's wall reaches the PARENT
2771
+ // only as a tool result, and the parent must not issue another model
2772
+ // call after it (see the short-circuit at the top of this loop).
2773
+ this.budgetExhaustedThisTurn = true;
2774
+ this.budgetWallDetail = budgetDetailOf(e);
2775
+ // Paired with the detail everywhere it is written: this is the
2776
+ // driver's OWN wall, so the sub-agent marker must not survive from an
2777
+ // earlier child and suppress the `lastResolvedProvider` fallback that
2778
+ // is legitimate here. Unreachable today (the short-circuit fires
2779
+ // before a second wall can land in one turn) — kept structural so the
2780
+ // pairing does not depend on that ordering staying true.
2781
+ this.budgetWallViaSubAgent = false;
2782
+ if (this.isSubAgent) {
2783
+ logger.error('ChatDriver: AI budget exhausted', e);
2784
+ recordTurnError(this.sessionKey, 'budget-exhausted', {
2785
+ agent: this.activeAgentName,
2786
+ provider: this.lastResolvedProviderName,
2787
+ vendor:
2788
+ vendorTypeOfLabel(e.vendorLabel) ??
2789
+ vendorTypeOfLabel(e.serverVendor) ??
2790
+ this.lastResolvedProvider,
2791
+ budgetUsd: e.budgetUsd,
2792
+ spentUsd: e.spentUsd,
2793
+ isSubAgent: true,
2794
+ });
2795
+ // Bubble a typed failure to the parent instead of speaking to the
2796
+ // user, carrying the attribution so the parent walls the vendor that
2797
+ // actually refused rather than its own.
2798
+ this.failSubAgent('budget_exhausted', this.budgetWallDetail);
2799
+ return this.turnDone('budget-exhausted', this.budgetWallDetail);
2800
+ }
2801
+ return this.reportBudgetExhausted(e);
2802
+ }
2493
2803
  // A request timeout from the transport (tagged `TimeoutError`) is not a
2494
2804
  // bug on our end — surface it distinctly instead of letting it fall
2495
2805
  // through to the generic "something went wrong" catch. No auto-retry: