@genesislcap/ai-assistant 15.19.6 → 15.20.0-alpha.3
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 +707 -69
- package/dist/ai-assistant.d.ts +443 -25
- package/dist/chat-driver.cjs +440 -34
- package/dist/chat-driver.cjs.map +4 -4
- package/dist/chat-driver.mjs +440 -34
- package/dist/chat-driver.mjs.map +4 -4
- package/dist/custom-elements.json +709 -47
- package/dist/dts/components/ai-driver/ai-driver.d.ts +40 -7
- package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +81 -2
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +23 -3
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/config/config.d.ts +44 -0
- package/dist/dts/config/config.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts +187 -5
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.styles.d.ts.map +1 -1
- package/dist/dts/main/main.template.d.ts.map +1 -1
- package/dist/dts/state/persistence/diagnostics-cursors.d.ts +7 -2
- package/dist/dts/state/persistence/diagnostics-cursors.d.ts.map +1 -1
- package/dist/dts/state/persistence/diagnostics.d.ts +30 -0
- package/dist/dts/state/persistence/diagnostics.d.ts.map +1 -1
- package/dist/dts/state/persistence/session-persister.d.ts +18 -0
- package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
- package/dist/dts/utils/condense-history.d.ts +35 -0
- package/dist/dts/utils/condense-history.d.ts.map +1 -1
- package/dist/dts/utils/context-tokens.d.ts +156 -0
- package/dist/dts/utils/context-tokens.d.ts.map +1 -0
- package/dist/dts/utils/history-transform.d.ts +76 -14
- package/dist/dts/utils/history-transform.d.ts.map +1 -1
- package/dist/dts/utils/resolve-context-budget.d.ts +98 -0
- package/dist/dts/utils/resolve-context-budget.d.ts.map +1 -0
- package/dist/esm/components/chat-driver/chat-driver.js +200 -35
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +28 -4
- package/dist/esm/main/main.js +400 -24
- package/dist/esm/main/main.styles.js +128 -0
- package/dist/esm/main/main.template.js +64 -29
- package/dist/esm/state/debug-event-log.js +1 -1
- package/dist/esm/state/persistence/diagnostics-cursors.js +1 -1
- package/dist/esm/state/persistence/diagnostics.js +49 -0
- package/dist/esm/state/persistence/session-persister.js +38 -18
- package/dist/esm/utils/condense-history.js +125 -26
- package/dist/esm/utils/context-tokens.js +339 -0
- package/dist/esm/utils/history-transform.js +101 -19
- package/dist/esm/utils/resolve-context-budget.js +84 -0
- package/package.json +16 -16
- package/sandbox/README.md +93 -4
- package/sandbox/controls.ts +77 -10
- package/sandbox/fixtures.ts +163 -6
- package/sandbox/sandbox.css +54 -1
- package/sandbox/sandbox.ts +384 -7
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { __rest } from "tslib";
|
|
2
|
+
import { APPROX_CHARS_PER_TOKEN } from './context-tokens';
|
|
2
3
|
/**
|
|
3
4
|
* Tool-context condensation: collapse stale tool payloads out of the history
|
|
4
5
|
* sent to the model, while leaving stored history untouched.
|
|
@@ -21,11 +22,6 @@ import { __rest } from "tslib";
|
|
|
21
22
|
export const CONDENSE_MIN_CHARS = 1000;
|
|
22
23
|
/** Key the collapsed args are stored under — a tool-call's args must stay a Record. */
|
|
23
24
|
export const CONDENSED_ARGS_KEY = 'condensed';
|
|
24
|
-
/**
|
|
25
|
-
* Rough chars-per-token divisor for the `tokensSaved` estimate. No tokenizer is
|
|
26
|
-
* available in this stack; ~4 chars/token is the usual English approximation.
|
|
27
|
-
*/
|
|
28
|
-
const APPROX_CHARS_PER_TOKEN = 4;
|
|
29
25
|
/** Short human-readable reason for the stub text, per fired trigger. */
|
|
30
26
|
function triggerReason(trigger) {
|
|
31
27
|
switch (trigger.kind) {
|
|
@@ -65,6 +61,38 @@ imageCount = 0) {
|
|
|
65
61
|
const images = imageCount > 0 ? ` + ${imageCount} image${imageCount === 1 ? '' : 's'}` : '';
|
|
66
62
|
return `[${tool}${key} — ${what} elided, ~${origLen} chars${images} (${triggerReason(trigger)})${restore}]`;
|
|
67
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* The replacement for a call's ARGS, in the shape args must keep. Shared by the request-time
|
|
66
|
+
* transform and {@link condenseForPersistence} so both render byte-identical stubs.
|
|
67
|
+
*
|
|
68
|
+
* Args must stay a Record, so every mode stashes its stub under a single key.
|
|
69
|
+
*
|
|
70
|
+
* GENC-1494: `drop` used to collapse args to a bare `{}`. That is indistinguishable from a call
|
|
71
|
+
* the model made with NO arguments, and the model reads its own history as fact: a ui-builder run
|
|
72
|
+
* saw its elided `vfs_write` calls as `vfs_write {}` beside a live
|
|
73
|
+
* `{"status":"buffered","path":"…"}` result, concluded it was calling the tool without content,
|
|
74
|
+
* and burned two hours re-writing files that were already correct. A bare marker is not enough
|
|
75
|
+
* either — it still fails to say WHICH file the call wrote, since `path` went with the args. So
|
|
76
|
+
* `drop` renders the same identifying stub as `pointer`, minus only the restore promise it cannot
|
|
77
|
+
* honour.
|
|
78
|
+
*/
|
|
79
|
+
function renderCondensedArgs(policy, tool, fired, origLen) {
|
|
80
|
+
return {
|
|
81
|
+
[CONDENSED_ARGS_KEY]: policy === 'drop' || policy === 'pointer'
|
|
82
|
+
? condenseStub('args', tool, fired, origLen, policy === 'pointer')
|
|
83
|
+
: policy.replaceWith,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The replacement for a call's RESULT. Kept non-empty — Anthropic rejects empty `tool_result`
|
|
88
|
+
* content. Shared with {@link condenseForPersistence} for the same reason as
|
|
89
|
+
* {@link renderCondensedArgs}.
|
|
90
|
+
*/
|
|
91
|
+
function renderCondensedResult(policy, tool, fired, textLen, imageCount) {
|
|
92
|
+
return policy === 'drop' || policy === 'pointer'
|
|
93
|
+
? condenseStub('response', tool, fired, textLen, policy === 'pointer', imageCount)
|
|
94
|
+
: policy.replaceWith;
|
|
95
|
+
}
|
|
68
96
|
/** Stable label for the `context.condensed` meta-event's `trigger` field. */
|
|
69
97
|
function triggerLabel(trigger) {
|
|
70
98
|
switch (trigger.kind) {
|
|
@@ -224,24 +252,9 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
|
|
|
224
252
|
if (origLen < CONDENSE_MIN_CHARS)
|
|
225
253
|
return tc;
|
|
226
254
|
changed = true;
|
|
227
|
-
const result = entry.policy.args;
|
|
228
|
-
// Args must stay a Record, so every mode stashes its stub under a single key.
|
|
229
255
|
// Spread `tc` so providerMetadata (e.g. the Gemini reasoning signature that must
|
|
230
256
|
// round-trip) and UI fields survive.
|
|
231
|
-
|
|
232
|
-
// GENC-1494: `drop` used to collapse args to a bare `{}`. That is indistinguishable
|
|
233
|
-
// from a call the model made with NO arguments, and the model reads its own history
|
|
234
|
-
// as fact: a ui-builder run saw its elided `vfs_write` calls as `vfs_write {}` beside
|
|
235
|
-
// a live `{"status":"buffered","path":"…"}` result, concluded it was calling the tool
|
|
236
|
-
// without content, and burned two hours re-writing files that were already correct.
|
|
237
|
-
// A bare marker is not enough either — it still fails to say WHICH file the call
|
|
238
|
-
// wrote, since `path` went with the args. So `drop` renders the same identifying stub
|
|
239
|
-
// as `pointer`, minus only the restore promise it cannot honour.
|
|
240
|
-
const args = {
|
|
241
|
-
[CONDENSED_ARGS_KEY]: result === 'drop' || result === 'pointer'
|
|
242
|
-
? condenseStub('args', tc.name, fired, origLen, result === 'pointer')
|
|
243
|
-
: result.replaceWith,
|
|
244
|
-
};
|
|
257
|
+
const args = renderCondensedArgs(entry.policy.args, tc.name, fired, origLen);
|
|
245
258
|
if (!entry.reportedArgs) {
|
|
246
259
|
entry.reportedArgs = true;
|
|
247
260
|
const stubLen = JSON.stringify(args).length;
|
|
@@ -281,11 +294,8 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
|
|
|
281
294
|
: undefined;
|
|
282
295
|
if (entry && fired && origLen >= CONDENSE_MIN_CHARS) {
|
|
283
296
|
const tool = (_e = nameById.get(msg.toolResult.toolCallId)) !== null && _e !== void 0 ? _e : msg.toolResult.toolCallId;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const content = result === 'drop' || result === 'pointer'
|
|
287
|
-
? condenseStub('response', tool, fired, textLen, result === 'pointer', images.length)
|
|
288
|
-
: result.replaceWith;
|
|
297
|
+
// `fired` is only set when a response policy is declared, so the `!` holds.
|
|
298
|
+
const content = renderCondensedResult(entry.policy.response, tool, fired, textLen, images.length);
|
|
289
299
|
if (!entry.reportedResponse) {
|
|
290
300
|
entry.reportedResponse = true;
|
|
291
301
|
onCondensed({
|
|
@@ -308,3 +318,92 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
|
|
|
308
318
|
return msg;
|
|
309
319
|
});
|
|
310
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* Project history for PERSISTENCE: collapse the payloads the model has **already** stopped
|
|
323
|
+
* seeing, using the same stubs it was sent (GENC-1566).
|
|
324
|
+
*
|
|
325
|
+
* `applyCondensation` leaves stored history whole on purpose, and that is right for the live
|
|
326
|
+
* transcript — but a host that WRITES the transcript to durable storage is then writing payloads
|
|
327
|
+
* the driver has already decided are spent, on every autosave, for the life of the project.
|
|
328
|
+
* Measured on one small Create session: 58,187 chars, 7.4% of a 790 KB session object, across
|
|
329
|
+
* five payloads the driver had condensed out of context to save 14,434 tokens. That session
|
|
330
|
+
* autosaved 86 times, so the bytes were written 86 times.
|
|
331
|
+
*
|
|
332
|
+
* **Deliberately dumber than {@link applyCondensation}, and that is the safety argument.** No
|
|
333
|
+
* clocks, no trigger evaluation, no events, no writes to the registry's latches. It reads only
|
|
334
|
+
* `reportedArgs` / `reportedResponse` — set when a payload was actually replaced by a stub — and
|
|
335
|
+
* `firedTrigger`, which is latched, so the stub it renders is byte-identical to the one the model
|
|
336
|
+
* received. It therefore cannot condense something the model can still see, and calling it is
|
|
337
|
+
* free of side effects: safe on a save path, and safe to call twice.
|
|
338
|
+
*
|
|
339
|
+
* What survives: `diag/`-style diagnostic streams keep the full payloads (they are a separate,
|
|
340
|
+
* chunked keyspace), and stored history in memory is untouched — this returns a copy. So the
|
|
341
|
+
* transcript a host RENDERS and the transcript a host STORES can differ, which is the point.
|
|
342
|
+
*
|
|
343
|
+
* **That divergence lasts only until the next reload, and the difference is user-visible** (PR
|
|
344
|
+
* #2495 review). Restore feeds the STORED transcript back into both the driver and the UI, so a
|
|
345
|
+
* resumed session renders the stubs: someone reopening a project sees
|
|
346
|
+
* `[read_context — result elided, ~26907 chars (agent finished)]` where the payload used to be,
|
|
347
|
+
* and the only route back to it is the debug-log download. That is the trade this function makes —
|
|
348
|
+
* 7.4% of a measured session object against a spent payload no longer being visible in a resumed
|
|
349
|
+
* transcript — and it is deliberate, but it is not invisible. Anyone handling a "where did my tool
|
|
350
|
+
* output go?" report should find the answer here.
|
|
351
|
+
*
|
|
352
|
+
* Returns `history` itself when there is nothing to collapse, so a caller that saves on every
|
|
353
|
+
* keystroke pays one walk and no allocation.
|
|
354
|
+
*/
|
|
355
|
+
export function condenseForPersistence(history, policies) {
|
|
356
|
+
var _a;
|
|
357
|
+
if (policies.size === 0)
|
|
358
|
+
return history;
|
|
359
|
+
// A tool message carries only an id, so the tool NAME for its stub comes from the call.
|
|
360
|
+
const nameById = new Map();
|
|
361
|
+
for (const msg of history) {
|
|
362
|
+
for (const tc of (_a = msg.toolCalls) !== null && _a !== void 0 ? _a : [])
|
|
363
|
+
nameById.set(tc.id, tc.name);
|
|
364
|
+
}
|
|
365
|
+
let changed = false;
|
|
366
|
+
const projected = history.map((msg) => {
|
|
367
|
+
var _a, _b, _c, _d, _e;
|
|
368
|
+
if ((_a = msg.toolCalls) === null || _a === void 0 ? void 0 : _a.length) {
|
|
369
|
+
let callsChanged = false;
|
|
370
|
+
const toolCalls = msg.toolCalls.map((tc) => {
|
|
371
|
+
var _a;
|
|
372
|
+
const entry = policies.get(tc.id);
|
|
373
|
+
if (!(entry === null || entry === void 0 ? void 0 : entry.reportedArgs) || !entry.firedTrigger || !entry.policy.args)
|
|
374
|
+
return tc;
|
|
375
|
+
callsChanged = true;
|
|
376
|
+
const origLen = JSON.stringify((_a = tc.args) !== null && _a !== void 0 ? _a : {}).length;
|
|
377
|
+
return Object.assign(Object.assign({}, tc), { args: renderCondensedArgs(entry.policy.args, tc.name, entry.firedTrigger, origLen) });
|
|
378
|
+
});
|
|
379
|
+
// Returns unconditionally, matching `applyCondensation`'s equivalent branch
|
|
380
|
+
// (`return changed ? { ...msg, toolCalls } : msg`). A message can carry BOTH `toolCalls` and
|
|
381
|
+
// a `toolResult`, so falling through here would walk a result the request-time transform
|
|
382
|
+
// never reaches — safe today only because `reportedResponse` can be set by nothing except
|
|
383
|
+
// `applyCondensation`, which cannot reach it either. The whole safety argument for this
|
|
384
|
+
// function is that it renders the stub the model was already sent, so the control flow is
|
|
385
|
+
// held identical rather than resting on that coincidence (PR #2495 review).
|
|
386
|
+
if (callsChanged) {
|
|
387
|
+
changed = true;
|
|
388
|
+
return Object.assign(Object.assign({}, msg), { toolCalls });
|
|
389
|
+
}
|
|
390
|
+
return msg;
|
|
391
|
+
}
|
|
392
|
+
if (msg.toolResult) {
|
|
393
|
+
const entry = policies.get(msg.toolResult.toolCallId);
|
|
394
|
+
if ((entry === null || entry === void 0 ? void 0 : entry.reportedResponse) && entry.firedTrigger && entry.policy.response) {
|
|
395
|
+
changed = true;
|
|
396
|
+
const tool = (_b = nameById.get(msg.toolResult.toolCallId)) !== null && _b !== void 0 ? _b : msg.toolResult.toolCallId;
|
|
397
|
+
const images = (_c = msg.toolResult.attachments) !== null && _c !== void 0 ? _c : [];
|
|
398
|
+
const content = renderCondensedResult(entry.policy.response, tool, entry.firedTrigger, (_e = (_d = msg.toolResult.content) === null || _d === void 0 ? void 0 : _d.length) !== null && _e !== void 0 ? _e : 0, images.length);
|
|
399
|
+
// `attachments` is dropped rather than spread through, exactly as the request-time
|
|
400
|
+
// transform drops it: the stub says the payload was elided, and keeping the base64
|
|
401
|
+
// would both make that a lie and shed none of the bytes this exists to shed.
|
|
402
|
+
const _f = msg.toolResult, { attachments: _elided } = _f, restOfResult = __rest(_f, ["attachments"]);
|
|
403
|
+
return Object.assign(Object.assign({}, msg), { toolResult: Object.assign(Object.assign({}, restOfResult), { content }) });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return msg;
|
|
407
|
+
});
|
|
408
|
+
return changed ? projected : history;
|
|
409
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-window accounting (GENC-1567) — estimating how much of the model's
|
|
3
|
+
* context each message occupies, so compaction can be sized in the unit that
|
|
4
|
+
* actually matters and a gate can promise a reclaim before spending a
|
|
5
|
+
* summarizer call.
|
|
6
|
+
*
|
|
7
|
+
* The estimates here are deliberately **conservative** (biased to over-count).
|
|
8
|
+
* They feed a gate that blocks sending: over-counting costs a little unused
|
|
9
|
+
* headroom, under-counting lets a turn overflow, which is the failure this
|
|
10
|
+
* whole feature exists to prevent.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Fallback ratio for messages with no measured anchor nearby. Shared with
|
|
14
|
+
* `condense-history.ts`, which uses it for the same purpose (reporting bytes
|
|
15
|
+
* shed as tokens) — one definition so the two never drift.
|
|
16
|
+
*/
|
|
17
|
+
export const APPROX_CHARS_PER_TOKEN = 4;
|
|
18
|
+
/**
|
|
19
|
+
* Flat per-image allowance. Both vendors price an image by its pixel area
|
|
20
|
+
* (Anthropic ≈ `w × h / 750`), and a `ChatImageAttachment` carries only base64
|
|
21
|
+
* bytes — no dimensions — so there is nothing to compute from. This stands in
|
|
22
|
+
* for a mid-size screenshot and is intentionally on the high side: an image is
|
|
23
|
+
* the one payload compaction cannot shrink (it is dropped, never summarized),
|
|
24
|
+
* so under-counting images is exactly how a projected reclaim comes up short.
|
|
25
|
+
*/
|
|
26
|
+
const IMAGE_TOKEN_ESTIMATE = 1600;
|
|
27
|
+
/** Per-message role/framing overhead every transport adds around the content. */
|
|
28
|
+
const PER_MESSAGE_FRAMING_TOKENS = 4;
|
|
29
|
+
/**
|
|
30
|
+
* Allowance for the summary a compaction will produce, used when projecting the
|
|
31
|
+
* post-compaction size before the summarizer has run. The system prompt asks for
|
|
32
|
+
* concise prose, but the length is the model's choice, so this is a ceiling
|
|
33
|
+
* rather than an average — a projection that under-estimates the summary is a
|
|
34
|
+
* gate that promises a reclaim it cannot deliver.
|
|
35
|
+
*/
|
|
36
|
+
export const COMPACTION_SUMMARY_TOKEN_ESTIMATE = 1200;
|
|
37
|
+
/**
|
|
38
|
+
* Whether a message contributes to the provider request at all.
|
|
39
|
+
*
|
|
40
|
+
* Exported because the cut logic needs the same answer: a boundary is judged by
|
|
41
|
+
* what the provider will actually see, so a display-only row sitting between an
|
|
42
|
+
* `assistant(toolCalls)` and its results must not be mistaken for a safe split.
|
|
43
|
+
*
|
|
44
|
+
* Mirrors what the request-build chokepoint and the transports actually send:
|
|
45
|
+
* `system-event` / `synthetic-user` are display-only rows, and the
|
|
46
|
+
* reasoning/narration splits (GENC-1410, plus the legacy `thinking` flag they
|
|
47
|
+
* superseded) are skipped when building the request. Counting any of them would
|
|
48
|
+
* inflate every estimate against a prompt that never carries them.
|
|
49
|
+
*/
|
|
50
|
+
export function reachesProvider(m) {
|
|
51
|
+
if (m.role === 'system-event' || m.role === 'synthetic-user')
|
|
52
|
+
return false;
|
|
53
|
+
if (m.category === 'reasoning' || m.category === 'narration')
|
|
54
|
+
return false;
|
|
55
|
+
return !m.thinking;
|
|
56
|
+
}
|
|
57
|
+
/** Images on a message — both the user-attached kind and the kind a tool handed back. */
|
|
58
|
+
function imageCount(m) {
|
|
59
|
+
var _a, _b, _c, _d, _e;
|
|
60
|
+
const onMessage = (_b = (_a = m.attachments) === null || _a === void 0 ? void 0 : _a.filter((a) => a.kind === 'image').length) !== null && _b !== void 0 ? _b : 0;
|
|
61
|
+
const onResult = (_e = (_d = (_c = m.toolResult) === null || _c === void 0 ? void 0 : _c.attachments) === null || _d === void 0 ? void 0 : _d.filter((a) => a.kind === 'image').length) !== null && _e !== void 0 ? _e : 0;
|
|
62
|
+
return onMessage + onResult;
|
|
63
|
+
}
|
|
64
|
+
/** Framing a text attachment adds on the wire: `[File: ` + `]\n`. */
|
|
65
|
+
const TEXT_ATTACHMENT_FRAMING_CHARS = 9;
|
|
66
|
+
/**
|
|
67
|
+
* Characters this message puts on the wire: prose, text-attachment contents,
|
|
68
|
+
* tool-call names and serialized arguments, and tool-result content.
|
|
69
|
+
*
|
|
70
|
+
* **Text attachments are counted here, not by `imageCount`.** A `kind: 'text'`
|
|
71
|
+
* attachment is sent verbatim as `[File: <name>]\n<content>` — it is ordinary
|
|
72
|
+
* prompt text, and a picked file can be megabytes of it. Charging only for
|
|
73
|
+
* images made the single largest sanctioned input invisible to every estimate,
|
|
74
|
+
* so both the composer gate and the mid-loop guard waved it straight through:
|
|
75
|
+
* the exact overrun they exist to catch. Note `kind` is OPTIONAL on the text arm
|
|
76
|
+
* (attachments authored before images existed carry none), so anything that is
|
|
77
|
+
* not explicitly an image is text.
|
|
78
|
+
*
|
|
79
|
+
* `toolResult.attachments` is deliberately not walked for text: that field is
|
|
80
|
+
* typed image-only, because a text attachment there would be indistinguishable
|
|
81
|
+
* from `toolResult.content` and the transports drop it rather than double-send.
|
|
82
|
+
*
|
|
83
|
+
* `toolCall.subAgentTrace` is deliberately not counted either — it hangs off the
|
|
84
|
+
* tool call for the UI's benefit and is never sent, so counting it would
|
|
85
|
+
* attribute a whole sub-agent transcript to the parent's context.
|
|
86
|
+
*/
|
|
87
|
+
function wireChars(m) {
|
|
88
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
|
|
89
|
+
let chars = (_b = (_a = m.content) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0;
|
|
90
|
+
for (const attachment of (_c = m.attachments) !== null && _c !== void 0 ? _c : []) {
|
|
91
|
+
if (attachment.kind === 'image')
|
|
92
|
+
continue;
|
|
93
|
+
chars +=
|
|
94
|
+
TEXT_ATTACHMENT_FRAMING_CHARS + attachment.name.length + ((_e = (_d = attachment.content) === null || _d === void 0 ? void 0 : _d.length) !== null && _e !== void 0 ? _e : 0);
|
|
95
|
+
}
|
|
96
|
+
for (const tc of (_f = m.toolCalls) !== null && _f !== void 0 ? _f : []) {
|
|
97
|
+
chars += tc.name.length;
|
|
98
|
+
try {
|
|
99
|
+
chars += JSON.stringify((_g = tc.args) !== null && _g !== void 0 ? _g : {}).length;
|
|
100
|
+
}
|
|
101
|
+
catch (_l) {
|
|
102
|
+
// Circular or otherwise unserializable args: skip rather than throw. The
|
|
103
|
+
// framing allowance below still counts the call itself.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
chars += (_k = (_j = (_h = m.toolResult) === null || _h === void 0 ? void 0 : _h.content) === null || _j === void 0 ? void 0 : _j.length) !== null && _k !== void 0 ? _k : 0;
|
|
107
|
+
return chars;
|
|
108
|
+
}
|
|
109
|
+
/** Char-derived token estimate for one message, before any measured calibration. */
|
|
110
|
+
function baselineTokens(m) {
|
|
111
|
+
if (!reachesProvider(m))
|
|
112
|
+
return 0;
|
|
113
|
+
return (Math.ceil(wireChars(m) / APPROX_CHARS_PER_TOKEN) +
|
|
114
|
+
imageCount(m) * IMAGE_TOKEN_ESTIMATE +
|
|
115
|
+
PER_MESSAGE_FRAMING_TOKENS);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* True creation time of the compaction that produced this history, if any.
|
|
119
|
+
*
|
|
120
|
+
* Read from `compaction.createdAt` rather than the summary's `timestamp`, which
|
|
121
|
+
* is deliberately backdated to the covered region's start so the summary sorts to
|
|
122
|
+
* the head of chronological views.
|
|
123
|
+
*/
|
|
124
|
+
function compactedAtOf(history) {
|
|
125
|
+
var _a, _b;
|
|
126
|
+
return (_b = (_a = history.find((m) => m.role === 'compacted-summary')) === null || _a === void 0 ? void 0 : _a.compaction) === null || _b === void 0 ? void 0 : _b.createdAt;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Whether a message's `inputTokens` still describes the history being sent.
|
|
130
|
+
*
|
|
131
|
+
* A measurement is only meaningful against the history that produced it, and
|
|
132
|
+
* compaction rewrites that history underneath the tail it keeps. Every retained
|
|
133
|
+
* message was appended before the compaction ran, so an older measurement is
|
|
134
|
+
* describing a transcript that no longer exists — and it is describing a much
|
|
135
|
+
* BIGGER one, which is what made trusting it a dead end rather than a rounding
|
|
136
|
+
* error (GENC-1567).
|
|
137
|
+
*
|
|
138
|
+
* A measurement with no timestamp is rejected once a compaction is present: its
|
|
139
|
+
* age is unknowable, and falling back to the character estimate is the safer of
|
|
140
|
+
* the two wrong answers.
|
|
141
|
+
*/
|
|
142
|
+
function usableAnchor(m, compactedAt) {
|
|
143
|
+
if (m.inputTokens == null)
|
|
144
|
+
return false;
|
|
145
|
+
if (!compactedAt)
|
|
146
|
+
return true;
|
|
147
|
+
return !!m.timestamp && m.timestamp >= compactedAt;
|
|
148
|
+
}
|
|
149
|
+
/** Index of the first message carrying a usable measurement, or `-1`. */
|
|
150
|
+
function firstAnchor(history, compactedAt) {
|
|
151
|
+
return history.findIndex((m) => usableAnchor(m, compactedAt));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Whether anything about this transcript's size is knowable.
|
|
155
|
+
*
|
|
156
|
+
* The gate's "should I have an opinion at all?" question. Deliberately broader
|
|
157
|
+
* than "is there a USABLE anchor": a compaction invalidates every retained
|
|
158
|
+
* measurement, which is the normal state immediately after one runs, and gating
|
|
159
|
+
* the recompute on a usable anchor there meant no assignment at all — leaving a
|
|
160
|
+
* blocked figure latched on a conversation that had just shrunk. That is the very
|
|
161
|
+
* dead end this machinery exists to prevent, so the question has to be whether the
|
|
162
|
+
* transcript was EVER measured, not whether the measurement is still good.
|
|
163
|
+
*
|
|
164
|
+
* When it is true, {@link estimateContextTokens} is the figure to use — it drops
|
|
165
|
+
* the invalidated anchors itself and falls back to characters, which is still the
|
|
166
|
+
* truth about the current history and, crucially, is not the stale number.
|
|
167
|
+
*
|
|
168
|
+
* A `compacted-summary` counts on its own: a conversation only reaches one by
|
|
169
|
+
* being large, and a compaction can summarise away every measured turn.
|
|
170
|
+
*
|
|
171
|
+
* When it is false nothing has ever reported usage, and the honest answer is to
|
|
172
|
+
* leave the gate inert rather than let a character estimate invent a percentage.
|
|
173
|
+
*/
|
|
174
|
+
export function isContextMeasurable(history) {
|
|
175
|
+
return history.some((m) => m.inputTokens != null || m.role === 'compacted-summary');
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Per-message token estimates, calibrated against measured prompt sizes wherever
|
|
179
|
+
* the transcript provides them.
|
|
180
|
+
*
|
|
181
|
+
* **How the calibration works.** `inputTokens` on the message produced by a call
|
|
182
|
+
* is the size of that call's WHOLE prompt (the cache buckets break it down
|
|
183
|
+
* rather than add to it), so it covers `history[0 … i-1]`. Two consecutive
|
|
184
|
+
* anchors therefore bracket a segment whose exact measured cost is their
|
|
185
|
+
* difference: `inputTokens[b] − inputTokens[a]` is what `history[a … b-1]` cost.
|
|
186
|
+
* Within a segment the measured total is redistributed across its messages in
|
|
187
|
+
* proportion to their baseline estimate — segment granularity is all that is
|
|
188
|
+
* needed, since compaction only ever cuts on boundaries.
|
|
189
|
+
*
|
|
190
|
+
* A non-positive delta means history SHRANK between the two calls (a compaction
|
|
191
|
+
* or a `condenseWhen` collapse landed in between); there is nothing to
|
|
192
|
+
* distribute, so those messages keep their baseline.
|
|
193
|
+
*
|
|
194
|
+
* The region before the first anchor keeps its baseline too: that anchor's
|
|
195
|
+
* measurement also contains the system prompt and tool definitions, which are
|
|
196
|
+
* not attributable to any message — see {@link estimateSystemOverhead}.
|
|
197
|
+
*/
|
|
198
|
+
export function estimateMessageTokens(history) {
|
|
199
|
+
const estimates = history.map(baselineTokens);
|
|
200
|
+
const compactedAt = compactedAtOf(history);
|
|
201
|
+
const anchors = [];
|
|
202
|
+
history.forEach((m, i) => {
|
|
203
|
+
if (usableAnchor(m, compactedAt))
|
|
204
|
+
anchors.push(i);
|
|
205
|
+
});
|
|
206
|
+
for (let a = 0; a < anchors.length - 1; a += 1) {
|
|
207
|
+
const from = anchors[a];
|
|
208
|
+
const to = anchors[a + 1];
|
|
209
|
+
const measured = history[to].inputTokens - history[from].inputTokens;
|
|
210
|
+
if (measured <= 0)
|
|
211
|
+
continue;
|
|
212
|
+
let baseline = 0;
|
|
213
|
+
for (let i = from; i < to; i += 1)
|
|
214
|
+
baseline += estimates[i];
|
|
215
|
+
if (baseline <= 0) {
|
|
216
|
+
// Nothing to scale against (an all-display-only segment): put the measured
|
|
217
|
+
// cost on the first message rather than losing it.
|
|
218
|
+
estimates[from] = measured;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
// Rescale, then put the rounding residual on the last message of the segment
|
|
222
|
+
// so the segment sums to EXACTLY what was measured. Without it the per-message
|
|
223
|
+
// rounding drifts, and every projection inherits the drift of every segment
|
|
224
|
+
// before it.
|
|
225
|
+
const scale = measured / baseline;
|
|
226
|
+
let assigned = 0;
|
|
227
|
+
for (let i = from; i < to - 1; i += 1) {
|
|
228
|
+
estimates[i] = Math.round(estimates[i] * scale);
|
|
229
|
+
assigned += estimates[i];
|
|
230
|
+
}
|
|
231
|
+
estimates[to - 1] = measured - assigned;
|
|
232
|
+
}
|
|
233
|
+
return estimates;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Context consumed by everything that is NOT a message — the system prompt and
|
|
237
|
+
* the tool definitions.
|
|
238
|
+
*
|
|
239
|
+
* Derived rather than guessed: the first measured prompt covers the messages
|
|
240
|
+
* before it plus that fixed preamble, so subtracting our estimate of those
|
|
241
|
+
* messages leaves the preamble. Floored at zero, since an over-estimate of the
|
|
242
|
+
* early messages would otherwise produce a negative overhead.
|
|
243
|
+
*
|
|
244
|
+
* It matters for the gate because it is real context the user never sees in the
|
|
245
|
+
* transcript, it does not shrink when history does, and on an agent with a large
|
|
246
|
+
* tool surface it is far from negligible.
|
|
247
|
+
*/
|
|
248
|
+
export function estimateSystemOverhead(history) {
|
|
249
|
+
const first = firstAnchor(history, compactedAtOf(history));
|
|
250
|
+
if (first < 0)
|
|
251
|
+
return 0;
|
|
252
|
+
const estimates = estimateMessageTokens(history);
|
|
253
|
+
let messages = 0;
|
|
254
|
+
for (let i = 0; i < first; i += 1)
|
|
255
|
+
messages += estimates[i];
|
|
256
|
+
return Math.max(0, history[first].inputTokens - messages);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Total estimated context for a transcript — the preamble plus every message.
|
|
260
|
+
*
|
|
261
|
+
* On a transcript with measured anchors this lands close to the last observed
|
|
262
|
+
* `inputTokens` by construction; the value of computing it rather than reading
|
|
263
|
+
* that figure is that it stays defined for a hypothetical history (the tail a
|
|
264
|
+
* compaction would leave behind), which is what {@link projectCompaction} needs.
|
|
265
|
+
*/
|
|
266
|
+
export function estimateContextTokens(history) {
|
|
267
|
+
const estimates = estimateMessageTokens(history);
|
|
268
|
+
let total = estimateSystemOverhead(history);
|
|
269
|
+
for (const t of estimates)
|
|
270
|
+
total += t;
|
|
271
|
+
return total;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Estimated size of the request that will actually be SENT, given the stored
|
|
275
|
+
* history it was derived from.
|
|
276
|
+
*
|
|
277
|
+
* The two differ by design and can differ by a lot. `condenseWhen` replaces spent
|
|
278
|
+
* tool payloads with short stubs, and multi-agent masking blanks another agent's
|
|
279
|
+
* payloads — both on a copy, leaving stored history full-fat for the UI and the
|
|
280
|
+
* log. A guard that measured stored history would therefore refuse turns whose
|
|
281
|
+
* real request is a fraction of the size.
|
|
282
|
+
*
|
|
283
|
+
* Naively estimating the transformed copy does NOT work either, and the reason is
|
|
284
|
+
* subtle: `inputTokens` anchors live on the stored messages and measured the
|
|
285
|
+
* untransformed prompt, so calibrating against them re-inflates the estimate back
|
|
286
|
+
* to roughly what it was before the transform — exactly the figure we were trying
|
|
287
|
+
* to avoid.
|
|
288
|
+
*
|
|
289
|
+
* So the calibrated total is scaled by how much smaller the request is in
|
|
290
|
+
* characters. That keeps the accuracy the measurements buy while still reflecting
|
|
291
|
+
* what the transform removed. The preamble is carried across unscaled: the system
|
|
292
|
+
* prompt and tool definitions are not what condensation touches.
|
|
293
|
+
*/
|
|
294
|
+
export function estimateRequestTokens(storedHistory, requestHistory) {
|
|
295
|
+
const overhead = estimateSystemOverhead(storedHistory);
|
|
296
|
+
const calibratedMessages = Math.max(0, estimateContextTokens(storedHistory) - overhead);
|
|
297
|
+
let storedBaseline = 0;
|
|
298
|
+
for (const m of storedHistory)
|
|
299
|
+
storedBaseline += baselineTokens(m);
|
|
300
|
+
let requestBaseline = 0;
|
|
301
|
+
for (const m of requestHistory)
|
|
302
|
+
requestBaseline += baselineTokens(m);
|
|
303
|
+
// No stored baseline to scale against (an all-display-only history): the
|
|
304
|
+
// request's own baseline is the best available answer.
|
|
305
|
+
if (storedBaseline <= 0)
|
|
306
|
+
return overhead + requestBaseline;
|
|
307
|
+
return Math.round(overhead + calibratedMessages * (requestBaseline / storedBaseline));
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Project the effect of compacting at `cut`, WITHOUT running the summarizer.
|
|
311
|
+
*
|
|
312
|
+
* This is what lets a gate tell the difference between "compaction will unblock
|
|
313
|
+
* you" and "compaction will spend a call and leave you exactly where you are" —
|
|
314
|
+
* a distinction the old boolean `canCompact()` could not express, because it
|
|
315
|
+
* answered only whether a legal cut existed. A legal cut that covers four short
|
|
316
|
+
* messages while a large tool loop sits in the tail is legal and useless.
|
|
317
|
+
*
|
|
318
|
+
* The system preamble is carried across unchanged: compaction removes messages,
|
|
319
|
+
* never the system prompt or the tool definitions.
|
|
320
|
+
*/
|
|
321
|
+
export function projectCompaction(history, cut, summaryTokens = COMPACTION_SUMMARY_TOKEN_ESTIMATE) {
|
|
322
|
+
const estimates = estimateMessageTokens(history);
|
|
323
|
+
const overhead = estimateSystemOverhead(history);
|
|
324
|
+
let tail = 0;
|
|
325
|
+
for (let i = cut; i < estimates.length; i += 1)
|
|
326
|
+
tail += estimates[i];
|
|
327
|
+
let all = 0;
|
|
328
|
+
for (const t of estimates)
|
|
329
|
+
all += t;
|
|
330
|
+
const tokensBefore = overhead + all;
|
|
331
|
+
const tokensAfter = overhead + summaryTokens + tail;
|
|
332
|
+
return {
|
|
333
|
+
cut,
|
|
334
|
+
compactedCount: cut,
|
|
335
|
+
tokensBefore,
|
|
336
|
+
tokensAfter,
|
|
337
|
+
reclaimed: Math.max(0, tokensBefore - tokensAfter),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { __rest } from "tslib";
|
|
2
|
+
import { estimateMessageTokens, projectCompaction, reachesProvider, } from './context-tokens';
|
|
2
3
|
/**
|
|
3
4
|
* Masks the tool-specific payload of a single message — clears tool call args
|
|
4
5
|
* and replaces tool result content with a placeholder. Used when passing history
|
|
@@ -40,32 +41,113 @@ export function applyHistoryCap(history, cap) {
|
|
|
40
41
|
const cutoff = history.length - cap;
|
|
41
42
|
return history.map((msg, i) => (i < cutoff ? maskToolPayload(msg) : msg));
|
|
42
43
|
}
|
|
43
|
-
// ───────────────────────────── Compaction (GENC-1351 §5.7) ─────────────────────────────
|
|
44
|
-
/** Recent messages kept verbatim when compacting; everything earlier is summarized. */
|
|
45
|
-
export const COMPACT_KEEP_RECENT_MESSAGES = 4;
|
|
46
|
-
/** Don't bother compacting unless at least this many messages would be summarized. */
|
|
47
|
-
export const COMPACT_MIN_MESSAGES_TO_COMPACT = 4;
|
|
44
|
+
// ───────────────────────────── Compaction (GENC-1351 §5.7, GENC-1567) ─────────────────────────────
|
|
48
45
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* when idle, so history is turn-complete). Returns `null` when there is nothing
|
|
54
|
-
* worth compacting (too short, or no earlier user boundary) — e.g. a freshly
|
|
55
|
-
* restored session whose only user turn is the current one.
|
|
46
|
+
* Default size of the verbatim tail a compaction leaves behind, in tokens —
|
|
47
|
+
* MESSAGES only, since the system preamble is not something compaction can
|
|
48
|
+
* remove. A caller with a resolved context limit should pass a budget derived
|
|
49
|
+
* from it rather than relying on this.
|
|
56
50
|
*/
|
|
57
|
-
export
|
|
58
|
-
|
|
51
|
+
export const DEFAULT_TAIL_TOKEN_BUDGET = 30000;
|
|
52
|
+
/**
|
|
53
|
+
* Default floor on what a compaction must reclaim to be worth running. Below
|
|
54
|
+
* this the summarizer call costs more than the space it buys — and, crucially,
|
|
55
|
+
* a gate that offered compaction as its escape would be promising a reclaim
|
|
56
|
+
* that does not clear the gate.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_MIN_RECLAIM_TOKENS = 5000;
|
|
59
|
+
/**
|
|
60
|
+
* Whether cutting at `i` — summarizing `[0, i)` and keeping `[i, end)` verbatim
|
|
61
|
+
* — leaves a request the provider will accept.
|
|
62
|
+
*
|
|
63
|
+
* The one hard constraint is tool pairing: a `tool_result` whose `tool_use` was
|
|
64
|
+
* summarized away is rejected outright by every provider. So the first message
|
|
65
|
+
* the tail actually SENDS must not be a tool result.
|
|
66
|
+
*
|
|
67
|
+
* This replaces the old "cut only on a `user` message" rule (GENC-1351), which
|
|
68
|
+
* was a proxy for the same invariant — a user message never appears between a
|
|
69
|
+
* tool call and its results, so it was guaranteed safe without any pairing
|
|
70
|
+
* check. The proxy was sound but far too narrow: an agentic tool loop contains
|
|
71
|
+
* no user messages at all, so the whole loop was one indivisible block and a
|
|
72
|
+
* runaway turn could not be compacted at any point. Checking the invariant
|
|
73
|
+
* directly opens up every gap between tool groups (GENC-1567).
|
|
74
|
+
*
|
|
75
|
+
* Display-only rows are skipped when looking for that first sent message: a
|
|
76
|
+
* `system-event` (e.g. "Stopped.") sitting between an `assistant(toolCalls)` and
|
|
77
|
+
* its results reaches no transport, so cutting in front of it would still orphan
|
|
78
|
+
* the results behind it.
|
|
79
|
+
*/
|
|
80
|
+
export function isSafeCompactionCut(history, i) {
|
|
81
|
+
if (i <= 0 || i >= history.length)
|
|
82
|
+
return false;
|
|
83
|
+
for (let j = i; j < history.length; j += 1) {
|
|
84
|
+
if (!reachesProvider(history[j]))
|
|
85
|
+
continue;
|
|
86
|
+
return history[j].role !== 'tool';
|
|
87
|
+
}
|
|
88
|
+
// A tail of nothing but display-only rows sends no tool result, so nothing can
|
|
89
|
+
// be orphaned.
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Index at which the verbatim tail begins — everything before it gets summarized
|
|
94
|
+
* — chosen so the tail fits `tailTokenBudget` (GENC-1567).
|
|
95
|
+
*
|
|
96
|
+
* Walks back from the end accumulating estimated tokens until the next message
|
|
97
|
+
* would overrun the budget, then snaps FORWARD to the nearest safe boundary.
|
|
98
|
+
* Forward is the correct direction: it can only shrink the tail, so the budget
|
|
99
|
+
* still holds after the snap, whereas snapping backwards could blow it.
|
|
100
|
+
*
|
|
101
|
+
* Returns `null` when the whole transcript already fits (nothing worth doing) or
|
|
102
|
+
* when no safe boundary exists ahead of the walk — e.g. a tail that is one
|
|
103
|
+
* enormous, indivisible tool group, which no cut can shrink because a cut cannot
|
|
104
|
+
* split a message.
|
|
105
|
+
*/
|
|
106
|
+
export function findCompactionCut(history, tailTokenBudget = DEFAULT_TAIL_TOKEN_BUDGET) {
|
|
107
|
+
const estimates = estimateMessageTokens(history);
|
|
108
|
+
let messagesTotal = 0;
|
|
109
|
+
for (const t of estimates)
|
|
110
|
+
messagesTotal += t;
|
|
111
|
+
if (messagesTotal <= tailTokenBudget)
|
|
59
112
|
return null;
|
|
113
|
+
let tail = 0;
|
|
114
|
+
let raw = history.length;
|
|
115
|
+
for (let i = history.length - 1; i > 0; i -= 1) {
|
|
116
|
+
if (tail + estimates[i] > tailTokenBudget)
|
|
117
|
+
break;
|
|
118
|
+
tail += estimates[i];
|
|
119
|
+
raw = i;
|
|
60
120
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return i >= COMPACT_MIN_MESSAGES_TO_COMPACT ? i : null;
|
|
65
|
-
}
|
|
121
|
+
for (let i = raw; i < history.length; i += 1) {
|
|
122
|
+
if (isSafeCompactionCut(history, i))
|
|
123
|
+
return i;
|
|
66
124
|
}
|
|
67
125
|
return null;
|
|
68
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* The single answer to "what would compacting do right now, and is it worth it?"
|
|
129
|
+
*
|
|
130
|
+
* Every consumer reads this one function — the menu's enabled state, its
|
|
131
|
+
* tooltip, the gate's banner copy, and the action itself — so the affordance can
|
|
132
|
+
* never promise something the action does not deliver. That is the same
|
|
133
|
+
* one-source-of-truth argument that produced `canCompact()` (the GENC-1351
|
|
134
|
+
* follow-up which stopped the gate reading a different history from the action);
|
|
135
|
+
* this extends it from *legality* to *sufficiency*, which is what a blocking
|
|
136
|
+
* gate needs. A legal cut that covers four short messages while a large tool
|
|
137
|
+
* loop sits in the tail is legal and useless, and the old boolean could not say
|
|
138
|
+
* so.
|
|
139
|
+
*
|
|
140
|
+
* Returns `null` when there is no safe cut, or when the projected reclaim does
|
|
141
|
+
* not clear `minReclaimTokens`.
|
|
142
|
+
*/
|
|
143
|
+
export function planCompaction(history, options = {}) {
|
|
144
|
+
const { tailTokenBudget = DEFAULT_TAIL_TOKEN_BUDGET, minReclaimTokens = DEFAULT_MIN_RECLAIM_TOKENS, } = options;
|
|
145
|
+
const cut = findCompactionCut(history, tailTokenBudget);
|
|
146
|
+
if (cut == null)
|
|
147
|
+
return null;
|
|
148
|
+
const projection = projectCompaction(history, cut);
|
|
149
|
+
return projection.reclaimed >= minReclaimTokens ? projection : null;
|
|
150
|
+
}
|
|
69
151
|
/** One-line rendering of a message for the summarizer transcript. */
|
|
70
152
|
function renderMessageForSummary(m) {
|
|
71
153
|
var _a;
|