@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/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 host-set `blockedReason` still wins, so a white-labelled host does not read
1368
- * its own explanation in the banner and the shipped default directly below it
1369
- * that is the whole reason the driver takes this at all.
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
- * What it deliberately is NOT is
1372
- * {@link FoundationAiAssistant.effectiveBlockedReason}. That getter
1373
- * composes advice `"…Switch to Gemini to keep going."` which is true only
1374
- * while that vendor has headroom, and the transcript is a permanent record. The
1375
- * driver reads this once at CONSTRUCTION, and `getOrCreateDriver` keys on the
1376
- * agents list, so an agents swap *after* a wall re-runs `createDriver` and would
1377
- * freeze that sentence into every later turn's bubble. Reading the latch's own
1378
- * copy instead is structurally incapable of carrying the advice: the only
1379
- * writer of `blockedReason` is the vendor-agnostic branch of
1380
- * `latchBlockedFrom`, whose `formatBlockedReason(budget)` form has no switch
1381
- * clause. Per-vendor copy lives in `blockedVendorReasons` and reaches the
1382
- * banner alone.
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 ?? DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
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
- assert.is(out[1].toolResult!.content, '[elided]', 'drop is a minimal non-empty placeholder');
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
- suite('args drop yields an empty object; pointer stashes a stub under one key', () => {
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
- assert.equal(out[0].toolCalls![0].args, {}, 'dropped args is an empty object');
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
- /** Framework-generated `pointer` stub — advertises that re-calling restores the content. */
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
- return `[${tool}${key} ${what} elided, ~${origLen} chars (${triggerReason(trigger)}); re-call to restore]`;
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, flip the `reported*` flag on the matching
185
- * `policies` entry the first time a payload collapses the report-once gate that
186
- * keeps `onCondensed` firing once per (tool call, payload) even though the
187
- * collapse itself re-runs before every provider call.
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
- triggersOf(entry).find(
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; `drop` is an empty object, otherwise stash the
310
- // stub under a single key. Spread `tc` so providerMetadata (e.g. the
311
- // Gemini reasoning signature that must round-trip) and UI fields survive.
312
- const args: Record<string, unknown> =
313
- result === 'drop'
314
- ? {}
315
- : {
316
- [CONDENSED_ARGS_KEY]:
317
- result === 'pointer'
318
- ? condenseStub('args', tc.name, fired, origLen)
319
- : result.replaceWith,
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
- ? '[elided]'
355
- : result === 'pointer'
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({