@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.
@@ -25,6 +25,8 @@ import type {
25
25
  import {
26
26
  BudgetExhaustedError,
27
27
  DEFAULT_BUDGET_EXHAUSTED_MESSAGE,
28
+ DEFAULT_PROVIDER_REFUSED_MESSAGE,
29
+ ProviderRefusedError,
28
30
  isObservableAIProviderRegistry,
29
31
  MalformedFunctionCallError,
30
32
  ResponseTruncatedError,
@@ -80,6 +82,10 @@ import type { AiDriver, AllAgentSummary } from '../ai-driver/ai-driver';
80
82
  * Derived from the type rather than restated so the two cannot drift.
81
83
  */
82
84
  type BudgetDetail = NonNullable<Extract<ChatDriverResult, { reason: 'done' }>['budget']>;
85
+ /** The `providerRefused` payload on a `'provider-refused'` {@link ChatDriverResult} (GENC-1506). */
86
+ type ProviderRefusedDetail = NonNullable<
87
+ Extract<ChatDriverResult, { reason: 'done' }>['providerRefused']
88
+ >;
83
89
 
84
90
  /**
85
91
  * Lift the reportable facts off a {@link BudgetExhaustedError}, or `undefined`
@@ -100,6 +106,21 @@ type BudgetDetail = NonNullable<Extract<ChatDriverResult, { reason: 'done' }>['b
100
106
  * `formatBlockedReason`, which returns `undefined` for a figure-less budget so a
101
107
  * host-set explanation survives the latch.
102
108
  */
109
+ /**
110
+ * Lift the reportable facts off a {@link ProviderRefusedError} (GENC-1506).
111
+ *
112
+ * Unlike `budgetDetailOf` this is total — it always returns a detail. The budget version can answer
113
+ * `undefined` because its payload is figures the proxy may not have sent; here every field either
114
+ * comes from the transport that was refused (`vendorLabel`, `kind`) or is plainly optional, so there
115
+ * is no "nothing worth reporting" case to model.
116
+ */
117
+ const providerRefusedDetailOf = (e: ProviderRefusedError): ProviderRefusedDetail => ({
118
+ vendorLabel: e.vendorLabel,
119
+ kind: e.kind,
120
+ ...(e.upstreamStatus != null ? { upstreamStatus: e.upstreamStatus } : {}),
121
+ ...(e.upstreamType ? { upstreamType: e.upstreamType } : {}),
122
+ });
123
+
103
124
  const budgetDetailOf = (e: BudgetExhaustedError): BudgetDetail | undefined => {
104
125
  // The typed vendor is derived from the LABEL, not from `lastResolvedProvider`:
105
126
  // the label comes from the transport that was actually refused, whereas the
@@ -352,6 +373,20 @@ export interface ChatDriverConfig {
352
373
  * reversible change.
353
374
  */
354
375
  budgetExhaustedMessage?: string;
376
+
377
+ /**
378
+ * Overrides the transcript sentence shown when the upstream PROVIDER refuses the account
379
+ * (GENC-1506) — see `DEFAULT_PROVIDER_REFUSED_MESSAGE`.
380
+ *
381
+ * Separate from `budgetExhaustedMessage` because the two conditions are separate: ours is a spend
382
+ * cap a host administrator can raise, this is the vendor declining to serve us at all. A host that
383
+ * white-labels one will usually want to white-label both, but conflating them into one field would
384
+ * force identical copy on two situations with different remedies.
385
+ *
386
+ * Rarely needed: unlike the budget default, the shipped sentence names no vendor and no Genesis, so
387
+ * it is already safe for a white-labelled deployment.
388
+ */
389
+ providerRefusedMessage?: string;
355
390
  }
356
391
 
357
392
  /**
@@ -549,7 +584,21 @@ export class ChatDriver extends EventTarget implements AiDriver {
549
584
  * the child's ATTRIBUTION, not just the fact of a wall — see
550
585
  * `budgetWallDetail`.
551
586
  */
552
- private subAgentFailure: { reason: SubAgentFailureReason; budget?: BudgetDetail } | undefined;
587
+ private subAgentFailure:
588
+ | {
589
+ reason: SubAgentFailureReason;
590
+ budget?: BudgetDetail;
591
+ /**
592
+ * The refusing vendor and kind, when a child hit the PROVIDER wall (GENC-1506).
593
+ *
594
+ * Carried for the same reason `budget` is: the child is a **separate driver instance**, so
595
+ * nothing it sets on itself is visible to the parent. Without this the parent could report
596
+ * `provider-refused` but not say which vendor or which kind — losing the only signal that
597
+ * distinguishes "top up the account" from "rotate the key", since the user copy is cause-free.
598
+ */
599
+ providerRefused?: ProviderRefusedDetail;
600
+ }
601
+ | undefined;
553
602
  /**
554
603
  * Set by `releaseAgent` inside a top-level tool handler — typically a stateful
555
604
  * agent's terminal-state handler signalling that its flow is complete and the
@@ -712,6 +761,27 @@ export class ChatDriver extends EventTarget implements AiDriver {
712
761
  * and the bubble is composed per wall by {@link ChatDriver.budgetExhaustedBubble}.
713
762
  */
714
763
  private readonly budgetExhaustedMessageOverride?: string;
764
+
765
+ /**
766
+ * Host override for the provider-refusal sentence, from
767
+ * `ChatDriverConfig.providerRefusedMessage`. `undefined` means no override, so the shipped default
768
+ * is used verbatim.
769
+ */
770
+ private readonly providerRefusedMessageOverride?: string;
771
+
772
+ /**
773
+ * A provider refusal was observed this turn, so the tool loop must not call the model again
774
+ * (GENC-1506).
775
+ *
776
+ * Exists for the SUB-AGENT path only, exactly like `budgetExhaustedThisTurn`: this driver's own
777
+ * refusal returns straight out of the catch, whereas a child's refusal reaches the parent as a tool
778
+ * result, and without this flag the loop would issue another call into the same wall — N batched
779
+ * children costing N doomed calls plus a doomed parent one.
780
+ */
781
+ private providerRefusedThisTurn = false;
782
+
783
+ /** The refusal detail latched off a sub-agent's failure, for the parent's own result. */
784
+ private providerRefusedDetail?: ProviderRefusedDetail;
715
785
  /**
716
786
  * Set the moment a budget wall is observed anywhere in this turn — this
717
787
  * driver's own 402, or a sub-agent's (which surfaces here only as a
@@ -761,12 +831,14 @@ export class ChatDriver extends EventTarget implements AiDriver {
761
831
  sessionKey = '',
762
832
  activityBus = NOOP_ACTIVITY_BUS,
763
833
  budgetExhaustedMessage,
834
+ providerRefusedMessage,
764
835
  } = config;
765
836
  this.maxToolIterations = maxToolIterations;
766
837
  this.condenseBatchCalls = condenseBatchCalls;
767
838
  this.sessionKey = sessionKey;
768
839
  this.activityBus = activityBus;
769
840
  this.budgetExhaustedMessageOverride = budgetExhaustedMessage;
841
+ this.providerRefusedMessageOverride = providerRefusedMessage;
770
842
  if (typeof toolHandlers === 'function') {
771
843
  this.toolHandlersFactory = toolHandlers;
772
844
  this.toolHandlers = {};
@@ -895,11 +967,20 @@ export class ChatDriver extends EventTarget implements AiDriver {
895
967
  * (rather than set to `undefined`) so a happy-path result stays byte-identical to
896
968
  * the historical `{ reason: 'done' }`.
897
969
  */
898
- private turnDone(failureReason?: TurnFailureReason, budget?: BudgetDetail): ChatDriverResult {
970
+ private turnDone(
971
+ failureReason?: TurnFailureReason,
972
+ budget?: BudgetDetail,
973
+ providerRefused?: ProviderRefusedDetail,
974
+ ): ChatDriverResult {
899
975
  if (!failureReason) return { reason: 'done' };
900
- // `budget` is likewise omitted rather than set to `undefined`, so a non-budget
901
- // failure's shape is unchanged for a consumer that structurally compares it.
902
- return budget ? { reason: 'done', failureReason, budget } : { reason: 'done', failureReason };
976
+ // Both payloads are omitted rather than set to `undefined`, so a failure that carries neither has
977
+ // a shape unchanged for a consumer that structurally compares it.
978
+ return {
979
+ reason: 'done',
980
+ failureReason,
981
+ ...(budget ? { budget } : {}),
982
+ ...(providerRefused ? { providerRefused } : {}),
983
+ };
903
984
  }
904
985
 
905
986
  /**
@@ -982,6 +1063,44 @@ export class ChatDriver extends EventTarget implements AiDriver {
982
1063
  return this.turnDone('budget-exhausted', budgetDetailOf(e));
983
1064
  }
984
1065
 
1066
+ /**
1067
+ * The sentence shown when the upstream provider refuses (GENC-1506).
1068
+ *
1069
+ * Deliberately far simpler than `budgetExhaustedBubble`: no vendor name, no figures, no
1070
+ * switch-provider advice, and no branching on `kind`. The copy is cause-free by design — a user who
1071
+ * can see their own remaining spend must not be told about a limit, and we must not imply the bill
1072
+ * has gone unpaid — so there is nothing here to compose. A host override wins verbatim.
1073
+ */
1074
+ private providerRefusedBubble(): string {
1075
+ return this.providerRefusedMessageOverride ?? DEFAULT_PROVIDER_REFUSED_MESSAGE;
1076
+ }
1077
+
1078
+ /**
1079
+ * Terminal provider-refusal outcome. Mirrors `reportBudgetExhausted` so the two walls behave
1080
+ * identically from the caller's side, while keeping their diagnostics distinct.
1081
+ *
1082
+ * `kind` reaches the debug log and the result but never the transcript: it is what lets an operator
1083
+ * tell "top up the account" from "rotate the key", and with cause-free user copy this is the only
1084
+ * place that distinction survives.
1085
+ */
1086
+ private reportProviderRefused(e: ProviderRefusedError): ChatDriverResult {
1087
+ const detail = providerRefusedDetailOf(e);
1088
+ this.providerRefusedThisTurn = true;
1089
+ this.providerRefusedDetail = detail;
1090
+ logger.error('ChatDriver: provider refused the request', e);
1091
+ recordTurnError(this.sessionKey, 'provider-refused', {
1092
+ agent: this.activeAgentName,
1093
+ provider: this.lastResolvedProviderName,
1094
+ vendor: vendorTypeOfLabel(e.vendorLabel) ?? this.lastResolvedProvider,
1095
+ kind: e.kind,
1096
+ upstreamStatus: e.upstreamStatus,
1097
+ upstreamType: e.upstreamType,
1098
+ isSubAgent: this.isSubAgent,
1099
+ });
1100
+ this.appendToHistory({ role: 'assistant', content: this.providerRefusedBubble() });
1101
+ return this.turnDone('provider-refused', undefined, detail);
1102
+ }
1103
+
985
1104
  /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
986
1105
  private static failureReasonOf(result: ChatDriverResult): TurnFailureReason | undefined {
987
1106
  return result.reason === 'done' ? result.failureReason : undefined;
@@ -1259,7 +1378,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
1259
1378
  * `completeSubAgent`, if any. Called by a parent `ChatDriver` after running
1260
1379
  * this instance as a sub-agent.
1261
1380
  */
1262
- getSubAgentFailure(): { reason: SubAgentFailureReason; budget?: BudgetDetail } | undefined {
1381
+ getSubAgentFailure():
1382
+ | {
1383
+ reason: SubAgentFailureReason;
1384
+ budget?: BudgetDetail;
1385
+ providerRefused?: ProviderRefusedDetail;
1386
+ }
1387
+ | undefined {
1263
1388
  return this.subAgentFailure;
1264
1389
  }
1265
1390
 
@@ -1271,11 +1396,19 @@ export class ChatDriver extends EventTarget implements AiDriver {
1271
1396
  * under a separate session key, so recording here would orphan the event off
1272
1397
  * the user-visible debug-log timeline.)
1273
1398
  */
1274
- private failSubAgent(reason: SubAgentFailureReason, budget?: BudgetDetail): void {
1399
+ private failSubAgent(
1400
+ reason: SubAgentFailureReason,
1401
+ budget?: BudgetDetail,
1402
+ providerRefused?: ProviderRefusedDetail,
1403
+ ): void {
1275
1404
  if (!this.isSubAgent || this.subAgentFailure) return;
1276
- // Omitted rather than set to `undefined` so a non-budget failure's shape is
1405
+ // Each payload is omitted rather than set to `undefined` so a failure carrying neither has a shape
1277
1406
  // unchanged for a structural comparison, matching `turnDone`.
1278
- this.subAgentFailure = budget ? { reason, budget } : { reason };
1407
+ this.subAgentFailure = {
1408
+ reason,
1409
+ ...(budget ? { budget } : {}),
1410
+ ...(providerRefused ? { providerRefused } : {}),
1411
+ };
1279
1412
  }
1280
1413
 
1281
1414
  /**
@@ -1924,6 +2057,8 @@ export class ChatDriver extends EventTarget implements AiDriver {
1924
2057
  this.budgetExhaustedThisTurn = false;
1925
2058
  this.budgetWallDetail = undefined;
1926
2059
  this.budgetWallViaSubAgent = false;
2060
+ this.providerRefusedThisTurn = false;
2061
+ this.providerRefusedDetail = undefined;
1927
2062
  this.appendToHistory({ role: 'user', content: userInput, attachments });
1928
2063
  this.turnStartedAt = Date.now();
1929
2064
  recordMetaEvent(this.sessionKey, 'turn.start', {
@@ -2321,6 +2456,20 @@ export class ChatDriver extends EventTarget implements AiDriver {
2321
2456
  this.budgetWallViaSubAgent = true;
2322
2457
  this.budgetWallDetail ??= failure?.budget;
2323
2458
  }
2459
+ // The PROVIDER wall via a child (GENC-1506) — the same shape as the budget wall above, and needed
2460
+ // for the same non-obvious reason: **the child is a separate driver instance**, so the flag it set
2461
+ // on itself is invisible here. Without this branch the child's failure was appended as an ordinary
2462
+ // tool result, the parent's short-circuit never fired, and the parent issued another model call
2463
+ // straight into the same wall. Worse, if that call happened to succeed the turn could finish
2464
+ // WITHOUT `failureReason: 'provider-refused'` at all — reporting a clean turn over a dead account.
2465
+ //
2466
+ // FIRST attribution wins (`??=`), for the reason spelled out on `budgetWallDetail`: batched
2467
+ // delegations can pair an attributable child refusal with an unattributable one, and a plain
2468
+ // assignment would let the later `undefined` erase what the earlier child knew.
2469
+ if (reason === 'provider_refused') {
2470
+ this.providerRefusedThisTurn = true;
2471
+ this.providerRefusedDetail ??= failure?.providerRefused;
2472
+ }
2324
2473
  return { outcome: { ok: false, reason }, trace };
2325
2474
  }
2326
2475
 
@@ -2338,6 +2487,8 @@ export class ChatDriver extends EventTarget implements AiDriver {
2338
2487
  this.budgetExhaustedThisTurn = false;
2339
2488
  this.budgetWallDetail = undefined;
2340
2489
  this.budgetWallViaSubAgent = false;
2490
+ this.providerRefusedThisTurn = false;
2491
+ this.providerRefusedDetail = undefined;
2341
2492
  this.turnStartedAt = Date.now();
2342
2493
  recordMetaEvent(this.sessionKey, 'turn.start', {
2343
2494
  phase: 'continueFromHistory',
@@ -2612,6 +2763,30 @@ export class ChatDriver extends EventTarget implements AiDriver {
2612
2763
  // Scoped strictly to the budget reason: every other SubAgentFailureReason
2613
2764
  // is something the parent can legitimately recover from, so those still
2614
2765
  // let the loop continue.
2766
+ // A provider refusal seen earlier this turn ends it HERE, before another model call. Reachable
2767
+ // only via a sub-agent — this driver's own refusal returns straight out of the catch — and
2768
+ // without it the loop would call the provider again into the same wall (GENC-1506).
2769
+ if (this.providerRefusedThisTurn) {
2770
+ logger.error('ChatDriver: ending the turn — a sub-agent hit the provider wall');
2771
+ recordTurnError(this.sessionKey, 'provider-refused', {
2772
+ agent: this.activeAgentName,
2773
+ provider: this.lastResolvedProviderName,
2774
+ kind: this.providerRefusedDetail?.kind,
2775
+ via: 'sub-agent',
2776
+ isSubAgent: this.isSubAgent,
2777
+ });
2778
+ if (this.isSubAgent) {
2779
+ // The detail is forwarded, not just the reason — this driver may itself be an INTERMEDIATE
2780
+ // sub-agent, and a refusal that started in a grandchild reaches its own parent only through
2781
+ // this call. Dropping it here still stopped the top-level turn (the reason travels), but
2782
+ // arrived with no vendor and no kind, which is the whole diagnostic this payload exists to
2783
+ // carry across hops. The adjacent budget path forwards `budgetWallDetail` for the same reason.
2784
+ this.failSubAgent('provider_refused', undefined, this.providerRefusedDetail);
2785
+ } else {
2786
+ this.appendToHistory({ role: 'assistant', content: this.providerRefusedBubble() });
2787
+ }
2788
+ return this.turnDone('provider-refused', undefined, this.providerRefusedDetail);
2789
+ }
2615
2790
  if (this.budgetExhaustedThisTurn) {
2616
2791
  logger.error('ChatDriver: ending the turn — a sub-agent hit the AI budget wall');
2617
2792
  recordTurnError(this.sessionKey, 'budget-exhausted', {
@@ -2959,6 +3134,37 @@ export class ChatDriver extends EventTarget implements AiDriver {
2959
3134
  // retry here and no "try again" in the copy; the turn ends and the host
2960
3135
  // locks the composer off the `'budget-exhausted'` failure reason (see
2961
3136
  // `FoundationAiAssistant.blocked`).
3137
+ // The upstream vendor refused this ACCOUNT (GENC-1506) — its credit is gone, a usage cap is
3138
+ // reached, or the credential is dead. Terminal in the strongest sense available: unlike a
3139
+ // truncation, no smaller request gets past it, and unlike our own budget wall nobody in this
3140
+ // system can raise anything to clear it. So no retry and no "try again" in the copy.
3141
+ //
3142
+ // Placed before the transient-retry step below, which is the whole point: without this the
3143
+ // error is an untyped transport failure, gets re-issued MAX_SETUP_TRANSPORT_RETRIES times
3144
+ // against a wall that cannot move, and then surfaces as "something went wrong on my end" —
3145
+ // wrong twice over, since nothing went wrong on our end and trying again will not help.
3146
+ if (e instanceof ProviderRefusedError) {
3147
+ if (this.isSubAgent) {
3148
+ logger.error('ChatDriver: provider refused the request', e);
3149
+ recordTurnError(this.sessionKey, 'provider-refused', {
3150
+ agent: this.activeAgentName,
3151
+ provider: this.lastResolvedProviderName,
3152
+ vendor: vendorTypeOfLabel(e.vendorLabel) ?? this.lastResolvedProvider,
3153
+ kind: e.kind,
3154
+ upstreamStatus: e.upstreamStatus,
3155
+ upstreamType: e.upstreamType,
3156
+ isSubAgent: true,
3157
+ });
3158
+ // The detail is BUBBLED, not merely flagged. Setting `providerRefusedThisTurn` on `this`
3159
+ // would be pointless here — `this` is the CHILD, and the parent that must stop is a
3160
+ // different driver instance. `invokeSubAgent` reads this payload off the child and sets its
3161
+ // own flag; see the branch there.
3162
+ const detail = providerRefusedDetailOf(e);
3163
+ this.failSubAgent('provider_refused', undefined, detail);
3164
+ return this.turnDone('provider-refused', undefined, detail);
3165
+ }
3166
+ return this.reportProviderRefused(e);
3167
+ }
2962
3168
  if (e instanceof BudgetExhaustedError) {
2963
3169
  // Flagged as well as returned: a sub-agent's wall reaches the PARENT
2964
3170
  // only as a tool result, and the parent must not issue another model
@@ -109,6 +109,7 @@ const ALL_TURN_FAILURE_REASONS = Object.keys({
109
109
  'response-truncated': true,
110
110
  refusal: true,
111
111
  'budget-exhausted': true,
112
+ 'provider-refused': true,
112
113
  } satisfies Record<TurnFailureReason, true>) as TurnFailureReason[];
113
114
 
114
115
  const ALL_SUB_AGENT_FAILURE_REASONS = Object.keys({
@@ -120,6 +121,7 @@ const ALL_SUB_AGENT_FAILURE_REASONS = Object.keys({
120
121
  response_truncated: true,
121
122
  refusal: true,
122
123
  budget_exhausted: true,
124
+ provider_refused: true,
123
125
  } satisfies Record<SubAgentFailureReason, true>) as SubAgentFailureReason[];
124
126
 
125
127
  const README_TEXT = DEBUG_LOG_README.join('\n');
@@ -328,7 +328,7 @@ export const DEBUG_LOG_README: readonly string[] = [
328
328
  "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.",
329
329
  "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'.",
330
330
  "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.",
331
- '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.',
331
+ '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.',
332
332
  '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.',
333
333
  "`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.",
334
334
  '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.',