@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.
@@ -0,0 +1,209 @@
1
+ # Migration Guide — GENC-1506 (the provider refused: a named, non-retried failure)
2
+
3
+ > **Additive — no migration required.** Nothing was removed, renamed, or given a new meaning. Every
4
+ > existing event name, public member, template `part` and failure reason behaves exactly as before.
5
+ > Upgrade, and the visible change is that a provider refusing your account produces one clear
6
+ > sentence instead of a generic apology — and stops costing doomed requests to discover.
7
+ >
8
+ > **There is deliberately no new UI.** Unlike GENC-1464 this adds no banner, no blocked state and no
9
+ > latch. See [Why no banner](#why-no-banner).
10
+ >
11
+ > All surfaces below are `@beta`.
12
+
13
+ ---
14
+
15
+ ## Why
16
+
17
+ When Anthropic refuses a request because the **account** cannot be served — its credit is gone, a
18
+ usage cap is reached, or the key is dead — nothing in the stack knew what that meant. The refusal fell
19
+ out of the transport as an untyped `Error`, landed in the chat driver's transient-retry catch, was
20
+ re-issued up to `MAX_SETUP_TRANSPORT_RETRIES` times against a wall no retry can clear, and the user
21
+ finally got:
22
+
23
+ > Sorry, something went wrong on my end. Please try again in a moment.
24
+
25
+ Wrong twice over: nothing went wrong on our end, and trying again will not help.
26
+
27
+ This is **not** the AI-spend budget wall (GENC-1464). That one is *us* declining to spend more on a
28
+ caller's behalf, and an administrator can raise the cap. This one is the *vendor* declining to serve
29
+ us at all, and nothing in our own system can clear it. The two were being conflated in code — see
30
+ [The 402 collision](#the-402-collision).
31
+
32
+ ---
33
+
34
+ ## What was added
35
+
36
+ ### `@genesislcap/foundation-ai`
37
+
38
+ | Addition | Kind | Notes |
39
+ | --- | --- | --- |
40
+ | `ProviderRefusedError` | exported class | Fields: `vendorLabel`, `kind`, `upstreamStatus?`, `upstreamType?`, `detail?` |
41
+ | `ProviderRefusalKind` | exported type | `'spend' \| 'auth'` |
42
+ | `DEFAULT_PROVIDER_REFUSED_MESSAGE` | exported const | The one sentence shown to the user |
43
+ | `PROVIDER_REFUSED_CODE` | exported const | `'PROVIDER_REFUSED'` — the proxy's wire code |
44
+ | `'provider-refused'` | new `TurnFailureReason` member | Rides `ChatDriverResult.failureReason` and the `tool-loop-end` detail |
45
+ | `'provider_refused'` | new `SubAgentFailureReason` member | Terminal for the parent turn too |
46
+ | `ChatDriverResult.providerRefused` | new optional field on the `done` arm | `{ vendorLabel, kind, upstreamStatus?, upstreamType? }` |
47
+
48
+ ### `@genesislcap/ai-assistant`
49
+
50
+ | Addition | Kind |
51
+ | --- | --- |
52
+ | `ChatDriverConfig.providerRefusedMessage` | new optional field — overrides the sentence |
53
+ | `DEFAULT_PROVIDER_REFUSED_MESSAGE`, `PROVIDER_REFUSED_CODE`, `ProviderRefusedError` | re-exported from the `./chat-driver` (Node) entry |
54
+
55
+ The chat driver gained a typed branch that sits **before** the transient-retry catch. It does not
56
+ retry; it appends the sentence to the transcript (or calls `failSubAgent('provider_refused')` when
57
+ running as a sub-agent) and ends the turn with `failureReason: 'provider-refused'`.
58
+
59
+ ### Across the sub-agent boundary
60
+
61
+ `getSubAgentFailure()` now carries a `providerRefused` payload alongside the existing `budget` one, and
62
+ `invokeSubAgent()` latches the parent's terminal state from it.
63
+
64
+ Both halves are needed for a reason that is easy to miss: **a sub-agent is a separate driver instance**,
65
+ so anything the child sets on itself is invisible to the parent. The refusal reaches the parent only as a
66
+ tool result. Without the latch that result is ordinary, the parent issues another model call straight into
67
+ the same wall, and — if that call happens to succeed — the turn can finish with no `failureReason` at all,
68
+ reporting a clean turn over a dead account. Without the payload the parent can report `provider-refused`
69
+ but not which vendor or which kind, which is the only signal distinguishing "top up" from "rotate the
70
+ key".
71
+
72
+ First attribution wins (`??=`), matching `budgetWallDetail`: batched delegations can pair an attributable
73
+ child refusal with an unattributable one, and a plain assignment would let the later `undefined` erase
74
+ what the earlier child knew.
75
+
76
+ ---
77
+
78
+ ## One error, two kinds
79
+
80
+ `kind` distinguishes the operator's fix while the user's experience stays identical:
81
+
82
+ - **`spend`** — the account cannot pay: credit balance at zero, a workspace/organisation usage cap, or
83
+ a billing/payment-information problem. Someone must top up or raise a cap.
84
+ - **`auth`** — the credential cannot be used: revoked, expired or malformed key (`401`), or a key
85
+ without access to the requested model (`403`). Someone must rotate a key or grant access.
86
+
87
+ Both produce the **same sentence**. The kind reaches `ChatDriverResult.providerRefused.kind`, the
88
+ `turn.error` debug entry, and the transport error — never the transcript. That is deliberate: the copy
89
+ is cause-free (see below), so `kind` is the only place the distinction survives, and a host's own
90
+ alerting is what it exists for.
91
+
92
+ Two designs were rejected. A type named for spend would *lie* about an expired key, and a debug
93
+ timeline reading `provider-spend-refused` for an auth fault misleads the next engineer. Two separate
94
+ error types would duplicate a branch, a message and a wire code to express one outcome.
95
+
96
+ ---
97
+
98
+ ## The copy, and the constraints on it
99
+
100
+ > AI requests are currently being refused by the provider. This isn't related to your account or your
101
+ > usage and needs no action from you — please contact your support team so it can be restored.
102
+
103
+ It names **no cause**, and a rewrite has to keep it that way. Three constraints:
104
+
105
+ 1. **It must not sound like a cap.** Users can often see their remaining AI spend in the same UI, so
106
+ *allowance / limit / quota / budget / usage / credit / balance* risk a visible self-contradiction —
107
+ a message saying there is nothing left, beside a figure showing there is. "or your usage" goes
108
+ further than avoiding the words: it pre-empts the user checking that figure and concluding the
109
+ message is wrong.
110
+ 2. **It must not imply the bill went unpaid.** *Billing / payment / funds / top up* all read that way.
111
+ 3. **It must not promise self-healing.** Clearing this needs a human, so "currently" not
112
+ "temporarily", and "so it can be restored" signals that someone must act.
113
+
114
+ Vendor-neutral and deployment-neutral, so it is already safe for a white-labelled host. Override with
115
+ `ChatDriverConfig.providerRefusedMessage` if you want your own wording.
116
+
117
+ ---
118
+
119
+ ## Why no banner
120
+
121
+ GENC-1464 gave the budget wall a latched banner, a locked composer and per-vendor blocking state. This
122
+ deliberately reuses **none** of it (Matt, 2026-08-18): a provider refusal is a distinctly separate
123
+ condition from a user's own spend allowance and must not borrow its UI.
124
+
125
+ Consequences, both accepted:
126
+
127
+ - **The composer stays live.** A user can send again straight into the same wall. With the
128
+ classification in place that costs exactly **one** refused request per send rather than the 3–18 it
129
+ cost before, and the sentence is self-explanatory.
130
+ - **Nothing survives a reload**, because there is no latched state — and nothing needs to. There is
131
+ also no pre-flight available even in principle: a refused account rejects `count_tokens` with the
132
+ same error, so there is no free way to ask "can this account spend?".
133
+
134
+ ---
135
+
136
+ ## The 402 collision
137
+
138
+ `isBudgetRejection` treats **any** `402` as our own budget verdict, and `402` is also Anthropic's
139
+ documented `billing_error` status. A relayed upstream billing failure was therefore reported as the
140
+ *user's* spend cap — locking their composer and sending them to an administrator to raise a limit that
141
+ had headroom.
142
+
143
+ Closed by **ordering plus positive detection**, not by loosening the budget predicate:
144
+ `providerRefusalOf` runs first in `postWithRetry`, and every trigger in it is a positive signal, so a
145
+ bare `402` with no provider envelope still falls through to the budget branch exactly as before. Both
146
+ directions are pinned by tests in `post-with-retry.test.ts`.
147
+
148
+ ---
149
+
150
+ ## Detection: measured, not assumed
151
+
152
+ The classifier is built from **verbatim captures** of the real Anthropic API (2026-08-18), and the
153
+ fixtures in `provider-refused.test.ts` are those responses. This matters because the obvious design —
154
+ classify on the documented `402 billing_error` — would have fixed **nothing**:
155
+
156
+ | Condition | Actual response |
157
+ | --- | --- |
158
+ | Credit balance at zero | `400` + `invalid_request_error` + "Your credit balance is too low to access the Anthropic API…" |
159
+ | Workspace usage cap | `400` + `invalid_request_error` + "You have reached your specified workspace API usage limits…" |
160
+ | Invalid key | `401` + `authentication_error` + "API key is invalid." (`request_id` is **null**) |
161
+
162
+ **Reading the `kind` off an explicit-code response.** When the proxy stamps `PROVIDER_REFUSED`, the kind
163
+ is resolved from a stated `kind` in **either** wire position (top level on a JSON body, under `details` on
164
+ an err frame), then from the relayed provider envelope's own type, and only then defaults to `spend`.
165
+ Reading the top level alone reported every auth refusal through our own proxy as `spend` — it stamps the
166
+ code with the envelope in `details` and no top-level `kind` — which sends an operator to top up an account
167
+ when the fix is a credential rotation. Invisible on screen, since the copy is identical either way.
168
+
169
+ Both spend refusals arrive in the **generic** bucket with no distinct type, no dedicated header and
170
+ nothing structural — so a small list of independent phrase signals (`credit balance`, `usage limit`, …)
171
+ is the only handle available. `auth` needs no prose: status plus `error.type` is enough.
172
+
173
+ False positives are near-impossible for a non-obvious reason: on a refused account the spend check runs
174
+ *before* request validation, so a genuine schema `400` is only observable on a healthy account, where
175
+ its message quotes field paths and matches nothing in the list.
176
+
177
+ **Gemini is deliberately not classified.** Google surfaces a spend cap as `429 RESOURCE_EXHAUSTED` —
178
+ the same status *and message* as an ordinary rate limit, and `429` is retryable — so a prose probe
179
+ there would break the ladder. The structural discriminator that would work
180
+ (`details[].QuotaFailure.violations[].quotaId`) is only partly observed. `providerRefusalOf` dispatches
181
+ on vendor, so adding Gemini later is a branch, not a rewrite.
182
+
183
+ ---
184
+
185
+ ## Also in this change: the retry ladder honours the server
186
+
187
+ Found while measuring the Gemini rate limit, and folded in because it lives in the same function.
188
+
189
+ `postWithRetry`'s ladder was `base * 2^attempt` — 31s across five retries — and read neither
190
+ `Retry-After` nor Google's in-body `RetryInfo`. A real rate-limited key asked for **53s**, so a
191
+ *legitimate* rate limit burned every retry inside the window we had been told to wait, then failed
192
+ opaquely.
193
+
194
+ Now: the wait is `max(our ladder, what the server asked)`, bounded twice — `MAX_SERVER_REQUESTED_BACKOFF_MS`
195
+ (60s) per attempt, and `MAX_TOTAL_SERVER_REQUESTED_BACKOFF_MS` (120s) across the whole sequence. An
196
+ explicit `x-should-retry: false` stops the ladder even on a retryable status. An **absent**
197
+ `x-should-retry` means "no opinion" and changes nothing — never read as `false`.
198
+
199
+ The sequence bound is not belt-and-braces. The per-attempt cap alone is no bound at all: the ladder
200
+ tops out at 16s, so a 60s hint wins on *every* attempt, and five of them park a turn for five minutes
201
+ with no timer running through any of them (the attempt's own timeout is cleared before each backoff, so
202
+ a caller abort is the only way out). Past the budget we stop honouring hints and fall back to the
203
+ ladder — declining a hint never stops the retrying. Only the *excess over the ladder* is charged, so
204
+ the ladder's own growth cannot exhaust the allowance on time we were always going to spend. Worst case
205
+ is now ~151s rather than 300s, and the 53s case the module was written for still fits twice over.
206
+
207
+ > **This is the one part of the change that affects healthy traffic.** Some failures that used to give
208
+ > up after 31s will now wait longer before succeeding or failing. That is correct and it is what the
209
+ > server asked for, but it is a timing change on a shared path, so review it on its own terms.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@genesislcap/ai-assistant",
3
3
  "description": "Genesis AI Assistant micro-frontend",
4
- "version": "15.14.0",
4
+ "version": "15.14.2",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -73,26 +73,26 @@
73
73
  }
74
74
  },
75
75
  "devDependencies": {
76
- "@genesislcap/foundation-testing": "15.14.0",
77
- "@genesislcap/genx": "15.14.0",
78
- "@genesislcap/rollup-builder": "15.14.0",
79
- "@genesislcap/ts-builder": "15.14.0",
80
- "@genesislcap/uvu-playwright-builder": "15.14.0",
81
- "@genesislcap/vite-builder": "15.14.0",
82
- "@genesislcap/webpack-builder": "15.14.0",
76
+ "@genesislcap/foundation-testing": "15.14.2",
77
+ "@genesislcap/genx": "15.14.2",
78
+ "@genesislcap/rollup-builder": "15.14.2",
79
+ "@genesislcap/ts-builder": "15.14.2",
80
+ "@genesislcap/uvu-playwright-builder": "15.14.2",
81
+ "@genesislcap/vite-builder": "15.14.2",
82
+ "@genesislcap/webpack-builder": "15.14.2",
83
83
  "@types/dompurify": "^3.0.5",
84
84
  "@types/marked": "^5.0.2",
85
85
  "esbuild": "0.25.12"
86
86
  },
87
87
  "dependencies": {
88
- "@genesislcap/foundation-ai": "15.14.0",
89
- "@genesislcap/foundation-logger": "15.14.0",
90
- "@genesislcap/foundation-notifications": "15.14.0",
91
- "@genesislcap/foundation-redux": "15.14.0",
92
- "@genesislcap/foundation-ui": "15.14.0",
93
- "@genesislcap/foundation-utils": "15.14.0",
94
- "@genesislcap/rapid-design-system": "15.14.0",
95
- "@genesislcap/web-core": "15.14.0",
88
+ "@genesislcap/foundation-ai": "15.14.2",
89
+ "@genesislcap/foundation-logger": "15.14.2",
90
+ "@genesislcap/foundation-notifications": "15.14.2",
91
+ "@genesislcap/foundation-redux": "15.14.2",
92
+ "@genesislcap/foundation-ui": "15.14.2",
93
+ "@genesislcap/foundation-utils": "15.14.2",
94
+ "@genesislcap/rapid-design-system": "15.14.2",
95
+ "@genesislcap/web-core": "15.14.2",
96
96
  "dompurify": "^3.3.1",
97
97
  "marked": "^17.0.3"
98
98
  },
@@ -105,5 +105,5 @@
105
105
  "access": "public"
106
106
  },
107
107
  "customElements": "dist/custom-elements.json",
108
- "gitHead": "d84656a0872b2274be6f6b20a87a28504b48ed2a"
108
+ "gitHead": "bd1cb4a789548d9c7c6777f8adb7bce3f43c3317"
109
109
  }
@@ -43,6 +43,16 @@ export {
43
43
  // GENC-1461, create PR #1644 review.
44
44
  SUPPORTED_ANTHROPIC_MODEL_IDS,
45
45
  SUPPORTED_GEMINI_MODEL_IDS,
46
+ // The provider wall (GENC-1506). Re-exported for the same anti-drift reason as the model lists
47
+ // above, and it matters more here because the other end of this contract is in ANOTHER REPO:
48
+ // genesis-create's ai-service stamps `PROVIDER_REFUSED_CODE` onto its proxy responses and shows
49
+ // `DEFAULT_PROVIDER_REFUSED_MESSAGE` on its own generation surfaces. Hand-copying either would
50
+ // drift silently — a renamed code stops classifying, and a second copy of the sentence survives
51
+ // the first reword. `ProviderRefusedError` is here so a headless caller can `instanceof` it
52
+ // against the same bundled instance the driver throws from.
53
+ DEFAULT_PROVIDER_REFUSED_MESSAGE,
54
+ PROVIDER_REFUSED_CODE,
55
+ ProviderRefusedError,
46
56
  isObservableAIProviderRegistry,
47
57
  // Request pricing. Re-exported HERE, not left to a direct `@genesislcap/foundation-ai`
48
58
  // import, for the same reason as everything above it: a headless host that adds its own
@@ -13,6 +13,8 @@ import type {
13
13
  } from '@genesislcap/foundation-ai';
14
14
  import {
15
15
  BudgetExhaustedError,
16
+ DEFAULT_PROVIDER_REFUSED_MESSAGE,
17
+ ProviderRefusedError,
16
18
  DEFAULT_BUDGET_EXHAUSTED_MESSAGE,
17
19
  isChatToolCallUnknown,
18
20
  MalformedFunctionCallError,
@@ -2504,6 +2506,29 @@ const budgetExhaustedProvider = (): AIProvider => ({
2504
2506
  },
2505
2507
  });
2506
2508
 
2509
+ /** A provider the vendor refused outright (GENC-1506 — terminal, must not retry). */
2510
+ const providerRefusedProvider = (kind: 'spend' | 'auth' = 'spend'): AIProvider => ({
2511
+ chat: async (): Promise<ChatMessage> => {
2512
+ // Both fixtures are the real captured shapes (2026-08-18), matched to their kind — an `auth`
2513
+ // fixture carrying a credit-balance message would read as a classification bug to the next person.
2514
+ throw kind === 'auth'
2515
+ ? new ProviderRefusedError(
2516
+ 'Anthropic',
2517
+ 'auth',
2518
+ 401,
2519
+ 'authentication_error',
2520
+ 'API key is invalid.',
2521
+ )
2522
+ : new ProviderRefusedError(
2523
+ 'Anthropic',
2524
+ 'spend',
2525
+ 400,
2526
+ 'invalid_request_error',
2527
+ 'Your credit balance is too low to access the Anthropic API.',
2528
+ );
2529
+ },
2530
+ });
2531
+
2507
2532
  /** A provider that throws a generic error (the sendMessage catch-all → 'exception'). */
2508
2533
  const throwingProvider = (): AIProvider => ({
2509
2534
  chat: async (): Promise<ChatMessage> => {
@@ -2628,6 +2653,121 @@ outcome('a clean turn leaves the legacy shape byte-unchanged (no failureReason)'
2628
2653
 
2629
2654
  // ── Budget exhaustion (GENC-1464) ──────────────────────────────────────────────
2630
2655
 
2656
+ outcome('provider-refused surfaces at both seams', async () => {
2657
+ await assertSurfacesReason('provider-refused', providerRefusedProvider(), 'provider-refused');
2658
+ });
2659
+
2660
+ outcome('a provider refusal is terminal — no retry, cause-free copy, kind in the log', async () => {
2661
+ // GENC-1506. The measured bug this pins: the refusal used to arrive as an untyped transport error,
2662
+ // get re-issued MAX_SETUP_TRANSPORT_RETRIES times against a wall that cannot move, and then surface
2663
+ // as "something went wrong on my end" — wrong twice over, since nothing went wrong on our end and
2664
+ // trying again cannot help.
2665
+ clearMetaEventRegistry();
2666
+ let calls = 0;
2667
+ const provider: AIProvider = {
2668
+ chat: async (): Promise<ChatMessage> => {
2669
+ calls += 1;
2670
+ throw new ProviderRefusedError(
2671
+ 'Anthropic',
2672
+ 'spend',
2673
+ 400,
2674
+ 'invalid_request_error',
2675
+ 'credit too low',
2676
+ );
2677
+ },
2678
+ getStatus: async () => ({ provider: 'anthropic', model: 'test-model' }),
2679
+ };
2680
+ const config = agent({
2681
+ name: 'Static',
2682
+ toolDefinitions: [def('noop')],
2683
+ toolHandlers: { noop: async () => 'ok' },
2684
+ });
2685
+ const driver = makeDriver(config, provider, 'outcome-provider-refused', outcomeBus);
2686
+
2687
+ const result: ChatDriverResult = await driver.sendMessage('go');
2688
+
2689
+ assert.is(calls, 1, 'a provider refusal is not retried — exactly one model call');
2690
+ assert.is(
2691
+ result.reason === 'done' ? result.failureReason : undefined,
2692
+ 'provider-refused',
2693
+ 'the turn ends with the provider-refused failure reason',
2694
+ );
2695
+
2696
+ // The kind reaches the CALLER, which is what lets ai-service's Sentry alert say whether an operator
2697
+ // must top up an account or rotate a key. It is the only place that distinction survives, because
2698
+ // the user-facing sentence is deliberately cause-free.
2699
+ const refused = result.reason === 'done' ? result.providerRefused : undefined;
2700
+ assert.is(refused?.kind, 'spend');
2701
+ assert.is(refused?.vendorLabel, 'Anthropic');
2702
+ assert.is(refused?.upstreamStatus, 400);
2703
+ assert.is(refused?.upstreamType, 'invalid_request_error');
2704
+
2705
+ const last = driver.getHistory().at(-1);
2706
+ assert.ok(last?.role === 'assistant', 'turn ends with an assistant message');
2707
+ // Identity, not a substring: the sentence is provisional copy shared with ai-service via one
2708
+ // exported const, and this is what stops a second copy drifting in.
2709
+ assert.is(last!.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
2710
+ assert.not.ok(
2711
+ last!.content.includes('something went wrong'),
2712
+ 'must not fall through to the generic apology',
2713
+ );
2714
+ // The copy must not leak the cause, whatever the kind — a user who can see remaining spend must not
2715
+ // be told about a limit, and we must not imply the bill went unpaid.
2716
+ for (const banned of ['credit', 'billing', 'balance', 'quota', 'limit']) {
2717
+ assert.not.ok(
2718
+ last!.content.toLowerCase().includes(banned),
2719
+ `copy must not mention "${banned}"`,
2720
+ );
2721
+ }
2722
+
2723
+ const err = getMetaEvents('outcome-provider-refused').find((e) => e.type === 'turn.error');
2724
+ assert.is((err?.detail as { reason?: string })?.reason, 'provider-refused');
2725
+ assert.is((err?.detail as { kind?: string })?.kind, 'spend');
2726
+ });
2727
+
2728
+ outcome('an auth refusal shows the SAME sentence but logs a different kind', async () => {
2729
+ // The whole justification for one type with two kinds: identical user experience, distinct
2730
+ // diagnostics. If these ever diverge in the transcript, the copy decision has been undone.
2731
+ clearMetaEventRegistry();
2732
+ const config = agent({
2733
+ name: 'Static',
2734
+ toolDefinitions: [def('noop')],
2735
+ toolHandlers: { noop: async () => 'ok' },
2736
+ });
2737
+ const driver = makeDriver(
2738
+ config,
2739
+ providerRefusedProvider('auth'),
2740
+ 'outcome-refused-auth',
2741
+ outcomeBus,
2742
+ );
2743
+
2744
+ const result: ChatDriverResult = await driver.sendMessage('go');
2745
+
2746
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
2747
+ assert.is(result.reason === 'done' ? result.providerRefused?.kind : undefined, 'auth');
2748
+ assert.is(driver.getHistory().at(-1)!.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
2749
+ });
2750
+
2751
+ outcome('a host override replaces the provider-refusal sentence verbatim', async () => {
2752
+ const config = agent({
2753
+ name: 'Static',
2754
+ toolDefinitions: [def('noop')],
2755
+ toolHandlers: { noop: async () => 'ok' },
2756
+ });
2757
+ const driver = new ChatDriver(makeRegistry(providerRefusedProvider()), {
2758
+ maxToolIterations: 50,
2759
+ maxFoldOperations: 5,
2760
+ sessionKey: 'outcome-refused-override',
2761
+ providerRefusedMessage: 'Bespoke wording for a white-labelled host.',
2762
+ });
2763
+ driver.applyAgent(config);
2764
+
2765
+ await driver.sendMessage('go');
2766
+
2767
+ assert.is(driver.getHistory().at(-1)!.content, 'Bespoke wording for a white-labelled host.');
2768
+ driver.dispose();
2769
+ });
2770
+
2631
2771
  outcome('budget-exhausted surfaces at both seams', async () => {
2632
2772
  await assertSurfacesReason('budget', budgetExhaustedProvider(), 'budget-exhausted');
2633
2773
  });
@@ -3005,6 +3145,160 @@ const walledWorker = (): AgentConfig =>
3005
3145
  toolHandlers: { finish: async () => 'x' },
3006
3146
  });
3007
3147
 
3148
+ /**
3149
+ * The child-refused / parent-fine provider, mirroring `parentOkChildWalled` — the parent's turn call
3150
+ * succeeds and delegates, and only the child's provider refuses.
3151
+ */
3152
+ const parentOkChildRefused = (
3153
+ kind: 'spend' | 'auth' = 'spend',
3154
+ ): { parentCalls: () => number; provider: AIProvider } => {
3155
+ let parentCalls = 0;
3156
+ return {
3157
+ parentCalls: () => parentCalls,
3158
+ provider: {
3159
+ chat: async (
3160
+ _h: ChatMessage[],
3161
+ _u: string,
3162
+ options?: ChatRequestOptions,
3163
+ ): Promise<ChatMessage> => {
3164
+ const names = (options?.tools ?? []).map((t) => t.name);
3165
+ if (names.includes('delegate')) {
3166
+ parentCalls += 1;
3167
+ return callsTool('delegate', `d${parentCalls}`);
3168
+ }
3169
+ throw kind === 'auth'
3170
+ ? new ProviderRefusedError(
3171
+ 'Anthropic',
3172
+ 'auth',
3173
+ 401,
3174
+ 'authentication_error',
3175
+ 'API key is invalid.',
3176
+ )
3177
+ : new ProviderRefusedError(
3178
+ 'Anthropic',
3179
+ 'spend',
3180
+ 400,
3181
+ 'invalid_request_error',
3182
+ 'credit too low',
3183
+ );
3184
+ },
3185
+ },
3186
+ };
3187
+ };
3188
+
3189
+ outcome(
3190
+ 'a sub-agent provider refusal ends the parent turn without another model call',
3191
+ async () => {
3192
+ /*
3193
+ * The seam a review caught, and it is genuinely counter-intuitive: the child is a SEPARATE driver
3194
+ * instance, so the flag it sets on itself is invisible to the parent. The refusal reaches the parent
3195
+ * only as a tool result, and without an `invokeSubAgent` branch that result was ordinary — so the
3196
+ * parent issued another model call straight into the same wall, and if that call had happened to
3197
+ * succeed the turn could have finished with no `failureReason` at all, reporting a clean turn over a
3198
+ * dead account. `parentCalls() === 1` is the assertion that pins it.
3199
+ */
3200
+ clearMetaEventRegistry();
3201
+ const { parentCalls, provider } = parentOkChildRefused();
3202
+ const parent = delegatingParent(walledWorker(), () => undefined);
3203
+ const driver = makeDriver(parent, provider, 'outcome-subagent-refused', outcomeBus);
3204
+
3205
+ const result: ChatDriverResult = await driver.sendMessage('go');
3206
+
3207
+ assert.is(parentCalls(), 1, 'the parent never calls the model again after the refusal');
3208
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
3209
+ assert.is(driver.getHistory().at(-1)!.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
3210
+ },
3211
+ );
3212
+
3213
+ outcome("a sub-agent refusal carries the CHILD's kind and vendor to the parent", async () => {
3214
+ // The other half of the same finding. The parent can only report what the child bubbled, so without a
3215
+ // payload on the failure the `providerRefused` diagnostic promised on `ChatDriverResult` would be
3216
+ // absent — and with cause-free user copy that payload is the ONLY place an operator learns whether to
3217
+ // top up an account or rotate a credential.
3218
+ clearMetaEventRegistry();
3219
+ const { provider } = parentOkChildRefused('auth');
3220
+ const parent = delegatingParent(walledWorker(), () => undefined);
3221
+ const driver = makeDriver(parent, provider, 'outcome-subagent-refused-kind', outcomeBus);
3222
+
3223
+ const result: ChatDriverResult = await driver.sendMessage('go');
3224
+
3225
+ const refused = result.reason === 'done' ? result.providerRefused : undefined;
3226
+ assert.is(refused?.kind, 'auth', "the child's kind survives the hop");
3227
+ assert.is(refused?.vendorLabel, 'Anthropic');
3228
+ assert.is(refused?.upstreamStatus, 401);
3229
+ });
3230
+
3231
+ outcome("a GRANDCHILD's refusal keeps its kind and vendor across two hops", async () => {
3232
+ /*
3233
+ * The third finding, and the one a single-hop test cannot reach. An INTERMEDIATE sub-agent latches its
3234
+ * grandchild's payload correctly via `invokeSubAgent`, but then has to hand it on through
3235
+ * `failSubAgent` — and passing only the reason there still stopped the top-level turn (the reason
3236
+ * travels) while arriving with no vendor and no kind. The turn looked handled and the diagnostics were
3237
+ * gone, which is the failure mode this payload exists to prevent.
3238
+ *
3239
+ * Three tiers: boss -> middle -> worker. Only the worker's turn is refused; every other model call
3240
+ * succeeds, so nothing but the forwarding can carry the detail to the top.
3241
+ */
3242
+ clearMetaEventRegistry();
3243
+ const worker = agent({
3244
+ name: 'worker',
3245
+ toolDefinitions: [def('finish')],
3246
+ toolHandlers: { finish: async () => 'x' },
3247
+ });
3248
+ const middle = agent({
3249
+ name: 'middle',
3250
+ subAgents: [worker],
3251
+ toolDefinitions: [def('sub_delegate')],
3252
+ toolHandlers: {
3253
+ sub_delegate: async (_args, ctx) => {
3254
+ const o = await ctx.requestSubAgent!('worker', { task: 'deeper' });
3255
+ return o.ok ? 'ok' : `failed: ${o.reason}`;
3256
+ },
3257
+ },
3258
+ });
3259
+ const boss = agent({
3260
+ name: 'boss',
3261
+ subAgents: [middle],
3262
+ toolDefinitions: [def('delegate')],
3263
+ toolHandlers: {
3264
+ delegate: async (_args, ctx) => {
3265
+ const o = await ctx.requestSubAgent!('middle', { task: 'do it' });
3266
+ return o.ok ? 'ok' : `failed: ${o.reason}`;
3267
+ },
3268
+ },
3269
+ });
3270
+
3271
+ // Routed by the tool surface each tier is given, so only the deepest turn throws.
3272
+ const provider: AIProvider = {
3273
+ chat: async (
3274
+ _h: ChatMessage[],
3275
+ _u: string,
3276
+ options?: ChatRequestOptions,
3277
+ ): Promise<ChatMessage> => {
3278
+ const names = (options?.tools ?? []).map((t) => t.name);
3279
+ if (names.includes('delegate')) return callsTool('delegate', 'd1');
3280
+ if (names.includes('sub_delegate')) return callsTool('sub_delegate', 's1');
3281
+ throw new ProviderRefusedError(
3282
+ 'Anthropic',
3283
+ 'auth',
3284
+ 401,
3285
+ 'authentication_error',
3286
+ 'API key is invalid.',
3287
+ );
3288
+ },
3289
+ };
3290
+ const driver = makeDriver(boss, provider, 'outcome-refused-grandchild', outcomeBus);
3291
+
3292
+ const result: ChatDriverResult = await driver.sendMessage('go');
3293
+
3294
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
3295
+ const refused = result.reason === 'done' ? result.providerRefused : undefined;
3296
+ assert.is(refused?.kind, 'auth', "the grandchild's kind survives BOTH hops");
3297
+ assert.is(refused?.vendorLabel, 'Anthropic');
3298
+ assert.is(refused?.upstreamStatus, 401);
3299
+ assert.is(driver.getHistory().at(-1)!.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
3300
+ });
3301
+
3008
3302
  outcome('a sub-agent budget wall ends the parent turn without another model call', async () => {
3009
3303
  clearMetaEventRegistry();
3010
3304
  const { parentCalls, provider } = parentOkChildWalled();