@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/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
|
/**
|
|
@@ -3,6 +3,7 @@ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
|
3
3
|
import {
|
|
4
4
|
applyCondensation,
|
|
5
5
|
CONDENSE_MIN_CHARS,
|
|
6
|
+
CONDENSED_ARGS_KEY,
|
|
6
7
|
type CondenseContext,
|
|
7
8
|
type CondensedEventDetail,
|
|
8
9
|
type RegisteredCondensePolicy,
|
|
@@ -193,7 +194,15 @@ suite('drop / pointer / replaceWith produce distinct collapsed forms', () => {
|
|
|
193
194
|
const { on } = sink();
|
|
194
195
|
const out = applyCondensation(history, policies, ctx(), on);
|
|
195
196
|
|
|
196
|
-
|
|
197
|
+
// `drop` differs from `pointer` ONLY by the restore promise — it still identifies the call,
|
|
198
|
+
// because a stub too bare to say what the call did is how GENC-1494 started.
|
|
199
|
+
assert.match(out[1].toolResult!.content, /vfs_read/, 'drop names the tool');
|
|
200
|
+
assert.match(out[1].toolResult!.content, /result elided/, 'drop says what was elided');
|
|
201
|
+
assert.not.match(
|
|
202
|
+
out[1].toolResult!.content,
|
|
203
|
+
/re-call to restore/,
|
|
204
|
+
'drop makes no restore promise',
|
|
205
|
+
);
|
|
197
206
|
assert.match(
|
|
198
207
|
out[5].toolResult!.content,
|
|
199
208
|
/re-call to restore/,
|
|
@@ -202,7 +211,12 @@ suite('drop / pointer / replaceWith produce distinct collapsed forms', () => {
|
|
|
202
211
|
assert.is(out[9].toolResult!.content, 'SUMMARY', 'replaceWith is verbatim — no hint appended');
|
|
203
212
|
});
|
|
204
213
|
|
|
205
|
-
|
|
214
|
+
// GENC-1494: dropped args must NEVER collapse to `{}`. A bare empty object is
|
|
215
|
+
// indistinguishable from a call the model made with no arguments at all, and the model
|
|
216
|
+
// treats its own history as fact — a ui-builder run read its elided `vfs_write` calls as
|
|
217
|
+
// proof it was writing files with no content and re-wrote them for two hours. A bare marker
|
|
218
|
+
// is not enough either: `path` goes with the args, so the stub must still say WHICH file.
|
|
219
|
+
suite('args drop identifies the call; pointer stashes a stub under one key', () => {
|
|
206
220
|
const history: ChatMessage[] = [
|
|
207
221
|
asstCall('w1', 'vfs_write', { path: 'A', content: big() }),
|
|
208
222
|
toolMsg('w1', 'ok'),
|
|
@@ -215,10 +229,58 @@ suite('args drop yields an empty object; pointer stashes a stub under one key',
|
|
|
215
229
|
]);
|
|
216
230
|
const { on } = sink();
|
|
217
231
|
const out = applyCondensation(history, policies, ctx(), on);
|
|
218
|
-
|
|
232
|
+
const dropped = out[0].toolCalls![0].args;
|
|
233
|
+
const stub = String(dropped[CONDENSED_ARGS_KEY]);
|
|
234
|
+
|
|
235
|
+
assert.ok(
|
|
236
|
+
Object.keys(dropped).length > 0,
|
|
237
|
+
'dropped args are never empty — that reads as a no-argument call',
|
|
238
|
+
);
|
|
239
|
+
assert.match(stub, /vfs_write/, 'stub names the tool');
|
|
240
|
+
assert.match(stub, /\bA\b/, 'stub names the superseded key — which file this call wrote');
|
|
241
|
+
assert.not.match(stub, /re-call to restore/, 'drop must not promise a restore it cannot honour');
|
|
219
242
|
assert.equal(out[2].toolCalls![0].args, { path: 'A', content: big() }, 'latest write kept');
|
|
220
243
|
});
|
|
221
244
|
|
|
245
|
+
// GENC-1494: trigger resolution picks the first LISTED trigger that is currently true, so it
|
|
246
|
+
// used to change after the collapse — with the usual `on: [{superseded}, {agentEnd}]`, a payload
|
|
247
|
+
// collapsed at `agentEnd` re-resolved to `superseded` the moment a newer same-key call landed,
|
|
248
|
+
// rewriting the stub and breaking the prompt cache a second time for an already-collapsed
|
|
249
|
+
// payload. In one reported session that was 22/22 args collapses and 97 response collapses.
|
|
250
|
+
suite('the fired trigger is latched, so a collapsed stub is never rewritten', () => {
|
|
251
|
+
const history: ChatMessage[] = [asstCall('r1', 'vfs_read', { path: 'A' }), toolMsg('r1', big())];
|
|
252
|
+
const entry = reg({
|
|
253
|
+
on: [{ kind: 'superseded', by: 'A' }, { kind: 'agentEnd' }],
|
|
254
|
+
response: 'pointer', // pointer embeds the reason, so a re-resolve is visible in the text
|
|
255
|
+
});
|
|
256
|
+
const policies = new Map<string, RegisteredCondensePolicy>([['r1', entry]]);
|
|
257
|
+
const { events, on } = sink();
|
|
258
|
+
|
|
259
|
+
// Pass 1: the activation has ended but nothing supersedes r1 yet → collapses as agentEnd.
|
|
260
|
+
const first = applyCondensation(history, policies, ctx({ activationEnded: () => true }), on);
|
|
261
|
+
assert.match(first[1].toolResult!.content, /agent finished/, 'collapses under agentEnd');
|
|
262
|
+
assert.is(events.length, 1);
|
|
263
|
+
assert.is(events[0].trigger, 'agentEnd');
|
|
264
|
+
|
|
265
|
+
// Pass 2: a newer read of the SAME key now exists, which would re-resolve the trigger to
|
|
266
|
+
// `superseded` (it is listed first). The already-collapsed stub must not change.
|
|
267
|
+
const grown: ChatMessage[] = [
|
|
268
|
+
...history,
|
|
269
|
+
asstCall('r2', 'vfs_read', { path: 'A' }),
|
|
270
|
+
toolMsg('r2', big()),
|
|
271
|
+
];
|
|
272
|
+
policies.set('r2', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' }));
|
|
273
|
+
const second = applyCondensation(grown, policies, ctx({ activationEnded: () => true }), on);
|
|
274
|
+
|
|
275
|
+
assert.match(second[1].toolResult!.content, /agent finished/, 'reason stays pinned to agentEnd');
|
|
276
|
+
assert.not.match(second[1].toolResult!.content, /superseded/, 'must not re-resolve');
|
|
277
|
+
assert.is(
|
|
278
|
+
second[1].toolResult!.content,
|
|
279
|
+
first[1].toolResult!.content,
|
|
280
|
+
'stub text is byte-stable',
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
222
284
|
// ---------------------------------------------------------------------------
|
|
223
285
|
// envelope + metadata invariants
|
|
224
286
|
// ---------------------------------------------------------------------------
|
|
@@ -56,6 +56,28 @@ export interface RegisteredCondensePolicy {
|
|
|
56
56
|
reportedArgs?: boolean;
|
|
57
57
|
/** `context.condensed` already emitted for the response payload (fire-once). */
|
|
58
58
|
reportedResponse?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The first trigger to fire for this call, latched on the pass it fired.
|
|
61
|
+
*
|
|
62
|
+
* "Fired", not "collapsed": it is written during trigger resolution, before the
|
|
63
|
+
* per-payload size floor is applied, so an entry whose payloads are all below the floor
|
|
64
|
+
* can latch without anything ever rendering. That is deliberate and safe — both payloads
|
|
65
|
+
* of a call resolve off the same clocks in the same pass, so they can never disagree
|
|
66
|
+
* about which trigger fired, and a payload's size is fixed for the life of the call
|
|
67
|
+
* (condensation never touches stored history), so a below-floor payload can never later
|
|
68
|
+
* become a large one under a stale reason.
|
|
69
|
+
*
|
|
70
|
+
* Trigger resolution picks the first LISTED trigger that is *currently* true, so without
|
|
71
|
+
* this it re-evaluates on every provider call and can change after the collapse: with the
|
|
72
|
+
* usual `on: [{superseded}, {agentEnd}]`, a payload collapsed at `agentEnd` silently
|
|
73
|
+
* re-resolves to `superseded` as soon as a newer call on the same key lands. Any stub that
|
|
74
|
+
* embeds the reason (`pointer` does) is then rewritten — an in-place history mutation that
|
|
75
|
+
* invalidates the prompt cache from that position a second time, for a payload that had
|
|
76
|
+
* already collapsed. Latching keeps the rendered stub stable, which is sound because a
|
|
77
|
+
* collapse is monotonic and never reverts, so the first trigger to fire is the correct
|
|
78
|
+
* one to keep.
|
|
79
|
+
*/
|
|
80
|
+
firedTrigger?: CondenseTrigger;
|
|
59
81
|
}
|
|
60
82
|
|
|
61
83
|
/**
|
|
@@ -131,16 +153,27 @@ function triggerReason(trigger: CondenseTrigger): string {
|
|
|
131
153
|
}
|
|
132
154
|
}
|
|
133
155
|
|
|
134
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* Framework-generated stub, shared by `pointer` and `drop`. Names the tool, the superseded key
|
|
158
|
+
* (the only surviving identifier once args are elided — the model cannot otherwise tell WHICH
|
|
159
|
+
* file a collapsed `vfs_write` wrote) and the original size.
|
|
160
|
+
*
|
|
161
|
+
* `restorable` is the ONLY difference between the two modes: `pointer` promises that re-calling
|
|
162
|
+
* reproduces the content and is valid solely for idempotent tools; `drop` makes no such promise
|
|
163
|
+
* and must not carry the clause. Identifying information is not what separates them — a stub too
|
|
164
|
+
* bare to say what the call did is how GENC-1494 started.
|
|
165
|
+
*/
|
|
135
166
|
function condenseStub(
|
|
136
167
|
target: 'args' | 'response',
|
|
137
168
|
tool: string,
|
|
138
169
|
trigger: CondenseTrigger,
|
|
139
170
|
origLen: number,
|
|
171
|
+
restorable: boolean,
|
|
140
172
|
): string {
|
|
141
173
|
const what = target === 'args' ? 'args' : 'result';
|
|
142
174
|
const key = trigger.kind === 'superseded' ? ` ${trigger.by}` : '';
|
|
143
|
-
|
|
175
|
+
const restore = restorable ? '; re-call to restore' : '';
|
|
176
|
+
return `[${tool}${key} — ${what} elided, ~${origLen} chars (${triggerReason(trigger)})${restore}]`;
|
|
144
177
|
}
|
|
145
178
|
|
|
146
179
|
/** Stable label for the `context.condensed` meta-event's `trigger` field. */
|
|
@@ -181,10 +214,12 @@ function marksDiscontinuity(kind: CondenseTrigger['kind']): boolean {
|
|
|
181
214
|
* Collapse stale tool payloads (declared via `condenseWhen`) out of the
|
|
182
215
|
* model-bound history. Pure over the `history` array — it emits new message
|
|
183
216
|
* objects and never mutates the input messages (mirroring the agent-masking
|
|
184
|
-
* transform). It DOES, however,
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
217
|
+
* transform). It DOES, however, write two latches onto the matching `policies`
|
|
218
|
+
* entry the first time a payload collapses, both because the collapse itself
|
|
219
|
+
* re-runs before every provider call: the `reported*` flag (the report-once gate
|
|
220
|
+
* that keeps `onCondensed` firing once per (tool call, payload)) and
|
|
221
|
+
* `firedTrigger` (which pins the reason so a reason-bearing stub is rendered
|
|
222
|
+
* once and never rewritten).
|
|
188
223
|
*
|
|
189
224
|
* Triggers (a policy may list several via `on: Trigger[]` — the FIRST to fire
|
|
190
225
|
* collapses the payload; all are monotonic, so the collapse never reverts):
|
|
@@ -284,14 +319,24 @@ export function applyCondensation(
|
|
|
284
319
|
|
|
285
320
|
// The first listed trigger that fires — drives the collapse, the stub reason,
|
|
286
321
|
// and the event label. `undefined` means the payload stays full.
|
|
322
|
+
//
|
|
323
|
+
// Latched on first fire (see `firedTrigger`): re-resolving on every pass lets the reason
|
|
324
|
+
// change under a payload that has already collapsed, which rewrites any reason-bearing stub
|
|
325
|
+
// and costs a second cache break. Both payloads of a call share the latch — they are
|
|
326
|
+
// evaluated in the same pass off the same clocks, so they never disagree about which
|
|
327
|
+
// trigger fired; only the size floor decides whether each one renders.
|
|
287
328
|
const firstFired = (
|
|
288
329
|
toolCallId: string,
|
|
289
330
|
entry: RegisteredCondensePolicy,
|
|
290
|
-
): CondenseTrigger | undefined =>
|
|
291
|
-
|
|
331
|
+
): CondenseTrigger | undefined => {
|
|
332
|
+
if (entry.firedTrigger) return entry.firedTrigger;
|
|
333
|
+
const fired = triggersOf(entry).find(
|
|
292
334
|
(t) =>
|
|
293
335
|
(marksDiscontinuity(t.kind) || withinBatch(entry)) && triggerFires(toolCallId, entry, t),
|
|
294
336
|
);
|
|
337
|
+
if (fired) entry.firedTrigger = fired;
|
|
338
|
+
return fired;
|
|
339
|
+
};
|
|
295
340
|
|
|
296
341
|
return history.map((msg) => {
|
|
297
342
|
// Tool-call ARGS live on the assistant message.
|
|
@@ -306,18 +351,24 @@ export function applyCondensation(
|
|
|
306
351
|
if (origLen < CONDENSE_MIN_CHARS) return tc;
|
|
307
352
|
changed = true;
|
|
308
353
|
const result = entry.policy.args;
|
|
309
|
-
// Args must stay a Record
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
354
|
+
// Args must stay a Record, so every mode stashes its stub under a single key.
|
|
355
|
+
// Spread `tc` so providerMetadata (e.g. the Gemini reasoning signature that must
|
|
356
|
+
// round-trip) and UI fields survive.
|
|
357
|
+
//
|
|
358
|
+
// GENC-1494: `drop` used to collapse args to a bare `{}`. That is indistinguishable
|
|
359
|
+
// from a call the model made with NO arguments, and the model reads its own history
|
|
360
|
+
// as fact: a ui-builder run saw its elided `vfs_write` calls as `vfs_write {}` beside
|
|
361
|
+
// a live `{"status":"buffered","path":"…"}` result, concluded it was calling the tool
|
|
362
|
+
// without content, and burned two hours re-writing files that were already correct.
|
|
363
|
+
// A bare marker is not enough either — it still fails to say WHICH file the call
|
|
364
|
+
// wrote, since `path` went with the args. So `drop` renders the same identifying stub
|
|
365
|
+
// as `pointer`, minus only the restore promise it cannot honour.
|
|
366
|
+
const args: Record<string, unknown> = {
|
|
367
|
+
[CONDENSED_ARGS_KEY]:
|
|
368
|
+
result === 'drop' || result === 'pointer'
|
|
369
|
+
? condenseStub('args', tc.name, fired, origLen, result === 'pointer')
|
|
370
|
+
: result.replaceWith,
|
|
371
|
+
};
|
|
321
372
|
if (!entry.reportedArgs) {
|
|
322
373
|
entry.reportedArgs = true;
|
|
323
374
|
const stubLen = JSON.stringify(args).length;
|
|
@@ -350,11 +401,9 @@ export function applyCondensation(
|
|
|
350
401
|
const result = entry.policy.response!; // `fired` is only set when response is declared
|
|
351
402
|
// Kept non-empty — Anthropic rejects empty tool_result content.
|
|
352
403
|
const content =
|
|
353
|
-
result === 'drop'
|
|
354
|
-
? '
|
|
355
|
-
: result
|
|
356
|
-
? condenseStub('response', tool, fired, origLen)
|
|
357
|
-
: result.replaceWith;
|
|
404
|
+
result === 'drop' || result === 'pointer'
|
|
405
|
+
? condenseStub('response', tool, fired, origLen, result === 'pointer')
|
|
406
|
+
: result.replaceWith;
|
|
358
407
|
if (!entry.reportedResponse) {
|
|
359
408
|
entry.reportedResponse = true;
|
|
360
409
|
onCondensed({
|