@genesislcap/ai-assistant 15.10.4 → 15.10.6
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 +68 -1
- package/dist/ai-assistant.d.ts +34 -22
- package/dist/chat-driver.cjs +13 -4
- package/dist/chat-driver.cjs.map +2 -2
- package/dist/chat-driver.mjs +13 -4
- package/dist/chat-driver.mjs.map +2 -2
- package/dist/custom-elements.json +90 -66
- package/dist/dts/components/chat-driver/chat-driver.d.ts +14 -4
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts +20 -18
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/react.d.ts +8 -8
- package/dist/esm/components/chat-driver/chat-driver.js +35 -4
- package/dist/esm/components/chat-driver/chat-driver.test.js +62 -0
- package/dist/esm/main/blocked-state.test.js +44 -0
- package/dist/esm/main/main.js +36 -17
- package/dist/react.cjs +5 -5
- package/dist/react.mjs +4 -4
- package/package.json +17 -17
- package/src/components/chat-driver/chat-driver.test.ts +107 -0
- package/src/components/chat-driver/chat-driver.ts +50 -8
- package/src/main/blocked-state.test.ts +59 -0
- package/src/main/main.ts +37 -18
package/dist/esm/main/main.js
CHANGED
|
@@ -1185,6 +1185,23 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1185
1185
|
if (reason !== 'budget-exhausted')
|
|
1186
1186
|
return;
|
|
1187
1187
|
const vendor = (_b = (_a = vendorTypeOfLabel(budget === null || budget === void 0 ? void 0 : budget.vendorLabel)) !== null && _a !== void 0 ? _a : budget === null || budget === void 0 ? void 0 : budget.vendor) !== null && _b !== void 0 ? _b : vendorHint;
|
|
1188
|
+
// Meter feed (GENC-1464): snap this vendor's meter to the figures the 402
|
|
1189
|
+
// itself reported, the moment the wall lands. Hosts refresh the meter on
|
|
1190
|
+
// `tool-loop-end`, and a walled turn may never reach one — the user then
|
|
1191
|
+
// stares at the pre-turn figures next to a banner saying the budget is gone
|
|
1192
|
+
// until something else ends a loop (observed live: the meter only caught up
|
|
1193
|
+
// when the user cancelled the stuck interaction). Deliberately NOT under the
|
|
1194
|
+
// per-vendor idempotence guard below, for the same reason as the sweep: a
|
|
1195
|
+
// later 402 for an already-walled vendor carries fresher spend. Both figures
|
|
1196
|
+
// are required (`VendorBudgetFigures` is total, and half a meter is a lie),
|
|
1197
|
+
// and the write goes through the captured `ref` like every other write here,
|
|
1198
|
+
// so a teardown race cannot land it in a new session's store.
|
|
1199
|
+
if (vendor && vendor !== 'none' && (budget === null || budget === void 0 ? void 0 : budget.budgetUsd) != null && (budget === null || budget === void 0 ? void 0 : budget.spentUsd) != null) {
|
|
1200
|
+
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBudget({
|
|
1201
|
+
vendor,
|
|
1202
|
+
figures: { budgetUsd: budget.budgetUsd, spentUsd: budget.spentUsd },
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1188
1205
|
if (!vendor || vendor === 'none') {
|
|
1189
1206
|
if (!this.blocked) {
|
|
1190
1207
|
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setBlocked({ blocked: true, reason: formatBlockedReason(budget) });
|
|
@@ -1312,28 +1329,30 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1312
1329
|
return `${walled.map(statementFor).join(' ')} ${action}`;
|
|
1313
1330
|
}
|
|
1314
1331
|
/**
|
|
1315
|
-
* Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall
|
|
1332
|
+
* Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall,
|
|
1333
|
+
* or `undefined` to let the driver compose it per wall.
|
|
1316
1334
|
*
|
|
1317
|
-
* A
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1335
|
+
* A set `blockedReason` still wins — a white-labelled host must not read its
|
|
1336
|
+
* own explanation in the banner and shipped copy directly below it, and the
|
|
1337
|
+
* vendor-agnostic latch's `formatBlockedReason(budget)` form stays advice-free
|
|
1338
|
+
* exactly as before.
|
|
1320
1339
|
*
|
|
1321
|
-
*
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1325
|
-
*
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1331
|
-
*
|
|
1332
|
-
*
|
|
1340
|
+
* `undefined` (the usual case) is NOT the old `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`
|
|
1341
|
+
* fallback: it hands composition to the driver, which builds the bubble from
|
|
1342
|
+
* EACH wall's own 402 — naming the exhausted vendor and advising a switch only
|
|
1343
|
+
* when that refusal's `otherVendorAvailable` positively vouched for headroom
|
|
1344
|
+
* elsewhere. That is immune to the staleness this getter used to guard against
|
|
1345
|
+
* by freezing the copy: construction-time advice could outlive its truth
|
|
1346
|
+
* (`getOrCreateDriver` re-runs on an agents swap and would have baked the
|
|
1347
|
+
* sentence into every later bubble), but per-wall advice is derived from the
|
|
1348
|
+
* refusal it annotates and dies with the turn. Forcing the default here was
|
|
1349
|
+
* also what made the driver's composed bubble unreachable in every real
|
|
1350
|
+
* mount — the banner advised switching while the bubble under it never did
|
|
1351
|
+
* (the GENC-1464 tester finding, root cause).
|
|
1333
1352
|
*/
|
|
1334
1353
|
get transcriptBudgetExhaustedMessage() {
|
|
1335
1354
|
var _a;
|
|
1336
|
-
return (_a = this.blockedReason) !== null && _a !== void 0 ? _a :
|
|
1355
|
+
return (_a = this.blockedReason) !== null && _a !== void 0 ? _a : undefined;
|
|
1337
1356
|
}
|
|
1338
1357
|
/**
|
|
1339
1358
|
* Name of the agent the user has pinned via the agent picker. `null` means
|
package/dist/react.cjs
CHANGED
|
@@ -124,14 +124,14 @@ const AiChatInteractionWrapper = React.forwardRef(function AiChatInteractionWrap
|
|
|
124
124
|
return React.createElement(customElements.getName(AiChatInteractionWrapperWC) ?? 'ai-chat-interaction-wrapper', { ...rest, ref: _mergeRefs(_innerRef, ref) }, children);
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
const
|
|
127
|
+
const FoundationAiPopoutManager = React.forwardRef(function FoundationAiPopoutManager(props, ref) {
|
|
128
128
|
const { children, ...rest } = props;
|
|
129
|
-
return React.createElement(customElements.getName(
|
|
129
|
+
return React.createElement(customElements.getName(FoundationAiPopoutManagerWC) ?? 'foundation-ai-popout-manager', { ...rest, ref }, children);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
const
|
|
132
|
+
const AiChatMarkdown = React.forwardRef(function AiChatMarkdown(props, ref) {
|
|
133
133
|
const { children, ...rest } = props;
|
|
134
|
-
return React.createElement(customElements.getName(
|
|
134
|
+
return React.createElement(customElements.getName(AiChatMarkdownWC) ?? 'ai-chat-markdown', { ...rest, ref }, children);
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
module.exports = {
|
|
@@ -145,6 +145,6 @@ module.exports = {
|
|
|
145
145
|
AgentPicker,
|
|
146
146
|
AiChatBubble,
|
|
147
147
|
AiChatInteractionWrapper,
|
|
148
|
-
AiChatMarkdown,
|
|
149
148
|
FoundationAiPopoutManager,
|
|
149
|
+
AiChatMarkdown,
|
|
150
150
|
};
|
package/dist/react.mjs
CHANGED
|
@@ -122,12 +122,12 @@ export const AiChatInteractionWrapper = React.forwardRef(function AiChatInteract
|
|
|
122
122
|
return React.createElement(customElements.getName(AiChatInteractionWrapperWC) ?? 'ai-chat-interaction-wrapper', { ...rest, ref: _mergeRefs(_innerRef, ref) }, children);
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
export const
|
|
125
|
+
export const FoundationAiPopoutManager = React.forwardRef(function FoundationAiPopoutManager(props, ref) {
|
|
126
126
|
const { children, ...rest } = props;
|
|
127
|
-
return React.createElement(customElements.getName(
|
|
127
|
+
return React.createElement(customElements.getName(FoundationAiPopoutManagerWC) ?? 'foundation-ai-popout-manager', { ...rest, ref }, children);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
export const
|
|
130
|
+
export const AiChatMarkdown = React.forwardRef(function AiChatMarkdown(props, ref) {
|
|
131
131
|
const { children, ...rest } = props;
|
|
132
|
-
return React.createElement(customElements.getName(
|
|
132
|
+
return React.createElement(customElements.getName(AiChatMarkdownWC) ?? 'ai-chat-markdown', { ...rest, ref }, children);
|
|
133
133
|
});
|
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.10.
|
|
4
|
+
"version": "15.10.6",
|
|
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.10.
|
|
77
|
-
"@genesislcap/genx": "15.10.
|
|
78
|
-
"@genesislcap/rollup-builder": "15.10.
|
|
79
|
-
"@genesislcap/ts-builder": "15.10.
|
|
80
|
-
"@genesislcap/uvu-playwright-builder": "15.10.
|
|
81
|
-
"@genesislcap/vite-builder": "15.10.
|
|
82
|
-
"@genesislcap/webpack-builder": "15.10.
|
|
76
|
+
"@genesislcap/foundation-testing": "15.10.6",
|
|
77
|
+
"@genesislcap/genx": "15.10.6",
|
|
78
|
+
"@genesislcap/rollup-builder": "15.10.6",
|
|
79
|
+
"@genesislcap/ts-builder": "15.10.6",
|
|
80
|
+
"@genesislcap/uvu-playwright-builder": "15.10.6",
|
|
81
|
+
"@genesislcap/vite-builder": "15.10.6",
|
|
82
|
+
"@genesislcap/webpack-builder": "15.10.6",
|
|
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.10.
|
|
89
|
-
"@genesislcap/foundation-logger": "15.10.
|
|
90
|
-
"@genesislcap/foundation-notifications": "15.10.
|
|
91
|
-
"@genesislcap/foundation-redux": "15.10.
|
|
92
|
-
"@genesislcap/foundation-ui": "15.10.
|
|
93
|
-
"@genesislcap/foundation-utils": "15.10.
|
|
94
|
-
"@genesislcap/rapid-design-system": "15.10.
|
|
95
|
-
"@genesislcap/web-core": "15.10.
|
|
88
|
+
"@genesislcap/foundation-ai": "15.10.6",
|
|
89
|
+
"@genesislcap/foundation-logger": "15.10.6",
|
|
90
|
+
"@genesislcap/foundation-notifications": "15.10.6",
|
|
91
|
+
"@genesislcap/foundation-redux": "15.10.6",
|
|
92
|
+
"@genesislcap/foundation-ui": "15.10.6",
|
|
93
|
+
"@genesislcap/foundation-utils": "15.10.6",
|
|
94
|
+
"@genesislcap/rapid-design-system": "15.10.6",
|
|
95
|
+
"@genesislcap/web-core": "15.10.6",
|
|
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": "
|
|
108
|
+
"gitHead": "2fd12f28a04c8dc20974ab54d3ea143b80229510"
|
|
109
109
|
}
|
|
@@ -2898,6 +2898,77 @@ outcome('a host-supplied budgetExhaustedMessage replaces the transcript copy', a
|
|
|
2898
2898
|
assert.is(driver.getHistory().at(-1)!.content, 'Contact ops@acme.com');
|
|
2899
2899
|
});
|
|
2900
2900
|
|
|
2901
|
+
// ── The composed default bubble (GENC-1464 tester finding) ─────────────────────
|
|
2902
|
+
// The blocked banner advises switching when another vendor has headroom; the
|
|
2903
|
+
// transcript bubble directly under it used to say only "contact your
|
|
2904
|
+
// administrator" regardless. The two surfaces must agree, and the bubble may
|
|
2905
|
+
// only advise a switch the proxy positively vouched for.
|
|
2906
|
+
|
|
2907
|
+
/** Drive one walled turn and return the transcript's final (bubble) content. */
|
|
2908
|
+
const bubbleAfterWall = async (
|
|
2909
|
+
sessionKey: string,
|
|
2910
|
+
e: BudgetExhaustedError,
|
|
2911
|
+
budgetExhaustedMessage?: string,
|
|
2912
|
+
): Promise<string> => {
|
|
2913
|
+
clearMetaEventRegistry();
|
|
2914
|
+
const provider: AIProvider = {
|
|
2915
|
+
chat: async (): Promise<ChatMessage> => {
|
|
2916
|
+
throw e;
|
|
2917
|
+
},
|
|
2918
|
+
};
|
|
2919
|
+
const driver = new ChatDriver(makeRegistry(provider), {
|
|
2920
|
+
sessionKey,
|
|
2921
|
+
activityBus: outcomeBus,
|
|
2922
|
+
...(budgetExhaustedMessage == null ? {} : { budgetExhaustedMessage }),
|
|
2923
|
+
});
|
|
2924
|
+
driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
|
|
2925
|
+
await driver.sendMessage('go');
|
|
2926
|
+
return String(driver.getHistory().at(-1)!.content);
|
|
2927
|
+
};
|
|
2928
|
+
|
|
2929
|
+
outcome(
|
|
2930
|
+
'default bubble advises switching when the 402 vouches for headroom elsewhere',
|
|
2931
|
+
async () => {
|
|
2932
|
+
const content = await bubbleAfterWall(
|
|
2933
|
+
'outcome-bubble-switch',
|
|
2934
|
+
new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, { otherVendorAvailable: true }),
|
|
2935
|
+
);
|
|
2936
|
+
assert.is(
|
|
2937
|
+
content,
|
|
2938
|
+
"You've reached Anthropic's AI usage limit. Switch to another AI provider in Settings to keep going, or contact your administrator to raise it.",
|
|
2939
|
+
);
|
|
2940
|
+
},
|
|
2941
|
+
);
|
|
2942
|
+
|
|
2943
|
+
outcome('default bubble stays neutral when every other vendor is exhausted too', async () => {
|
|
2944
|
+
const content = await bubbleAfterWall(
|
|
2945
|
+
'outcome-bubble-all-walled',
|
|
2946
|
+
new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, { otherVendorAvailable: false }),
|
|
2947
|
+
);
|
|
2948
|
+
// Advising a switch to a vendor the proxy just said is empty would be worse
|
|
2949
|
+
// than the neutral copy.
|
|
2950
|
+
assert.is(content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
|
|
2951
|
+
});
|
|
2952
|
+
|
|
2953
|
+
outcome('default bubble stays neutral when an older proxy did not say', async () => {
|
|
2954
|
+
const content = await bubbleAfterWall(
|
|
2955
|
+
'outcome-bubble-no-verdict',
|
|
2956
|
+
new BudgetExhaustedError('Anthropic', 25, 25.4),
|
|
2957
|
+
);
|
|
2958
|
+
// `undefined` is "the proxy did not say", not "another vendor is free".
|
|
2959
|
+
assert.is(content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
|
|
2960
|
+
});
|
|
2961
|
+
|
|
2962
|
+
outcome('a host override wins verbatim even when switching would be honest advice', async () => {
|
|
2963
|
+
const content = await bubbleAfterWall(
|
|
2964
|
+
'outcome-bubble-override-wins',
|
|
2965
|
+
new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, { otherVendorAvailable: true }),
|
|
2966
|
+
'Contact ops@acme.com',
|
|
2967
|
+
);
|
|
2968
|
+
// The white-label contract: host copy is never composed onto.
|
|
2969
|
+
assert.is(content, 'Contact ops@acme.com');
|
|
2970
|
+
});
|
|
2971
|
+
|
|
2901
2972
|
// ── A sub-agent's wall is terminal for the parent too (GENC-1464) ──────────────
|
|
2902
2973
|
// `SubAgentFailureReason.budget_exhausted` is documented as "terminal for the
|
|
2903
2974
|
// parent too". Nothing parent-side read it, so the parent's tool loop appended
|
|
@@ -2947,6 +3018,42 @@ outcome('a sub-agent budget wall ends the parent turn without another model call
|
|
|
2947
3018
|
assert.is(driver.getHistory().at(-1)!.content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
|
|
2948
3019
|
});
|
|
2949
3020
|
|
|
3021
|
+
outcome(
|
|
3022
|
+
"a sub-agent wall with headroom elsewhere composes the parent's switch advice",
|
|
3023
|
+
async () => {
|
|
3024
|
+
// The parent's bubble is composed from the latched `budgetWallDetail` — on the
|
|
3025
|
+
// sub-agent path there is NO error object in scope, only the failure payload
|
|
3026
|
+
// the child attributed (this pins the exact seam a scope bug once lived on).
|
|
3027
|
+
clearMetaEventRegistry();
|
|
3028
|
+
let parentCalls = 0;
|
|
3029
|
+
const provider: AIProvider = {
|
|
3030
|
+
chat: async (
|
|
3031
|
+
_h: ChatMessage[],
|
|
3032
|
+
_u: string,
|
|
3033
|
+
options?: ChatRequestOptions,
|
|
3034
|
+
): Promise<ChatMessage> => {
|
|
3035
|
+
if ((options?.tools ?? []).some((t) => t.name === 'delegate')) {
|
|
3036
|
+
parentCalls += 1;
|
|
3037
|
+
return callsTool('delegate', `d${parentCalls}`);
|
|
3038
|
+
}
|
|
3039
|
+
throw new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, {
|
|
3040
|
+
otherVendorAvailable: true,
|
|
3041
|
+
});
|
|
3042
|
+
},
|
|
3043
|
+
};
|
|
3044
|
+
const parent = delegatingParent(walledWorker(), () => undefined);
|
|
3045
|
+
const driver = makeDriver(parent, provider, 'outcome-subagent-budget-switch', outcomeBus);
|
|
3046
|
+
|
|
3047
|
+
const result: ChatDriverResult = await driver.sendMessage('go');
|
|
3048
|
+
|
|
3049
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
|
|
3050
|
+
assert.is(
|
|
3051
|
+
driver.getHistory().at(-1)!.content,
|
|
3052
|
+
"You've reached Anthropic's AI usage limit. Switch to another AI provider in Settings to keep going, or contact your administrator to raise it.",
|
|
3053
|
+
);
|
|
3054
|
+
},
|
|
3055
|
+
);
|
|
3056
|
+
|
|
2950
3057
|
outcome("a sub-agent's wall is attributed to the CHILD's vendor, not the parent's", async () => {
|
|
2951
3058
|
// A sub-agent can sit on a different vendor from its parent (`applyAgent` reads
|
|
2952
3059
|
// `config.provider`). The wall reaches the parent only as a tool result, so
|
|
@@ -289,8 +289,13 @@ export interface ChatDriverConfig {
|
|
|
289
289
|
*/
|
|
290
290
|
activityBus?: ActivityBus;
|
|
291
291
|
/**
|
|
292
|
-
* Transcript copy appended when the AI-spend budget wall is hit (GENC-1464)
|
|
293
|
-
*
|
|
292
|
+
* Transcript copy appended when the AI-spend budget wall is hit (GENC-1464),
|
|
293
|
+
* used VERBATIM — set it only to own the copy entirely (white-labelling).
|
|
294
|
+
* When unset, the driver composes the bubble per wall: the neutral
|
|
295
|
+
* `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`, upgraded with "switch to another AI
|
|
296
|
+
* provider" advice only when the 402's `otherVendorAvailable` positively says
|
|
297
|
+
* another metered vendor still has headroom — the same rule the blocked
|
|
298
|
+
* banner applies, so the two surfaces can no longer contradict each other.
|
|
294
299
|
*
|
|
295
300
|
* The assistant element already lets a host override the blocked **banner**
|
|
296
301
|
* via `setBlocked(true, reason)`; without this the transcript **bubble** stayed
|
|
@@ -652,8 +657,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
652
657
|
private readonly sessionKey: string;
|
|
653
658
|
/** Injected activity bus; defaults to a no-op off-browser (Node/tests/headless). */
|
|
654
659
|
private readonly activityBus: ActivityBus;
|
|
655
|
-
/**
|
|
656
|
-
|
|
660
|
+
/**
|
|
661
|
+
* Host override for the budget-wall transcript copy, used verbatim — see
|
|
662
|
+
* `ChatDriverConfig.budgetExhaustedMessage`. `undefined` means no override,
|
|
663
|
+
* and the bubble is composed per wall by {@link ChatDriver.budgetExhaustedBubble}.
|
|
664
|
+
*/
|
|
665
|
+
private readonly budgetExhaustedMessageOverride?: string;
|
|
657
666
|
/**
|
|
658
667
|
* Set the moment a budget wall is observed anywhere in this turn — this
|
|
659
668
|
* driver's own 402, or a sub-agent's (which surfaces here only as a
|
|
@@ -702,13 +711,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
702
711
|
maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS,
|
|
703
712
|
sessionKey = '',
|
|
704
713
|
activityBus = NOOP_ACTIVITY_BUS,
|
|
705
|
-
budgetExhaustedMessage
|
|
714
|
+
budgetExhaustedMessage,
|
|
706
715
|
} = config;
|
|
707
716
|
this.maxToolIterations = maxToolIterations;
|
|
708
717
|
this.condenseBatchCalls = condenseBatchCalls;
|
|
709
718
|
this.sessionKey = sessionKey;
|
|
710
719
|
this.activityBus = activityBus;
|
|
711
|
-
this.
|
|
720
|
+
this.budgetExhaustedMessageOverride = budgetExhaustedMessage;
|
|
712
721
|
if (typeof toolHandlers === 'function') {
|
|
713
722
|
this.toolHandlersFactory = toolHandlers;
|
|
714
723
|
this.toolHandlers = {};
|
|
@@ -870,6 +879,32 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
870
879
|
*
|
|
871
880
|
* @internal
|
|
872
881
|
*/
|
|
882
|
+
private budgetExhaustedBubble(wall?: {
|
|
883
|
+
vendorLabel?: string;
|
|
884
|
+
otherVendorAvailable?: boolean;
|
|
885
|
+
}): string {
|
|
886
|
+
// A host override wins verbatim — the white-label contract on
|
|
887
|
+
// `ChatDriverConfig.budgetExhaustedMessage` (its copy may not even be
|
|
888
|
+
// English; composing onto it would mangle it).
|
|
889
|
+
if (this.budgetExhaustedMessageOverride != null) return this.budgetExhaustedMessageOverride;
|
|
890
|
+
// Only a POSITIVE `otherVendorAvailable` earns the switch advice. `false`
|
|
891
|
+
// means every other metered vendor is exhausted too, and `undefined` means
|
|
892
|
+
// an older proxy did not say — in both cases advertising a switch to a
|
|
893
|
+
// vendor that may itself be walled is worse than the neutral default. This
|
|
894
|
+
// mirrors the blocked banner's rule, and closes the gap where the banner
|
|
895
|
+
// said "switch" while the transcript bubble directly under it said only
|
|
896
|
+
// "contact your administrator" (GENC-1464 tester finding).
|
|
897
|
+
if (wall?.otherVendorAvailable !== true) return DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
|
|
898
|
+
// The label is display copy, nothing more — attribution (which vendor gets
|
|
899
|
+
// WALLED) stays `budgetDetailOf`'s job. The parameter is the shared shape of
|
|
900
|
+
// both call sites: the thrown `BudgetExhaustedError` on the direct path, and
|
|
901
|
+
// the latched `budgetWallDetail` on the sub-agent path.
|
|
902
|
+
const subject = wall?.vendorLabel
|
|
903
|
+
? `${wall.vendorLabel}'s AI usage limit`
|
|
904
|
+
: 'your AI usage limit';
|
|
905
|
+
return `You've reached ${subject}. Switch to another AI provider in Settings to keep going, or contact your administrator to raise it.`;
|
|
906
|
+
}
|
|
907
|
+
|
|
873
908
|
reportBudgetExhausted(
|
|
874
909
|
e: BudgetExhaustedError,
|
|
875
910
|
pendingUserMessage?: ChatMessage,
|
|
@@ -894,7 +929,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
894
929
|
spentUsd: e.spentUsd,
|
|
895
930
|
isSubAgent: this.isSubAgent,
|
|
896
931
|
});
|
|
897
|
-
this.appendToHistory({ role: 'assistant', content: this.
|
|
932
|
+
this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedBubble(e) });
|
|
898
933
|
return this.turnDone('budget-exhausted', budgetDetailOf(e));
|
|
899
934
|
}
|
|
900
935
|
|
|
@@ -2506,7 +2541,14 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
2506
2541
|
if (this.isSubAgent) {
|
|
2507
2542
|
this.failSubAgent('budget_exhausted', this.budgetWallDetail);
|
|
2508
2543
|
} else {
|
|
2509
|
-
|
|
2544
|
+
// Composed from the DETAIL, not an error object: on this path the wall
|
|
2545
|
+
// arrived as a sub-agent's failure payload — `budgetWallDetail`, first
|
|
2546
|
+
// attribution wins — and an unattributable child wall (detail
|
|
2547
|
+
// undefined) composes the neutral default.
|
|
2548
|
+
this.appendToHistory({
|
|
2549
|
+
role: 'assistant',
|
|
2550
|
+
content: this.budgetExhaustedBubble(this.budgetWallDetail),
|
|
2551
|
+
});
|
|
2510
2552
|
}
|
|
2511
2553
|
// Carried onto the result so `loopEndDetail` publishes the refusing
|
|
2512
2554
|
// vendor rather than falling through to `lastResolvedProvider`, and the
|
|
@@ -289,6 +289,65 @@ Suite('el.blocked = true after a host reason keeps the reason', () => {
|
|
|
289
289
|
|
|
290
290
|
// ── The driver latch (GENC-1464) ───────────────────────────────────────────────
|
|
291
291
|
|
|
292
|
+
/** The raw stored figures, read the way budget-meter.test.ts reads them. */
|
|
293
|
+
const storedBudgets = (el: FoundationAiAssistant) =>
|
|
294
|
+
(
|
|
295
|
+
el as unknown as {
|
|
296
|
+
_sessionRef: { store: { aiAssistant: { vendorBudgets: Record<string, unknown> } } };
|
|
297
|
+
}
|
|
298
|
+
)._sessionRef.store.aiAssistant.vendorBudgets;
|
|
299
|
+
|
|
300
|
+
Suite('the transcript override is passed only when a blockedReason actually exists', () => {
|
|
301
|
+
// Forcing DEFAULT_BUDGET_EXHAUSTED_MESSAGE here was what made the driver's
|
|
302
|
+
// per-wall composed bubble unreachable in every real mount (the banner said
|
|
303
|
+
// "switch", the bubble under it never did). `undefined` hands composition to
|
|
304
|
+
// the driver; a set reason — host white-labelling, or the vendor-agnostic
|
|
305
|
+
// latch's advice-free copy — still wins verbatim.
|
|
306
|
+
const read = (el: FoundationAiAssistant) =>
|
|
307
|
+
(el as unknown as { transcriptBudgetExhaustedMessage?: string })
|
|
308
|
+
.transcriptBudgetExhaustedMessage;
|
|
309
|
+
|
|
310
|
+
const el = element();
|
|
311
|
+
assert.is(read(el), undefined, 'no reason -> the driver composes per wall');
|
|
312
|
+
|
|
313
|
+
el.setBlocked(true, 'Contact ops@acme.com');
|
|
314
|
+
assert.is(read(el), 'Contact ops@acme.com', 'a set reason wins verbatim');
|
|
315
|
+
|
|
316
|
+
el.setBlocked(false);
|
|
317
|
+
assert.is(read(el), undefined, 'unblocking hands composition back to the driver');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
Suite('a wall with figures snaps the vendor meter at latch time', () => {
|
|
321
|
+
// The GENC-1464 stale-meter finding: hosts refresh the meter on
|
|
322
|
+
// `tool-loop-end`, which a walled turn may never reach — the 402's own
|
|
323
|
+
// figures are the freshest truth available and must land immediately.
|
|
324
|
+
const el = element();
|
|
325
|
+
reachable(el, 'anthropic', 'gemini');
|
|
326
|
+
latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', budgetUsd: 20, spentUsd: 20.1 });
|
|
327
|
+
assert.equal(storedBudgets(el)['anthropic'], { budgetUsd: 20, spentUsd: 20.1 });
|
|
328
|
+
|
|
329
|
+
// A later 402 for the same (already-walled) vendor carries fresher spend —
|
|
330
|
+
// the feed is deliberately outside the idempotence guard, like the sweep.
|
|
331
|
+
latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', budgetUsd: 20, spentUsd: 21.5 });
|
|
332
|
+
assert.equal(storedBudgets(el)['anthropic'], { budgetUsd: 20, spentUsd: 21.5 });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
Suite('a figure-less or unattributable wall feeds no meter', () => {
|
|
336
|
+
// Half a meter is a lie: `VendorBudgetFigures` is total, so one missing
|
|
337
|
+
// figure means no feed at all — the wall itself still latches.
|
|
338
|
+
const el = element();
|
|
339
|
+
reachable(el, 'anthropic', 'gemini');
|
|
340
|
+
latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', spentUsd: 21 });
|
|
341
|
+
assert.is(storedBudgets(el)['anthropic'], undefined);
|
|
342
|
+
assert.is(el.isVendorBlocked('anthropic'), true, 'the latch itself is unaffected');
|
|
343
|
+
|
|
344
|
+
// No vendor resolves: nothing to key the meter on.
|
|
345
|
+
const el2 = element();
|
|
346
|
+
reachable(el2, 'anthropic', 'gemini');
|
|
347
|
+
latch(el2, 'budget-exhausted', { vendorLabel: 'Acme Gateway', budgetUsd: 20, spentUsd: 21 });
|
|
348
|
+
assert.equal(storedBudgets(el2), {});
|
|
349
|
+
});
|
|
350
|
+
|
|
292
351
|
Suite('latchBlockedFrom latches only on budget-exhausted', () => {
|
|
293
352
|
const budget = element();
|
|
294
353
|
latch(budget, 'budget-exhausted');
|
package/src/main/main.ts
CHANGED
|
@@ -1232,6 +1232,23 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
1232
1232
|
): void {
|
|
1233
1233
|
if (reason !== 'budget-exhausted') return;
|
|
1234
1234
|
const vendor = vendorTypeOfLabel(budget?.vendorLabel) ?? budget?.vendor ?? vendorHint;
|
|
1235
|
+
// Meter feed (GENC-1464): snap this vendor's meter to the figures the 402
|
|
1236
|
+
// itself reported, the moment the wall lands. Hosts refresh the meter on
|
|
1237
|
+
// `tool-loop-end`, and a walled turn may never reach one — the user then
|
|
1238
|
+
// stares at the pre-turn figures next to a banner saying the budget is gone
|
|
1239
|
+
// until something else ends a loop (observed live: the meter only caught up
|
|
1240
|
+
// when the user cancelled the stuck interaction). Deliberately NOT under the
|
|
1241
|
+
// per-vendor idempotence guard below, for the same reason as the sweep: a
|
|
1242
|
+
// later 402 for an already-walled vendor carries fresher spend. Both figures
|
|
1243
|
+
// are required (`VendorBudgetFigures` is total, and half a meter is a lie),
|
|
1244
|
+
// and the write goes through the captured `ref` like every other write here,
|
|
1245
|
+
// so a teardown race cannot land it in a new session's store.
|
|
1246
|
+
if (vendor && vendor !== 'none' && budget?.budgetUsd != null && budget?.spentUsd != null) {
|
|
1247
|
+
ref?.actions.aiAssistant.setVendorBudget({
|
|
1248
|
+
vendor,
|
|
1249
|
+
figures: { budgetUsd: budget.budgetUsd, spentUsd: budget.spentUsd },
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1235
1252
|
if (!vendor || vendor === 'none') {
|
|
1236
1253
|
if (!this.blocked) {
|
|
1237
1254
|
ref?.actions.aiAssistant.setBlocked({ blocked: true, reason: formatBlockedReason(budget) });
|
|
@@ -1362,27 +1379,29 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
1362
1379
|
}
|
|
1363
1380
|
|
|
1364
1381
|
/**
|
|
1365
|
-
* Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall
|
|
1382
|
+
* Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall,
|
|
1383
|
+
* or `undefined` to let the driver compose it per wall.
|
|
1366
1384
|
*
|
|
1367
|
-
* A
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1385
|
+
* A set `blockedReason` still wins — a white-labelled host must not read its
|
|
1386
|
+
* own explanation in the banner and shipped copy directly below it, and the
|
|
1387
|
+
* vendor-agnostic latch's `formatBlockedReason(budget)` form stays advice-free
|
|
1388
|
+
* exactly as before.
|
|
1370
1389
|
*
|
|
1371
|
-
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1376
|
-
*
|
|
1377
|
-
*
|
|
1378
|
-
*
|
|
1379
|
-
*
|
|
1380
|
-
*
|
|
1381
|
-
*
|
|
1382
|
-
*
|
|
1390
|
+
* `undefined` (the usual case) is NOT the old `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`
|
|
1391
|
+
* fallback: it hands composition to the driver, which builds the bubble from
|
|
1392
|
+
* EACH wall's own 402 — naming the exhausted vendor and advising a switch only
|
|
1393
|
+
* when that refusal's `otherVendorAvailable` positively vouched for headroom
|
|
1394
|
+
* elsewhere. That is immune to the staleness this getter used to guard against
|
|
1395
|
+
* by freezing the copy: construction-time advice could outlive its truth
|
|
1396
|
+
* (`getOrCreateDriver` re-runs on an agents swap and would have baked the
|
|
1397
|
+
* sentence into every later bubble), but per-wall advice is derived from the
|
|
1398
|
+
* refusal it annotates and dies with the turn. Forcing the default here was
|
|
1399
|
+
* also what made the driver's composed bubble unreachable in every real
|
|
1400
|
+
* mount — the banner advised switching while the bubble under it never did
|
|
1401
|
+
* (the GENC-1464 tester finding, root cause).
|
|
1383
1402
|
*/
|
|
1384
|
-
private get transcriptBudgetExhaustedMessage(): string {
|
|
1385
|
-
return this.blockedReason ??
|
|
1403
|
+
private get transcriptBudgetExhaustedMessage(): string | undefined {
|
|
1404
|
+
return this.blockedReason ?? undefined;
|
|
1386
1405
|
}
|
|
1387
1406
|
|
|
1388
1407
|
/**
|