@genesislcap/ai-assistant 15.6.2 → 15.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/ai-assistant.api.json +391 -5
  2. package/dist/ai-assistant.d.ts +613 -6
  3. package/dist/chat-driver.cjs +285 -26
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +285 -26
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +254 -10
  8. package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
  9. package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
  13. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
  14. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
  15. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
  16. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  17. package/dist/dts/main/blocked-state.test.d.ts +2 -0
  18. package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
  19. package/dist/dts/main/main.d.ts +394 -6
  20. package/dist/dts/main/main.d.ts.map +1 -1
  21. package/dist/dts/main/main.styles.d.ts.map +1 -1
  22. package/dist/dts/main/main.styles.test.d.ts +2 -0
  23. package/dist/dts/main/main.styles.test.d.ts.map +1 -0
  24. package/dist/dts/main/main.template.d.ts +53 -0
  25. package/dist/dts/main/main.template.d.ts.map +1 -1
  26. package/dist/dts/state/ai-assistant-slice.d.ts +162 -6
  27. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  28. package/dist/dts/state/debug-event-log.d.ts +6 -1
  29. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  30. package/dist/dts/state/session-store.d.ts +11 -0
  31. package/dist/dts/state/session-store.d.ts.map +1 -1
  32. package/dist/esm/components/chat-driver/chat-driver.js +263 -21
  33. package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
  34. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
  35. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
  36. package/dist/esm/main/blocked-state.test.js +969 -0
  37. package/dist/esm/main/main.js +704 -16
  38. package/dist/esm/main/main.styles.js +47 -0
  39. package/dist/esm/main/main.styles.test.js +86 -0
  40. package/dist/esm/main/main.template.js +121 -4
  41. package/dist/esm/state/ai-assistant-slice.js +145 -7
  42. package/dist/esm/state/ai-assistant-slice.test.js +138 -1
  43. package/dist/esm/state/debug-event-log.js +7 -2
  44. package/dist/esm/state/debug-event-log.test.js +49 -1
  45. package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/docs/migration-GENC-1464.md +562 -0
  48. package/docs/sub_agent.md +20 -3
  49. package/package.json +17 -17
  50. package/src/channel/ai-activity-channel.ts +56 -2
  51. package/src/components/chat-driver/chat-driver.test.ts +549 -0
  52. package/src/components/chat-driver/chat-driver.ts +324 -14
  53. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
  54. package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
  55. package/src/main/blocked-state.test.ts +1316 -0
  56. package/src/main/main.styles.test.ts +103 -0
  57. package/src/main/main.styles.ts +47 -0
  58. package/src/main/main.template.ts +131 -4
  59. package/src/main/main.ts +704 -10
  60. package/src/state/ai-assistant-slice.test.ts +215 -0
  61. package/src/state/ai-assistant-slice.ts +218 -8
  62. package/src/state/debug-event-log.test.ts +63 -0
  63. package/src/state/debug-event-log.ts +7 -2
  64. package/src/state/persistence/session-snapshot.test.ts +22 -0
@@ -1,4 +1,4 @@
1
- import type { TurnFailureReason } from '@genesislcap/foundation-ai';
1
+ import type { AIProviderType, TurnFailureReason } from '@genesislcap/foundation-ai';
2
2
 
3
3
  /**
4
4
  * Event map for the AI activity bus.
@@ -67,8 +67,62 @@ export interface AgenticActivityEvents {
67
67
  * driver dispose, and an agent handoff the detail is `undefined` — the historical shape,
68
68
  * kept byte-identical so subscribers that only care about the boundary can keep ignoring
69
69
  * it. Structured-cloneable so it survives the cross-tab BroadcastChannel hop.
70
+ *
71
+ * A `'budget-exhausted'` failure additionally carries `vendor` — the concrete
72
+ * vendor the walled turn resolved to (GENC-1464), which is NOT the same thing as
73
+ * the registry alias recorded on the debug-log entry. Optional and additive: a
74
+ * subscriber reading only `failureReason` is unaffected, and every other
75
+ * failure still emits the historical `{ failureReason }` with no `vendor` key.
76
+ * It exists so a later per-vendor budget model is an additive change rather
77
+ * than a retrofit — the vendor is known at the transport and at the driver, and
78
+ * was previously discarded between them.
79
+ *
80
+ * Note this topic is forwarded on the tab-scoped channel, so a wall hit in a
81
+ * popped-out window reaches the main window's subscribers too — which is why
82
+ * the assistant's blocked latch fires there as well. Correct for a shared spend
83
+ * cap; see `docs/migration-GENC-1464.md` §Scope.
70
84
  */
71
- 'tool-loop-end': { failureReason?: TurnFailureReason } | undefined;
85
+ 'tool-loop-end':
86
+ | {
87
+ failureReason?: TurnFailureReason;
88
+ vendor?: AIProviderType;
89
+ /**
90
+ * Proxy-reported spend figures, present only on `budget-exhausted`. Carried
91
+ * here because this event — not the driver's return value — is what latches
92
+ * the blocked state for a wall hit INSIDE the tool loop (the common case):
93
+ * the publish happens in `sendMessage`'s `finally`, so it lands before the
94
+ * return-value seam and wins the latch. Without these the banner would fall
95
+ * back to the generic copy in exactly the path the figures were added for.
96
+ * Plain numbers + string keep the detail structured-cloneable for the
97
+ * cross-tab hop.
98
+ */
99
+ budget?: {
100
+ budgetUsd?: number;
101
+ spentUsd?: number;
102
+ vendorLabel: string;
103
+ /**
104
+ * The refusing vendor as a typed value: normalised from `vendorLabel`
105
+ * where a vendor claims that label, otherwise from the proxy's own
106
+ * `vendor` field on the 402 — so it can disagree with `vendorLabel`,
107
+ * which is what keeps attribution working behind a white-labelled or
108
+ * multiplexing gateway.
109
+ *
110
+ * Distinct from the detail's top-level `vendor` (the driver's
111
+ * last-resolved provider): this one comes from the transport that was
112
+ * actually refused, so it is the one a per-vendor latch trusts first.
113
+ */
114
+ vendor?: AIProviderType;
115
+ /**
116
+ * The proxy's verdict on whether any OTHER vendor it meters still has
117
+ * headroom. The one fact here a subscriber cannot work out for itself:
118
+ * the registry says which vendors EXIST, never which still have
119
+ * budget. A `false` is what stops the banner advising a switch to a
120
+ * vendor that is equally spent.
121
+ */
122
+ otherVendorAvailable?: boolean;
123
+ };
124
+ }
125
+ | undefined;
72
126
  /**
73
127
  * Fired when a tool handler hands a widget to the user mid-loop and parks awaiting it
74
128
  * (`requestInteraction`) — the turn is suspended between provider calls, with no request
@@ -12,6 +12,8 @@ import type {
12
12
  TurnFailureReason,
13
13
  } from '@genesislcap/foundation-ai';
14
14
  import {
15
+ BudgetExhaustedError,
16
+ DEFAULT_BUDGET_EXHAUSTED_MESSAGE,
15
17
  isChatToolCallUnknown,
16
18
  MalformedFunctionCallError,
17
19
  ResponseTruncatedError,
@@ -2495,6 +2497,13 @@ const truncatedProvider = (): AIProvider => ({
2495
2497
  },
2496
2498
  });
2497
2499
 
2500
+ /** A provider refused for budget (deterministic, terminal — must not retry). */
2501
+ const budgetExhaustedProvider = (): AIProvider => ({
2502
+ chat: async (): Promise<ChatMessage> => {
2503
+ throw new BudgetExhaustedError('Anthropic', 25, 25.4, 'AI budget exhausted');
2504
+ },
2505
+ });
2506
+
2498
2507
  /** A provider that throws a generic error (the sendMessage catch-all → 'exception'). */
2499
2508
  const throwingProvider = (): AIProvider => ({
2500
2509
  chat: async (): Promise<ChatMessage> => {
@@ -2617,4 +2626,544 @@ outcome('a clean turn leaves the legacy shape byte-unchanged (no failureReason)'
2617
2626
  cap.stop();
2618
2627
  });
2619
2628
 
2629
+ // ── Budget exhaustion (GENC-1464) ──────────────────────────────────────────────
2630
+
2631
+ outcome('budget-exhausted surfaces at both seams', async () => {
2632
+ await assertSurfacesReason('budget', budgetExhaustedProvider(), 'budget-exhausted');
2633
+ });
2634
+
2635
+ outcome('a budget wall is terminal — no transient retry, and actionable copy', async () => {
2636
+ clearMetaEventRegistry();
2637
+ let calls = 0;
2638
+ const provider: AIProvider = {
2639
+ chat: async (): Promise<ChatMessage> => {
2640
+ calls += 1;
2641
+ throw new BudgetExhaustedError('Anthropic', 25, 25.4);
2642
+ },
2643
+ // Resolvable vendor, so the per-vendor fields below are exercised.
2644
+ getStatus: async () => ({ provider: 'anthropic', model: 'test-model' }),
2645
+ };
2646
+ const config = agent({
2647
+ name: 'Static',
2648
+ toolDefinitions: [def('noop')],
2649
+ toolHandlers: { noop: async () => 'ok' },
2650
+ });
2651
+ const driver = makeDriver(config, provider, 'outcome-budget-once', outcomeBus);
2652
+ const cap = captureLoopEnd();
2653
+
2654
+ const result: ChatDriverResult = await driver.sendMessage('go');
2655
+
2656
+ // The whole point of the typed branch: it sits BEFORE the transient-retry
2657
+ // catch, so the driver must not burn MAX_SETUP_TRANSPORT_RETRIES attempts
2658
+ // against a wall that no retry can clear.
2659
+ assert.is(calls, 1, 'a budget wall is not retried — exactly one model call');
2660
+ assert.is(
2661
+ result.reason === 'done' ? result.failureReason : undefined,
2662
+ 'budget-exhausted',
2663
+ 'the turn ends with the budget-exhausted failure reason',
2664
+ );
2665
+
2666
+ // The figures now reach the CALLER, not just the debug log — this is what lets
2667
+ // the element render "$25.40 of $25" in its banner without catching the
2668
+ // transport error itself.
2669
+ const budget = result.reason === 'done' ? result.budget : undefined;
2670
+ assert.is(budget?.budgetUsd, 25);
2671
+ assert.is(budget?.spentUsd, 25.4);
2672
+ assert.is(budget?.vendorLabel, 'Anthropic');
2673
+
2674
+ // The bus detail carries the resolved vendor beside the reason.
2675
+ assert.is(cap.detail()!.failureReason, 'budget-exhausted');
2676
+ assert.is((cap.detail() as { vendor?: string }).vendor, 'anthropic');
2677
+ cap.stop();
2678
+
2679
+ const last = driver.getHistory().at(-1);
2680
+ assert.ok(last?.role === 'assistant', 'turn ends with an assistant message');
2681
+ // Identity, not substrings: the transcript bubble and the element's blocked
2682
+ // banner now read from one shared const, and this is what pins them together.
2683
+ assert.is(last!.content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
2684
+ assert.not.ok(
2685
+ last!.content.includes('something went wrong'),
2686
+ 'must not fall through to the generic apology',
2687
+ );
2688
+
2689
+ // The budget figures ride the debug-log entry so a support ticket can show them.
2690
+ const err = getMetaEvents('outcome-budget-once').find((e) => e.type === 'turn.error');
2691
+ assert.is(err!.detail?.budgetUsd, 25);
2692
+ assert.is(err!.detail?.spentUsd, 25.4);
2693
+ // The registry ALIAS and the VENDOR are different things; record both.
2694
+ assert.is(err!.detail?.provider, 'test');
2695
+ assert.is(err!.detail?.vendor, 'anthropic');
2696
+ });
2697
+
2698
+ outcome('a non-budget failure emits the historical detail shape, with no vendor', async () => {
2699
+ // Back-compat for subscribers that structurally compare the `tool-loop-end`
2700
+ // detail: only a budget failure grew a field.
2701
+ clearMetaEventRegistry();
2702
+ const config = agent({
2703
+ name: 'Static',
2704
+ toolDefinitions: [def('noop')],
2705
+ toolHandlers: { noop: async () => 'ok' },
2706
+ });
2707
+ const driver = makeDriver(config, throwingProvider(), 'outcome-vendor-compat', outcomeBus);
2708
+ const cap = captureLoopEnd();
2709
+
2710
+ await driver.sendMessage('go');
2711
+
2712
+ assert.equal(cap.detail(), { failureReason: 'exception' }, 'no vendor key was added');
2713
+ cap.stop();
2714
+ });
2715
+
2716
+ outcome('a 402 with no figures still carries the vendor that refused it', async () => {
2717
+ // The regression this pins: the driver used to drop the ENTIRE budget object
2718
+ // when both figures were absent, and `vendorLabel` — the authoritative
2719
+ // attribution source — went with it. The element then fell back to the
2720
+ // driver's last-resolved provider, i.e. the PREVIOUS turn's vendor, and walled
2721
+ // the wrong one. Three live shapes arrive here with no figures: a bare gateway
2722
+ // 402, a code-only body, and the framed err frame whose figures are `null`.
2723
+ clearMetaEventRegistry();
2724
+ const provider: AIProvider = {
2725
+ chat: async (): Promise<ChatMessage> => {
2726
+ throw new BudgetExhaustedError('Anthropic');
2727
+ },
2728
+ };
2729
+ const config = agent({
2730
+ name: 'Static',
2731
+ toolDefinitions: [def('noop')],
2732
+ toolHandlers: { noop: async () => 'ok' },
2733
+ });
2734
+ const driver = makeDriver(config, provider, 'outcome-budget-nofigures', outcomeBus);
2735
+ const cap = captureLoopEnd();
2736
+
2737
+ const result: ChatDriverResult = await driver.sendMessage('go');
2738
+
2739
+ const budget = result.reason === 'done' ? result.budget : undefined;
2740
+ assert.ok(budget, 'a figure-less wall still reports who refused it');
2741
+ assert.is(budget!.vendorLabel, 'Anthropic');
2742
+ assert.is(budget!.vendor, 'anthropic');
2743
+ // Still no figures — the element reads their ABSENCE as "leave any host-set
2744
+ // banner copy alone", so they must not be invented.
2745
+ assert.is(budget!.budgetUsd, undefined);
2746
+ assert.is(budget!.spentUsd, undefined);
2747
+ // Both seams, since the bus one lands first and wins the latch.
2748
+ assert.is((cap.detail() as { budget?: { vendor?: string } }).budget?.vendor, 'anthropic');
2749
+ cap.stop();
2750
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
2751
+ });
2752
+
2753
+ outcome('a wall with nothing to report at all still omits the budget key', async () => {
2754
+ // The other direction: `budget` is present when there is something to say and
2755
+ // absent when there is not, so presence keeps meaning something. No figures AND
2756
+ // an unclaimed label leaves nothing to carry — and the element's fail-safe
2757
+ // (block everything) is the correct outcome for an unattributable wall.
2758
+ clearMetaEventRegistry();
2759
+ const provider: AIProvider = {
2760
+ chat: async (): Promise<ChatMessage> => {
2761
+ throw new BudgetExhaustedError('Acme AI');
2762
+ },
2763
+ };
2764
+ const config = agent({
2765
+ name: 'Static',
2766
+ toolDefinitions: [def('noop')],
2767
+ toolHandlers: { noop: async () => 'ok' },
2768
+ });
2769
+ const driver = makeDriver(config, provider, 'outcome-budget-nothing', outcomeBus);
2770
+
2771
+ const result: ChatDriverResult = await driver.sendMessage('go');
2772
+
2773
+ assert.not.ok('budget' in result, 'nothing reportable → no budget key');
2774
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
2775
+ });
2776
+
2777
+ outcome('a figure-less wall attributes to the refusing transport, not the last turn', async () => {
2778
+ // The exact failure: turn 1 runs on Gemini, turn 2's request is refused by
2779
+ // Anthropic with a figure-less 402. `lastResolvedProvider` still says `gemini`,
2780
+ // so the pre-fix driver reported Gemini and the user was told to switch to the
2781
+ // vendor that had actually run out.
2782
+ clearMetaEventRegistry();
2783
+ let walled = false;
2784
+ const provider: AIProvider = {
2785
+ chat: async (): Promise<ChatMessage> => {
2786
+ if (!walled) {
2787
+ walled = true;
2788
+ return { role: 'assistant', content: 'first turn, on Gemini' };
2789
+ }
2790
+ throw new BudgetExhaustedError('Anthropic');
2791
+ },
2792
+ };
2793
+ // The registry resolves to Gemini, so `lastResolvedProvider` is `gemini` by the
2794
+ // time the Anthropic wall lands — the disagreement has to be seeded to prove
2795
+ // which source is believed.
2796
+ const registry: AIProviderRegistry = {
2797
+ get: () => provider,
2798
+ default: () => provider,
2799
+ defaultName: () => 'test',
2800
+ names: () => ['test'],
2801
+ getStatus: async () => ({ provider: 'gemini', model: 'gemini-test' }),
2802
+ listStatuses: async () => [],
2803
+ };
2804
+ const driver = new ChatDriver(registry, {
2805
+ sessionKey: 'outcome-budget-stale-vendor',
2806
+ activityBus: outcomeBus,
2807
+ });
2808
+ driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
2809
+
2810
+ await driver.sendMessage('one');
2811
+ const result: ChatDriverResult = await driver.sendMessage('two');
2812
+
2813
+ const budget = result.reason === 'done' ? result.budget : undefined;
2814
+ assert.is(budget?.vendor, 'anthropic', 'the transport that said no, not the one before it');
2815
+ const err = getMetaEvents('outcome-budget-stale-vendor').find((e) => e.type === 'turn.error');
2816
+ assert.is(err!.detail?.vendor, 'anthropic', 'and the debug log agrees');
2817
+ });
2818
+
2819
+ outcome("the proxy's own `vendor` rescues an unclaimed transport label", async () => {
2820
+ // A multiplexing or white-labelled gateway fronts several upstreams behind one
2821
+ // transport, so its static label names none of them. The 402's `vendor` is then
2822
+ // the only thing that knows which pot was actually booked against — second
2823
+ // opinion, never first: the transport's own label still wins where it resolves.
2824
+ clearMetaEventRegistry();
2825
+ const provider: AIProvider = {
2826
+ chat: async (): Promise<ChatMessage> => {
2827
+ throw new BudgetExhaustedError('Acme AI', undefined, undefined, undefined, {
2828
+ serverVendor: 'Gemini',
2829
+ });
2830
+ },
2831
+ };
2832
+ const config = agent({
2833
+ name: 'Static',
2834
+ toolDefinitions: [def('noop')],
2835
+ toolHandlers: { noop: async () => 'ok' },
2836
+ });
2837
+ const driver = makeDriver(config, provider, 'outcome-budget-server-vendor', outcomeBus);
2838
+
2839
+ const result: ChatDriverResult = await driver.sendMessage('go');
2840
+
2841
+ const budget = result.reason === 'done' ? result.budget : undefined;
2842
+ assert.is(budget?.vendorLabel, 'Acme AI', 'the transport label is reported verbatim');
2843
+ assert.is(budget?.vendor, 'gemini', 'and attribution still lands');
2844
+ });
2845
+
2846
+ outcome("the proxy's `otherVendorAvailable` verdict reaches both seams", async () => {
2847
+ // The one fact the element cannot re-derive: registry membership says which
2848
+ // vendors EXIST, never which still have budget. It has to survive the trip.
2849
+ clearMetaEventRegistry();
2850
+ const provider: AIProvider = {
2851
+ chat: async (): Promise<ChatMessage> => {
2852
+ throw new BudgetExhaustedError('Anthropic', 25, 25, undefined, {
2853
+ otherVendorAvailable: false,
2854
+ });
2855
+ },
2856
+ };
2857
+ const config = agent({
2858
+ name: 'Static',
2859
+ toolDefinitions: [def('noop')],
2860
+ toolHandlers: { noop: async () => 'ok' },
2861
+ });
2862
+ const driver = makeDriver(config, provider, 'outcome-budget-other-vendor', outcomeBus);
2863
+ const cap = captureLoopEnd();
2864
+
2865
+ const result: ChatDriverResult = await driver.sendMessage('go');
2866
+
2867
+ assert.is(
2868
+ result.reason === 'done' ? result.budget?.otherVendorAvailable : undefined,
2869
+ false,
2870
+ 'the return-value seam',
2871
+ );
2872
+ assert.is(
2873
+ (cap.detail() as { budget?: { otherVendorAvailable?: boolean } }).budget?.otherVendorAvailable,
2874
+ false,
2875
+ 'and the bus seam, which lands first and wins the latch',
2876
+ );
2877
+ cap.stop();
2878
+ });
2879
+
2880
+ outcome('a host-supplied budgetExhaustedMessage replaces the transcript copy', async () => {
2881
+ clearMetaEventRegistry();
2882
+ const provider: AIProvider = {
2883
+ chat: async (): Promise<ChatMessage> => {
2884
+ throw new BudgetExhaustedError('Anthropic', 25, 25.4);
2885
+ },
2886
+ };
2887
+ const driver = new ChatDriver(makeRegistry(provider), {
2888
+ sessionKey: 'outcome-budget-custom',
2889
+ activityBus: outcomeBus,
2890
+ budgetExhaustedMessage: 'Contact ops@acme.com',
2891
+ });
2892
+ driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
2893
+
2894
+ await driver.sendMessage('go');
2895
+
2896
+ // The banner was already overridable; without this seam a white-labelled host
2897
+ // got its own copy in the banner and 'contact Genesis' directly below it.
2898
+ assert.is(driver.getHistory().at(-1)!.content, 'Contact ops@acme.com');
2899
+ });
2900
+
2901
+ // ── A sub-agent's wall is terminal for the parent too (GENC-1464) ──────────────
2902
+ // `SubAgentFailureReason.budget_exhausted` is documented as "terminal for the
2903
+ // parent too". Nothing parent-side read it, so the parent's tool loop appended
2904
+ // the failed outcome as a tool result and called the model again — into the same
2905
+ // wall. With N batched delegations that was N doomed children AND a doomed parent
2906
+ // call, and the element's `failureReason === 'budget-exhausted'` latch never fired.
2907
+
2908
+ /** A sub-agent whose own turn is refused for budget; the parent's turns are not. */
2909
+ const parentOkChildWalled = (): { parentCalls: () => number; provider: AIProvider } => {
2910
+ let parentCalls = 0;
2911
+ return {
2912
+ parentCalls: () => parentCalls,
2913
+ provider: {
2914
+ chat: async (
2915
+ _h: ChatMessage[],
2916
+ _u: string,
2917
+ options?: ChatRequestOptions,
2918
+ ): Promise<ChatMessage> => {
2919
+ const names = (options?.tools ?? []).map((t) => t.name);
2920
+ if (names.includes('delegate')) {
2921
+ parentCalls += 1;
2922
+ return callsTool('delegate', `d${parentCalls}`);
2923
+ }
2924
+ throw new BudgetExhaustedError('Anthropic', 25, 25.4);
2925
+ },
2926
+ },
2927
+ };
2928
+ };
2929
+
2930
+ const walledWorker = (): AgentConfig =>
2931
+ agent({
2932
+ name: 'worker',
2933
+ toolDefinitions: [def('finish')],
2934
+ toolHandlers: { finish: async () => 'x' },
2935
+ });
2936
+
2937
+ outcome('a sub-agent budget wall ends the parent turn without another model call', async () => {
2938
+ clearMetaEventRegistry();
2939
+ const { parentCalls, provider } = parentOkChildWalled();
2940
+ const parent = delegatingParent(walledWorker(), () => undefined);
2941
+ const driver = makeDriver(parent, provider, 'outcome-subagent-budget', outcomeBus);
2942
+
2943
+ const result: ChatDriverResult = await driver.sendMessage('go');
2944
+
2945
+ assert.is(parentCalls(), 1, 'the parent never calls the model again after the wall');
2946
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
2947
+ assert.is(driver.getHistory().at(-1)!.content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
2948
+ });
2949
+
2950
+ outcome("a sub-agent's wall is attributed to the CHILD's vendor, not the parent's", async () => {
2951
+ // A sub-agent can sit on a different vendor from its parent (`applyAgent` reads
2952
+ // `config.provider`). The wall reaches the parent only as a tool result, so
2953
+ // while the parent carried nothing but a boolean it reported
2954
+ // `lastResolvedProvider` — its own vendor, i.e. precisely the one that did NOT
2955
+ // refuse. Under a two-vendor registry that walls Gemini because an Anthropic
2956
+ // child 402'd, and the host then blocks a composer that still had headroom.
2957
+ clearMetaEventRegistry();
2958
+ const { provider } = parentOkChildWalled();
2959
+ // Seeded disagreement: the registry resolves Gemini, so the parent's
2960
+ // `lastResolvedProvider` is `gemini` while the child is refused by Anthropic.
2961
+ const registry: AIProviderRegistry = {
2962
+ get: () => provider,
2963
+ default: () => provider,
2964
+ defaultName: () => 'test',
2965
+ names: () => ['test'],
2966
+ getStatus: async () => ({ provider: 'gemini', model: 'gemini-test' }),
2967
+ listStatuses: async () => [],
2968
+ };
2969
+ const driver = new ChatDriver(registry, {
2970
+ sessionKey: 'outcome-subagent-budget-vendor',
2971
+ activityBus: outcomeBus,
2972
+ });
2973
+ driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
2974
+
2975
+ const result: ChatDriverResult = await driver.sendMessage('go');
2976
+
2977
+ const budget = result.reason === 'done' ? result.budget : undefined;
2978
+ assert.is(budget?.vendor, 'anthropic', "the child's refusing vendor survives the hop");
2979
+ assert.is(budget?.budgetUsd, 25, "and so do the child's figures");
2980
+ assert.is(budget?.spentUsd, 25.4);
2981
+ const err = getMetaEvents('outcome-subagent-budget-vendor').find((e) => e.type === 'turn.error');
2982
+ assert.is(err!.detail?.vendor, 'anthropic', 'and the debug log agrees');
2983
+ });
2984
+
2985
+ outcome('N batched sub-agent calls still produce at most one doomed parent call', async () => {
2986
+ clearMetaEventRegistry();
2987
+ let parentCalls = 0;
2988
+ const provider: AIProvider = {
2989
+ chat: async (
2990
+ _h: ChatMessage[],
2991
+ _u: string,
2992
+ options?: ChatRequestOptions,
2993
+ ): Promise<ChatMessage> => {
2994
+ const names = (options?.tools ?? []).map((t) => t.name);
2995
+ if (names.includes('delegate')) {
2996
+ parentCalls += 1;
2997
+ return {
2998
+ role: 'assistant',
2999
+ content: '',
3000
+ toolCalls: [
3001
+ { id: 'd1', name: 'delegate', args: {} },
3002
+ { id: 'd2', name: 'delegate', args: {} },
3003
+ ],
3004
+ };
3005
+ }
3006
+ throw new BudgetExhaustedError('Anthropic', 25, 25.4);
3007
+ },
3008
+ };
3009
+ const parent = delegatingParent(walledWorker(), () => undefined);
3010
+ const driver = makeDriver(parent, provider, 'outcome-subagent-budget-batched', outcomeBus);
3011
+
3012
+ const result: ChatDriverResult = await driver.sendMessage('go');
3013
+
3014
+ assert.is(parentCalls, 1);
3015
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
3016
+ });
3017
+
3018
+ outcome('a non-terminal sub-agent failure still lets the parent continue', async () => {
3019
+ // The short-circuit is scoped strictly to the budget reason — every other
3020
+ // SubAgentFailureReason is something the parent can legitimately recover from.
3021
+ clearMetaEventRegistry();
3022
+ let parentCalls = 0;
3023
+ const provider: AIProvider = {
3024
+ chat: async (
3025
+ _h: ChatMessage[],
3026
+ _u: string,
3027
+ options?: ChatRequestOptions,
3028
+ ): Promise<ChatMessage> => {
3029
+ const names = (options?.tools ?? []).map((t) => t.name);
3030
+ if (names.includes('delegate')) {
3031
+ parentCalls += 1;
3032
+ return parentCalls === 1
3033
+ ? callsTool('delegate', 'd1')
3034
+ : { role: 'assistant', content: 'carried on regardless' };
3035
+ }
3036
+ // The child answers in free text without calling its completion tool →
3037
+ // `max_iterations`, a recoverable failure.
3038
+ return { role: 'assistant', content: 'no structured result' };
3039
+ },
3040
+ };
3041
+ let captured: unknown;
3042
+ const parent = delegatingParent(walledWorker(), (o) => {
3043
+ captured = o;
3044
+ });
3045
+ const driver = makeDriver(parent, provider, 'outcome-subagent-nonterminal', outcomeBus);
3046
+
3047
+ const result: ChatDriverResult = await driver.sendMessage('go');
3048
+
3049
+ assert.equal(captured, { ok: false, reason: 'max_iterations' });
3050
+ assert.is(parentCalls, 2, 'the parent loop kept running');
3051
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, undefined);
3052
+ });
3053
+
3054
+ outcome(
3055
+ "a later unattributable child wall does not erase the first child's attribution",
3056
+ async () => {
3057
+ // Batched delegations: child A is refused by Anthropic WITH attribution,
3058
+ // child B by a transport whose label no vendor claims (figure-less 402 → no
3059
+ // BudgetDetail at all). A plain assignment let B's `undefined` clobber A's
3060
+ // attribution, and the fallback then reported the PARENT's vendor — under a
3061
+ // Gemini-resolving registry, precisely the vendor that did not refuse.
3062
+ clearMetaEventRegistry();
3063
+ let childCalls = 0;
3064
+ const provider: AIProvider = {
3065
+ chat: async (
3066
+ _h: ChatMessage[],
3067
+ _u: string,
3068
+ options?: ChatRequestOptions,
3069
+ ): Promise<ChatMessage> => {
3070
+ const names = (options?.tools ?? []).map((t) => t.name);
3071
+ if (names.includes('delegate')) {
3072
+ return {
3073
+ role: 'assistant',
3074
+ content: '',
3075
+ toolCalls: [
3076
+ { id: 'd1', name: 'delegate', args: {} },
3077
+ { id: 'd2', name: 'delegate', args: {} },
3078
+ ],
3079
+ };
3080
+ }
3081
+ childCalls += 1;
3082
+ throw childCalls === 1
3083
+ ? new BudgetExhaustedError('Anthropic', 25, 25.4)
3084
+ : new BudgetExhaustedError('Acme AI'); // unclaimed label, no figures
3085
+ },
3086
+ };
3087
+ const registry: AIProviderRegistry = {
3088
+ get: () => provider,
3089
+ default: () => provider,
3090
+ defaultName: () => 'test',
3091
+ names: () => ['test'],
3092
+ getStatus: async () => ({ provider: 'gemini', model: 'gemini-test' }),
3093
+ listStatuses: async () => [],
3094
+ };
3095
+ const driver = new ChatDriver(registry, {
3096
+ sessionKey: 'outcome-subagent-budget-clobber',
3097
+ activityBus: outcomeBus,
3098
+ });
3099
+ driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
3100
+
3101
+ const result: ChatDriverResult = await driver.sendMessage('one');
3102
+
3103
+ const budget = result.reason === 'done' ? result.budget : undefined;
3104
+ assert.is(budget?.vendor, 'anthropic', "child A's attribution survives child B's blank wall");
3105
+ },
3106
+ );
3107
+
3108
+ outcome(
3109
+ 'a fully unattributable child wall degrades vendor-agnostic, never to the parent',
3110
+ async () => {
3111
+ // When NO child wall carries attribution, the honest event is vendor-less.
3112
+ // Falling back to `lastResolvedProvider` named the parent's vendor — walling
3113
+ // BOTH vendors (the child's via its own tool-loop-end, the parent's via this
3114
+ // one) and deriving `blocked` over headroom that still existed.
3115
+ clearMetaEventRegistry();
3116
+ const events: Array<{ vendor?: string } | undefined> = [];
3117
+ const bus = new AgenticActivityBus();
3118
+ bus.subscribe('tool-loop-end', (d) => events.push(d as { vendor?: string } | undefined));
3119
+ // The provider must carry its own `getStatus` — `lastResolvedProvider` is
3120
+ // stamped from the PROVIDER's status, not the registry's, and a status-less
3121
+ // mock leaves it unset, which lets the buggy fallback pass vacuously (there
3122
+ // is no wrong vendor to fall back to).
3123
+ const provider: AIProvider = {
3124
+ getStatus: async () => ({ provider: 'gemini', model: 'gemini-test' }),
3125
+ chat: async (
3126
+ _h: ChatMessage[],
3127
+ _u: string,
3128
+ options?: ChatRequestOptions,
3129
+ ): Promise<ChatMessage> => {
3130
+ const names = (options?.tools ?? []).map((t) => t.name);
3131
+ if (names.includes('delegate')) return callsTool('delegate', 'd1');
3132
+ // The CHILD's turn: refused by a transport whose label no vendor claims.
3133
+ throw new BudgetExhaustedError('Acme AI'); // no figures either
3134
+ },
3135
+ } as AIProvider;
3136
+ const registry: AIProviderRegistry = {
3137
+ get: () => provider,
3138
+ default: () => provider,
3139
+ defaultName: () => 'test',
3140
+ names: () => ['test'],
3141
+ getStatus: async () => ({ provider: 'gemini', model: 'gemini-test' }),
3142
+ listStatuses: async () => [],
3143
+ };
3144
+ const driver = new ChatDriver(registry, {
3145
+ sessionKey: 'outcome-subagent-budget-agnostic',
3146
+ activityBus: bus,
3147
+ });
3148
+ driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
3149
+
3150
+ await driver.sendMessage('go');
3151
+
3152
+ // The PARENT's loop-end is the last one on the bus. It must carry the
3153
+ // failure without naming the parent's (gemini) vendor.
3154
+ const parentEnd = events.at(-1) as { failureReason?: string; vendor?: string } | undefined;
3155
+ assert.is(parentEnd?.failureReason, 'budget-exhausted');
3156
+ assert.is(parentEnd?.vendor, undefined, 'no vendor is better than the wrong vendor');
3157
+ // The CHILD's folded-in turn.error legitimately carries its own best-effort
3158
+ // self-attribution (its resolved provider really did make the refused call);
3159
+ // the record this fix governs is the PARENT's short-circuit, tagged
3160
+ // `via: 'sub-agent'`.
3161
+ const err = getMetaEvents('outcome-subagent-budget-agnostic').find(
3162
+ (e) => e.type === 'turn.error' && e.detail?.via === 'sub-agent',
3163
+ );
3164
+ assert.ok(err, "the parent's short-circuit recorded the wall");
3165
+ assert.is(err!.detail?.vendor, undefined, 'and named no vendor rather than the wrong one');
3166
+ },
3167
+ );
3168
+
2620
3169
  outcome.run();