@genesislcap/ai-assistant 15.19.6 → 15.20.0
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 +605 -72
- package/dist/ai-assistant.d.ts +404 -25
- package/dist/chat-driver.cjs +341 -28
- package/dist/chat-driver.cjs.map +4 -4
- package/dist/chat-driver.mjs +341 -28
- package/dist/chat-driver.mjs.map +4 -4
- package/dist/custom-elements.json +630 -20
- package/dist/dts/components/ai-driver/ai-driver.d.ts +33 -7
- package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +63 -2
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +9 -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/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 +179 -34
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +12 -4
- package/dist/esm/main/main.js +391 -21
- 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/utils/condense-history.js +1 -5
- 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
|
@@ -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;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { COMPACTION_SUMMARY_TOKEN_ESTIMATE } from './context-tokens';
|
|
2
|
+
// Re-exported, not redeclared: this module and `planCompaction` must apply the
|
|
3
|
+
// SAME worth-it floor, or a host passing explicit options and a caller relying
|
|
4
|
+
// on the module default would silently disagree about whether a compaction is
|
|
5
|
+
// worth running. `history-transform` owns it because that is where the plan is
|
|
6
|
+
// made; this module only threads it through.
|
|
7
|
+
import { DEFAULT_MIN_RECLAIM_TOKENS } from './history-transform';
|
|
8
|
+
export { DEFAULT_MIN_RECLAIM_TOKENS };
|
|
9
|
+
/**
|
|
10
|
+
* Context headroom thresholds (GENC-1567) — resolving `chatConfig.context`,
|
|
11
|
+
* the active model's window, and any per-agent reserve into the four numbers
|
|
12
|
+
* the gate and the compaction planner actually read.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Tokens kept free for a single turn by default.
|
|
16
|
+
*
|
|
17
|
+
* A turn is not atomic in cost: a send allowed just under a threshold can run a
|
|
18
|
+
* long tool loop and add a great deal on its own, so what has to be bounded is
|
|
19
|
+
* the growth of ONE turn — a token count, not a fraction of the window. On a
|
|
20
|
+
* 200k model this leaves ~12% free; on a 1M model the same absolute room, rather
|
|
21
|
+
* than the 200k a percentage rule would strand.
|
|
22
|
+
*
|
|
23
|
+
* Chosen as a starting position, not a measured figure. Per-turn `inputTokens`
|
|
24
|
+
* is recorded for every call, so this should be revisited against real traffic.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_RESERVE_TOKENS = 25000;
|
|
27
|
+
/** Warning appears this many reserves ahead of the block. */
|
|
28
|
+
export const DEFAULT_WARN_MULTIPLIER = 2;
|
|
29
|
+
/**
|
|
30
|
+
* How close to the window a running turn is allowed to get before the driver
|
|
31
|
+
* stops it (GENC-1567). Roughly one more tool result plus a reply — enough that
|
|
32
|
+
* the next request would very likely be refused.
|
|
33
|
+
*/
|
|
34
|
+
export const CONTEXT_ABORT_MARGIN_TOKENS = 4096;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the headroom thresholds for the active model and agent.
|
|
37
|
+
*
|
|
38
|
+
* Everything downstream — the warning, the block, the compaction planner and the
|
|
39
|
+
* banner copy — reads this one result, so the thresholds a user is judged
|
|
40
|
+
* against and the budget compaction aims at can never disagree.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveContextBudget(input) {
|
|
43
|
+
var _a, _b, _c;
|
|
44
|
+
const { chatConfig, contextLimit, agentReserveTokens, systemOverhead = 0 } = input;
|
|
45
|
+
const cfg = chatConfig.context;
|
|
46
|
+
const reserveTokens = Math.max(0, (_a = agentReserveTokens !== null && agentReserveTokens !== void 0 ? agentReserveTokens : cfg === null || cfg === void 0 ? void 0 : cfg.reserveTokens) !== null && _a !== void 0 ? _a : DEFAULT_RESERVE_TOKENS);
|
|
47
|
+
const warnMultiplier = Math.max(1, (_b = cfg === null || cfg === void 0 ? void 0 : cfg.warnMultiplier) !== null && _b !== void 0 ? _b : DEFAULT_WARN_MULTIPLIER);
|
|
48
|
+
const minReclaimTokens = Math.max(0, (_c = cfg === null || cfg === void 0 ? void 0 : cfg.minReclaimTokens) !== null && _c !== void 0 ? _c : DEFAULT_MIN_RECLAIM_TOKENS);
|
|
49
|
+
const limit = contextLimit !== null && contextLimit !== void 0 ? contextLimit : cfg === null || cfg === void 0 ? void 0 : cfg.fallbackContextLimit;
|
|
50
|
+
// No limit → no thresholds. The gate stays inert rather than guessing a window
|
|
51
|
+
// and blocking the composer against the guess.
|
|
52
|
+
if (limit == null || limit <= 0) {
|
|
53
|
+
return {
|
|
54
|
+
enabled: false,
|
|
55
|
+
limit: undefined,
|
|
56
|
+
reserveTokens,
|
|
57
|
+
tailTokenBudget: Math.max(0, reserveTokens),
|
|
58
|
+
minReclaimTokens,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const enabled = (cfg === null || cfg === void 0 ? void 0 : cfg.enabled) !== false;
|
|
62
|
+
const blockAt = Math.max(0, limit - reserveTokens);
|
|
63
|
+
const warnAt = Math.max(0, limit - warnMultiplier * reserveTokens);
|
|
64
|
+
// Aim compaction at the warning line, and leave room for the summary itself.
|
|
65
|
+
// Floored at one reserve so a pathological config (a reserve wider than the
|
|
66
|
+
// window) still asks for a tail rather than an impossible zero.
|
|
67
|
+
const tailTokenBudget = Math.max(reserveTokens, warnAt - systemOverhead - COMPACTION_SUMMARY_TOKEN_ESTIMATE);
|
|
68
|
+
return {
|
|
69
|
+
enabled,
|
|
70
|
+
limit,
|
|
71
|
+
reserveTokens,
|
|
72
|
+
warnAt,
|
|
73
|
+
blockAt,
|
|
74
|
+
tailTokenBudget,
|
|
75
|
+
minReclaimTokens,
|
|
76
|
+
// Withheld when the host opted out. `enabled: false` is documented as "no
|
|
77
|
+
// warning and no block", and a host that would rather take the provider's
|
|
78
|
+
// answer than ever have its composer disabled means the mid-loop guard too —
|
|
79
|
+
// otherwise the visible gate disappears as asked while a long tool loop still
|
|
80
|
+
// dies with `context-exhausted`, which is the same refusal wearing a
|
|
81
|
+
// different hat.
|
|
82
|
+
abortAt: enabled ? Math.max(blockAt, limit - CONTEXT_ABORT_MARGIN_TOKENS) : undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
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.
|
|
4
|
+
"version": "15.20.0",
|
|
5
5
|
"license": "SEE LICENSE IN license.txt",
|
|
6
6
|
"main": "dist/esm/index.js",
|
|
7
7
|
"types": "dist/ai-assistant.d.ts",
|
|
@@ -74,26 +74,26 @@
|
|
|
74
74
|
}
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@genesislcap/foundation-testing": "15.
|
|
78
|
-
"@genesislcap/genx": "15.
|
|
79
|
-
"@genesislcap/rollup-builder": "15.
|
|
80
|
-
"@genesislcap/ts-builder": "15.
|
|
81
|
-
"@genesislcap/uvu-playwright-builder": "15.
|
|
82
|
-
"@genesislcap/vite-builder": "15.
|
|
83
|
-
"@genesislcap/webpack-builder": "15.
|
|
77
|
+
"@genesislcap/foundation-testing": "15.20.0",
|
|
78
|
+
"@genesislcap/genx": "15.20.0",
|
|
79
|
+
"@genesislcap/rollup-builder": "15.20.0",
|
|
80
|
+
"@genesislcap/ts-builder": "15.20.0",
|
|
81
|
+
"@genesislcap/uvu-playwright-builder": "15.20.0",
|
|
82
|
+
"@genesislcap/vite-builder": "15.20.0",
|
|
83
|
+
"@genesislcap/webpack-builder": "15.20.0",
|
|
84
84
|
"@types/dompurify": "^3.0.5",
|
|
85
85
|
"@types/marked": "^5.0.2",
|
|
86
86
|
"esbuild": "0.25.12"
|
|
87
87
|
},
|
|
88
88
|
"dependencies": {
|
|
89
|
-
"@genesislcap/foundation-ai": "15.
|
|
90
|
-
"@genesislcap/foundation-logger": "15.
|
|
91
|
-
"@genesislcap/foundation-notifications": "15.
|
|
92
|
-
"@genesislcap/foundation-redux": "15.
|
|
93
|
-
"@genesislcap/foundation-ui": "15.
|
|
94
|
-
"@genesislcap/foundation-utils": "15.
|
|
95
|
-
"@genesislcap/rapid-design-system": "15.
|
|
96
|
-
"@genesislcap/web-core": "15.
|
|
89
|
+
"@genesislcap/foundation-ai": "15.20.0",
|
|
90
|
+
"@genesislcap/foundation-logger": "15.20.0",
|
|
91
|
+
"@genesislcap/foundation-notifications": "15.20.0",
|
|
92
|
+
"@genesislcap/foundation-redux": "15.20.0",
|
|
93
|
+
"@genesislcap/foundation-ui": "15.20.0",
|
|
94
|
+
"@genesislcap/foundation-utils": "15.20.0",
|
|
95
|
+
"@genesislcap/rapid-design-system": "15.20.0",
|
|
96
|
+
"@genesislcap/web-core": "15.20.0",
|
|
97
97
|
"dompurify": "^3.3.1",
|
|
98
98
|
"marked": "^17.0.3"
|
|
99
99
|
},
|