@genesislcap/ai-assistant 15.10.3 → 15.10.5
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 +27 -12
- package/dist/chat-driver.cjs.map +2 -2
- package/dist/chat-driver.mjs +27 -12
- package/dist/chat-driver.mjs.map +2 -2
- package/dist/custom-elements.json +30 -6
- 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/utils/condense-history.d.ts +29 -5
- package/dist/dts/utils/condense-history.d.ts.map +1 -1
- 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/esm/utils/condense-history.js +53 -23
- package/dist/esm/utils/condense-history.test.js +49 -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/src/utils/condense-history.test.ts +65 -3
- package/src/utils/condense-history.ts +74 -25
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
|
|
@@ -40,11 +40,21 @@ function triggerReason(trigger) {
|
|
|
40
40
|
return 'superseded';
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Framework-generated stub, shared by `pointer` and `drop`. Names the tool, the superseded key
|
|
45
|
+
* (the only surviving identifier once args are elided — the model cannot otherwise tell WHICH
|
|
46
|
+
* file a collapsed `vfs_write` wrote) and the original size.
|
|
47
|
+
*
|
|
48
|
+
* `restorable` is the ONLY difference between the two modes: `pointer` promises that re-calling
|
|
49
|
+
* reproduces the content and is valid solely for idempotent tools; `drop` makes no such promise
|
|
50
|
+
* and must not carry the clause. Identifying information is not what separates them — a stub too
|
|
51
|
+
* bare to say what the call did is how GENC-1494 started.
|
|
52
|
+
*/
|
|
53
|
+
function condenseStub(target, tool, trigger, origLen, restorable) {
|
|
45
54
|
const what = target === 'args' ? 'args' : 'result';
|
|
46
55
|
const key = trigger.kind === 'superseded' ? ` ${trigger.by}` : '';
|
|
47
|
-
|
|
56
|
+
const restore = restorable ? '; re-call to restore' : '';
|
|
57
|
+
return `[${tool}${key} — ${what} elided, ~${origLen} chars (${triggerReason(trigger)})${restore}]`;
|
|
48
58
|
}
|
|
49
59
|
/** Stable label for the `context.condensed` meta-event's `trigger` field. */
|
|
50
60
|
function triggerLabel(trigger) {
|
|
@@ -81,10 +91,12 @@ function marksDiscontinuity(kind) {
|
|
|
81
91
|
* Collapse stale tool payloads (declared via `condenseWhen`) out of the
|
|
82
92
|
* model-bound history. Pure over the `history` array — it emits new message
|
|
83
93
|
* objects and never mutates the input messages (mirroring the agent-masking
|
|
84
|
-
* transform). It DOES, however,
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
94
|
+
* transform). It DOES, however, write two latches onto the matching `policies`
|
|
95
|
+
* entry the first time a payload collapses, both because the collapse itself
|
|
96
|
+
* re-runs before every provider call: the `reported*` flag (the report-once gate
|
|
97
|
+
* that keeps `onCondensed` firing once per (tool call, payload)) and
|
|
98
|
+
* `firedTrigger` (which pins the reason so a reason-bearing stub is rendered
|
|
99
|
+
* once and never rewritten).
|
|
88
100
|
*
|
|
89
101
|
* Triggers (a policy may list several via `on: Trigger[]` — the FIRST to fire
|
|
90
102
|
* collapses the payload; all are monotonic, so the collapse never reverts):
|
|
@@ -172,7 +184,20 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
|
|
|
172
184
|
};
|
|
173
185
|
// The first listed trigger that fires — drives the collapse, the stub reason,
|
|
174
186
|
// and the event label. `undefined` means the payload stays full.
|
|
175
|
-
|
|
187
|
+
//
|
|
188
|
+
// Latched on first fire (see `firedTrigger`): re-resolving on every pass lets the reason
|
|
189
|
+
// change under a payload that has already collapsed, which rewrites any reason-bearing stub
|
|
190
|
+
// and costs a second cache break. Both payloads of a call share the latch — they are
|
|
191
|
+
// evaluated in the same pass off the same clocks, so they never disagree about which
|
|
192
|
+
// trigger fired; only the size floor decides whether each one renders.
|
|
193
|
+
const firstFired = (toolCallId, entry) => {
|
|
194
|
+
if (entry.firedTrigger)
|
|
195
|
+
return entry.firedTrigger;
|
|
196
|
+
const fired = triggersOf(entry).find((t) => (marksDiscontinuity(t.kind) || withinBatch(entry)) && triggerFires(toolCallId, entry, t));
|
|
197
|
+
if (fired)
|
|
198
|
+
entry.firedTrigger = fired;
|
|
199
|
+
return fired;
|
|
200
|
+
};
|
|
176
201
|
return history.map((msg) => {
|
|
177
202
|
var _a, _b, _c, _d;
|
|
178
203
|
// Tool-call ARGS live on the assistant message.
|
|
@@ -191,16 +216,23 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
|
|
|
191
216
|
return tc;
|
|
192
217
|
changed = true;
|
|
193
218
|
const result = entry.policy.args;
|
|
194
|
-
// Args must stay a Record
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
219
|
+
// Args must stay a Record, so every mode stashes its stub under a single key.
|
|
220
|
+
// Spread `tc` so providerMetadata (e.g. the Gemini reasoning signature that must
|
|
221
|
+
// round-trip) and UI fields survive.
|
|
222
|
+
//
|
|
223
|
+
// GENC-1494: `drop` used to collapse args to a bare `{}`. That is indistinguishable
|
|
224
|
+
// from a call the model made with NO arguments, and the model reads its own history
|
|
225
|
+
// as fact: a ui-builder run saw its elided `vfs_write` calls as `vfs_write {}` beside
|
|
226
|
+
// a live `{"status":"buffered","path":"…"}` result, concluded it was calling the tool
|
|
227
|
+
// without content, and burned two hours re-writing files that were already correct.
|
|
228
|
+
// A bare marker is not enough either — it still fails to say WHICH file the call
|
|
229
|
+
// wrote, since `path` went with the args. So `drop` renders the same identifying stub
|
|
230
|
+
// as `pointer`, minus only the restore promise it cannot honour.
|
|
231
|
+
const args = {
|
|
232
|
+
[CONDENSED_ARGS_KEY]: result === 'drop' || result === 'pointer'
|
|
233
|
+
? condenseStub('args', tc.name, fired, origLen, result === 'pointer')
|
|
234
|
+
: result.replaceWith,
|
|
235
|
+
};
|
|
204
236
|
if (!entry.reportedArgs) {
|
|
205
237
|
entry.reportedArgs = true;
|
|
206
238
|
const stubLen = JSON.stringify(args).length;
|
|
@@ -231,11 +263,9 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
|
|
|
231
263
|
const tool = (_d = nameById.get(msg.toolResult.toolCallId)) !== null && _d !== void 0 ? _d : msg.toolResult.toolCallId;
|
|
232
264
|
const result = entry.policy.response; // `fired` is only set when response is declared
|
|
233
265
|
// Kept non-empty — Anthropic rejects empty tool_result content.
|
|
234
|
-
const content = result === 'drop'
|
|
235
|
-
? '
|
|
236
|
-
: result
|
|
237
|
-
? condenseStub('response', tool, fired, origLen)
|
|
238
|
-
: result.replaceWith;
|
|
266
|
+
const content = result === 'drop' || result === 'pointer'
|
|
267
|
+
? condenseStub('response', tool, fired, origLen, result === 'pointer')
|
|
268
|
+
: result.replaceWith;
|
|
239
269
|
if (!entry.reportedResponse) {
|
|
240
270
|
entry.reportedResponse = true;
|
|
241
271
|
onCondensed({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
2
|
-
import { applyCondensation, CONDENSE_MIN_CHARS, } from './condense-history';
|
|
2
|
+
import { applyCondensation, CONDENSE_MIN_CHARS, CONDENSED_ARGS_KEY, } from './condense-history';
|
|
3
3
|
const suite = createLogicSuite('applyCondensation');
|
|
4
4
|
// Payloads must clear the driver-level floor to be condensed.
|
|
5
5
|
const big = (n = CONDENSE_MIN_CHARS + 500) => 'x'.repeat(n);
|
|
@@ -154,11 +154,20 @@ suite('drop / pointer / replaceWith produce distinct collapsed forms', () => {
|
|
|
154
154
|
]);
|
|
155
155
|
const { on } = sink();
|
|
156
156
|
const out = applyCondensation(history, policies, ctx(), on);
|
|
157
|
-
|
|
157
|
+
// `drop` differs from `pointer` ONLY by the restore promise — it still identifies the call,
|
|
158
|
+
// because a stub too bare to say what the call did is how GENC-1494 started.
|
|
159
|
+
assert.match(out[1].toolResult.content, /vfs_read/, 'drop names the tool');
|
|
160
|
+
assert.match(out[1].toolResult.content, /result elided/, 'drop says what was elided');
|
|
161
|
+
assert.not.match(out[1].toolResult.content, /re-call to restore/, 'drop makes no restore promise');
|
|
158
162
|
assert.match(out[5].toolResult.content, /re-call to restore/, 'pointer carries the restore hint');
|
|
159
163
|
assert.is(out[9].toolResult.content, 'SUMMARY', 'replaceWith is verbatim — no hint appended');
|
|
160
164
|
});
|
|
161
|
-
|
|
165
|
+
// GENC-1494: dropped args must NEVER collapse to `{}`. A bare empty object is
|
|
166
|
+
// indistinguishable from a call the model made with no arguments at all, and the model
|
|
167
|
+
// treats its own history as fact — a ui-builder run read its elided `vfs_write` calls as
|
|
168
|
+
// proof it was writing files with no content and re-wrote them for two hours. A bare marker
|
|
169
|
+
// is not enough either: `path` goes with the args, so the stub must still say WHICH file.
|
|
170
|
+
suite('args drop identifies the call; pointer stashes a stub under one key', () => {
|
|
162
171
|
const history = [
|
|
163
172
|
asstCall('w1', 'vfs_write', { path: 'A', content: big() }),
|
|
164
173
|
toolMsg('w1', 'ok'),
|
|
@@ -171,9 +180,45 @@ suite('args drop yields an empty object; pointer stashes a stub under one key',
|
|
|
171
180
|
]);
|
|
172
181
|
const { on } = sink();
|
|
173
182
|
const out = applyCondensation(history, policies, ctx(), on);
|
|
174
|
-
|
|
183
|
+
const dropped = out[0].toolCalls[0].args;
|
|
184
|
+
const stub = String(dropped[CONDENSED_ARGS_KEY]);
|
|
185
|
+
assert.ok(Object.keys(dropped).length > 0, 'dropped args are never empty — that reads as a no-argument call');
|
|
186
|
+
assert.match(stub, /vfs_write/, 'stub names the tool');
|
|
187
|
+
assert.match(stub, /\bA\b/, 'stub names the superseded key — which file this call wrote');
|
|
188
|
+
assert.not.match(stub, /re-call to restore/, 'drop must not promise a restore it cannot honour');
|
|
175
189
|
assert.equal(out[2].toolCalls[0].args, { path: 'A', content: big() }, 'latest write kept');
|
|
176
190
|
});
|
|
191
|
+
// GENC-1494: trigger resolution picks the first LISTED trigger that is currently true, so it
|
|
192
|
+
// used to change after the collapse — with the usual `on: [{superseded}, {agentEnd}]`, a payload
|
|
193
|
+
// collapsed at `agentEnd` re-resolved to `superseded` the moment a newer same-key call landed,
|
|
194
|
+
// rewriting the stub and breaking the prompt cache a second time for an already-collapsed
|
|
195
|
+
// payload. In one reported session that was 22/22 args collapses and 97 response collapses.
|
|
196
|
+
suite('the fired trigger is latched, so a collapsed stub is never rewritten', () => {
|
|
197
|
+
const history = [asstCall('r1', 'vfs_read', { path: 'A' }), toolMsg('r1', big())];
|
|
198
|
+
const entry = reg({
|
|
199
|
+
on: [{ kind: 'superseded', by: 'A' }, { kind: 'agentEnd' }],
|
|
200
|
+
response: 'pointer', // pointer embeds the reason, so a re-resolve is visible in the text
|
|
201
|
+
});
|
|
202
|
+
const policies = new Map([['r1', entry]]);
|
|
203
|
+
const { events, on } = sink();
|
|
204
|
+
// Pass 1: the activation has ended but nothing supersedes r1 yet → collapses as agentEnd.
|
|
205
|
+
const first = applyCondensation(history, policies, ctx({ activationEnded: () => true }), on);
|
|
206
|
+
assert.match(first[1].toolResult.content, /agent finished/, 'collapses under agentEnd');
|
|
207
|
+
assert.is(events.length, 1);
|
|
208
|
+
assert.is(events[0].trigger, 'agentEnd');
|
|
209
|
+
// Pass 2: a newer read of the SAME key now exists, which would re-resolve the trigger to
|
|
210
|
+
// `superseded` (it is listed first). The already-collapsed stub must not change.
|
|
211
|
+
const grown = [
|
|
212
|
+
...history,
|
|
213
|
+
asstCall('r2', 'vfs_read', { path: 'A' }),
|
|
214
|
+
toolMsg('r2', big()),
|
|
215
|
+
];
|
|
216
|
+
policies.set('r2', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' }));
|
|
217
|
+
const second = applyCondensation(grown, policies, ctx({ activationEnded: () => true }), on);
|
|
218
|
+
assert.match(second[1].toolResult.content, /agent finished/, 'reason stays pinned to agentEnd');
|
|
219
|
+
assert.not.match(second[1].toolResult.content, /superseded/, 'must not re-resolve');
|
|
220
|
+
assert.is(second[1].toolResult.content, first[1].toolResult.content, 'stub text is byte-stable');
|
|
221
|
+
});
|
|
177
222
|
// ---------------------------------------------------------------------------
|
|
178
223
|
// envelope + metadata invariants
|
|
179
224
|
// ---------------------------------------------------------------------------
|
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.5",
|
|
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.5",
|
|
77
|
+
"@genesislcap/genx": "15.10.5",
|
|
78
|
+
"@genesislcap/rollup-builder": "15.10.5",
|
|
79
|
+
"@genesislcap/ts-builder": "15.10.5",
|
|
80
|
+
"@genesislcap/uvu-playwright-builder": "15.10.5",
|
|
81
|
+
"@genesislcap/vite-builder": "15.10.5",
|
|
82
|
+
"@genesislcap/webpack-builder": "15.10.5",
|
|
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.5",
|
|
89
|
+
"@genesislcap/foundation-logger": "15.10.5",
|
|
90
|
+
"@genesislcap/foundation-notifications": "15.10.5",
|
|
91
|
+
"@genesislcap/foundation-redux": "15.10.5",
|
|
92
|
+
"@genesislcap/foundation-ui": "15.10.5",
|
|
93
|
+
"@genesislcap/foundation-utils": "15.10.5",
|
|
94
|
+
"@genesislcap/rapid-design-system": "15.10.5",
|
|
95
|
+
"@genesislcap/web-core": "15.10.5",
|
|
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": "3d50e0bb42cbf0f4a92b4bc74114c2bc7f581e4d"
|
|
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');
|