@womp/spark-agent 6.5.0 → 8.0.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/CHANGELOG.md +86 -0
- package/README.md +7 -4
- package/dist/agent.d.ts +3 -6
- package/dist/agent.js +41 -23
- package/dist/capabilities/geometry.d.ts +17 -5
- package/dist/capabilities/geometry.js +11 -12
- package/dist/compaction.d.ts +41 -8
- package/dist/compaction.js +241 -77
- package/dist/context-window.d.ts +23 -0
- package/dist/context-window.js +141 -0
- package/dist/generated/geometry-declarations.d.ts +1 -1
- package/dist/generated/geometry-declarations.js +1 -1
- package/dist/geometry/backendRegistry.d.ts +1 -0
- package/dist/geometry/common.d.ts +5 -2
- package/dist/geometry/common.js +18 -3
- package/dist/geometry/compiler.js +2 -2
- package/dist/geometry/diagnostics.d.ts +2 -7
- package/dist/geometry/diagnostics.js +3 -51
- package/dist/geometry/graph.d.ts +1 -1
- package/dist/geometry/graph.js +1 -0
- package/dist/geometry/index.d.ts +0 -1
- package/dist/geometry/index.js +0 -3
- package/dist/geometry/lift.js +105 -113
- package/dist/geometry/lower.js +29 -49
- package/dist/geometry/schemas/flowir-dialect.json +117 -117
- package/dist/geometry/writeParamData.js +4 -4
- package/dist/geometry.d.ts +2 -5
- package/dist/geometry.js +7 -11
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/intent-bench.js +1 -1
- package/dist/sdk-geometry-host.d.ts +1 -1
- package/dist/sdk-geometry-host.js +253 -251
- package/dist/ui.d.ts +7 -0
- package/dist/ui.js +1 -0
- package/dist/womp-scene-api.d.ts +9 -1
- package/dist/womp-scene-api.js +41 -32
- package/package.json +2 -2
- package/dist/context-budget.d.ts +0 -65
- package/dist/context-budget.js +0 -364
package/dist/compaction.js
CHANGED
|
@@ -1,25 +1,44 @@
|
|
|
1
1
|
// Replace a long conversation's earlier messages with a summary.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// This is the only thing that takes messages out of what the model reads. Nothing
|
|
4
|
+
// trims a request to fit the window: a request still over it after compaction is
|
|
5
|
+
// sent as it is, and fails with the provider's own error.
|
|
6
|
+
//
|
|
7
|
+
// WHEN. Before every step of a turn, once the conversation reaches
|
|
8
|
+
// COMPACTION_THRESHOLD_TOKENS (or the model's window less its output, if that is
|
|
9
|
+
// lower):
|
|
10
|
+
//
|
|
11
|
+
// - Before the first step (`compactSparkHistory`), everything before the most
|
|
12
|
+
// recent COMPACTION_KEEP_RECENT_TOKENS of whole turns is summarized.
|
|
13
|
+
// - Before a later step (`createStepCompaction`), the turn has grown by its own
|
|
14
|
+
// work: everything before the current user message, and the turn's steps
|
|
15
|
+
// before its most recent COMPACTION_KEEP_RECENT_TOKENS, is summarized. The
|
|
16
|
+
// user's message itself is kept as it is. A compaction that would save less
|
|
17
|
+
// than a quarter of the threshold is not made, so a turn whose own message is
|
|
18
|
+
// over the threshold does not summarize again on every step.
|
|
19
|
+
//
|
|
20
|
+
// A summary that fails leaves the step as it was.
|
|
9
21
|
//
|
|
10
22
|
// WHERE IT IS KEPT. The summary is a `data-compaction` part on the assistant
|
|
11
23
|
// message of the turn that wrote it (SparkCompaction, ./ui.ts), so every host
|
|
12
24
|
// stores and reloads it with the message, and the chat still shows every
|
|
13
25
|
// message it replaces. `applyCompaction`, called by `convertSparkMessages`,
|
|
14
|
-
// sends the latest one in place of the messages before `firstKeptMessageId
|
|
15
|
-
//
|
|
16
|
-
//
|
|
26
|
+
// sends the latest one in place of the messages before `firstKeptMessageId` --
|
|
27
|
+
// and, for one written during its turn, in place of that message's steps before
|
|
28
|
+
// `firstKeptStep`. A later compaction is written from the conversation as the
|
|
29
|
+
// model then sees it -- the earlier summary first -- so the summary is updated,
|
|
30
|
+
// not restarted.
|
|
31
|
+
//
|
|
32
|
+
// WHAT IS SUMMARIZED. Only the messages the summary replaces. A kept message
|
|
33
|
+
// it also told of would come after it again, and the model would take that work
|
|
34
|
+
// for done twice.
|
|
17
35
|
//
|
|
18
|
-
// THE CACHE. The summary request is
|
|
19
|
-
//
|
|
20
|
-
// provider's prompt cache serves almost all of it. Tool definitions are sent
|
|
36
|
+
// THE CACHE. The summary request is an earlier request -- the same system
|
|
37
|
+
// prompt, tool definitions and messages, up to the kept ones -- plus one
|
|
38
|
+
// instruction, so the provider's prompt cache serves almost all of it. Tool definitions are sent
|
|
21
39
|
// without `execute`: a call the model makes anyway is never run, and fails the
|
|
22
|
-
// summary instead.
|
|
40
|
+
// summary instead. The messages a step is sent after a compaction are the ones a
|
|
41
|
+
// later turn rebuilds from storage, so the next turn's prefix is cached too.
|
|
23
42
|
//
|
|
24
43
|
// SKILLS. A skill read in the summarized messages would otherwise be lost with
|
|
25
44
|
// them. The compaction names them, and its summary message carries their
|
|
@@ -30,15 +49,17 @@
|
|
|
30
49
|
// COMPACTION_USER_IMAGES of them too, as the same file parts; the summary
|
|
31
50
|
// describes the rest. They are still on the stored messages: nothing records them.
|
|
32
51
|
import { generateText } from "ai";
|
|
33
|
-
import { CHARS_PER_TOKEN, IMAGE_TOKENS,
|
|
52
|
+
import { CHARS_PER_TOKEN, IMAGE_TOKENS, estimateMessagesTokens, estimateRequestTokens } from "./context-window.js";
|
|
34
53
|
import { sparkDataPartSchemas } from "./ui.js";
|
|
35
54
|
import { userImages } from "./user-images.js";
|
|
36
55
|
/** Compact once the conversation reaches this many tokens. */
|
|
37
56
|
export const COMPACTION_THRESHOLD_TOKENS = 256_000;
|
|
38
|
-
/** Recent whole turns kept verbatim, up to this many tokens
|
|
57
|
+
/** Recent whole turns, or recent steps of the current turn, kept verbatim, up to this many tokens. */
|
|
39
58
|
export const COMPACTION_KEEP_RECENT_TOKENS = 16_000;
|
|
40
59
|
/** The user's newest images from the summarized messages that the summary message carries. */
|
|
41
60
|
export const COMPACTION_USER_IMAGES = 4;
|
|
61
|
+
/** A compaction during a turn saves at least this share of the threshold, or is not made. */
|
|
62
|
+
export const COMPACTION_MIN_SAVED_RATIO = 0.25;
|
|
42
63
|
export const COMPACTION_PROMPT = `Write a checkpoint summary of this conversation so far. It replaces the earlier messages in your context, so the work must be able to continue from it alone. Reply with the summary only, without calling tools. This request is not part of the conversation: leave it out of the summary.
|
|
43
64
|
If the conversation begins with an earlier summary, carry its content forward and update it.
|
|
44
65
|
Use these sections:
|
|
@@ -51,22 +72,23 @@ Use these sections:
|
|
|
51
72
|
## Errors and approaches to avoid
|
|
52
73
|
Keep exact names, IDs, numbers and the user's own wording for requirements. Stay under 1,500 words.`;
|
|
53
74
|
export const COMPACTION_NOTE = "[Earlier messages in this conversation were replaced by the summary below. The scene itself is unchanged; inspect it for its exact state.]";
|
|
75
|
+
export const COMPACTION_STEPS_NOTE = "[Earlier messages in this conversation, and the earlier steps of your work on the message after this one, were replaced by the summary below. The scene itself is unchanged; inspect it for its exact state.]";
|
|
54
76
|
/** The latest compaction in the conversation whose kept message is still in it. */
|
|
55
77
|
export function latestCompaction(history) {
|
|
56
78
|
for (let carrier = history.length - 1; carrier >= 0; carrier -= 1) {
|
|
57
79
|
const message = history[carrier];
|
|
58
80
|
if (message?.role !== "assistant")
|
|
59
81
|
continue;
|
|
60
|
-
for (let
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
82
|
+
for (let part = message.parts.length - 1; part >= 0; part -= 1) {
|
|
83
|
+
const value = message.parts[part];
|
|
84
|
+
if (value?.type !== "data-compaction")
|
|
63
85
|
continue;
|
|
64
|
-
const parsed = sparkDataPartSchemas.compaction.safeParse(
|
|
86
|
+
const parsed = sparkDataPartSchemas.compaction.safeParse(value.data);
|
|
65
87
|
if (!parsed.success)
|
|
66
88
|
continue;
|
|
67
89
|
const firstKept = history.findIndex(({ id }) => id === parsed.data.firstKeptMessageId);
|
|
68
90
|
if (firstKept >= 0)
|
|
69
|
-
return { compaction: parsed.data, carrier, firstKept };
|
|
91
|
+
return { compaction: parsed.data, carrier, part, firstKept };
|
|
70
92
|
}
|
|
71
93
|
}
|
|
72
94
|
return undefined;
|
|
@@ -74,21 +96,37 @@ export function latestCompaction(history) {
|
|
|
74
96
|
/**
|
|
75
97
|
* The conversation as the model reads it: the messages before the compaction's
|
|
76
98
|
* kept message replaced by its summary, with the newest COMPACTION_USER_IMAGES
|
|
77
|
-
* images the user attached to them
|
|
78
|
-
*
|
|
79
|
-
*
|
|
99
|
+
* images the user attached to them, and -- for the latest compaction, when it was
|
|
100
|
+
* written during its turn -- the recording message's steps before
|
|
101
|
+
* `firstKeptStep` left out with them. `compaction` defaults to the latest one in
|
|
102
|
+
* `history`; `skills` are the host's, to send the current text of the skills it
|
|
103
|
+
* names. Without a compaction, `history` is returned as it is.
|
|
80
104
|
*/
|
|
81
105
|
export function applyCompaction(history, options = {}) {
|
|
82
|
-
const
|
|
106
|
+
const latest = options.compaction === undefined ? latestCompaction(history) : undefined;
|
|
107
|
+
const compaction = options.compaction ?? latest?.compaction;
|
|
83
108
|
if (compaction === undefined)
|
|
84
109
|
return history;
|
|
85
110
|
const firstKept = history.findIndex(({ id }) => id === compaction.firstKeptMessageId);
|
|
86
111
|
if (firstKept < 0)
|
|
87
112
|
return history;
|
|
113
|
+
const kept = history.slice(firstKept);
|
|
114
|
+
const carrier = latest === undefined ? -1 : latest.carrier - firstKept;
|
|
115
|
+
if (compaction.firstKeptStep !== undefined && carrier >= 0) {
|
|
116
|
+
kept[carrier] = fromStep(kept[carrier], compaction.firstKeptStep);
|
|
117
|
+
}
|
|
118
|
+
return [compactionMessage(history, firstKept, compaction, options.skills), ...kept];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The message the model reads in place of the summarized ones: the note, the
|
|
122
|
+
* skills, the user's newest images from before `firstKept`, then the summary.
|
|
123
|
+
*/
|
|
124
|
+
function compactionMessage(history, firstKept, compaction, available) {
|
|
88
125
|
// The skills go before the summary, in a part of their own: they change less
|
|
89
126
|
// often, so the next compaction leaves them in the cached prefix.
|
|
90
|
-
const skills = skillsText(compaction.skills,
|
|
91
|
-
const
|
|
127
|
+
const skills = skillsText(compaction.skills, available);
|
|
128
|
+
const note = compaction.firstKeptStep === undefined ? COMPACTION_NOTE : COMPACTION_STEPS_NOTE;
|
|
129
|
+
const parts = [{ type: "text", text: note }];
|
|
92
130
|
if (skills !== undefined)
|
|
93
131
|
parts.push({ type: "text", text: skills });
|
|
94
132
|
const shown = new Set(userImages(history.slice(firstKept)).map(({ url }) => url));
|
|
@@ -99,7 +137,17 @@ export function applyCompaction(history, options = {}) {
|
|
|
99
137
|
parts.push({ type: "text", text: "The newest images the user attached to the messages the summary replaces, in the order sent:" }, ...images);
|
|
100
138
|
}
|
|
101
139
|
parts.push({ type: "text", text: `<conversation-summary>\n${compaction.summary}\n</conversation-summary>` });
|
|
102
|
-
return
|
|
140
|
+
return { id: `compaction-${compaction.firstKeptMessageId}`, role: "user", parts };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* `message` from its `step`th step on, counted from 0 by its `step-start` parts.
|
|
144
|
+
* A message with fewer steps than that is kept whole: sending a step twice, once
|
|
145
|
+
* summarized, is better than losing one.
|
|
146
|
+
*/
|
|
147
|
+
function fromStep(message, step) {
|
|
148
|
+
let seen = -1;
|
|
149
|
+
const start = message.parts.findIndex((part) => part.type === "step-start" && ++seen === step);
|
|
150
|
+
return start < 0 ? message : { ...message, parts: message.parts.slice(start) };
|
|
103
151
|
}
|
|
104
152
|
function skillsText(names, available) {
|
|
105
153
|
if (names.length === 0)
|
|
@@ -123,35 +171,27 @@ function skillsText(names, available) {
|
|
|
123
171
|
export async function compactSparkHistory(options) {
|
|
124
172
|
const { history } = options;
|
|
125
173
|
const messages = await options.convert();
|
|
126
|
-
const threshold = Math.min(options.limits?.thresholdTokens ?? COMPACTION_THRESHOLD_TOKENS, options.contextWindow === undefined ? Infinity : options.contextWindow - (options.maxOutputTokens ?? 0));
|
|
127
174
|
const previous = latestCompaction(history);
|
|
128
|
-
const size = conversationTokens(history, previous
|
|
129
|
-
if (size < threshold)
|
|
175
|
+
const size = conversationTokens(history, previous) ?? estimate(messages, options);
|
|
176
|
+
if (size < threshold(options))
|
|
130
177
|
return { messages };
|
|
131
178
|
const firstKept = cutIndex(history, previous?.firstKept ?? 0, options.limits?.keepRecentTokens ?? COMPACTION_KEEP_RECENT_TOKENS);
|
|
132
179
|
if (firstKept === undefined)
|
|
133
180
|
return { messages };
|
|
134
|
-
// The
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
181
|
+
// The summary covers what it replaces and nothing it keeps: a kept message it
|
|
182
|
+
// also told of would read as done twice. Those are the messages before the
|
|
183
|
+
// kept tail, which ends a request the provider has cached.
|
|
184
|
+
const kept = keptTail(messages, await options.convert({ ...placeholder(options), firstKeptMessageId: history[firstKept].id }));
|
|
185
|
+
const end = Math.min(messages.length - kept, lastIndex(messages, ({ role }) => role === "user"));
|
|
186
|
+
if (end <= 0)
|
|
138
187
|
return { messages };
|
|
139
|
-
|
|
140
|
-
let summary;
|
|
141
|
-
try {
|
|
142
|
-
summary = await summarize(messages.slice(0, current), options);
|
|
143
|
-
}
|
|
144
|
-
catch (error) {
|
|
145
|
-
if (options.signal?.aborted)
|
|
146
|
-
throw error;
|
|
147
|
-
}
|
|
148
|
-
options.signal?.throwIfAborted();
|
|
188
|
+
const summary = await summarize(messages.slice(0, end), options);
|
|
149
189
|
if (summary === undefined)
|
|
150
190
|
return { messages };
|
|
151
|
-
const
|
|
191
|
+
const keptSkills = readSkills(history.slice(firstKept));
|
|
152
192
|
const skills = [
|
|
153
193
|
...new Set([...(previous?.compaction.skills ?? []), ...readSkills(history.slice(previous?.firstKept ?? 0, firstKept))]),
|
|
154
|
-
].filter((name) => !
|
|
194
|
+
].filter((name) => !keptSkills.has(name));
|
|
155
195
|
const compaction = {
|
|
156
196
|
firstKeptMessageId: history[firstKept].id,
|
|
157
197
|
summary,
|
|
@@ -165,29 +205,135 @@ export async function compactSparkHistory(options) {
|
|
|
165
205
|
compaction.tokensAfter = estimate(compacted, options);
|
|
166
206
|
return { messages: compacted, compaction };
|
|
167
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Compaction before each later step of a turn. Returns the step's messages,
|
|
210
|
+
* compacted when the conversation has reached the threshold; the AI SDK carries
|
|
211
|
+
* what a step was sent into the next, so a compaction holds for the rest of the
|
|
212
|
+
* turn.
|
|
213
|
+
*/
|
|
214
|
+
export function createStepCompaction(options) {
|
|
215
|
+
const { history } = options;
|
|
216
|
+
const request = history[history.length - 1];
|
|
217
|
+
let previous = options.previous;
|
|
218
|
+
/** Steps of the turn already summarized. */
|
|
219
|
+
let summarizedSteps = 0;
|
|
220
|
+
return async (messages, last) => {
|
|
221
|
+
if (request?.role !== "user")
|
|
222
|
+
return messages;
|
|
223
|
+
const current = lastIndex(messages, ({ role }) => role === "user");
|
|
224
|
+
const lastStep = lastIndex(messages, ({ role }) => role === "assistant");
|
|
225
|
+
if (current < 0 || lastStep < current)
|
|
226
|
+
return messages;
|
|
227
|
+
// The provider's count for the step before, plus the results that came after it.
|
|
228
|
+
const size = last?.inputTokens !== undefined
|
|
229
|
+
? last.inputTokens + (last.outputTokens ?? 0) + estimateMessagesTokens(messages.slice(lastStep + 1))
|
|
230
|
+
: estimate(messages, options);
|
|
231
|
+
const limit = threshold(options);
|
|
232
|
+
if (size < limit)
|
|
233
|
+
return messages;
|
|
234
|
+
// The turn's steps kept: the most recent that fit, whole, each an assistant
|
|
235
|
+
// message and the tool results after it.
|
|
236
|
+
const keepRecent = options.limits?.keepRecentTokens ?? COMPACTION_KEEP_RECENT_TOKENS;
|
|
237
|
+
let firstKept = messages.length;
|
|
238
|
+
for (let i = messages.length - 1; i > current; i -= 1) {
|
|
239
|
+
if (messages[i].role !== "assistant")
|
|
240
|
+
continue;
|
|
241
|
+
if (estimateMessagesTokens(messages.slice(i)) > keepRecent)
|
|
242
|
+
break;
|
|
243
|
+
firstKept = i;
|
|
244
|
+
}
|
|
245
|
+
const steps = summarizedSteps + messages.slice(current + 1, firstKept).filter(({ role }) => role === "assistant").length;
|
|
246
|
+
const compaction = (summary) => ({
|
|
247
|
+
firstKeptMessageId: request.id,
|
|
248
|
+
...(steps > 0 ? { firstKeptStep: steps } : {}),
|
|
249
|
+
summary,
|
|
250
|
+
skills: summarizedSkills(messages, current, firstKept),
|
|
251
|
+
modelId: options.modelId,
|
|
252
|
+
tokensBefore: estimate(messages, options),
|
|
253
|
+
tokensAfter: 0,
|
|
254
|
+
createdAt: new Date().toISOString(),
|
|
255
|
+
});
|
|
256
|
+
const compacted = async (value) => [
|
|
257
|
+
...(await options.convert([compactionMessage(history, history.length - 1, value, options.skills)])),
|
|
258
|
+
messages[current],
|
|
259
|
+
...messages.slice(firstKept),
|
|
260
|
+
];
|
|
261
|
+
// A summary costs a request; one that saves too little is not worth it.
|
|
262
|
+
const placeholder = await compacted(compaction("x".repeat(1_500 * 6)));
|
|
263
|
+
if (estimate(messages, options) - estimate(placeholder, options) < limit * COMPACTION_MIN_SAVED_RATIO)
|
|
264
|
+
return messages;
|
|
265
|
+
// Only the steps it replaces: a kept step it also told of would read as done
|
|
266
|
+
// twice. They end the request of the first kept step, which the provider has
|
|
267
|
+
// cached.
|
|
268
|
+
const summary = await summarize(messages.slice(0, firstKept), options);
|
|
269
|
+
if (summary === undefined)
|
|
270
|
+
return messages;
|
|
271
|
+
const next = compaction(summary);
|
|
272
|
+
const sent = await compacted(next);
|
|
273
|
+
next.tokensAfter = estimate(sent, options);
|
|
274
|
+
previous = next;
|
|
275
|
+
summarizedSteps = steps;
|
|
276
|
+
options.onCompaction(next);
|
|
277
|
+
return sent;
|
|
278
|
+
};
|
|
279
|
+
/** Skills read in what this compaction summarizes and not read again in what it keeps. */
|
|
280
|
+
function summarizedSkills(messages, current, firstKept) {
|
|
281
|
+
const from = previous === undefined ? 0 : Math.max(0, history.findIndex(({ id }) => id === previous.firstKeptMessageId));
|
|
282
|
+
const kept = modelSkills(messages.slice(firstKept));
|
|
283
|
+
return [
|
|
284
|
+
...new Set([
|
|
285
|
+
...(previous?.skills ?? []),
|
|
286
|
+
...readSkills(history.slice(from, history.length - 1)),
|
|
287
|
+
...modelSkills(messages.slice(current + 1, firstKept)),
|
|
288
|
+
]),
|
|
289
|
+
].filter((name) => !kept.has(name));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** A compaction to measure with, before its summary is written. */
|
|
293
|
+
function placeholder(options) {
|
|
294
|
+
return { summary: "", skills: [], modelId: options.modelId, tokensBefore: 0, tokensAfter: 0, createdAt: new Date(0).toISOString() };
|
|
295
|
+
}
|
|
296
|
+
/** How many of `messages`' last messages `compacted` still sends as they are. */
|
|
297
|
+
function keptTail(messages, compacted) {
|
|
298
|
+
let kept = 0;
|
|
299
|
+
while (kept < messages.length &&
|
|
300
|
+
kept < compacted.length - 1 &&
|
|
301
|
+
JSON.stringify(messages[messages.length - 1 - kept]) === JSON.stringify(compacted[compacted.length - 1 - kept])) {
|
|
302
|
+
kept += 1;
|
|
303
|
+
}
|
|
304
|
+
return kept;
|
|
305
|
+
}
|
|
168
306
|
async function summarize(messages, options) {
|
|
307
|
+
options.onCompacting?.();
|
|
169
308
|
const definitions = Object.fromEntries(Object.entries(options.tools).map(([name, tool]) => {
|
|
170
309
|
const { execute: _execute, onInputStart: _start, onInputDelta: _delta, onInputAvailable: _available, ...definition } = tool;
|
|
171
310
|
return [name, definition];
|
|
172
311
|
}));
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
312
|
+
let summary;
|
|
313
|
+
try {
|
|
314
|
+
const result = await generateText({
|
|
315
|
+
model: options.model,
|
|
316
|
+
system: options.system,
|
|
317
|
+
tools: definitions,
|
|
318
|
+
toolChoice: "auto",
|
|
319
|
+
messages: [...messages, { role: "user", content: [{ type: "text", text: COMPACTION_PROMPT }] }],
|
|
320
|
+
providerOptions: options.providerOptions,
|
|
321
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
322
|
+
abortSignal: options.signal,
|
|
323
|
+
});
|
|
324
|
+
const text = result.text.trim();
|
|
325
|
+
if (result.finishReason === "stop" && result.toolCalls.length === 0 && text.length > 0)
|
|
326
|
+
summary = text;
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (options.signal?.aborted)
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
options.signal?.throwIfAborted();
|
|
333
|
+
return summary;
|
|
334
|
+
}
|
|
335
|
+
function threshold(options) {
|
|
336
|
+
return Math.min(options.limits?.thresholdTokens ?? COMPACTION_THRESHOLD_TOKENS, options.contextWindow === undefined ? Infinity : options.contextWindow - (options.maxOutputTokens ?? 0));
|
|
191
337
|
}
|
|
192
338
|
function estimate(messages, options) {
|
|
193
339
|
return estimateRequestTokens(messages, { system: options.system, tools: options.tools });
|
|
@@ -197,8 +343,8 @@ function estimate(messages, options) {
|
|
|
197
343
|
* estimate of what came after it. Undefined when there is no count to go by:
|
|
198
344
|
* the request is then estimated whole, which errs high.
|
|
199
345
|
*/
|
|
200
|
-
function conversationTokens(history,
|
|
201
|
-
const step = lastStepTokens(history,
|
|
346
|
+
function conversationTokens(history, latest) {
|
|
347
|
+
const step = lastStepTokens(history, latest);
|
|
202
348
|
if (step === undefined)
|
|
203
349
|
return undefined;
|
|
204
350
|
return step.tokens + sum(history.slice(step.index + 1).map(uiMessageTokens));
|
|
@@ -206,14 +352,16 @@ function conversationTokens(history, carrier) {
|
|
|
206
352
|
/**
|
|
207
353
|
* Input and output tokens of the conversation's last recorded step, when that
|
|
208
354
|
* step was sent after the latest compaction -- otherwise it measured messages
|
|
209
|
-
* the model no longer reads.
|
|
355
|
+
* the model no longer reads. The compaction's own message counts from the part
|
|
356
|
+
* that records it on.
|
|
210
357
|
*/
|
|
211
|
-
function lastStepTokens(history,
|
|
212
|
-
for (let index = history.length - 1; index
|
|
358
|
+
function lastStepTokens(history, latest) {
|
|
359
|
+
for (let index = history.length - 1; index >= (latest?.carrier ?? 0); index -= 1) {
|
|
213
360
|
const message = history[index];
|
|
214
361
|
if (message?.role !== "assistant")
|
|
215
362
|
continue;
|
|
216
|
-
|
|
363
|
+
const after = index === latest?.carrier ? latest.part : -1;
|
|
364
|
+
for (let i = message.parts.length - 1; i > after; i -= 1) {
|
|
217
365
|
const part = message.parts[i];
|
|
218
366
|
if (part?.type !== "data-step-metrics")
|
|
219
367
|
continue;
|
|
@@ -246,7 +394,7 @@ function cutIndex(history, floor, keepRecent) {
|
|
|
246
394
|
}
|
|
247
395
|
return cut;
|
|
248
396
|
}
|
|
249
|
-
/** Skills read successfully in these messages, in the order first read. */
|
|
397
|
+
/** Skills read successfully in these stored messages, in the order first read. */
|
|
250
398
|
function readSkills(messages) {
|
|
251
399
|
const names = new Set();
|
|
252
400
|
for (const message of messages) {
|
|
@@ -256,17 +404,33 @@ function readSkills(messages) {
|
|
|
256
404
|
const isReadSkill = part.type === "tool-read_skill" || (part.type === "dynamic-tool" && part.toolName === "read_skill");
|
|
257
405
|
if (!isReadSkill || part.state !== "output-available")
|
|
258
406
|
continue;
|
|
259
|
-
|
|
260
|
-
|
|
407
|
+
addSkills(names, part.output?.skills);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return names;
|
|
411
|
+
}
|
|
412
|
+
/** Skills read successfully in these model messages, in the order first read. */
|
|
413
|
+
function modelSkills(messages) {
|
|
414
|
+
const names = new Set();
|
|
415
|
+
for (const message of messages) {
|
|
416
|
+
if (message.role !== "tool")
|
|
417
|
+
continue;
|
|
418
|
+
for (const part of message.content) {
|
|
419
|
+
if (part.type !== "tool-result" || part.toolName !== "read_skill" || part.output.type !== "json")
|
|
261
420
|
continue;
|
|
262
|
-
|
|
263
|
-
if (skill?.ok === true && typeof skill.name === "string")
|
|
264
|
-
names.add(skill.name);
|
|
265
|
-
}
|
|
421
|
+
addSkills(names, part.output.value?.skills);
|
|
266
422
|
}
|
|
267
423
|
}
|
|
268
424
|
return names;
|
|
269
425
|
}
|
|
426
|
+
function addSkills(names, skills) {
|
|
427
|
+
if (!Array.isArray(skills))
|
|
428
|
+
return;
|
|
429
|
+
for (const skill of skills) {
|
|
430
|
+
if (skill?.ok === true && typeof skill.name === "string")
|
|
431
|
+
names.add(skill.name);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
270
434
|
/** A stored message's size, roughly: its JSON, with images at their model cost and data URLs left out. */
|
|
271
435
|
function uiMessageTokens(message) {
|
|
272
436
|
let images = 0;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ModelMessage } from "ai";
|
|
2
|
+
/** Under prose's ~3.5-4, so the estimate errs high. */
|
|
3
|
+
export declare const CHARS_PER_TOKEN = 3;
|
|
4
|
+
/** About what one image at the providers' 1568 px ceiling costs. */
|
|
5
|
+
export declare const IMAGE_TOKENS = 1600;
|
|
6
|
+
export declare const MESSAGE_OVERHEAD_TOKENS = 4;
|
|
7
|
+
/** Per tool: its name, description and input schema around the counted text. */
|
|
8
|
+
export declare const TOOL_DEFINITION_OVERHEAD_TOKENS = 300;
|
|
9
|
+
/**
|
|
10
|
+
* The model's context window, or undefined when it is not known.
|
|
11
|
+
*
|
|
12
|
+
* Listed models first, then model families whose window is certain. Anything
|
|
13
|
+
* else -- a third-party model routed through OpenRouter -- is undefined:
|
|
14
|
+
* compaction then goes by its own threshold alone.
|
|
15
|
+
*/
|
|
16
|
+
export declare function contextWindowFor(modelId: string): number | undefined;
|
|
17
|
+
/** The estimate for a list of messages. */
|
|
18
|
+
export declare function estimateMessagesTokens(messages: ModelMessage[]): number;
|
|
19
|
+
/** The estimate for a whole request: its messages, system prompt and tool definitions. */
|
|
20
|
+
export declare function estimateRequestTokens(messages: ModelMessage[], options: {
|
|
21
|
+
system?: string;
|
|
22
|
+
tools?: Record<string, unknown>;
|
|
23
|
+
}): number;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// A model's context window, and how much of it a request uses.
|
|
2
|
+
//
|
|
3
|
+
// Spark never cuts a request down to fit: compaction (./compaction.ts) is the one
|
|
4
|
+
// thing that takes earlier messages out of what the model reads, and it decides
|
|
5
|
+
// when with these numbers. A request that is still over the window after it goes
|
|
6
|
+
// out as it is, and the provider's own error says so.
|
|
7
|
+
//
|
|
8
|
+
// ESTIMATES, NOT COUNTS. Counting needs the provider's tokenizer or a request.
|
|
9
|
+
// The estimate is deliberately high (CHARS_PER_TOKEN is under the ~3.5-4 of
|
|
10
|
+
// prose), so dense JSON that tokenizes worse than prose is not underestimated.
|
|
11
|
+
// Images count by number, never by their bytes. Where the provider has counted a
|
|
12
|
+
// step already, compaction uses its count instead.
|
|
13
|
+
import { SPARK_MODELS } from "./models.js";
|
|
14
|
+
/** Under prose's ~3.5-4, so the estimate errs high. */
|
|
15
|
+
export const CHARS_PER_TOKEN = 3;
|
|
16
|
+
/** About what one image at the providers' 1568 px ceiling costs. */
|
|
17
|
+
export const IMAGE_TOKENS = 1_600;
|
|
18
|
+
export const MESSAGE_OVERHEAD_TOKENS = 4;
|
|
19
|
+
/** Per tool: its name, description and input schema around the counted text. */
|
|
20
|
+
export const TOOL_DEFINITION_OVERHEAD_TOKENS = 300;
|
|
21
|
+
/**
|
|
22
|
+
* The model's context window, or undefined when it is not known.
|
|
23
|
+
*
|
|
24
|
+
* Listed models first, then model families whose window is certain. Anything
|
|
25
|
+
* else -- a third-party model routed through OpenRouter -- is undefined:
|
|
26
|
+
* compaction then goes by its own threshold alone.
|
|
27
|
+
*/
|
|
28
|
+
export function contextWindowFor(modelId) {
|
|
29
|
+
const listed = SPARK_MODELS.find(({ id }) => id === modelId)?.contextWindow;
|
|
30
|
+
if (listed !== undefined)
|
|
31
|
+
return listed;
|
|
32
|
+
if (/^claude-haiku-/.test(modelId))
|
|
33
|
+
return 200_000;
|
|
34
|
+
if (/^claude-/.test(modelId))
|
|
35
|
+
return 1_000_000;
|
|
36
|
+
if (/^gpt-6-/.test(modelId))
|
|
37
|
+
return 1_050_000;
|
|
38
|
+
if (/^gemini-/.test(modelId))
|
|
39
|
+
return 1_048_576;
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
/** The estimate for a list of messages. */
|
|
43
|
+
export function estimateMessagesTokens(messages) {
|
|
44
|
+
return sum(messages.map(messageTokens));
|
|
45
|
+
}
|
|
46
|
+
/** The estimate for a whole request: its messages, system prompt and tool definitions. */
|
|
47
|
+
export function estimateRequestTokens(messages, options) {
|
|
48
|
+
return estimateMessagesTokens(messages) + textTokens(options.system ?? "") + toolsTokens(options.tools);
|
|
49
|
+
}
|
|
50
|
+
const estimates = new WeakMap();
|
|
51
|
+
function messageTokens(message) {
|
|
52
|
+
const known = estimates.get(message);
|
|
53
|
+
if (known !== undefined)
|
|
54
|
+
return known;
|
|
55
|
+
const content = message.content;
|
|
56
|
+
const size = MESSAGE_OVERHEAD_TOKENS +
|
|
57
|
+
(typeof content === "string"
|
|
58
|
+
? textTokens(content)
|
|
59
|
+
: Array.isArray(content)
|
|
60
|
+
? sum(content.map(partTokens))
|
|
61
|
+
: 0);
|
|
62
|
+
estimates.set(message, size);
|
|
63
|
+
return size;
|
|
64
|
+
}
|
|
65
|
+
function partTokens(part) {
|
|
66
|
+
if (isImage(part))
|
|
67
|
+
return IMAGE_TOKENS;
|
|
68
|
+
switch (part.type) {
|
|
69
|
+
case "text":
|
|
70
|
+
case "reasoning":
|
|
71
|
+
return textTokens(String(part.text ?? ""));
|
|
72
|
+
case "file":
|
|
73
|
+
return dataTokens(part.data);
|
|
74
|
+
case "tool-call":
|
|
75
|
+
return textTokens(String(part.toolName ?? "")) + textTokens(json(part.input));
|
|
76
|
+
case "tool-result":
|
|
77
|
+
return isToolResult(part) ? outputTokens(part.output) : 0;
|
|
78
|
+
default:
|
|
79
|
+
return textTokens(json(part));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function outputTokens(output) {
|
|
83
|
+
switch (output.type) {
|
|
84
|
+
case "text":
|
|
85
|
+
case "error-text":
|
|
86
|
+
return textTokens(String(output.value ?? ""));
|
|
87
|
+
case "content":
|
|
88
|
+
return Array.isArray(output.value)
|
|
89
|
+
? sum(output.value.map((item) => isImage(item) ? IMAGE_TOKENS : item.type === "text" ? textTokens(String(item.text ?? "")) : dataTokens(item.data)))
|
|
90
|
+
: 0;
|
|
91
|
+
default:
|
|
92
|
+
return textTokens(json(output.value));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function textTokens(text) {
|
|
96
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
97
|
+
}
|
|
98
|
+
function dataTokens(data) {
|
|
99
|
+
if (typeof data === "string")
|
|
100
|
+
return textTokens(data);
|
|
101
|
+
if (data instanceof Uint8Array || data instanceof ArrayBuffer)
|
|
102
|
+
return Math.ceil(data.byteLength / CHARS_PER_TOKEN);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
function toolsTokens(tools) {
|
|
106
|
+
if (!tools)
|
|
107
|
+
return 0;
|
|
108
|
+
return sum(Object.entries(tools).map(([name, tool]) => {
|
|
109
|
+
const description = tool?.description;
|
|
110
|
+
return (TOOL_DEFINITION_OVERHEAD_TOKENS + textTokens(name) + textTokens(typeof description === "string" ? description : ""));
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
/** JSON text, with binary left out: its size is not text the model reads. */
|
|
114
|
+
function json(value) {
|
|
115
|
+
try {
|
|
116
|
+
return (JSON.stringify(value, (_key, v) => (v instanceof Uint8Array || v instanceof ArrayBuffer ? "" : v)) ?? "");
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function isImage(part) {
|
|
123
|
+
if (part.type === "image" || part.type === "image-data" || part.type === "image-url")
|
|
124
|
+
return true;
|
|
125
|
+
const mediaType = part.mediaType;
|
|
126
|
+
return ((part.type === "file" || part.type === "media" || part.type === "file-data" || part.type === "file-url") &&
|
|
127
|
+
typeof mediaType === "string" &&
|
|
128
|
+
mediaType.startsWith("image/"));
|
|
129
|
+
}
|
|
130
|
+
function isToolResult(part) {
|
|
131
|
+
return (part.type === "tool-result" &&
|
|
132
|
+
typeof part.output === "object" &&
|
|
133
|
+
part.output !== null &&
|
|
134
|
+
typeof part.output.type === "string");
|
|
135
|
+
}
|
|
136
|
+
function sum(values) {
|
|
137
|
+
let total = 0;
|
|
138
|
+
for (const value of values)
|
|
139
|
+
total += value;
|
|
140
|
+
return total;
|
|
141
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const WOMP_GEOMETRY_DECLARATIONS = "declare const womp: { geometry: {\n\"read\": ((objectId: number) => Promise<{\n\"kind\": \"container\" | \"graph\";\n\"objectId\": number;\n\"title\": string;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"source\": null | string;\n\"instances\": Array<{\n\"instanceId\": number;\n\"objectIds\": Array<number>;\n\"params\": {\n[key: string]: number | {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n}>;\n\"diagnostics\": Array<string>;\n}>);\n\"write\": ((options: {\n\"objectId\": number;\n\"source\": string;\n}) => Promise<{\n\"status\": \"applied\" | \"rejected\";\n\"objectId\": number;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"affectedObjectIds\": Array<number>;\n\"diagnostics\": Array<string>;\n}>);\n} };\ndeclare function print(...values: unknown[]): void;\ndeclare function emitImage(ref: { id: string; mediaType: string; name?: string }, options?: { inspect?: boolean }): Promise<void>;\ndeclare function emitFile(ref: { id: string; mediaType: string; name?: string }): Promise<void>;\n";
|
|
1
|
+
export declare const WOMP_GEOMETRY_DECLARATIONS = "declare const womp: { geometry: {\n\"read\": ((objectId: number) => Promise<{\n\"kind\": \"container\" | \"graph\";\n\"objectId\": number;\n\"title\": string;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"source\": null | string;\n\"transform\": null | {\n\"position\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"rotation\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"size\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"bounds\": null | {\n\"min\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"max\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"instances\": Array<{\n\"instanceId\": number;\n\"objectIds\": Array<number>;\n\"params\": {\n[key: string]: number | {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n}>;\n\"diagnostics\": Array<string>;\n}>);\n\"write\": ((options: {\n\"objectId\": number;\n\"source\": string;\n}) => Promise<{\n\"status\": \"applied\" | \"rejected\";\n\"objectId\": number;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"source\"?: undefined | string;\n\"transform\"?: undefined | null | {\n\"position\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"rotation\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"size\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"bounds\"?: undefined | null | {\n\"min\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"max\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"affectedObjectIds\": Array<number>;\n\"diagnostics\": Array<string>;\n}>);\n} };\ndeclare function print(...values: unknown[]): void;\ndeclare function emitImage(ref: { id: string; mediaType: string; name?: string }, options?: { inspect?: boolean }): Promise<void>;\ndeclare function emitFile(ref: { id: string; mediaType: string; name?: string }): Promise<void>;\n";
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by scripts/generate-skill-declarations.mjs.
|
|
2
2
|
// prettier-ignore
|
|
3
|
-
export const WOMP_GEOMETRY_DECLARATIONS = "declare const womp: { geometry: {\n\"read\": ((objectId: number) => Promise<{\n\"kind\": \"container\" | \"graph\";\n\"objectId\": number;\n\"title\": string;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"source\": null | string;\n\"instances\": Array<{\n\"instanceId\": number;\n\"objectIds\": Array<number>;\n\"params\": {\n[key: string]: number | {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n}>;\n\"diagnostics\": Array<string>;\n}>);\n\"write\": ((options: {\n\"objectId\": number;\n\"source\": string;\n}) => Promise<{\n\"status\": \"applied\" | \"rejected\";\n\"objectId\": number;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"affectedObjectIds\": Array<number>;\n\"diagnostics\": Array<string>;\n}>);\n} };\ndeclare function print(...values: unknown[]): void;\ndeclare function emitImage(ref: { id: string; mediaType: string; name?: string }, options?: { inspect?: boolean }): Promise<void>;\ndeclare function emitFile(ref: { id: string; mediaType: string; name?: string }): Promise<void>;\n";
|
|
3
|
+
export const WOMP_GEOMETRY_DECLARATIONS = "declare const womp: { geometry: {\n\"read\": ((objectId: number) => Promise<{\n\"kind\": \"container\" | \"graph\";\n\"objectId\": number;\n\"title\": string;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"source\": null | string;\n\"transform\": null | {\n\"position\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"rotation\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"size\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"bounds\": null | {\n\"min\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"max\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"instances\": Array<{\n\"instanceId\": number;\n\"objectIds\": Array<number>;\n\"params\": {\n[key: string]: number | {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n}>;\n\"diagnostics\": Array<string>;\n}>);\n\"write\": ((options: {\n\"objectId\": number;\n\"source\": string;\n}) => Promise<{\n\"status\": \"applied\" | \"rejected\";\n\"objectId\": number;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"source\"?: undefined | string;\n\"transform\"?: undefined | null | {\n\"position\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"rotation\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"size\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"bounds\"?: undefined | null | {\n\"min\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"max\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"affectedObjectIds\": Array<number>;\n\"diagnostics\": Array<string>;\n}>);\n} };\ndeclare function print(...values: unknown[]): void;\ndeclare function emitImage(ref: { id: string; mediaType: string; name?: string }, options?: { inspect?: boolean }): Promise<void>;\ndeclare function emitFile(ref: { id: string; mediaType: string; name?: string }): Promise<void>;\n";
|