@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
@@ -1016,6 +1016,67 @@ var GeminiProvider = class {
1016
1016
  }
1017
1017
  };
1018
1018
 
1019
+ // ../../foundation-ai/dist/esm/transports/budget-exhausted-error.js
1020
+ var BUDGET_EXCEEDED_CODE = "BUDGET_EXCEEDED";
1021
+ var VENDOR_LABELS = Object.freeze({
1022
+ anthropic: "Anthropic",
1023
+ gemini: "Gemini",
1024
+ openai: "OpenAI",
1025
+ chrome: "Chrome"
1026
+ });
1027
+ var BUDGETED_VENDORS = Object.freeze([
1028
+ "anthropic",
1029
+ "gemini"
1030
+ ]);
1031
+ var VENDOR_TYPE_BY_LABEL = new Map(Object.entries(VENDOR_LABELS).map(([type, label]) => [
1032
+ label.toLowerCase(),
1033
+ type
1034
+ ]));
1035
+ function vendorTypeOfLabel(label) {
1036
+ if (typeof label !== "string")
1037
+ return void 0;
1038
+ return VENDOR_TYPE_BY_LABEL.get(label.trim().toLowerCase());
1039
+ }
1040
+ var BUDGET_EXCEEDED_STATUS = 402;
1041
+ var DEFAULT_BUDGET_EXHAUSTED_MESSAGE = "You've reached your AI usage limit. Contact your administrator to raise it.";
1042
+ var BudgetExhaustedError = class extends Error {
1043
+ constructor(vendorLabel, budgetUsd, spentUsd, detail, extra) {
1044
+ super(`${vendorLabel} request refused: the AI usage budget is exhausted` + (budgetUsd != null ? ` (spent ${spentUsd != null ? `$${spentUsd}` : "an unknown amount"} of a $${budgetUsd} budget)` : "") + (detail ? `: ${detail}` : "") + ". Retrying will not clear this \u2014 the budget must be raised.");
1045
+ this.vendorLabel = vendorLabel;
1046
+ this.budgetUsd = budgetUsd;
1047
+ this.spentUsd = spentUsd;
1048
+ this.detail = detail;
1049
+ this.name = "BudgetExhaustedError";
1050
+ this.otherVendorAvailable = extra === null || extra === void 0 ? void 0 : extra.otherVendorAvailable;
1051
+ this.serverVendor = extra === null || extra === void 0 ? void 0 : extra.serverVendor;
1052
+ }
1053
+ };
1054
+ var isBudgetRejection = (status, code) => status === BUDGET_EXCEEDED_STATUS || code === BUDGET_EXCEEDED_CODE;
1055
+ var parseJsonOrUndefined = (text) => {
1056
+ try {
1057
+ return JSON.parse(text);
1058
+ } catch (_a) {
1059
+ return void 0;
1060
+ }
1061
+ };
1062
+ var codeOf = (payload) => {
1063
+ const code = payload === null || payload === void 0 ? void 0 : payload.code;
1064
+ return typeof code === "string" ? code : void 0;
1065
+ };
1066
+ var numberOrUndefined = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
1067
+ var booleanOrUndefined = (value) => typeof value === "boolean" ? value : void 0;
1068
+ var stringOrUndefined = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
1069
+ function budgetExhaustedFrom(vendorLabel, payload, fallbackDetail) {
1070
+ var _a, _b, _c, _d;
1071
+ const body = typeof payload === "object" && payload !== null ? payload : {};
1072
+ const details = typeof body.details === "object" && body.details !== null ? body.details : {};
1073
+ const detail = typeof body.error === "string" ? body.error : fallbackDetail;
1074
+ return new BudgetExhaustedError(vendorLabel, (_a = numberOrUndefined(body.budgetUsd)) !== null && _a !== void 0 ? _a : numberOrUndefined(details.budgetUsd), (_b = numberOrUndefined(body.spentUsd)) !== null && _b !== void 0 ? _b : numberOrUndefined(details.spentUsd), detail, {
1075
+ otherVendorAvailable: (_c = booleanOrUndefined(body.otherVendorAvailable)) !== null && _c !== void 0 ? _c : booleanOrUndefined(details.otherVendorAvailable),
1076
+ serverVendor: (_d = stringOrUndefined(body.vendor)) !== null && _d !== void 0 ? _d : stringOrUndefined(details.vendor)
1077
+ });
1078
+ }
1079
+
1019
1080
  // ../../foundation-ai/dist/esm/types/config.types.js
1020
1081
  var SUPPORTED_GEMINI_MODEL_IDS = [
1021
1082
  "gemini-2.5-pro",
@@ -1170,15 +1231,19 @@ function postWithRetry(options) {
1170
1231
  signal: combinedSignal,
1171
1232
  credentials
1172
1233
  });
1173
- if (retryableStatuses.includes(response.status) && attempt < MAX_RETRIES) {
1174
- logger.warn(`${vendorLabel}Transport: retryable status ${response.status}, retrying (attempt ${attempt + 1}/${MAX_RETRIES})`);
1175
- clearTimeout(timeoutId);
1176
- yield backoff(attempt);
1177
- continue;
1178
- }
1179
1234
  if (!response.ok) {
1180
- const err = yield response.text();
1181
- throw new Error(`${vendorLabel} request error ${response.status}: ${err}`);
1235
+ const errText = yield response.text();
1236
+ const parsed = parseJsonOrUndefined(errText);
1237
+ if (isBudgetRejection(response.status, codeOf(parsed))) {
1238
+ throw budgetExhaustedFrom(vendorLabel, parsed, errText || void 0);
1239
+ }
1240
+ if (retryableStatuses.includes(response.status) && attempt < MAX_RETRIES) {
1241
+ logger.warn(`${vendorLabel}Transport: retryable status ${response.status}, retrying (attempt ${attempt + 1}/${MAX_RETRIES})`);
1242
+ clearTimeout(timeoutId);
1243
+ yield backoff(attempt);
1244
+ continue;
1245
+ }
1246
+ throw new Error(`${vendorLabel} request error ${response.status}: ${errText}`);
1182
1247
  }
1183
1248
  if (((_b = (_a = response.headers) === null || _a === void 0 ? void 0 : _a.get("content-type")) === null || _b === void 0 ? void 0 : _b.toLowerCase().includes("application/x-ndjson")) && response.body) {
1184
1249
  rearmStallTimer();
@@ -1188,6 +1253,9 @@ function postWithRetry(options) {
1188
1253
  if (frame.code === "UPSTREAM_STALLED") {
1189
1254
  throw new DOMException(`${vendorLabel} request stalled: ${(_c = frame.error) !== null && _c !== void 0 ? _c : "no upstream data"}`, "TimeoutError");
1190
1255
  }
1256
+ if (isBudgetRejection(frame.status, frame.code)) {
1257
+ throw budgetExhaustedFrom(vendorLabel, frame);
1258
+ }
1191
1259
  if (retryableStatuses.includes(frame.status) && attempt < MAX_RETRIES) {
1192
1260
  logger.warn(`${vendorLabel}Transport: retryable status ${frame.status} (err frame), retrying (attempt ${attempt + 1}/${MAX_RETRIES})`);
1193
1261
  clearTimeout(timeoutId);
@@ -1650,7 +1718,7 @@ ${att.content}` });
1650
1718
  headers: requestHeaders,
1651
1719
  body,
1652
1720
  credentials,
1653
- vendorLabel: "Anthropic",
1721
+ vendorLabel: VENDOR_LABELS.anthropic,
1654
1722
  retryableStatuses: _AnthropicTransport.RETRYABLE_STATUSES,
1655
1723
  timeout: this.timeout,
1656
1724
  stallTimeout: this.stallTimeout,
@@ -2299,7 +2367,7 @@ ${att.content}`
2299
2367
  headers,
2300
2368
  body: payload,
2301
2369
  credentials,
2302
- vendorLabel: "Gemini",
2370
+ vendorLabel: VENDOR_LABELS.gemini,
2303
2371
  retryableStatuses: _GeminiTransport.RETRYABLE_STATUSES,
2304
2372
  timeout: this.timeout,
2305
2373
  stallTimeout: this.stallTimeout,
@@ -3001,6 +3069,19 @@ The above summarizes earlier parts of this conversation that were compacted. Con
3001
3069
  var TOOL_FOLD_SYMBOL = Symbol("toolFold");
3002
3070
 
3003
3071
  // src/components/chat-driver/chat-driver.ts
3072
+ var budgetDetailOf = (e) => {
3073
+ const vendor = vendorTypeOfLabel(e.vendorLabel) ?? vendorTypeOfLabel(e.serverVendor);
3074
+ if (e.budgetUsd == null && e.spentUsd == null && !vendor) return void 0;
3075
+ return {
3076
+ budgetUsd: e.budgetUsd,
3077
+ spentUsd: e.spentUsd,
3078
+ vendorLabel: e.vendorLabel,
3079
+ ...vendor ? { vendor } : {},
3080
+ // Server-known truth the client cannot re-derive, so it rides all the way to
3081
+ // the banner rather than being re-guessed there from registry membership.
3082
+ ...e.otherVendorAvailable != null ? { otherVendorAvailable: e.otherVendorAvailable } : {}
3083
+ };
3084
+ };
3004
3085
  var DEFAULT_MAX_TOOL_ITERATIONS = 50;
3005
3086
  var DEFAULT_MAX_FOLD_OPERATIONS = 5;
3006
3087
  var DEFAULT_MAX_TURN_SNAPSHOTS = 400;
@@ -3183,6 +3264,22 @@ var ChatDriver = class _ChatDriver extends EventTarget {
3183
3264
  * picked up on the next turn.
3184
3265
  */
3185
3266
  this.resolvedStatusCache = /* @__PURE__ */ new Map();
3267
+ /**
3268
+ * Set the moment a budget wall is observed anywhere in this turn — this
3269
+ * driver's own 402, or a sub-agent's (which surfaces here only as a
3270
+ * `'budget_exhausted'` tool result). Read at the top of the tool loop to end
3271
+ * the turn before issuing another model call that would hit the same wall.
3272
+ * Reset per turn alongside the other per-turn counters.
3273
+ */
3274
+ this.budgetExhaustedThisTurn = false;
3275
+ /**
3276
+ * Whether this turn's budget wall came from a SUB-AGENT rather than this
3277
+ * driver's own request. Decides whether `lastResolvedProvider` is a valid
3278
+ * attribution fallback: for an own wall it is the refusing vendor, for a
3279
+ * child's wall it is the parent's vendor — the one known NOT to have refused.
3280
+ * Reset per turn alongside `budgetWallDetail`.
3281
+ */
3282
+ this.budgetWallViaSubAgent = false;
3186
3283
  const {
3187
3284
  toolHandlers = {},
3188
3285
  toolDefinitions = [],
@@ -3192,11 +3289,13 @@ var ChatDriver = class _ChatDriver extends EventTarget {
3192
3289
  maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS,
3193
3290
  maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS,
3194
3291
  sessionKey = "",
3195
- activityBus = NOOP_ACTIVITY_BUS
3292
+ activityBus = NOOP_ACTIVITY_BUS,
3293
+ budgetExhaustedMessage = DEFAULT_BUDGET_EXHAUSTED_MESSAGE
3196
3294
  } = config;
3197
3295
  this.maxToolIterations = maxToolIterations;
3198
3296
  this.sessionKey = sessionKey;
3199
3297
  this.activityBus = activityBus;
3298
+ this.budgetExhaustedMessage = budgetExhaustedMessage;
3200
3299
  if (typeof toolHandlers === "function") {
3201
3300
  this.toolHandlersFactory = toolHandlers;
3202
3301
  this.toolHandlers = {};
@@ -3303,8 +3402,56 @@ var ChatDriver = class _ChatDriver extends EventTarget {
3303
3402
  * (rather than set to `undefined`) so a happy-path result stays byte-identical to
3304
3403
  * the historical `{ reason: 'done' }`.
3305
3404
  */
3306
- turnDone(failureReason) {
3307
- return failureReason ? { reason: "done", failureReason } : { reason: "done" };
3405
+ turnDone(failureReason, budget) {
3406
+ if (!failureReason) return { reason: "done" };
3407
+ return budget ? { reason: "done", failureReason, budget } : { reason: "done", failureReason };
3408
+ }
3409
+ /**
3410
+ * Terminal budget outcome for a wall hit **outside** the tool loop — today,
3411
+ * `OrchestratingDriver`'s classification phase, which calls the provider
3412
+ * directly and so never enters `runToolLoop`.
3413
+ *
3414
+ * Does **not** publish `tool-loop-end`: no `tool-loop-start` was published for
3415
+ * the classify phase, and an unbalanced end would break start/end pairing for
3416
+ * subscribers that rely on it. The driver **return value** is what reports this
3417
+ * case — see `FoundationAiAssistant`'s latch, which reads both seams for
3418
+ * exactly this reason.
3419
+ *
3420
+ * The non-sub-agent tail of the in-loop `BudgetExhaustedError` branch lives
3421
+ * here so there is one copy of the log line, the debug-log entry, the
3422
+ * transcript bubble and the result shape rather than two that can drift.
3423
+ *
3424
+ * @param pendingUserMessage - a user message that has NOT yet been appended,
3425
+ * appended first so the answer does not end up replying to nothing. Only the
3426
+ * classification seam passes it: `OrchestratingDriver` dispatches the user's
3427
+ * text as an optimistic `history-updated` detail and leaves the real append
3428
+ * to `chatDriver.sendMessage`, which never runs when `classify()` throws — so
3429
+ * the bubble below would re-dispatch a history the user's own message was
3430
+ * never in, and it would vanish from the transcript on the next render. The
3431
+ * in-loop caller has already appended it and passes nothing.
3432
+ *
3433
+ * @internal
3434
+ */
3435
+ reportBudgetExhausted(e, pendingUserMessage) {
3436
+ if (pendingUserMessage) this.appendToHistory(pendingUserMessage);
3437
+ this.budgetExhaustedThisTurn = true;
3438
+ logger2.error("ChatDriver: AI budget exhausted", e);
3439
+ recordTurnError(this.sessionKey, "budget-exhausted", {
3440
+ agent: this.activeAgentName,
3441
+ provider: this.lastResolvedProviderName,
3442
+ // The registry ALIAS (e.g. 'high') is what `provider` records; the vendor
3443
+ // is the thing a per-vendor budget is actually scoped to, and it was known
3444
+ // at both ends and discarded in the middle until now. Taken from the
3445
+ // refusing transport's label first — this method also serves the
3446
+ // classification seam, where `lastResolvedProvider` is the PREVIOUS turn's
3447
+ // vendor (or nothing), because classify runs against the registry default.
3448
+ vendor: vendorTypeOfLabel(e.vendorLabel) ?? vendorTypeOfLabel(e.serverVendor) ?? this.lastResolvedProvider,
3449
+ budgetUsd: e.budgetUsd,
3450
+ spentUsd: e.spentUsd,
3451
+ isSubAgent: this.isSubAgent
3452
+ });
3453
+ this.appendToHistory({ role: "assistant", content: this.budgetExhaustedMessage });
3454
+ return this.turnDone("budget-exhausted", budgetDetailOf(e));
3308
3455
  }
3309
3456
  /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
3310
3457
  static failureReasonOf(result) {
@@ -3314,10 +3461,28 @@ var ChatDriver = class _ChatDriver extends EventTarget {
3314
3461
  * Build the `tool-loop-end` event detail for a turn's result. A failure carries a
3315
3462
  * `{ failureReason }` detail; a clean turn emits `undefined` — the historical shape,
3316
3463
  * kept byte-identical so subscribers see exactly what they always have.
3464
+ *
3465
+ * A budget failure additionally carries `vendor` — the concrete vendor
3466
+ * (`'anthropic'`/`'gemini'`) the walled turn resolved to, which the driver knows
3467
+ * and used to discard. Optional and additive: a subscriber reading only
3468
+ * `failureReason` is unaffected, a non-budget failure still emits the historical
3469
+ * `{ failureReason }` with no `vendor` key, and the value is a plain string so
3470
+ * the detail stays structured-cloneable for the cross-tab hop. It is the field a
3471
+ * per-vendor budget model needs and the one that would be awkward to retrofit.
3317
3472
  */
3318
- static loopEndDetail(result) {
3473
+ loopEndDetail(result) {
3319
3474
  const failureReason = _ChatDriver.failureReasonOf(result);
3320
- return failureReason ? { failureReason } : void 0;
3475
+ if (!failureReason) return void 0;
3476
+ if (failureReason !== "budget-exhausted") {
3477
+ return { failureReason };
3478
+ }
3479
+ const budget = result.reason === "done" ? result.budget : void 0;
3480
+ const vendor = budget?.vendor ?? (this.budgetWallViaSubAgent ? void 0 : this.lastResolvedProvider);
3481
+ return {
3482
+ failureReason,
3483
+ ...vendor ? { vendor } : {},
3484
+ ...budget ? { budget } : {}
3485
+ };
3321
3486
  }
3322
3487
  /**
3323
3488
  * Swap in a new agent's configuration. Called by OrchestratingDriver before
@@ -3503,9 +3668,9 @@ var ChatDriver = class _ChatDriver extends EventTarget {
3503
3668
  * under a separate session key, so recording here would orphan the event off
3504
3669
  * the user-visible debug-log timeline.)
3505
3670
  */
3506
- failSubAgent(reason) {
3671
+ failSubAgent(reason, budget) {
3507
3672
  if (!this.isSubAgent || this.subAgentFailure) return;
3508
- this.subAgentFailure = { reason };
3673
+ this.subAgentFailure = budget ? { reason, budget } : { reason };
3509
3674
  }
3510
3675
  /**
3511
3676
  * Returns true if `releaseAgent` was called during the most recent turn.
@@ -3950,6 +4115,9 @@ Output format (strict):
3950
4115
  this.subAgentCompletion = void 0;
3951
4116
  this.subAgentFailure = void 0;
3952
4117
  this.agentReleaseRequested = false;
4118
+ this.budgetExhaustedThisTurn = false;
4119
+ this.budgetWallDetail = void 0;
4120
+ this.budgetWallViaSubAgent = false;
3953
4121
  this.appendToHistory({ role: "user", content: userInput, attachments });
3954
4122
  this.turnStartedAt = Date.now();
3955
4123
  recordMetaEvent(this.sessionKey, "turn.start", {
@@ -3984,7 +4152,7 @@ Output format (strict):
3984
4152
  });
3985
4153
  this.busy = false;
3986
4154
  this.endTurn();
3987
- this.activityBus.publish("tool-loop-end", _ChatDriver.loopEndDetail(result));
4155
+ this.activityBus.publish("tool-loop-end", this.loopEndDetail(result));
3988
4156
  }
3989
4157
  }
3990
4158
  /**
@@ -4175,8 +4343,14 @@ Output format (strict):
4175
4343
  recordMetaEvent(this.sessionKey, "subagent.completed", { agent: name });
4176
4344
  return { outcome: { ok: true, result: completion.result }, trace };
4177
4345
  }
4178
- const reason = child.getSubAgentFailure()?.reason ?? "max_iterations";
4346
+ const failure = child.getSubAgentFailure();
4347
+ const reason = failure?.reason ?? "max_iterations";
4179
4348
  recordMetaEvent(this.sessionKey, "subagent.failed", { agent: name, reason });
4349
+ if (reason === "budget_exhausted") {
4350
+ this.budgetExhaustedThisTurn = true;
4351
+ this.budgetWallViaSubAgent = true;
4352
+ this.budgetWallDetail ??= failure?.budget;
4353
+ }
4180
4354
  return { outcome: { ok: false, reason }, trace };
4181
4355
  }
4182
4356
  /**
@@ -4189,6 +4363,9 @@ Output format (strict):
4189
4363
  this.beginTurn();
4190
4364
  this.subAgentCompletion = void 0;
4191
4365
  this.subAgentFailure = void 0;
4366
+ this.budgetExhaustedThisTurn = false;
4367
+ this.budgetWallDetail = void 0;
4368
+ this.budgetWallViaSubAgent = false;
4192
4369
  this.turnStartedAt = Date.now();
4193
4370
  recordMetaEvent(this.sessionKey, "turn.start", {
4194
4371
  phase: "continueFromHistory",
@@ -4222,7 +4399,7 @@ Output format (strict):
4222
4399
  });
4223
4400
  this.busy = false;
4224
4401
  this.endTurn();
4225
- this.activityBus.publish("tool-loop-end", _ChatDriver.loopEndDetail(result));
4402
+ this.activityBus.publish("tool-loop-end", this.loopEndDetail(result));
4226
4403
  }
4227
4404
  }
4228
4405
  // ---------------------------------------------------------------------------
@@ -4376,6 +4553,31 @@ Output format (strict):
4376
4553
  if (this.turnController.signal.aborted) {
4377
4554
  return this.completeAbortedTurn();
4378
4555
  }
4556
+ if (this.budgetExhaustedThisTurn) {
4557
+ logger2.error("ChatDriver: ending the turn \u2014 a sub-agent hit the AI budget wall");
4558
+ recordTurnError(this.sessionKey, "budget-exhausted", {
4559
+ agent: this.activeAgentName,
4560
+ provider: this.lastResolvedProviderName,
4561
+ // The CHILD's vendor when it knew one, and NOTHING otherwise. On this
4562
+ // path the wall is definitionally the child's, and on a mixed registry
4563
+ // `lastResolvedProvider` is this driver's own vendor — the one known
4564
+ // NOT to have refused. An unattributable child wall must degrade to
4565
+ // the vendor-agnostic block (which `latchBlockedFrom` handles
4566
+ // fail-safe), never to a vendor that is known to be wrong: naming the
4567
+ // parent's vendor here walled BOTH — the child's via its own
4568
+ // tool-loop-end, the parent's via this event — and derived `blocked`
4569
+ // over headroom that still existed.
4570
+ vendor: this.budgetWallDetail?.vendor,
4571
+ via: "sub-agent",
4572
+ isSubAgent: this.isSubAgent
4573
+ });
4574
+ if (this.isSubAgent) {
4575
+ this.failSubAgent("budget_exhausted", this.budgetWallDetail);
4576
+ } else {
4577
+ this.appendToHistory({ role: "assistant", content: this.budgetExhaustedMessage });
4578
+ }
4579
+ return this.turnDone("budget-exhausted", this.budgetWallDetail);
4580
+ }
4379
4581
  const promptCtx = {
4380
4582
  agentName: this.activeAgentName ?? "",
4381
4583
  history: this.history,
@@ -4546,6 +4748,25 @@ ${tailBody}
4546
4748
  }
4547
4749
  return this.turnDone("response-truncated");
4548
4750
  }
4751
+ if (e instanceof BudgetExhaustedError) {
4752
+ this.budgetExhaustedThisTurn = true;
4753
+ this.budgetWallDetail = budgetDetailOf(e);
4754
+ this.budgetWallViaSubAgent = false;
4755
+ if (this.isSubAgent) {
4756
+ logger2.error("ChatDriver: AI budget exhausted", e);
4757
+ recordTurnError(this.sessionKey, "budget-exhausted", {
4758
+ agent: this.activeAgentName,
4759
+ provider: this.lastResolvedProviderName,
4760
+ vendor: vendorTypeOfLabel(e.vendorLabel) ?? vendorTypeOfLabel(e.serverVendor) ?? this.lastResolvedProvider,
4761
+ budgetUsd: e.budgetUsd,
4762
+ spentUsd: e.spentUsd,
4763
+ isSubAgent: true
4764
+ });
4765
+ this.failSubAgent("budget_exhausted", this.budgetWallDetail);
4766
+ return this.turnDone("budget-exhausted", this.budgetWallDetail);
4767
+ }
4768
+ return this.reportBudgetExhausted(e);
4769
+ }
4549
4770
  if (e instanceof DOMException && e.name === "TimeoutError") {
4550
4771
  logger2.error("ChatDriver: request timed out", e);
4551
4772
  recordTurnError(this.sessionKey, "exception", {
@@ -5021,6 +5242,17 @@ var OrchestratingDriver = class extends EventTarget {
5021
5242
  * user stops. Reset at the start of each `sendMessage`.
5022
5243
  */
5023
5244
  this.cancelled = false;
5245
+ /**
5246
+ * Whether the CURRENT turn's user message has reached the inner driver's
5247
+ * history. False through the pre-first-turn classify (where the only echo of
5248
+ * the message is an optimistic `history-updated` dispatch), true from the
5249
+ * moment `chatDriver.sendMessage` is entered — including through every later
5250
+ * handoff classify. Read by the budget-wall catch in `sendMessage` to decide
5251
+ * whether `reportBudgetExhausted` must append the message itself: appending
5252
+ * it when already appended duplicated it; not appending it when unappended
5253
+ * made it vanish. Reset at the top of each `runOrchestratedTurn`.
5254
+ */
5255
+ this.userMessageAppended = false;
5024
5256
  /**
5025
5257
  * Sticky user pick from the picker (or the host's `setAgent` API). Only
5026
5258
  * changes on explicit user action. Survives flow completion: when a stateful
@@ -5062,7 +5294,8 @@ var OrchestratingDriver = class extends EventTarget {
5062
5294
  maxFoldOperations: options.maxFoldOperations,
5063
5295
  maxTurnSnapshots: options.maxTurnSnapshots,
5064
5296
  sessionKey: this.sessionKey,
5065
- activityBus: options.activityBus
5297
+ activityBus: options.activityBus,
5298
+ budgetExhaustedMessage: options.budgetExhaustedMessage
5066
5299
  });
5067
5300
  this.chatDriver.addEventListener("history-updated", (e) => {
5068
5301
  this.dispatchEvent(new CustomEvent("history-updated", { detail: e.detail }));
@@ -5161,7 +5394,25 @@ var OrchestratingDriver = class extends EventTarget {
5161
5394
  return this.chatDriver.getSuggestions(history, prompt2, count, agentInfo);
5162
5395
  }
5163
5396
  async sendMessage(input, attachments) {
5397
+ try {
5398
+ return await this.runOrchestratedTurn(input, attachments);
5399
+ } catch (e) {
5400
+ if (e instanceof BudgetExhaustedError) {
5401
+ return this.chatDriver.reportBudgetExhausted(
5402
+ e,
5403
+ this.userMessageAppended ? void 0 : {
5404
+ role: "user",
5405
+ content: input,
5406
+ ...attachments ? { attachments } : {}
5407
+ }
5408
+ );
5409
+ }
5410
+ throw e;
5411
+ }
5412
+ }
5413
+ async runOrchestratedTurn(input, attachments) {
5164
5414
  this.cancelled = false;
5415
+ this.userMessageAppended = false;
5165
5416
  const history = this.chatDriver.getHistory();
5166
5417
  this.dispatchEvent(
5167
5418
  new CustomEvent("history-updated", {
@@ -5175,6 +5426,7 @@ var OrchestratingDriver = class extends EventTarget {
5175
5426
  let handoffs = 0;
5176
5427
  let handoffSummary = "";
5177
5428
  let remainingTask = "";
5429
+ let lastResult;
5178
5430
  while (true) {
5179
5431
  if (this.cancelled) break;
5180
5432
  await this.applyAgent(currentAgent);
@@ -5183,8 +5435,10 @@ var OrchestratingDriver = class extends EventTarget {
5183
5435
  const contextPrimer = handoffSummary ? [{ role: "user", content: `[Context from previous agent]: ${handoffSummary}` }] : [];
5184
5436
  result = await this.chatDriver.continueFromHistory(contextPrimer);
5185
5437
  } else {
5438
+ this.userMessageAppended = true;
5186
5439
  result = await this.chatDriver.sendMessage(input, attachments);
5187
5440
  }
5441
+ lastResult = result;
5188
5442
  if (this.chatDriver.getAgentReleaseRequested()) {
5189
5443
  await this.releaseActiveAgent();
5190
5444
  break;
@@ -5206,7 +5460,7 @@ var OrchestratingDriver = class extends EventTarget {
5206
5460
  this.dispatchEvent(new CustomEvent("orchestrating-start"));
5207
5461
  currentAgent = await this.classify(remainingTask, updatedHistory);
5208
5462
  }
5209
- return { reason: "done" };
5463
+ return lastResult?.reason === "done" ? lastResult : { reason: "done" };
5210
5464
  }
5211
5465
  async continueFromHistory(transientPrimer) {
5212
5466
  return this.chatDriver.continueFromHistory(transientPrimer);
@@ -5389,6 +5643,7 @@ ${recentMessages}` : ""}`;
5389
5643
  }
5390
5644
  }
5391
5645
  };
5646
+ let routedNoMatch = false;
5392
5647
  for (let attempt = 0; attempt <= this.classifierRetries; attempt += 1) {
5393
5648
  try {
5394
5649
  const options = {
@@ -5408,8 +5663,10 @@ ${recentMessages}` : ""}`;
5408
5663
  if (index >= 0 && index < this.specialists.length) {
5409
5664
  return this.specialists[index];
5410
5665
  }
5666
+ routedNoMatch = true;
5411
5667
  break;
5412
5668
  } catch (e) {
5669
+ if (e instanceof BudgetExhaustedError) throw e;
5413
5670
  logger2.warn(`OrchestratingDriver: classifier attempt ${attempt + 1} failed:`, e);
5414
5671
  if (attempt === this.classifierRetries) {
5415
5672
  logger2.error("OrchestratingDriver: classifier failed after all retries, using fallback");
@@ -5417,10 +5674,12 @@ ${recentMessages}` : ""}`;
5417
5674
  }
5418
5675
  }
5419
5676
  if (this.fallback) return this.fallback;
5420
- const specialistNames = this.specialists.map((s) => s.name).join(", ");
5421
- this.appendInlineMessage(
5422
- `I'm not sure how to help with that. I can assist with: ${specialistNames}.`
5423
- );
5677
+ if (routedNoMatch) {
5678
+ const specialistNames = this.specialists.map((s) => s.name).join(", ");
5679
+ this.appendInlineMessage(
5680
+ `I'm not sure how to help with that. I can assist with: ${specialistNames}.`
5681
+ );
5682
+ }
5424
5683
  return this.specialists[0];
5425
5684
  }
5426
5685
  /**