@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.
- package/dist/ai-assistant.api.json +391 -5
- package/dist/ai-assistant.d.ts +613 -6
- package/dist/chat-driver.cjs +285 -26
- package/dist/chat-driver.cjs.map +3 -3
- package/dist/chat-driver.mjs +285 -26
- package/dist/chat-driver.mjs.map +3 -3
- package/dist/custom-elements.json +254 -10
- package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
- package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/main/blocked-state.test.d.ts +2 -0
- package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +394 -6
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.styles.d.ts.map +1 -1
- package/dist/dts/main/main.styles.test.d.ts +2 -0
- package/dist/dts/main/main.styles.test.d.ts.map +1 -0
- package/dist/dts/main/main.template.d.ts +53 -0
- package/dist/dts/main/main.template.d.ts.map +1 -1
- package/dist/dts/state/ai-assistant-slice.d.ts +162 -6
- package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
- package/dist/dts/state/debug-event-log.d.ts +6 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/dts/state/session-store.d.ts +11 -0
- package/dist/dts/state/session-store.d.ts.map +1 -1
- package/dist/esm/components/chat-driver/chat-driver.js +263 -21
- package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
- package/dist/esm/main/blocked-state.test.js +969 -0
- package/dist/esm/main/main.js +704 -16
- package/dist/esm/main/main.styles.js +47 -0
- package/dist/esm/main/main.styles.test.js +86 -0
- package/dist/esm/main/main.template.js +121 -4
- package/dist/esm/state/ai-assistant-slice.js +145 -7
- package/dist/esm/state/ai-assistant-slice.test.js +138 -1
- package/dist/esm/state/debug-event-log.js +7 -2
- package/dist/esm/state/debug-event-log.test.js +49 -1
- package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/docs/migration-GENC-1464.md +562 -0
- package/docs/sub_agent.md +20 -3
- package/package.json +17 -17
- package/src/channel/ai-activity-channel.ts +56 -2
- package/src/components/chat-driver/chat-driver.test.ts +549 -0
- package/src/components/chat-driver/chat-driver.ts +324 -14
- package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
- package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
- package/src/main/blocked-state.test.ts +1316 -0
- package/src/main/main.styles.test.ts +103 -0
- package/src/main/main.styles.ts +47 -0
- package/src/main/main.template.ts +131 -4
- package/src/main/main.ts +704 -10
- package/src/state/ai-assistant-slice.test.ts +215 -0
- package/src/state/ai-assistant-slice.ts +218 -8
- package/src/state/debug-event-log.test.ts +63 -0
- package/src/state/debug-event-log.ts +7 -2
- 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':
|
|
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();
|