@genesislcap/ai-assistant 15.14.0 → 15.14.2

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.
@@ -1,5 +1,5 @@
1
1
  import { __awaiter, __rest } from "tslib";
2
- import { BudgetExhaustedError, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, isObservableAIProviderRegistry, MalformedFunctionCallError, ResponseTruncatedError, vendorTypeOfLabel, } from '@genesislcap/foundation-ai';
2
+ import { BudgetExhaustedError, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, DEFAULT_PROVIDER_REFUSED_MESSAGE, ProviderRefusedError, isObservableAIProviderRegistry, MalformedFunctionCallError, ResponseTruncatedError, vendorTypeOfLabel, } from '@genesislcap/foundation-ai';
3
3
  import { NOOP_ACTIVITY_BUS } from '../../channel/ai-activity-bus';
4
4
  import { resolveChatProvider } from '../../config/validate-providers';
5
5
  import { clearSession, getMetaEvents, mergeMetaEvents, recordMetaEvent, recordTurnError, recordTurnRetry, } from '../../state/debug-event-log';
@@ -28,6 +28,15 @@ import { TOOL_FOLD_SYMBOL } from '../../utils/tool-fold';
28
28
  * `formatBlockedReason`, which returns `undefined` for a figure-less budget so a
29
29
  * host-set explanation survives the latch.
30
30
  */
31
+ /**
32
+ * Lift the reportable facts off a {@link ProviderRefusedError} (GENC-1506).
33
+ *
34
+ * Unlike `budgetDetailOf` this is total — it always returns a detail. The budget version can answer
35
+ * `undefined` because its payload is figures the proxy may not have sent; here every field either
36
+ * comes from the transport that was refused (`vendorLabel`, `kind`) or is plainly optional, so there
37
+ * is no "nothing worth reporting" case to model.
38
+ */
39
+ const providerRefusedDetailOf = (e) => (Object.assign(Object.assign({ vendorLabel: e.vendorLabel, kind: e.kind }, (e.upstreamStatus != null ? { upstreamStatus: e.upstreamStatus } : {})), (e.upstreamType ? { upstreamType: e.upstreamType } : {})));
31
40
  const budgetDetailOf = (e) => {
32
41
  var _a;
33
42
  // The typed vendor is derived from the LABEL, not from `lastResolvedProvider`:
@@ -264,6 +273,16 @@ export class ChatDriver extends EventTarget {
264
273
  * picked up on the next turn.
265
274
  */
266
275
  this.resolvedStatusCache = new Map();
276
+ /**
277
+ * A provider refusal was observed this turn, so the tool loop must not call the model again
278
+ * (GENC-1506).
279
+ *
280
+ * Exists for the SUB-AGENT path only, exactly like `budgetExhaustedThisTurn`: this driver's own
281
+ * refusal returns straight out of the catch, whereas a child's refusal reaches the parent as a tool
282
+ * result, and without this flag the loop would issue another call into the same wall — N batched
283
+ * children costing N doomed calls plus a doomed parent one.
284
+ */
285
+ this.providerRefusedThisTurn = false;
267
286
  /**
268
287
  * Set the moment a budget wall is observed anywhere in this turn — this
269
288
  * driver's own 402, or a sub-agent's (which surfaces here only as a
@@ -280,12 +299,13 @@ export class ChatDriver extends EventTarget {
280
299
  * Reset per turn alongside `budgetWallDetail`.
281
300
  */
282
301
  this.budgetWallViaSubAgent = false;
283
- const { toolHandlers = {}, toolDefinitions = [], systemPrompt, primerHistory, maxToolIterations = DEFAULT_MAX_TOOL_ITERATIONS, maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS, condenseBatchCalls = 1, maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS, sessionKey = '', activityBus = NOOP_ACTIVITY_BUS, budgetExhaustedMessage, } = config;
302
+ const { toolHandlers = {}, toolDefinitions = [], systemPrompt, primerHistory, maxToolIterations = DEFAULT_MAX_TOOL_ITERATIONS, maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS, condenseBatchCalls = 1, maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS, sessionKey = '', activityBus = NOOP_ACTIVITY_BUS, budgetExhaustedMessage, providerRefusedMessage, } = config;
284
303
  this.maxToolIterations = maxToolIterations;
285
304
  this.condenseBatchCalls = condenseBatchCalls;
286
305
  this.sessionKey = sessionKey;
287
306
  this.activityBus = activityBus;
288
307
  this.budgetExhaustedMessageOverride = budgetExhaustedMessage;
308
+ this.providerRefusedMessageOverride = providerRefusedMessage;
289
309
  if (typeof toolHandlers === 'function') {
290
310
  this.toolHandlersFactory = toolHandlers;
291
311
  this.toolHandlers = {};
@@ -415,12 +435,12 @@ export class ChatDriver extends EventTarget {
415
435
  * (rather than set to `undefined`) so a happy-path result stays byte-identical to
416
436
  * the historical `{ reason: 'done' }`.
417
437
  */
418
- turnDone(failureReason, budget) {
438
+ turnDone(failureReason, budget, providerRefused) {
419
439
  if (!failureReason)
420
440
  return { reason: 'done' };
421
- // `budget` is likewise omitted rather than set to `undefined`, so a non-budget
422
- // failure's shape is unchanged for a consumer that structurally compares it.
423
- return budget ? { reason: 'done', failureReason, budget } : { reason: 'done', failureReason };
441
+ // Both payloads are omitted rather than set to `undefined`, so a failure that carries neither has
442
+ // a shape unchanged for a consumer that structurally compares it.
443
+ return Object.assign(Object.assign({ reason: 'done', failureReason }, (budget ? { budget } : {})), (providerRefused ? { providerRefused } : {}));
424
444
  }
425
445
  /**
426
446
  * Terminal budget outcome for a wall hit **outside** the tool loop — today,
@@ -495,6 +515,44 @@ export class ChatDriver extends EventTarget {
495
515
  this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedBubble(e) });
496
516
  return this.turnDone('budget-exhausted', budgetDetailOf(e));
497
517
  }
518
+ /**
519
+ * The sentence shown when the upstream provider refuses (GENC-1506).
520
+ *
521
+ * Deliberately far simpler than `budgetExhaustedBubble`: no vendor name, no figures, no
522
+ * switch-provider advice, and no branching on `kind`. The copy is cause-free by design — a user who
523
+ * can see their own remaining spend must not be told about a limit, and we must not imply the bill
524
+ * has gone unpaid — so there is nothing here to compose. A host override wins verbatim.
525
+ */
526
+ providerRefusedBubble() {
527
+ var _a;
528
+ return (_a = this.providerRefusedMessageOverride) !== null && _a !== void 0 ? _a : DEFAULT_PROVIDER_REFUSED_MESSAGE;
529
+ }
530
+ /**
531
+ * Terminal provider-refusal outcome. Mirrors `reportBudgetExhausted` so the two walls behave
532
+ * identically from the caller's side, while keeping their diagnostics distinct.
533
+ *
534
+ * `kind` reaches the debug log and the result but never the transcript: it is what lets an operator
535
+ * tell "top up the account" from "rotate the key", and with cause-free user copy this is the only
536
+ * place that distinction survives.
537
+ */
538
+ reportProviderRefused(e) {
539
+ var _a;
540
+ const detail = providerRefusedDetailOf(e);
541
+ this.providerRefusedThisTurn = true;
542
+ this.providerRefusedDetail = detail;
543
+ logger.error('ChatDriver: provider refused the request', e);
544
+ recordTurnError(this.sessionKey, 'provider-refused', {
545
+ agent: this.activeAgentName,
546
+ provider: this.lastResolvedProviderName,
547
+ vendor: (_a = vendorTypeOfLabel(e.vendorLabel)) !== null && _a !== void 0 ? _a : this.lastResolvedProvider,
548
+ kind: e.kind,
549
+ upstreamStatus: e.upstreamStatus,
550
+ upstreamType: e.upstreamType,
551
+ isSubAgent: this.isSubAgent,
552
+ });
553
+ this.appendToHistory({ role: 'assistant', content: this.providerRefusedBubble() });
554
+ return this.turnDone('provider-refused', undefined, detail);
555
+ }
498
556
  /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
499
557
  static failureReasonOf(result) {
500
558
  return result.reason === 'done' ? result.failureReason : undefined;
@@ -768,12 +826,12 @@ export class ChatDriver extends EventTarget {
768
826
  * under a separate session key, so recording here would orphan the event off
769
827
  * the user-visible debug-log timeline.)
770
828
  */
771
- failSubAgent(reason, budget) {
829
+ failSubAgent(reason, budget, providerRefused) {
772
830
  if (!this.isSubAgent || this.subAgentFailure)
773
831
  return;
774
- // Omitted rather than set to `undefined` so a non-budget failure's shape is
832
+ // Each payload is omitted rather than set to `undefined` so a failure carrying neither has a shape
775
833
  // unchanged for a structural comparison, matching `turnDone`.
776
- this.subAgentFailure = budget ? { reason, budget } : { reason };
834
+ this.subAgentFailure = Object.assign(Object.assign({ reason }, (budget ? { budget } : {})), (providerRefused ? { providerRefused } : {}));
777
835
  }
778
836
  /**
779
837
  * Returns true if `releaseAgent` was called during the most recent turn.
@@ -1367,6 +1425,8 @@ export class ChatDriver extends EventTarget {
1367
1425
  this.budgetExhaustedThisTurn = false;
1368
1426
  this.budgetWallDetail = undefined;
1369
1427
  this.budgetWallViaSubAgent = false;
1428
+ this.providerRefusedThisTurn = false;
1429
+ this.providerRefusedDetail = undefined;
1370
1430
  this.appendToHistory({ role: 'user', content: userInput, attachments });
1371
1431
  this.turnStartedAt = Date.now();
1372
1432
  recordMetaEvent(this.sessionKey, 'turn.start', {
@@ -1512,7 +1572,7 @@ export class ChatDriver extends EventTarget {
1512
1572
  */
1513
1573
  invokeSubAgent(name, options) {
1514
1574
  return __awaiter(this, void 0, void 0, function* () {
1515
- var _a, _b, _c, _d, _e;
1575
+ var _a, _b, _c, _d, _e, _f;
1516
1576
  const subConfig = this.subAgentsMap.get(name);
1517
1577
  if (!subConfig) {
1518
1578
  const available = [...this.subAgentsMap.keys()].join(', ') || '(none)';
@@ -1710,6 +1770,20 @@ export class ChatDriver extends EventTarget {
1710
1770
  this.budgetWallViaSubAgent = true;
1711
1771
  (_e = this.budgetWallDetail) !== null && _e !== void 0 ? _e : (this.budgetWallDetail = failure === null || failure === void 0 ? void 0 : failure.budget);
1712
1772
  }
1773
+ // The PROVIDER wall via a child (GENC-1506) — the same shape as the budget wall above, and needed
1774
+ // for the same non-obvious reason: **the child is a separate driver instance**, so the flag it set
1775
+ // on itself is invisible here. Without this branch the child's failure was appended as an ordinary
1776
+ // tool result, the parent's short-circuit never fired, and the parent issued another model call
1777
+ // straight into the same wall. Worse, if that call happened to succeed the turn could finish
1778
+ // WITHOUT `failureReason: 'provider-refused'` at all — reporting a clean turn over a dead account.
1779
+ //
1780
+ // FIRST attribution wins (`??=`), for the reason spelled out on `budgetWallDetail`: batched
1781
+ // delegations can pair an attributable child refusal with an unattributable one, and a plain
1782
+ // assignment would let the later `undefined` erase what the earlier child knew.
1783
+ if (reason === 'provider_refused') {
1784
+ this.providerRefusedThisTurn = true;
1785
+ (_f = this.providerRefusedDetail) !== null && _f !== void 0 ? _f : (this.providerRefusedDetail = failure === null || failure === void 0 ? void 0 : failure.providerRefused);
1786
+ }
1713
1787
  return { outcome: { ok: false, reason }, trace };
1714
1788
  });
1715
1789
  }
@@ -1728,6 +1802,8 @@ export class ChatDriver extends EventTarget {
1728
1802
  this.budgetExhaustedThisTurn = false;
1729
1803
  this.budgetWallDetail = undefined;
1730
1804
  this.budgetWallViaSubAgent = false;
1805
+ this.providerRefusedThisTurn = false;
1806
+ this.providerRefusedDetail = undefined;
1731
1807
  this.turnStartedAt = Date.now();
1732
1808
  recordMetaEvent(this.sessionKey, 'turn.start', {
1733
1809
  phase: 'continueFromHistory',
@@ -1918,7 +1994,7 @@ export class ChatDriver extends EventTarget {
1918
1994
  // oxlint-disable-next-line complexity
1919
1995
  runToolLoop(userInput, attachments, transientPrimer) {
1920
1996
  return __awaiter(this, void 0, void 0, function* () {
1921
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
1997
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
1922
1998
  if (!this.systemPrompt) {
1923
1999
  logger.warn('ChatDriver: no systemPrompt set. The assistant will have no instructions — provide a systemPrompt via agents config or the foundation-ai-assistant property.');
1924
2000
  }
@@ -1971,6 +2047,31 @@ export class ChatDriver extends EventTarget {
1971
2047
  // Scoped strictly to the budget reason: every other SubAgentFailureReason
1972
2048
  // is something the parent can legitimately recover from, so those still
1973
2049
  // let the loop continue.
2050
+ // A provider refusal seen earlier this turn ends it HERE, before another model call. Reachable
2051
+ // only via a sub-agent — this driver's own refusal returns straight out of the catch — and
2052
+ // without it the loop would call the provider again into the same wall (GENC-1506).
2053
+ if (this.providerRefusedThisTurn) {
2054
+ logger.error('ChatDriver: ending the turn — a sub-agent hit the provider wall');
2055
+ recordTurnError(this.sessionKey, 'provider-refused', {
2056
+ agent: this.activeAgentName,
2057
+ provider: this.lastResolvedProviderName,
2058
+ kind: (_a = this.providerRefusedDetail) === null || _a === void 0 ? void 0 : _a.kind,
2059
+ via: 'sub-agent',
2060
+ isSubAgent: this.isSubAgent,
2061
+ });
2062
+ if (this.isSubAgent) {
2063
+ // The detail is forwarded, not just the reason — this driver may itself be an INTERMEDIATE
2064
+ // sub-agent, and a refusal that started in a grandchild reaches its own parent only through
2065
+ // this call. Dropping it here still stopped the top-level turn (the reason travels), but
2066
+ // arrived with no vendor and no kind, which is the whole diagnostic this payload exists to
2067
+ // carry across hops. The adjacent budget path forwards `budgetWallDetail` for the same reason.
2068
+ this.failSubAgent('provider_refused', undefined, this.providerRefusedDetail);
2069
+ }
2070
+ else {
2071
+ this.appendToHistory({ role: 'assistant', content: this.providerRefusedBubble() });
2072
+ }
2073
+ return this.turnDone('provider-refused', undefined, this.providerRefusedDetail);
2074
+ }
1974
2075
  if (this.budgetExhaustedThisTurn) {
1975
2076
  logger.error('ChatDriver: ending the turn — a sub-agent hit the AI budget wall');
1976
2077
  recordTurnError(this.sessionKey, 'budget-exhausted', {
@@ -1985,7 +2086,7 @@ export class ChatDriver extends EventTarget {
1985
2086
  // parent's vendor here walled BOTH — the child's via its own
1986
2087
  // tool-loop-end, the parent's via this event — and derived `blocked`
1987
2088
  // over headroom that still existed.
1988
- vendor: (_a = this.budgetWallDetail) === null || _a === void 0 ? void 0 : _a.vendor,
2089
+ vendor: (_b = this.budgetWallDetail) === null || _b === void 0 ? void 0 : _b.vendor,
1989
2090
  via: 'sub-agent',
1990
2091
  isSubAgent: this.isSubAgent,
1991
2092
  });
@@ -2008,7 +2109,7 @@ export class ChatDriver extends EventTarget {
2008
2109
  return this.turnDone('budget-exhausted', this.budgetWallDetail);
2009
2110
  }
2010
2111
  const promptCtx = {
2011
- agentName: (_b = this.activeAgentName) !== null && _b !== void 0 ? _b : '',
2112
+ agentName: (_c = this.activeAgentName) !== null && _c !== void 0 ? _c : '',
2012
2113
  history: this.history,
2013
2114
  turnIndex: iterations - 1,
2014
2115
  signal: this.turnController.signal,
@@ -2081,7 +2182,7 @@ export class ChatDriver extends EventTarget {
2081
2182
  // loses all summarized context. `normalizeForProvider` only touches
2082
2183
  // `compacted-summary`, so it's a safe pass-through for everything else.
2083
2184
  const primer = normalizeForProvider([
2084
- ...((_c = this.primerHistory) !== null && _c !== void 0 ? _c : []),
2185
+ ...((_d = this.primerHistory) !== null && _d !== void 0 ? _d : []),
2085
2186
  ...(transientPrimer !== null && transientPrimer !== void 0 ? transientPrimer : []),
2086
2187
  ]);
2087
2188
  const baseHistory = firstLlmCall ? this.history.slice(0, -1) : this.history;
@@ -2283,6 +2384,37 @@ export class ChatDriver extends EventTarget {
2283
2384
  // retry here and no "try again" in the copy; the turn ends and the host
2284
2385
  // locks the composer off the `'budget-exhausted'` failure reason (see
2285
2386
  // `FoundationAiAssistant.blocked`).
2387
+ // The upstream vendor refused this ACCOUNT (GENC-1506) — its credit is gone, a usage cap is
2388
+ // reached, or the credential is dead. Terminal in the strongest sense available: unlike a
2389
+ // truncation, no smaller request gets past it, and unlike our own budget wall nobody in this
2390
+ // system can raise anything to clear it. So no retry and no "try again" in the copy.
2391
+ //
2392
+ // Placed before the transient-retry step below, which is the whole point: without this the
2393
+ // error is an untyped transport failure, gets re-issued MAX_SETUP_TRANSPORT_RETRIES times
2394
+ // against a wall that cannot move, and then surfaces as "something went wrong on my end" —
2395
+ // wrong twice over, since nothing went wrong on our end and trying again will not help.
2396
+ if (e instanceof ProviderRefusedError) {
2397
+ if (this.isSubAgent) {
2398
+ logger.error('ChatDriver: provider refused the request', e);
2399
+ recordTurnError(this.sessionKey, 'provider-refused', {
2400
+ agent: this.activeAgentName,
2401
+ provider: this.lastResolvedProviderName,
2402
+ vendor: (_e = vendorTypeOfLabel(e.vendorLabel)) !== null && _e !== void 0 ? _e : this.lastResolvedProvider,
2403
+ kind: e.kind,
2404
+ upstreamStatus: e.upstreamStatus,
2405
+ upstreamType: e.upstreamType,
2406
+ isSubAgent: true,
2407
+ });
2408
+ // The detail is BUBBLED, not merely flagged. Setting `providerRefusedThisTurn` on `this`
2409
+ // would be pointless here — `this` is the CHILD, and the parent that must stop is a
2410
+ // different driver instance. `invokeSubAgent` reads this payload off the child and sets its
2411
+ // own flag; see the branch there.
2412
+ const detail = providerRefusedDetailOf(e);
2413
+ this.failSubAgent('provider_refused', undefined, detail);
2414
+ return this.turnDone('provider-refused', undefined, detail);
2415
+ }
2416
+ return this.reportProviderRefused(e);
2417
+ }
2286
2418
  if (e instanceof BudgetExhaustedError) {
2287
2419
  // Flagged as well as returned: a sub-agent's wall reaches the PARENT
2288
2420
  // only as a tool result, and the parent must not issue another model
@@ -2301,7 +2433,7 @@ export class ChatDriver extends EventTarget {
2301
2433
  recordTurnError(this.sessionKey, 'budget-exhausted', {
2302
2434
  agent: this.activeAgentName,
2303
2435
  provider: this.lastResolvedProviderName,
2304
- vendor: (_e = (_d = vendorTypeOfLabel(e.vendorLabel)) !== null && _d !== void 0 ? _d : vendorTypeOfLabel(e.serverVendor)) !== null && _e !== void 0 ? _e : this.lastResolvedProvider,
2436
+ vendor: (_g = (_f = vendorTypeOfLabel(e.vendorLabel)) !== null && _f !== void 0 ? _f : vendorTypeOfLabel(e.serverVendor)) !== null && _g !== void 0 ? _g : this.lastResolvedProvider,
2305
2437
  budgetUsd: e.budgetUsd,
2306
2438
  spentUsd: e.spentUsd,
2307
2439
  isSubAgent: true,
@@ -2399,15 +2531,15 @@ export class ChatDriver extends EventTarget {
2399
2531
  // fallback chain answered on a different model than the one we asked for.
2400
2532
  if (response.model !== undefined)
2401
2533
  turnSnapshot.model = response.model;
2402
- const isThinkingStep = response.content && ((_f = response.toolCalls) === null || _f === void 0 ? void 0 : _f.length);
2403
- const isEmptyResponse = !((_g = response.content) === null || _g === void 0 ? void 0 : _g.trim()) && !((_h = response.toolCalls) === null || _h === void 0 ? void 0 : _h.length);
2534
+ const isThinkingStep = response.content && ((_h = response.toolCalls) === null || _h === void 0 ? void 0 : _h.length);
2535
+ const isEmptyResponse = !((_j = response.content) === null || _j === void 0 ? void 0 : _j.trim()) && !((_k = response.toolCalls) === null || _k === void 0 ? void 0 : _k.length);
2404
2536
  // A pre-output refusal (safety-classifier decline, e.g. Fable 5 `stop_reason: 'refusal'`)
2405
2537
  // comes back with empty content, so it looks like a blank response — but it is deterministic:
2406
2538
  // retrying re-sends the identical request and refuses again, burning up to
2407
2539
  // MAX_EMPTY_RESPONSE_RETRIES turns on the most expensive models for the same outcome, ending
2408
2540
  // in the misleading "blank response" message. Treat it as a terminal, non-retried failure with
2409
2541
  // its own reason and message. (GENC-1461)
2410
- const isRefusal = ((_j = response.responseMeta) === null || _j === void 0 ? void 0 : _j.finishReason) === 'refusal';
2542
+ const isRefusal = ((_l = response.responseMeta) === null || _l === void 0 ? void 0 : _l.finishReason) === 'refusal';
2411
2543
  if (isEmptyResponse) {
2412
2544
  emptyResponseAttempts += 1;
2413
2545
  if (!isRefusal && emptyResponseAttempts < MAX_EMPTY_RESPONSE_RETRIES) {
@@ -2481,7 +2613,7 @@ export class ChatDriver extends EventTarget {
2481
2613
  emptyResponseAttempts = 0;
2482
2614
  malformedAttempts = 0;
2483
2615
  setupTransportAttempts = 0;
2484
- if (!((_k = response.toolCalls) === null || _k === void 0 ? void 0 : _k.length)) {
2616
+ if (!((_m = response.toolCalls) === null || _m === void 0 ? void 0 : _m.length)) {
2485
2617
  break;
2486
2618
  }
2487
2619
  const [toolCalls, systemCalls] = response.toolCalls.reduce((acc, tc) => {
@@ -2701,7 +2833,7 @@ export class ChatDriver extends EventTarget {
2701
2833
  // The response was appended before execution — find it and annotate.
2702
2834
  let tcMsgIdx = -1;
2703
2835
  for (let i = this.history.length - 1; i >= 0; i -= 1) {
2704
- if (this.history[i].role === 'assistant' && ((_l = this.history[i].toolCalls) === null || _l === void 0 ? void 0 : _l.length)) {
2836
+ if (this.history[i].role === 'assistant' && ((_o = this.history[i].toolCalls) === null || _o === void 0 ? void 0 : _o.length)) {
2705
2837
  tcMsgIdx = i;
2706
2838
  break;
2707
2839
  }
@@ -2739,7 +2871,7 @@ export class ChatDriver extends EventTarget {
2739
2871
  const unknownTools = [
2740
2872
  ...new Set([
2741
2873
  ...this.recentUnknownToolNames,
2742
- ...((_m = response.toolCalls) !== null && _m !== void 0 ? _m : [])
2874
+ ...((_p = response.toolCalls) !== null && _p !== void 0 ? _p : [])
2743
2875
  .filter((tc) => unknownToolIds.has(tc.id))
2744
2876
  .map((tc) => tc.name),
2745
2877
  ]),
@@ -2751,7 +2883,7 @@ export class ChatDriver extends EventTarget {
2751
2883
  const staleTools = [
2752
2884
  ...new Set([
2753
2885
  ...this.recentStaleToolNames,
2754
- ...((_o = response.toolCalls) !== null && _o !== void 0 ? _o : [])
2886
+ ...((_q = response.toolCalls) !== null && _q !== void 0 ? _q : [])
2755
2887
  .filter((tc) => staleToolIds.has(tc.id))
2756
2888
  .map((tc) => tc.name),
2757
2889
  ]),
@@ -1,5 +1,5 @@
1
1
  import { __awaiter } from "tslib";
2
- import { BudgetExhaustedError, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, isChatToolCallUnknown, MalformedFunctionCallError, ResponseTruncatedError, } from '@genesislcap/foundation-ai';
2
+ import { BudgetExhaustedError, DEFAULT_PROVIDER_REFUSED_MESSAGE, ProviderRefusedError, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, isChatToolCallUnknown, MalformedFunctionCallError, ResponseTruncatedError, } from '@genesislcap/foundation-ai';
3
3
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
4
4
  import { AgenticActivityBus } from '../../channel/ai-activity-bus';
5
5
  import { clearMetaEventRegistry, getMetaEvents } from '../../state/debug-event-log';
@@ -1860,6 +1860,16 @@ const budgetExhaustedProvider = () => ({
1860
1860
  throw new BudgetExhaustedError('Anthropic', 25, 25.4, 'AI budget exhausted');
1861
1861
  }),
1862
1862
  });
1863
+ /** A provider the vendor refused outright (GENC-1506 — terminal, must not retry). */
1864
+ const providerRefusedProvider = (kind = 'spend') => ({
1865
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
1866
+ // Both fixtures are the real captured shapes (2026-08-18), matched to their kind — an `auth`
1867
+ // fixture carrying a credit-balance message would read as a classification bug to the next person.
1868
+ throw kind === 'auth'
1869
+ ? new ProviderRefusedError('Anthropic', 'auth', 401, 'authentication_error', 'API key is invalid.')
1870
+ : new ProviderRefusedError('Anthropic', 'spend', 400, 'invalid_request_error', 'Your credit balance is too low to access the Anthropic API.');
1871
+ }),
1872
+ });
1863
1873
  /** A provider that throws a generic error (the sendMessage catch-all → 'exception'). */
1864
1874
  const throwingProvider = () => ({
1865
1875
  chat: () => __awaiter(void 0, void 0, void 0, function* () {
@@ -1950,6 +1960,89 @@ outcome('a clean turn leaves the legacy shape byte-unchanged (no failureReason)'
1950
1960
  cap.stop();
1951
1961
  }));
1952
1962
  // ── Budget exhaustion (GENC-1464) ──────────────────────────────────────────────
1963
+ outcome('provider-refused surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
1964
+ yield assertSurfacesReason('provider-refused', providerRefusedProvider(), 'provider-refused');
1965
+ }));
1966
+ outcome('a provider refusal is terminal — no retry, cause-free copy, kind in the log', () => __awaiter(void 0, void 0, void 0, function* () {
1967
+ var _a, _b;
1968
+ // GENC-1506. The measured bug this pins: the refusal used to arrive as an untyped transport error,
1969
+ // get re-issued MAX_SETUP_TRANSPORT_RETRIES times against a wall that cannot move, and then surface
1970
+ // as "something went wrong on my end" — wrong twice over, since nothing went wrong on our end and
1971
+ // trying again cannot help.
1972
+ clearMetaEventRegistry();
1973
+ let calls = 0;
1974
+ const provider = {
1975
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
1976
+ calls += 1;
1977
+ throw new ProviderRefusedError('Anthropic', 'spend', 400, 'invalid_request_error', 'credit too low');
1978
+ }),
1979
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'anthropic', model: 'test-model' }); }),
1980
+ };
1981
+ const config = agent({
1982
+ name: 'Static',
1983
+ toolDefinitions: [def('noop')],
1984
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
1985
+ });
1986
+ const driver = makeDriver(config, provider, 'outcome-provider-refused', outcomeBus);
1987
+ const result = yield driver.sendMessage('go');
1988
+ assert.is(calls, 1, 'a provider refusal is not retried — exactly one model call');
1989
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused', 'the turn ends with the provider-refused failure reason');
1990
+ // The kind reaches the CALLER, which is what lets ai-service's Sentry alert say whether an operator
1991
+ // must top up an account or rotate a key. It is the only place that distinction survives, because
1992
+ // the user-facing sentence is deliberately cause-free.
1993
+ const refused = result.reason === 'done' ? result.providerRefused : undefined;
1994
+ assert.is(refused === null || refused === void 0 ? void 0 : refused.kind, 'spend');
1995
+ assert.is(refused === null || refused === void 0 ? void 0 : refused.vendorLabel, 'Anthropic');
1996
+ assert.is(refused === null || refused === void 0 ? void 0 : refused.upstreamStatus, 400);
1997
+ assert.is(refused === null || refused === void 0 ? void 0 : refused.upstreamType, 'invalid_request_error');
1998
+ const last = driver.getHistory().at(-1);
1999
+ assert.ok((last === null || last === void 0 ? void 0 : last.role) === 'assistant', 'turn ends with an assistant message');
2000
+ // Identity, not a substring: the sentence is provisional copy shared with ai-service via one
2001
+ // exported const, and this is what stops a second copy drifting in.
2002
+ assert.is(last.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
2003
+ assert.not.ok(last.content.includes('something went wrong'), 'must not fall through to the generic apology');
2004
+ // The copy must not leak the cause, whatever the kind — a user who can see remaining spend must not
2005
+ // be told about a limit, and we must not imply the bill went unpaid.
2006
+ for (const banned of ['credit', 'billing', 'balance', 'quota', 'limit']) {
2007
+ assert.not.ok(last.content.toLowerCase().includes(banned), `copy must not mention "${banned}"`);
2008
+ }
2009
+ const err = getMetaEvents('outcome-provider-refused').find((e) => e.type === 'turn.error');
2010
+ assert.is((_a = err === null || err === void 0 ? void 0 : err.detail) === null || _a === void 0 ? void 0 : _a.reason, 'provider-refused');
2011
+ assert.is((_b = err === null || err === void 0 ? void 0 : err.detail) === null || _b === void 0 ? void 0 : _b.kind, 'spend');
2012
+ }));
2013
+ outcome('an auth refusal shows the SAME sentence but logs a different kind', () => __awaiter(void 0, void 0, void 0, function* () {
2014
+ var _a;
2015
+ // The whole justification for one type with two kinds: identical user experience, distinct
2016
+ // diagnostics. If these ever diverge in the transcript, the copy decision has been undone.
2017
+ clearMetaEventRegistry();
2018
+ const config = agent({
2019
+ name: 'Static',
2020
+ toolDefinitions: [def('noop')],
2021
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
2022
+ });
2023
+ const driver = makeDriver(config, providerRefusedProvider('auth'), 'outcome-refused-auth', outcomeBus);
2024
+ const result = yield driver.sendMessage('go');
2025
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
2026
+ assert.is(result.reason === 'done' ? (_a = result.providerRefused) === null || _a === void 0 ? void 0 : _a.kind : undefined, 'auth');
2027
+ assert.is(driver.getHistory().at(-1).content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
2028
+ }));
2029
+ outcome('a host override replaces the provider-refusal sentence verbatim', () => __awaiter(void 0, void 0, void 0, function* () {
2030
+ const config = agent({
2031
+ name: 'Static',
2032
+ toolDefinitions: [def('noop')],
2033
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
2034
+ });
2035
+ const driver = new ChatDriver(makeRegistry(providerRefusedProvider()), {
2036
+ maxToolIterations: 50,
2037
+ maxFoldOperations: 5,
2038
+ sessionKey: 'outcome-refused-override',
2039
+ providerRefusedMessage: 'Bespoke wording for a white-labelled host.',
2040
+ });
2041
+ driver.applyAgent(config);
2042
+ yield driver.sendMessage('go');
2043
+ assert.is(driver.getHistory().at(-1).content, 'Bespoke wording for a white-labelled host.');
2044
+ driver.dispose();
2045
+ }));
1953
2046
  outcome('budget-exhausted surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
1954
2047
  yield assertSurfacesReason('budget', budgetExhaustedProvider(), 'budget-exhausted');
1955
2048
  }));
@@ -2247,6 +2340,122 @@ const walledWorker = () => agent({
2247
2340
  toolDefinitions: [def('finish')],
2248
2341
  toolHandlers: { finish: () => __awaiter(void 0, void 0, void 0, function* () { return 'x'; }) },
2249
2342
  });
2343
+ /**
2344
+ * The child-refused / parent-fine provider, mirroring `parentOkChildWalled` — the parent's turn call
2345
+ * succeeds and delegates, and only the child's provider refuses.
2346
+ */
2347
+ const parentOkChildRefused = (kind = 'spend') => {
2348
+ let parentCalls = 0;
2349
+ return {
2350
+ parentCalls: () => parentCalls,
2351
+ provider: {
2352
+ chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
2353
+ var _a;
2354
+ const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
2355
+ if (names.includes('delegate')) {
2356
+ parentCalls += 1;
2357
+ return callsTool('delegate', `d${parentCalls}`);
2358
+ }
2359
+ throw kind === 'auth'
2360
+ ? new ProviderRefusedError('Anthropic', 'auth', 401, 'authentication_error', 'API key is invalid.')
2361
+ : new ProviderRefusedError('Anthropic', 'spend', 400, 'invalid_request_error', 'credit too low');
2362
+ }),
2363
+ },
2364
+ };
2365
+ };
2366
+ outcome('a sub-agent provider refusal ends the parent turn without another model call', () => __awaiter(void 0, void 0, void 0, function* () {
2367
+ /*
2368
+ * The seam a review caught, and it is genuinely counter-intuitive: the child is a SEPARATE driver
2369
+ * instance, so the flag it sets on itself is invisible to the parent. The refusal reaches the parent
2370
+ * only as a tool result, and without an `invokeSubAgent` branch that result was ordinary — so the
2371
+ * parent issued another model call straight into the same wall, and if that call had happened to
2372
+ * succeed the turn could have finished with no `failureReason` at all, reporting a clean turn over a
2373
+ * dead account. `parentCalls() === 1` is the assertion that pins it.
2374
+ */
2375
+ clearMetaEventRegistry();
2376
+ const { parentCalls, provider } = parentOkChildRefused();
2377
+ const parent = delegatingParent(walledWorker(), () => undefined);
2378
+ const driver = makeDriver(parent, provider, 'outcome-subagent-refused', outcomeBus);
2379
+ const result = yield driver.sendMessage('go');
2380
+ assert.is(parentCalls(), 1, 'the parent never calls the model again after the refusal');
2381
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
2382
+ assert.is(driver.getHistory().at(-1).content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
2383
+ }));
2384
+ outcome("a sub-agent refusal carries the CHILD's kind and vendor to the parent", () => __awaiter(void 0, void 0, void 0, function* () {
2385
+ // The other half of the same finding. The parent can only report what the child bubbled, so without a
2386
+ // payload on the failure the `providerRefused` diagnostic promised on `ChatDriverResult` would be
2387
+ // absent — and with cause-free user copy that payload is the ONLY place an operator learns whether to
2388
+ // top up an account or rotate a credential.
2389
+ clearMetaEventRegistry();
2390
+ const { provider } = parentOkChildRefused('auth');
2391
+ const parent = delegatingParent(walledWorker(), () => undefined);
2392
+ const driver = makeDriver(parent, provider, 'outcome-subagent-refused-kind', outcomeBus);
2393
+ const result = yield driver.sendMessage('go');
2394
+ const refused = result.reason === 'done' ? result.providerRefused : undefined;
2395
+ assert.is(refused === null || refused === void 0 ? void 0 : refused.kind, 'auth', "the child's kind survives the hop");
2396
+ assert.is(refused === null || refused === void 0 ? void 0 : refused.vendorLabel, 'Anthropic');
2397
+ assert.is(refused === null || refused === void 0 ? void 0 : refused.upstreamStatus, 401);
2398
+ }));
2399
+ outcome("a GRANDCHILD's refusal keeps its kind and vendor across two hops", () => __awaiter(void 0, void 0, void 0, function* () {
2400
+ /*
2401
+ * The third finding, and the one a single-hop test cannot reach. An INTERMEDIATE sub-agent latches its
2402
+ * grandchild's payload correctly via `invokeSubAgent`, but then has to hand it on through
2403
+ * `failSubAgent` — and passing only the reason there still stopped the top-level turn (the reason
2404
+ * travels) while arriving with no vendor and no kind. The turn looked handled and the diagnostics were
2405
+ * gone, which is the failure mode this payload exists to prevent.
2406
+ *
2407
+ * Three tiers: boss -> middle -> worker. Only the worker's turn is refused; every other model call
2408
+ * succeeds, so nothing but the forwarding can carry the detail to the top.
2409
+ */
2410
+ clearMetaEventRegistry();
2411
+ const worker = agent({
2412
+ name: 'worker',
2413
+ toolDefinitions: [def('finish')],
2414
+ toolHandlers: { finish: () => __awaiter(void 0, void 0, void 0, function* () { return 'x'; }) },
2415
+ });
2416
+ const middle = agent({
2417
+ name: 'middle',
2418
+ subAgents: [worker],
2419
+ toolDefinitions: [def('sub_delegate')],
2420
+ toolHandlers: {
2421
+ sub_delegate: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
2422
+ const o = yield ctx.requestSubAgent('worker', { task: 'deeper' });
2423
+ return o.ok ? 'ok' : `failed: ${o.reason}`;
2424
+ }),
2425
+ },
2426
+ });
2427
+ const boss = agent({
2428
+ name: 'boss',
2429
+ subAgents: [middle],
2430
+ toolDefinitions: [def('delegate')],
2431
+ toolHandlers: {
2432
+ delegate: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
2433
+ const o = yield ctx.requestSubAgent('middle', { task: 'do it' });
2434
+ return o.ok ? 'ok' : `failed: ${o.reason}`;
2435
+ }),
2436
+ },
2437
+ });
2438
+ // Routed by the tool surface each tier is given, so only the deepest turn throws.
2439
+ const provider = {
2440
+ chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
2441
+ var _a;
2442
+ const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
2443
+ if (names.includes('delegate'))
2444
+ return callsTool('delegate', 'd1');
2445
+ if (names.includes('sub_delegate'))
2446
+ return callsTool('sub_delegate', 's1');
2447
+ throw new ProviderRefusedError('Anthropic', 'auth', 401, 'authentication_error', 'API key is invalid.');
2448
+ }),
2449
+ };
2450
+ const driver = makeDriver(boss, provider, 'outcome-refused-grandchild', outcomeBus);
2451
+ const result = yield driver.sendMessage('go');
2452
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
2453
+ const refused = result.reason === 'done' ? result.providerRefused : undefined;
2454
+ assert.is(refused === null || refused === void 0 ? void 0 : refused.kind, 'auth', "the grandchild's kind survives BOTH hops");
2455
+ assert.is(refused === null || refused === void 0 ? void 0 : refused.vendorLabel, 'Anthropic');
2456
+ assert.is(refused === null || refused === void 0 ? void 0 : refused.upstreamStatus, 401);
2457
+ assert.is(driver.getHistory().at(-1).content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
2458
+ }));
2250
2459
  outcome('a sub-agent budget wall ends the parent turn without another model call', () => __awaiter(void 0, void 0, void 0, function* () {
2251
2460
  clearMetaEventRegistry();
2252
2461
  const { parentCalls, provider } = parentOkChildWalled();
@@ -210,7 +210,7 @@ export const DEBUG_LOG_README = [
210
210
  "kind:'turn'.`agentSnapshot` — the active agent's own view of its internal state, captured at that turn. An agent opts into this by exposing a `getDebugSnapshot()` that returns JSON-serializable per-state info; stateful/flow agents wire it automatically, so you can watch a flow advance turn-by-turn (e.g. current step, cursor, collected fields, pending changes). Absent for agents that don't expose one.",
211
211
  "kind:'event' — a meta/lifecycle event. `type` names it (see below); `detail` carries structured data. `detail.placement` is the emitting UI instance: 'bubble' (collapsed), 'panel' (popped-out), or 'standalone'.",
212
212
  "Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, subagent.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions, sub-agent start/complete), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated, context.condensed). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.",
213
- 'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated/refusal/budget-exhausted, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, for budget-exhausted the budgetUsd + spentUsd figures reported by the proxy plus the resolved vendor, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated/refusal/budget_exhausted; budget_exhausted is terminal for the PARENT turn too — the parent stops rather than calling the model again into the same wall), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (the resolved provider for the upcoming turns — detail.provider is the registry SLOT/tier name, detail.model the concrete model behind it and detail.vendor its vendor; emitted only when the slot CHANGES, so read the per-turn `model` for the model of any given call rather than assuming the nearest event still applies), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
213
+ 'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated/refusal/budget-exhausted/provider-refused, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, for budget-exhausted the budgetUsd + spentUsd figures reported by the proxy plus the resolved vendor, for provider-refused the kind (spend|auth) plus the upstream status and error type — the vendor refusing the ACCOUNT rather than us refusing to spend, so the remedy is a top-up or a key rotation rather than a raised cap, and the kind is the only place that distinction survives because the user-facing copy is deliberately cause-free, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated/refusal/budget_exhausted/provider_refused; budget_exhausted and provider_refused are both terminal for the PARENT turn too — the parent stops rather than calling the model again into the same wall), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (the resolved provider for the upcoming turns — detail.provider is the registry SLOT/tier name, detail.model the concrete model behind it and detail.vendor its vendor; emitted only when the slot CHANGES, so read the per-turn `model` for the model of any given call rather than assuming the nearest event still applies), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
214
214
  'Sub-agent meta events: a sub-agent\'s own turn.retry/turn.error/tool.failed/tool.unresolved events are merged into this same timeline, tagged with `detail.subAgent` — a `"<parent> › <sub-agent>"` breadcrumb that composes when nested (e.g. `"UI Builder › Planner › Grounding"`) — and interleaved by their original timestamps within the subagent.started→completed/failed bracket. These are the per-attempt/per-failure signals that do NOT appear among the sub-agent\'s (hoisted) messages: a malformed/empty attempt that gets retried produces no message, and the stale-vs-hallucinated split and streak counts live only on the event. A sub-agent\'s high-volume, message-derivable events (turn.start/turn.end, provider.selected, context.updated) are intentionally NOT merged — read its hoisted messages for model/tokens/cost and turn-by-turn activity, and the bracketing subagent.* events for the run\'s span.',
215
215
  "`meta` holds context captured at export time: agentSummary (full agent configs), context (active model, contextTokens/contextLimit/contextUsagePercent for the last call, and the session totals — sessionCostUsd, sessionTokensConsumed, and the four-bucket sessionUsage lifted to the top of this log), activeDebugSnapshot (the active agent's `getDebugSnapshot()` taken fresh at export — reflects state NOW, which may have advanced beyond the last turn's agentSnapshot), debug (optional host-supplied debug state), host, and the export timestamp.",
216
216
  'Note the two different scopes in `meta.context`: `contextTokens` is the prompt size of the LAST call (against `contextLimit`, the model context window), while `sessionUsage`/`sessionTokensConsumed` are cumulative BILLED throughput. Every turn resends the conversation, so the cumulative figure counts each turn’s prompt again in the next turn’s and is expected to dwarf the context size — that is not double-counting.',
@@ -84,6 +84,7 @@ const ALL_TURN_FAILURE_REASONS = Object.keys({
84
84
  'response-truncated': true,
85
85
  refusal: true,
86
86
  'budget-exhausted': true,
87
+ 'provider-refused': true,
87
88
  });
88
89
  const ALL_SUB_AGENT_FAILURE_REASONS = Object.keys({
89
90
  max_iterations: true,
@@ -94,6 +95,7 @@ const ALL_SUB_AGENT_FAILURE_REASONS = Object.keys({
94
95
  response_truncated: true,
95
96
  refusal: true,
96
97
  budget_exhausted: true,
98
+ provider_refused: true,
97
99
  });
98
100
  const README_TEXT = DEBUG_LOG_README.join('\n');
99
101
  suite('DEBUG_LOG_README enumerates every TurnFailureReason', () => {