@genesislcap/ai-assistant 15.6.2 → 15.7.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 +613 -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 +254 -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 +394 -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 +162 -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 +11 -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 +969 -0
  37. package/dist/esm/main/main.js +704 -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 +145 -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 +1316 -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 +704 -10
  60. package/src/state/ai-assistant-slice.test.ts +215 -0
  61. package/src/state/ai-assistant-slice.ts +218 -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
@@ -969,6 +969,67 @@ var GeminiProvider = class {
969
969
  }
970
970
  };
971
971
 
972
+ // ../../foundation-ai/dist/esm/transports/budget-exhausted-error.js
973
+ var BUDGET_EXCEEDED_CODE = "BUDGET_EXCEEDED";
974
+ var VENDOR_LABELS = Object.freeze({
975
+ anthropic: "Anthropic",
976
+ gemini: "Gemini",
977
+ openai: "OpenAI",
978
+ chrome: "Chrome"
979
+ });
980
+ var BUDGETED_VENDORS = Object.freeze([
981
+ "anthropic",
982
+ "gemini"
983
+ ]);
984
+ var VENDOR_TYPE_BY_LABEL = new Map(Object.entries(VENDOR_LABELS).map(([type, label]) => [
985
+ label.toLowerCase(),
986
+ type
987
+ ]));
988
+ function vendorTypeOfLabel(label) {
989
+ if (typeof label !== "string")
990
+ return void 0;
991
+ return VENDOR_TYPE_BY_LABEL.get(label.trim().toLowerCase());
992
+ }
993
+ var BUDGET_EXCEEDED_STATUS = 402;
994
+ var DEFAULT_BUDGET_EXHAUSTED_MESSAGE = "You've reached your AI usage limit. Contact your administrator to raise it.";
995
+ var BudgetExhaustedError = class extends Error {
996
+ constructor(vendorLabel, budgetUsd, spentUsd, detail, extra) {
997
+ 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.");
998
+ this.vendorLabel = vendorLabel;
999
+ this.budgetUsd = budgetUsd;
1000
+ this.spentUsd = spentUsd;
1001
+ this.detail = detail;
1002
+ this.name = "BudgetExhaustedError";
1003
+ this.otherVendorAvailable = extra === null || extra === void 0 ? void 0 : extra.otherVendorAvailable;
1004
+ this.serverVendor = extra === null || extra === void 0 ? void 0 : extra.serverVendor;
1005
+ }
1006
+ };
1007
+ var isBudgetRejection = (status, code) => status === BUDGET_EXCEEDED_STATUS || code === BUDGET_EXCEEDED_CODE;
1008
+ var parseJsonOrUndefined = (text) => {
1009
+ try {
1010
+ return JSON.parse(text);
1011
+ } catch (_a) {
1012
+ return void 0;
1013
+ }
1014
+ };
1015
+ var codeOf = (payload) => {
1016
+ const code = payload === null || payload === void 0 ? void 0 : payload.code;
1017
+ return typeof code === "string" ? code : void 0;
1018
+ };
1019
+ var numberOrUndefined = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
1020
+ var booleanOrUndefined = (value) => typeof value === "boolean" ? value : void 0;
1021
+ var stringOrUndefined = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
1022
+ function budgetExhaustedFrom(vendorLabel, payload, fallbackDetail) {
1023
+ var _a, _b, _c, _d;
1024
+ const body = typeof payload === "object" && payload !== null ? payload : {};
1025
+ const details = typeof body.details === "object" && body.details !== null ? body.details : {};
1026
+ const detail = typeof body.error === "string" ? body.error : fallbackDetail;
1027
+ 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, {
1028
+ otherVendorAvailable: (_c = booleanOrUndefined(body.otherVendorAvailable)) !== null && _c !== void 0 ? _c : booleanOrUndefined(details.otherVendorAvailable),
1029
+ serverVendor: (_d = stringOrUndefined(body.vendor)) !== null && _d !== void 0 ? _d : stringOrUndefined(details.vendor)
1030
+ });
1031
+ }
1032
+
972
1033
  // ../../foundation-ai/dist/esm/types/config.types.js
973
1034
  var SUPPORTED_GEMINI_MODEL_IDS = [
974
1035
  "gemini-2.5-pro",
@@ -1123,15 +1184,19 @@ function postWithRetry(options) {
1123
1184
  signal: combinedSignal,
1124
1185
  credentials
1125
1186
  });
1126
- if (retryableStatuses.includes(response.status) && attempt < MAX_RETRIES) {
1127
- logger.warn(`${vendorLabel}Transport: retryable status ${response.status}, retrying (attempt ${attempt + 1}/${MAX_RETRIES})`);
1128
- clearTimeout(timeoutId);
1129
- yield backoff(attempt);
1130
- continue;
1131
- }
1132
1187
  if (!response.ok) {
1133
- const err = yield response.text();
1134
- throw new Error(`${vendorLabel} request error ${response.status}: ${err}`);
1188
+ const errText = yield response.text();
1189
+ const parsed = parseJsonOrUndefined(errText);
1190
+ if (isBudgetRejection(response.status, codeOf(parsed))) {
1191
+ throw budgetExhaustedFrom(vendorLabel, parsed, errText || void 0);
1192
+ }
1193
+ if (retryableStatuses.includes(response.status) && attempt < MAX_RETRIES) {
1194
+ logger.warn(`${vendorLabel}Transport: retryable status ${response.status}, retrying (attempt ${attempt + 1}/${MAX_RETRIES})`);
1195
+ clearTimeout(timeoutId);
1196
+ yield backoff(attempt);
1197
+ continue;
1198
+ }
1199
+ throw new Error(`${vendorLabel} request error ${response.status}: ${errText}`);
1135
1200
  }
1136
1201
  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) {
1137
1202
  rearmStallTimer();
@@ -1141,6 +1206,9 @@ function postWithRetry(options) {
1141
1206
  if (frame.code === "UPSTREAM_STALLED") {
1142
1207
  throw new DOMException(`${vendorLabel} request stalled: ${(_c = frame.error) !== null && _c !== void 0 ? _c : "no upstream data"}`, "TimeoutError");
1143
1208
  }
1209
+ if (isBudgetRejection(frame.status, frame.code)) {
1210
+ throw budgetExhaustedFrom(vendorLabel, frame);
1211
+ }
1144
1212
  if (retryableStatuses.includes(frame.status) && attempt < MAX_RETRIES) {
1145
1213
  logger.warn(`${vendorLabel}Transport: retryable status ${frame.status} (err frame), retrying (attempt ${attempt + 1}/${MAX_RETRIES})`);
1146
1214
  clearTimeout(timeoutId);
@@ -1611,7 +1679,7 @@ ${att.content}` });
1611
1679
  headers: requestHeaders,
1612
1680
  body,
1613
1681
  credentials,
1614
- vendorLabel: "Anthropic",
1682
+ vendorLabel: VENDOR_LABELS.anthropic,
1615
1683
  retryableStatuses: _AnthropicTransport.RETRYABLE_STATUSES,
1616
1684
  timeout: this.timeout,
1617
1685
  stallTimeout: this.stallTimeout,
@@ -2279,7 +2347,7 @@ ${att.content}`
2279
2347
  headers,
2280
2348
  body: payload,
2281
2349
  credentials,
2282
- vendorLabel: "Gemini",
2350
+ vendorLabel: VENDOR_LABELS.gemini,
2283
2351
  retryableStatuses: _GeminiTransport.RETRYABLE_STATUSES,
2284
2352
  timeout: this.timeout,
2285
2353
  stallTimeout: this.stallTimeout,
@@ -3029,6 +3097,19 @@ function accumulate(messages, into) {
3029
3097
  var TOOL_FOLD_SYMBOL = Symbol("toolFold");
3030
3098
 
3031
3099
  // src/components/chat-driver/chat-driver.ts
3100
+ var budgetDetailOf = (e) => {
3101
+ const vendor = vendorTypeOfLabel(e.vendorLabel) ?? vendorTypeOfLabel(e.serverVendor);
3102
+ if (e.budgetUsd == null && e.spentUsd == null && !vendor) return void 0;
3103
+ return {
3104
+ budgetUsd: e.budgetUsd,
3105
+ spentUsd: e.spentUsd,
3106
+ vendorLabel: e.vendorLabel,
3107
+ ...vendor ? { vendor } : {},
3108
+ // Server-known truth the client cannot re-derive, so it rides all the way to
3109
+ // the banner rather than being re-guessed there from registry membership.
3110
+ ...e.otherVendorAvailable != null ? { otherVendorAvailable: e.otherVendorAvailable } : {}
3111
+ };
3112
+ };
3032
3113
  var DEFAULT_MAX_TOOL_ITERATIONS = 50;
3033
3114
  var DEFAULT_MAX_FOLD_OPERATIONS = 5;
3034
3115
  var DEFAULT_MAX_TURN_SNAPSHOTS = 400;
@@ -3211,6 +3292,22 @@ var ChatDriver = class _ChatDriver extends EventTarget {
3211
3292
  * picked up on the next turn.
3212
3293
  */
3213
3294
  this.resolvedStatusCache = /* @__PURE__ */ new Map();
3295
+ /**
3296
+ * Set the moment a budget wall is observed anywhere in this turn — this
3297
+ * driver's own 402, or a sub-agent's (which surfaces here only as a
3298
+ * `'budget_exhausted'` tool result). Read at the top of the tool loop to end
3299
+ * the turn before issuing another model call that would hit the same wall.
3300
+ * Reset per turn alongside the other per-turn counters.
3301
+ */
3302
+ this.budgetExhaustedThisTurn = false;
3303
+ /**
3304
+ * Whether this turn's budget wall came from a SUB-AGENT rather than this
3305
+ * driver's own request. Decides whether `lastResolvedProvider` is a valid
3306
+ * attribution fallback: for an own wall it is the refusing vendor, for a
3307
+ * child's wall it is the parent's vendor — the one known NOT to have refused.
3308
+ * Reset per turn alongside `budgetWallDetail`.
3309
+ */
3310
+ this.budgetWallViaSubAgent = false;
3214
3311
  const {
3215
3312
  toolHandlers = {},
3216
3313
  toolDefinitions = [],
@@ -3221,12 +3318,14 @@ var ChatDriver = class _ChatDriver extends EventTarget {
3221
3318
  condenseBatchCalls = 1,
3222
3319
  maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS,
3223
3320
  sessionKey = "",
3224
- activityBus = NOOP_ACTIVITY_BUS
3321
+ activityBus = NOOP_ACTIVITY_BUS,
3322
+ budgetExhaustedMessage = DEFAULT_BUDGET_EXHAUSTED_MESSAGE
3225
3323
  } = config;
3226
3324
  this.maxToolIterations = maxToolIterations;
3227
3325
  this.condenseBatchCalls = condenseBatchCalls;
3228
3326
  this.sessionKey = sessionKey;
3229
3327
  this.activityBus = activityBus;
3328
+ this.budgetExhaustedMessage = budgetExhaustedMessage;
3230
3329
  if (typeof toolHandlers === "function") {
3231
3330
  this.toolHandlersFactory = toolHandlers;
3232
3331
  this.toolHandlers = {};
@@ -3333,8 +3432,56 @@ var ChatDriver = class _ChatDriver extends EventTarget {
3333
3432
  * (rather than set to `undefined`) so a happy-path result stays byte-identical to
3334
3433
  * the historical `{ reason: 'done' }`.
3335
3434
  */
3336
- turnDone(failureReason) {
3337
- return failureReason ? { reason: "done", failureReason } : { reason: "done" };
3435
+ turnDone(failureReason, budget) {
3436
+ if (!failureReason) return { reason: "done" };
3437
+ return budget ? { reason: "done", failureReason, budget } : { reason: "done", failureReason };
3438
+ }
3439
+ /**
3440
+ * Terminal budget outcome for a wall hit **outside** the tool loop — today,
3441
+ * `OrchestratingDriver`'s classification phase, which calls the provider
3442
+ * directly and so never enters `runToolLoop`.
3443
+ *
3444
+ * Does **not** publish `tool-loop-end`: no `tool-loop-start` was published for
3445
+ * the classify phase, and an unbalanced end would break start/end pairing for
3446
+ * subscribers that rely on it. The driver **return value** is what reports this
3447
+ * case — see `FoundationAiAssistant`'s latch, which reads both seams for
3448
+ * exactly this reason.
3449
+ *
3450
+ * The non-sub-agent tail of the in-loop `BudgetExhaustedError` branch lives
3451
+ * here so there is one copy of the log line, the debug-log entry, the
3452
+ * transcript bubble and the result shape rather than two that can drift.
3453
+ *
3454
+ * @param pendingUserMessage - a user message that has NOT yet been appended,
3455
+ * appended first so the answer does not end up replying to nothing. Only the
3456
+ * classification seam passes it: `OrchestratingDriver` dispatches the user's
3457
+ * text as an optimistic `history-updated` detail and leaves the real append
3458
+ * to `chatDriver.sendMessage`, which never runs when `classify()` throws — so
3459
+ * the bubble below would re-dispatch a history the user's own message was
3460
+ * never in, and it would vanish from the transcript on the next render. The
3461
+ * in-loop caller has already appended it and passes nothing.
3462
+ *
3463
+ * @internal
3464
+ */
3465
+ reportBudgetExhausted(e, pendingUserMessage) {
3466
+ if (pendingUserMessage) this.appendToHistory(pendingUserMessage);
3467
+ this.budgetExhaustedThisTurn = true;
3468
+ logger2.error("ChatDriver: AI budget exhausted", e);
3469
+ recordTurnError(this.sessionKey, "budget-exhausted", {
3470
+ agent: this.activeAgentName,
3471
+ provider: this.lastResolvedProviderName,
3472
+ // The registry ALIAS (e.g. 'high') is what `provider` records; the vendor
3473
+ // is the thing a per-vendor budget is actually scoped to, and it was known
3474
+ // at both ends and discarded in the middle until now. Taken from the
3475
+ // refusing transport's label first — this method also serves the
3476
+ // classification seam, where `lastResolvedProvider` is the PREVIOUS turn's
3477
+ // vendor (or nothing), because classify runs against the registry default.
3478
+ vendor: vendorTypeOfLabel(e.vendorLabel) ?? vendorTypeOfLabel(e.serverVendor) ?? this.lastResolvedProvider,
3479
+ budgetUsd: e.budgetUsd,
3480
+ spentUsd: e.spentUsd,
3481
+ isSubAgent: this.isSubAgent
3482
+ });
3483
+ this.appendToHistory({ role: "assistant", content: this.budgetExhaustedMessage });
3484
+ return this.turnDone("budget-exhausted", budgetDetailOf(e));
3338
3485
  }
3339
3486
  /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
3340
3487
  static failureReasonOf(result) {
@@ -3344,10 +3491,28 @@ var ChatDriver = class _ChatDriver extends EventTarget {
3344
3491
  * Build the `tool-loop-end` event detail for a turn's result. A failure carries a
3345
3492
  * `{ failureReason }` detail; a clean turn emits `undefined` — the historical shape,
3346
3493
  * kept byte-identical so subscribers see exactly what they always have.
3494
+ *
3495
+ * A budget failure additionally carries `vendor` — the concrete vendor
3496
+ * (`'anthropic'`/`'gemini'`) the walled turn resolved to, which the driver knows
3497
+ * and used to discard. Optional and additive: a subscriber reading only
3498
+ * `failureReason` is unaffected, a non-budget failure still emits the historical
3499
+ * `{ failureReason }` with no `vendor` key, and the value is a plain string so
3500
+ * the detail stays structured-cloneable for the cross-tab hop. It is the field a
3501
+ * per-vendor budget model needs and the one that would be awkward to retrofit.
3347
3502
  */
3348
- static loopEndDetail(result) {
3503
+ loopEndDetail(result) {
3349
3504
  const failureReason = _ChatDriver.failureReasonOf(result);
3350
- return failureReason ? { failureReason } : void 0;
3505
+ if (!failureReason) return void 0;
3506
+ if (failureReason !== "budget-exhausted") {
3507
+ return { failureReason };
3508
+ }
3509
+ const budget = result.reason === "done" ? result.budget : void 0;
3510
+ const vendor = budget?.vendor ?? (this.budgetWallViaSubAgent ? void 0 : this.lastResolvedProvider);
3511
+ return {
3512
+ failureReason,
3513
+ ...vendor ? { vendor } : {},
3514
+ ...budget ? { budget } : {}
3515
+ };
3351
3516
  }
3352
3517
  /**
3353
3518
  * Swap in a new agent's configuration. Called by OrchestratingDriver before
@@ -3533,9 +3698,9 @@ var ChatDriver = class _ChatDriver extends EventTarget {
3533
3698
  * under a separate session key, so recording here would orphan the event off
3534
3699
  * the user-visible debug-log timeline.)
3535
3700
  */
3536
- failSubAgent(reason) {
3701
+ failSubAgent(reason, budget) {
3537
3702
  if (!this.isSubAgent || this.subAgentFailure) return;
3538
- this.subAgentFailure = { reason };
3703
+ this.subAgentFailure = budget ? { reason, budget } : { reason };
3539
3704
  }
3540
3705
  /**
3541
3706
  * Returns true if `releaseAgent` was called during the most recent turn.
@@ -3985,6 +4150,9 @@ Output format (strict):
3985
4150
  this.subAgentCompletion = void 0;
3986
4151
  this.subAgentFailure = void 0;
3987
4152
  this.agentReleaseRequested = false;
4153
+ this.budgetExhaustedThisTurn = false;
4154
+ this.budgetWallDetail = void 0;
4155
+ this.budgetWallViaSubAgent = false;
3988
4156
  this.appendToHistory({ role: "user", content: userInput, attachments });
3989
4157
  this.turnStartedAt = Date.now();
3990
4158
  recordMetaEvent(this.sessionKey, "turn.start", {
@@ -4019,7 +4187,7 @@ Output format (strict):
4019
4187
  });
4020
4188
  this.busy = false;
4021
4189
  this.endTurn();
4022
- this.activityBus.publish("tool-loop-end", _ChatDriver.loopEndDetail(result));
4190
+ this.activityBus.publish("tool-loop-end", this.loopEndDetail(result));
4023
4191
  }
4024
4192
  }
4025
4193
  /**
@@ -4210,8 +4378,14 @@ Output format (strict):
4210
4378
  recordMetaEvent(this.sessionKey, "subagent.completed", { agent: name });
4211
4379
  return { outcome: { ok: true, result: completion.result }, trace };
4212
4380
  }
4213
- const reason = child.getSubAgentFailure()?.reason ?? "max_iterations";
4381
+ const failure = child.getSubAgentFailure();
4382
+ const reason = failure?.reason ?? "max_iterations";
4214
4383
  recordMetaEvent(this.sessionKey, "subagent.failed", { agent: name, reason });
4384
+ if (reason === "budget_exhausted") {
4385
+ this.budgetExhaustedThisTurn = true;
4386
+ this.budgetWallViaSubAgent = true;
4387
+ this.budgetWallDetail ??= failure?.budget;
4388
+ }
4215
4389
  return { outcome: { ok: false, reason }, trace };
4216
4390
  }
4217
4391
  /**
@@ -4224,6 +4398,9 @@ Output format (strict):
4224
4398
  this.beginTurn();
4225
4399
  this.subAgentCompletion = void 0;
4226
4400
  this.subAgentFailure = void 0;
4401
+ this.budgetExhaustedThisTurn = false;
4402
+ this.budgetWallDetail = void 0;
4403
+ this.budgetWallViaSubAgent = false;
4227
4404
  this.turnStartedAt = Date.now();
4228
4405
  recordMetaEvent(this.sessionKey, "turn.start", {
4229
4406
  phase: "continueFromHistory",
@@ -4257,7 +4434,7 @@ Output format (strict):
4257
4434
  });
4258
4435
  this.busy = false;
4259
4436
  this.endTurn();
4260
- this.activityBus.publish("tool-loop-end", _ChatDriver.loopEndDetail(result));
4437
+ this.activityBus.publish("tool-loop-end", this.loopEndDetail(result));
4261
4438
  }
4262
4439
  }
4263
4440
  // ---------------------------------------------------------------------------
@@ -4411,6 +4588,31 @@ Output format (strict):
4411
4588
  if (this.turnController.signal.aborted) {
4412
4589
  return this.completeAbortedTurn();
4413
4590
  }
4591
+ if (this.budgetExhaustedThisTurn) {
4592
+ logger2.error("ChatDriver: ending the turn \u2014 a sub-agent hit the AI budget wall");
4593
+ recordTurnError(this.sessionKey, "budget-exhausted", {
4594
+ agent: this.activeAgentName,
4595
+ provider: this.lastResolvedProviderName,
4596
+ // The CHILD's vendor when it knew one, and NOTHING otherwise. On this
4597
+ // path the wall is definitionally the child's, and on a mixed registry
4598
+ // `lastResolvedProvider` is this driver's own vendor — the one known
4599
+ // NOT to have refused. An unattributable child wall must degrade to
4600
+ // the vendor-agnostic block (which `latchBlockedFrom` handles
4601
+ // fail-safe), never to a vendor that is known to be wrong: naming the
4602
+ // parent's vendor here walled BOTH — the child's via its own
4603
+ // tool-loop-end, the parent's via this event — and derived `blocked`
4604
+ // over headroom that still existed.
4605
+ vendor: this.budgetWallDetail?.vendor,
4606
+ via: "sub-agent",
4607
+ isSubAgent: this.isSubAgent
4608
+ });
4609
+ if (this.isSubAgent) {
4610
+ this.failSubAgent("budget_exhausted", this.budgetWallDetail);
4611
+ } else {
4612
+ this.appendToHistory({ role: "assistant", content: this.budgetExhaustedMessage });
4613
+ }
4614
+ return this.turnDone("budget-exhausted", this.budgetWallDetail);
4615
+ }
4414
4616
  const promptCtx = {
4415
4617
  agentName: this.activeAgentName ?? "",
4416
4618
  history: this.history,
@@ -4582,6 +4784,25 @@ ${tailBody}
4582
4784
  }
4583
4785
  return this.turnDone("response-truncated");
4584
4786
  }
4787
+ if (e instanceof BudgetExhaustedError) {
4788
+ this.budgetExhaustedThisTurn = true;
4789
+ this.budgetWallDetail = budgetDetailOf(e);
4790
+ this.budgetWallViaSubAgent = false;
4791
+ if (this.isSubAgent) {
4792
+ logger2.error("ChatDriver: AI budget exhausted", e);
4793
+ recordTurnError(this.sessionKey, "budget-exhausted", {
4794
+ agent: this.activeAgentName,
4795
+ provider: this.lastResolvedProviderName,
4796
+ vendor: vendorTypeOfLabel(e.vendorLabel) ?? vendorTypeOfLabel(e.serverVendor) ?? this.lastResolvedProvider,
4797
+ budgetUsd: e.budgetUsd,
4798
+ spentUsd: e.spentUsd,
4799
+ isSubAgent: true
4800
+ });
4801
+ this.failSubAgent("budget_exhausted", this.budgetWallDetail);
4802
+ return this.turnDone("budget-exhausted", this.budgetWallDetail);
4803
+ }
4804
+ return this.reportBudgetExhausted(e);
4805
+ }
4585
4806
  if (e instanceof DOMException && e.name === "TimeoutError") {
4586
4807
  logger2.error("ChatDriver: request timed out", e);
4587
4808
  recordTurnError(this.sessionKey, "exception", {
@@ -5057,6 +5278,17 @@ var OrchestratingDriver = class extends EventTarget {
5057
5278
  * user stops. Reset at the start of each `sendMessage`.
5058
5279
  */
5059
5280
  this.cancelled = false;
5281
+ /**
5282
+ * Whether the CURRENT turn's user message has reached the inner driver's
5283
+ * history. False through the pre-first-turn classify (where the only echo of
5284
+ * the message is an optimistic `history-updated` dispatch), true from the
5285
+ * moment `chatDriver.sendMessage` is entered — including through every later
5286
+ * handoff classify. Read by the budget-wall catch in `sendMessage` to decide
5287
+ * whether `reportBudgetExhausted` must append the message itself: appending
5288
+ * it when already appended duplicated it; not appending it when unappended
5289
+ * made it vanish. Reset at the top of each `runOrchestratedTurn`.
5290
+ */
5291
+ this.userMessageAppended = false;
5060
5292
  /**
5061
5293
  * Sticky user pick from the picker (or the host's `setAgent` API). Only
5062
5294
  * changes on explicit user action. Survives flow completion: when a stateful
@@ -5098,7 +5330,8 @@ var OrchestratingDriver = class extends EventTarget {
5098
5330
  maxFoldOperations: options.maxFoldOperations,
5099
5331
  maxTurnSnapshots: options.maxTurnSnapshots,
5100
5332
  sessionKey: this.sessionKey,
5101
- activityBus: options.activityBus
5333
+ activityBus: options.activityBus,
5334
+ budgetExhaustedMessage: options.budgetExhaustedMessage
5102
5335
  });
5103
5336
  this.chatDriver.addEventListener("history-updated", (e) => {
5104
5337
  this.dispatchEvent(new CustomEvent("history-updated", { detail: e.detail }));
@@ -5197,7 +5430,25 @@ var OrchestratingDriver = class extends EventTarget {
5197
5430
  return this.chatDriver.getSuggestions(history, prompt2, count, agentInfo);
5198
5431
  }
5199
5432
  async sendMessage(input, attachments) {
5433
+ try {
5434
+ return await this.runOrchestratedTurn(input, attachments);
5435
+ } catch (e) {
5436
+ if (e instanceof BudgetExhaustedError) {
5437
+ return this.chatDriver.reportBudgetExhausted(
5438
+ e,
5439
+ this.userMessageAppended ? void 0 : {
5440
+ role: "user",
5441
+ content: input,
5442
+ ...attachments ? { attachments } : {}
5443
+ }
5444
+ );
5445
+ }
5446
+ throw e;
5447
+ }
5448
+ }
5449
+ async runOrchestratedTurn(input, attachments) {
5200
5450
  this.cancelled = false;
5451
+ this.userMessageAppended = false;
5201
5452
  const history = this.chatDriver.getHistory();
5202
5453
  this.dispatchEvent(
5203
5454
  new CustomEvent("history-updated", {
@@ -5211,6 +5462,7 @@ var OrchestratingDriver = class extends EventTarget {
5211
5462
  let handoffs = 0;
5212
5463
  let handoffSummary = "";
5213
5464
  let remainingTask = "";
5465
+ let lastResult;
5214
5466
  while (true) {
5215
5467
  if (this.cancelled) break;
5216
5468
  await this.applyAgent(currentAgent);
@@ -5219,8 +5471,10 @@ var OrchestratingDriver = class extends EventTarget {
5219
5471
  const contextPrimer = handoffSummary ? [{ role: "user", content: `[Context from previous agent]: ${handoffSummary}` }] : [];
5220
5472
  result = await this.chatDriver.continueFromHistory(contextPrimer);
5221
5473
  } else {
5474
+ this.userMessageAppended = true;
5222
5475
  result = await this.chatDriver.sendMessage(input, attachments);
5223
5476
  }
5477
+ lastResult = result;
5224
5478
  if (this.chatDriver.getAgentReleaseRequested()) {
5225
5479
  await this.releaseActiveAgent();
5226
5480
  break;
@@ -5242,7 +5496,7 @@ var OrchestratingDriver = class extends EventTarget {
5242
5496
  this.dispatchEvent(new CustomEvent("orchestrating-start"));
5243
5497
  currentAgent = await this.classify(remainingTask, updatedHistory);
5244
5498
  }
5245
- return { reason: "done" };
5499
+ return lastResult?.reason === "done" ? lastResult : { reason: "done" };
5246
5500
  }
5247
5501
  async continueFromHistory(transientPrimer) {
5248
5502
  return this.chatDriver.continueFromHistory(transientPrimer);
@@ -5425,6 +5679,7 @@ ${recentMessages}` : ""}`;
5425
5679
  }
5426
5680
  }
5427
5681
  };
5682
+ let routedNoMatch = false;
5428
5683
  for (let attempt = 0; attempt <= this.classifierRetries; attempt += 1) {
5429
5684
  try {
5430
5685
  const options = {
@@ -5444,8 +5699,10 @@ ${recentMessages}` : ""}`;
5444
5699
  if (index >= 0 && index < this.specialists.length) {
5445
5700
  return this.specialists[index];
5446
5701
  }
5702
+ routedNoMatch = true;
5447
5703
  break;
5448
5704
  } catch (e) {
5705
+ if (e instanceof BudgetExhaustedError) throw e;
5449
5706
  logger2.warn(`OrchestratingDriver: classifier attempt ${attempt + 1} failed:`, e);
5450
5707
  if (attempt === this.classifierRetries) {
5451
5708
  logger2.error("OrchestratingDriver: classifier failed after all retries, using fallback");
@@ -5453,10 +5710,12 @@ ${recentMessages}` : ""}`;
5453
5710
  }
5454
5711
  }
5455
5712
  if (this.fallback) return this.fallback;
5456
- const specialistNames = this.specialists.map((s) => s.name).join(", ");
5457
- this.appendInlineMessage(
5458
- `I'm not sure how to help with that. I can assist with: ${specialistNames}.`
5459
- );
5713
+ if (routedNoMatch) {
5714
+ const specialistNames = this.specialists.map((s) => s.name).join(", ");
5715
+ this.appendInlineMessage(
5716
+ `I'm not sure how to help with that. I can assist with: ${specialistNames}.`
5717
+ );
5718
+ }
5460
5719
  return this.specialists[0];
5461
5720
  }
5462
5721
  /**