@genesislcap/ai-assistant 15.10.3 → 15.10.4
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/chat-driver.cjs +14 -8
- package/dist/chat-driver.cjs.map +2 -2
- package/dist/chat-driver.mjs +14 -8
- package/dist/chat-driver.mjs.map +2 -2
- package/dist/custom-elements.json +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/utils/condense-history.js +53 -23
- package/dist/esm/utils/condense-history.test.js +49 -4
- package/package.json +17 -17
- package/src/utils/condense-history.test.ts +65 -3
- package/src/utils/condense-history.ts +74 -25
|
@@ -5334,7 +5334,7 @@
|
|
|
5334
5334
|
"description": "Invoked once per payload at its full→stub transition."
|
|
5335
5335
|
}
|
|
5336
5336
|
],
|
|
5337
|
-
"description": "Collapse stale tool payloads (declared via `condenseWhen`) out of the\nmodel-bound history. Pure over the `history` array — it emits new message\nobjects and never mutates the input messages (mirroring the agent-masking\ntransform). It DOES, however,
|
|
5337
|
+
"description": "Collapse stale tool payloads (declared via `condenseWhen`) out of the\nmodel-bound history. Pure over the `history` array — it emits new message\nobjects and never mutates the input messages (mirroring the agent-masking\ntransform). It DOES, however, write two latches onto the matching `policies`\nentry the first time a payload collapses, both because the collapse itself\nre-runs before every provider call: the `reported*` flag (the report-once gate\nthat keeps `onCondensed` firing once per (tool call, payload)) and\n`firedTrigger` (which pins the reason so a reason-bearing stub is rendered\nonce and never rewritten).\n\nTriggers (a policy may list several via `on: Trigger[]` — the FIRST to fire\ncollapses the payload; all are monotonic, so the collapse never reverts):\n- `superseded` — among all registered calls sharing a `by` key, the LAST in\n history order survives; every earlier one's targeted payload collapses. A\n re-call with the same key becomes the new survivor and re-arms the rest.\n- `age` — the result is first visible one model-call after the call, so `turns`\n is how many model-calls may see the full result before it collapses\n (fires when `modelCall − callModelCall > turns`): `turns: 1` is seen once,\n then collapses. The clock is monotonic across turns.\n- `turnEnd` — collapses once the turn (`sendMessage`) that made the call has\n ended (fires when `turn > callTurn`): full for the rest of that request,\n gone on every later one.\n- `agentEnd` — collapses once the agent activation that made the call has ended\n (a swap to another agent, or `releaseAgent`/`completeSubAgent`): kept across\n all of the agent's turns, dropped when its flow finishes.\n- `phaseEnd` — collapses once the app advances its phase epoch via `endPhase()`\n after the call (a sub-`agentEnd` boundary the author declares), OR at\n `agentEnd` as a backstop if no boundary is ever declared.\n\nThe tool-call/result envelope is never removed (providers reject orphaned\ncalls/results) — only the args object or the result content is replaced."
|
|
5338
5338
|
}
|
|
5339
5339
|
],
|
|
5340
5340
|
"exports": [
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChatMessage, CondensePolicy } from '@genesislcap/foundation-ai';
|
|
1
|
+
import type { ChatMessage, CondensePolicy, CondenseTrigger } from '@genesislcap/foundation-ai';
|
|
2
2
|
/**
|
|
3
3
|
* Tool-context condensation: collapse stale tool payloads out of the history
|
|
4
4
|
* sent to the model, while leaving stored history untouched.
|
|
@@ -46,6 +46,28 @@ export interface RegisteredCondensePolicy {
|
|
|
46
46
|
reportedArgs?: boolean;
|
|
47
47
|
/** `context.condensed` already emitted for the response payload (fire-once). */
|
|
48
48
|
reportedResponse?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The first trigger to fire for this call, latched on the pass it fired.
|
|
51
|
+
*
|
|
52
|
+
* "Fired", not "collapsed": it is written during trigger resolution, before the
|
|
53
|
+
* per-payload size floor is applied, so an entry whose payloads are all below the floor
|
|
54
|
+
* can latch without anything ever rendering. That is deliberate and safe — both payloads
|
|
55
|
+
* of a call resolve off the same clocks in the same pass, so they can never disagree
|
|
56
|
+
* about which trigger fired, and a payload's size is fixed for the life of the call
|
|
57
|
+
* (condensation never touches stored history), so a below-floor payload can never later
|
|
58
|
+
* become a large one under a stale reason.
|
|
59
|
+
*
|
|
60
|
+
* Trigger resolution picks the first LISTED trigger that is *currently* true, so without
|
|
61
|
+
* this it re-evaluates on every provider call and can change after the collapse: with the
|
|
62
|
+
* usual `on: [{superseded}, {agentEnd}]`, a payload collapsed at `agentEnd` silently
|
|
63
|
+
* re-resolves to `superseded` as soon as a newer call on the same key lands. Any stub that
|
|
64
|
+
* embeds the reason (`pointer` does) is then rewritten — an in-place history mutation that
|
|
65
|
+
* invalidates the prompt cache from that position a second time, for a payload that had
|
|
66
|
+
* already collapsed. Latching keeps the rendered stub stable, which is sound because a
|
|
67
|
+
* collapse is monotonic and never reverts, so the first trigger to fire is the correct
|
|
68
|
+
* one to keep.
|
|
69
|
+
*/
|
|
70
|
+
firedTrigger?: CondenseTrigger;
|
|
49
71
|
}
|
|
50
72
|
/**
|
|
51
73
|
* The driver's live state at the moment of a provider call — the clocks every
|
|
@@ -106,10 +128,12 @@ export interface CondensedEventDetail {
|
|
|
106
128
|
* Collapse stale tool payloads (declared via `condenseWhen`) out of the
|
|
107
129
|
* model-bound history. Pure over the `history` array — it emits new message
|
|
108
130
|
* objects and never mutates the input messages (mirroring the agent-masking
|
|
109
|
-
* transform). It DOES, however,
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
131
|
+
* transform). It DOES, however, write two latches onto the matching `policies`
|
|
132
|
+
* entry the first time a payload collapses, both because the collapse itself
|
|
133
|
+
* re-runs before every provider call: the `reported*` flag (the report-once gate
|
|
134
|
+
* that keeps `onCondensed` firing once per (tool call, payload)) and
|
|
135
|
+
* `firedTrigger` (which pins the reason so a reason-bearing stub is rendered
|
|
136
|
+
* once and never rewritten).
|
|
113
137
|
*
|
|
114
138
|
* Triggers (a policy may list several via `on: Trigger[]` — the FIRST to fire
|
|
115
139
|
* collapses the payload; all are monotonic, so the collapse never reverts):
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"condense-history.d.ts","sourceRoot":"","sources":["../../../src/utils/condense-history.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,
|
|
1
|
+
{"version":3,"file":"condense-history.d.ts","sourceRoot":"","sources":["../../../src/utils/condense-history.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAE/F;;;;;;;;;;;;;GAaG;AAEH;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC,uFAAuF;AACvF,eAAO,MAAM,kBAAkB,cAAc,CAAC;AAQ9C;;;;;GAKG;AACH,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,cAAc,CAAC;IACvB;;;;;OAKG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,UAAU,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,UAAU,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gFAAgF;IAChF,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,YAAY,CAAC,EAAE,eAAe,CAAC;CAChC;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,2DAA2D;IAC3D,SAAS,EAAE,MAAM,CAAC;IAClB,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,4FAA4F;IAC5F,eAAe,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD;;;OAGG;IACH,UAAU,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC;IAC5C;;;;;;;;;;;;;;;;OAgBG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACnC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IACvB,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;IAC5B,oEAAoE;IACpE,OAAO,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,mFAAmF;IACnF,WAAW,EAAE,MAAM,CAAC;CACrB;AA2ED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,WAAW,EAAE,EACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,wBAAwB,CAAC,EAC/C,GAAG,EAAE,eAAe,EACpB,WAAW,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,GAClD,WAAW,EAAE,CAwKf"}
|
|
@@ -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.4",
|
|
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.4",
|
|
77
|
+
"@genesislcap/genx": "15.10.4",
|
|
78
|
+
"@genesislcap/rollup-builder": "15.10.4",
|
|
79
|
+
"@genesislcap/ts-builder": "15.10.4",
|
|
80
|
+
"@genesislcap/uvu-playwright-builder": "15.10.4",
|
|
81
|
+
"@genesislcap/vite-builder": "15.10.4",
|
|
82
|
+
"@genesislcap/webpack-builder": "15.10.4",
|
|
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.4",
|
|
89
|
+
"@genesislcap/foundation-logger": "15.10.4",
|
|
90
|
+
"@genesislcap/foundation-notifications": "15.10.4",
|
|
91
|
+
"@genesislcap/foundation-redux": "15.10.4",
|
|
92
|
+
"@genesislcap/foundation-ui": "15.10.4",
|
|
93
|
+
"@genesislcap/foundation-utils": "15.10.4",
|
|
94
|
+
"@genesislcap/rapid-design-system": "15.10.4",
|
|
95
|
+
"@genesislcap/web-core": "15.10.4",
|
|
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": "2747f342a1681832c041ac612b854039ef908ab7"
|
|
109
109
|
}
|
|
@@ -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({
|