@womp/spark-agent 6.3.0 → 6.5.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 +81 -0
- package/README.md +13 -11
- package/dist/agent-tools.d.ts +19 -3
- package/dist/agent-tools.js +38 -0
- package/dist/agent.d.ts +15 -3
- package/dist/agent.js +32 -1
- package/dist/client.d.ts +2 -4
- package/dist/client.js +0 -11
- package/dist/compaction.d.ts +64 -0
- package/dist/compaction.js +296 -0
- package/dist/context-budget.d.ts +2 -0
- package/dist/context-budget.js +14 -6
- package/dist/goals.d.ts +41 -9
- package/dist/goals.js +28 -8
- package/dist/index.d.ts +1 -0
- package/dist/model-history.d.ts +13 -3
- package/dist/model-history.js +10 -17
- package/dist/server/executor.d.ts +2 -0
- package/dist/server/executor.js +3 -10
- package/dist/server/express.js +0 -9
- package/dist/server/goal-evaluator.d.ts +7 -0
- package/dist/server/goal-evaluator.js +22 -0
- package/dist/server/index.d.ts +2 -3
- package/dist/server/index.js +2 -3
- package/dist/server/service.d.ts +24 -10
- package/dist/server/service.js +55 -30
- package/dist/server/standalone.d.ts +1 -1
- package/dist/server/standalone.js +21 -3
- package/dist/server/types.d.ts +3 -0
- package/dist/ui.d.ts +50 -7
- package/dist/ui.js +15 -5
- package/dist/user-images.d.ts +10 -0
- package/dist/user-images.js +20 -0
- package/package.json +1 -1
- package/dist/server/goal-proposer.d.ts +0 -15
- package/dist/server/goal-proposer.js +0 -64
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Replace a long conversation's earlier messages with a summary.
|
|
2
|
+
//
|
|
3
|
+
// WHEN. Once per turn, before its first step: when the conversation reaches
|
|
4
|
+
// COMPACTION_THRESHOLD_TOKENS, everything before the most recent
|
|
5
|
+
// COMPACTION_KEEP_RECENT_TOKENS of whole turns is summarized by the run's own
|
|
6
|
+
// model. Every later request sends the summary in their place. Below the
|
|
7
|
+
// threshold nothing happens, and a summary that fails leaves the turn as it was:
|
|
8
|
+
// ./context-budget.ts still keeps every request inside the model's window.
|
|
9
|
+
//
|
|
10
|
+
// WHERE IT IS KEPT. The summary is a `data-compaction` part on the assistant
|
|
11
|
+
// message of the turn that wrote it (SparkCompaction, ./ui.ts), so every host
|
|
12
|
+
// stores and reloads it with the message, and the chat still shows every
|
|
13
|
+
// message it replaces. `applyCompaction`, called by `convertSparkMessages`,
|
|
14
|
+
// sends the latest one in place of the messages before `firstKeptMessageId`.
|
|
15
|
+
// A later compaction is written from the conversation as the model then sees
|
|
16
|
+
// it -- the earlier summary first -- so the summary is updated, not restarted.
|
|
17
|
+
//
|
|
18
|
+
// THE CACHE. The summary request is the previous request's prefix -- the same
|
|
19
|
+
// system prompt, tool definitions and messages -- plus one instruction, so the
|
|
20
|
+
// provider's prompt cache serves almost all of it. Tool definitions are sent
|
|
21
|
+
// without `execute`: a call the model makes anyway is never run, and fails the
|
|
22
|
+
// summary instead.
|
|
23
|
+
//
|
|
24
|
+
// SKILLS. A skill read in the summarized messages would otherwise be lost with
|
|
25
|
+
// them. The compaction names them, and its summary message carries their
|
|
26
|
+
// current text, ahead of the summary, so the model does not have to read them again.
|
|
27
|
+
//
|
|
28
|
+
// IMAGES. The images the user attached to the summarized messages are what the
|
|
29
|
+
// work is often built from, so the summary message carries the newest
|
|
30
|
+
// COMPACTION_USER_IMAGES of them too, as the same file parts; the summary
|
|
31
|
+
// describes the rest. They are still on the stored messages: nothing records them.
|
|
32
|
+
import { generateText } from "ai";
|
|
33
|
+
import { CHARS_PER_TOKEN, IMAGE_TOKENS, estimateRequestTokens, fitMessagesToContext, } from "./context-budget.js";
|
|
34
|
+
import { sparkDataPartSchemas } from "./ui.js";
|
|
35
|
+
import { userImages } from "./user-images.js";
|
|
36
|
+
/** Compact once the conversation reaches this many tokens. */
|
|
37
|
+
export const COMPACTION_THRESHOLD_TOKENS = 256_000;
|
|
38
|
+
/** Recent whole turns kept verbatim, up to this many tokens, besides the current turn. */
|
|
39
|
+
export const COMPACTION_KEEP_RECENT_TOKENS = 16_000;
|
|
40
|
+
/** The user's newest images from the summarized messages that the summary message carries. */
|
|
41
|
+
export const COMPACTION_USER_IMAGES = 4;
|
|
42
|
+
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
|
+
If the conversation begins with an earlier summary, carry its content forward and update it.
|
|
44
|
+
Use these sections:
|
|
45
|
+
## Goal
|
|
46
|
+
## User preferences and constraints
|
|
47
|
+
## Decisions and current scene state (objects with their names and IDs)
|
|
48
|
+
## Done
|
|
49
|
+
## Open and next steps
|
|
50
|
+
## Artifacts and images (each artifact ID, and what each image the user attached shows)
|
|
51
|
+
## Errors and approaches to avoid
|
|
52
|
+
Keep exact names, IDs, numbers and the user's own wording for requirements. Stay under 1,500 words.`;
|
|
53
|
+
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.]";
|
|
54
|
+
/** The latest compaction in the conversation whose kept message is still in it. */
|
|
55
|
+
export function latestCompaction(history) {
|
|
56
|
+
for (let carrier = history.length - 1; carrier >= 0; carrier -= 1) {
|
|
57
|
+
const message = history[carrier];
|
|
58
|
+
if (message?.role !== "assistant")
|
|
59
|
+
continue;
|
|
60
|
+
for (let i = message.parts.length - 1; i >= 0; i -= 1) {
|
|
61
|
+
const part = message.parts[i];
|
|
62
|
+
if (part?.type !== "data-compaction")
|
|
63
|
+
continue;
|
|
64
|
+
const parsed = sparkDataPartSchemas.compaction.safeParse(part.data);
|
|
65
|
+
if (!parsed.success)
|
|
66
|
+
continue;
|
|
67
|
+
const firstKept = history.findIndex(({ id }) => id === parsed.data.firstKeptMessageId);
|
|
68
|
+
if (firstKept >= 0)
|
|
69
|
+
return { compaction: parsed.data, carrier, firstKept };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The conversation as the model reads it: the messages before the compaction's
|
|
76
|
+
* kept message replaced by its summary, with the newest COMPACTION_USER_IMAGES
|
|
77
|
+
* images the user attached to them. `compaction` defaults to the latest one in `history`; `skills` are the
|
|
78
|
+
* host's, to send the current text of the skills it names. Without a
|
|
79
|
+
* compaction, `history` is returned as it is.
|
|
80
|
+
*/
|
|
81
|
+
export function applyCompaction(history, options = {}) {
|
|
82
|
+
const compaction = options.compaction ?? latestCompaction(history)?.compaction;
|
|
83
|
+
if (compaction === undefined)
|
|
84
|
+
return history;
|
|
85
|
+
const firstKept = history.findIndex(({ id }) => id === compaction.firstKeptMessageId);
|
|
86
|
+
if (firstKept < 0)
|
|
87
|
+
return history;
|
|
88
|
+
// The skills go before the summary, in a part of their own: they change less
|
|
89
|
+
// often, so the next compaction leaves them in the cached prefix.
|
|
90
|
+
const skills = skillsText(compaction.skills, options.skills);
|
|
91
|
+
const parts = [{ type: "text", text: COMPACTION_NOTE }];
|
|
92
|
+
if (skills !== undefined)
|
|
93
|
+
parts.push({ type: "text", text: skills });
|
|
94
|
+
const shown = new Set(userImages(history.slice(firstKept)).map(({ url }) => url));
|
|
95
|
+
const images = userImages(history.slice(0, firstKept))
|
|
96
|
+
.filter(({ url }) => !shown.has(url))
|
|
97
|
+
.slice(-COMPACTION_USER_IMAGES);
|
|
98
|
+
if (images.length > 0) {
|
|
99
|
+
parts.push({ type: "text", text: "The newest images the user attached to the messages the summary replaces, in the order sent:" }, ...images);
|
|
100
|
+
}
|
|
101
|
+
parts.push({ type: "text", text: `<conversation-summary>\n${compaction.summary}\n</conversation-summary>` });
|
|
102
|
+
return [{ id: `compaction-${compaction.firstKeptMessageId}`, role: "user", parts }, ...history.slice(firstKept)];
|
|
103
|
+
}
|
|
104
|
+
function skillsText(names, available) {
|
|
105
|
+
if (names.length === 0)
|
|
106
|
+
return undefined;
|
|
107
|
+
if (available === undefined) {
|
|
108
|
+
return `Skills read earlier in this conversation (read them again before using their APIs): ${names.join(", ")}`;
|
|
109
|
+
}
|
|
110
|
+
const skills = names.flatMap((name) => available.filter((skill) => skill.name === name));
|
|
111
|
+
if (skills.length === 0)
|
|
112
|
+
return undefined;
|
|
113
|
+
return [
|
|
114
|
+
"Skills already read in this conversation, as read_skill returns them now; they need no reading again:",
|
|
115
|
+
...skills.map((skill) => `<skill name="${skill.name}" version="${skill.version}">\n${skill.instructions}\n\n${skill.declarations}${skill.examples.length > 0 ? `\n\nExamples:\n${skill.examples.join("\n\n")}` : ""}\n</skill>`),
|
|
116
|
+
].join("\n\n");
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The turn's model messages, compacted first when the conversation has reached
|
|
120
|
+
* the threshold. `compaction` is the new one, for the host to record; it is
|
|
121
|
+
* absent when nothing was compacted, including when the summary failed.
|
|
122
|
+
*/
|
|
123
|
+
export async function compactSparkHistory(options) {
|
|
124
|
+
const { history } = options;
|
|
125
|
+
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
|
+
const previous = latestCompaction(history);
|
|
128
|
+
const size = conversationTokens(history, previous?.carrier ?? -1) ?? estimate(messages, options);
|
|
129
|
+
if (size < threshold)
|
|
130
|
+
return { messages };
|
|
131
|
+
const firstKept = cutIndex(history, previous?.firstKept ?? 0, options.limits?.keepRecentTokens ?? COMPACTION_KEEP_RECENT_TOKENS);
|
|
132
|
+
if (firstKept === undefined)
|
|
133
|
+
return { messages };
|
|
134
|
+
// The request ends before the current user message: the previous request's
|
|
135
|
+
// prefix, which the provider has cached.
|
|
136
|
+
const current = lastIndex(messages, ({ role }) => role === "user");
|
|
137
|
+
if (current <= 0)
|
|
138
|
+
return { messages };
|
|
139
|
+
options.onCompacting?.();
|
|
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();
|
|
149
|
+
if (summary === undefined)
|
|
150
|
+
return { messages };
|
|
151
|
+
const kept = readSkills(history.slice(firstKept));
|
|
152
|
+
const skills = [
|
|
153
|
+
...new Set([...(previous?.compaction.skills ?? []), ...readSkills(history.slice(previous?.firstKept ?? 0, firstKept))]),
|
|
154
|
+
].filter((name) => !kept.has(name));
|
|
155
|
+
const compaction = {
|
|
156
|
+
firstKeptMessageId: history[firstKept].id,
|
|
157
|
+
summary,
|
|
158
|
+
skills,
|
|
159
|
+
modelId: options.modelId,
|
|
160
|
+
tokensBefore: estimate(messages, options),
|
|
161
|
+
tokensAfter: 0,
|
|
162
|
+
createdAt: new Date().toISOString(),
|
|
163
|
+
};
|
|
164
|
+
const compacted = await options.convert(compaction);
|
|
165
|
+
compaction.tokensAfter = estimate(compacted, options);
|
|
166
|
+
return { messages: compacted, compaction };
|
|
167
|
+
}
|
|
168
|
+
async function summarize(messages, options) {
|
|
169
|
+
const definitions = Object.fromEntries(Object.entries(options.tools).map(([name, tool]) => {
|
|
170
|
+
const { execute: _execute, onInputStart: _start, onInputDelta: _delta, onInputAvailable: _available, ...definition } = tool;
|
|
171
|
+
return [name, definition];
|
|
172
|
+
}));
|
|
173
|
+
const fitted = fitMessagesToContext(messages, {
|
|
174
|
+
contextWindow: options.contextWindow,
|
|
175
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
176
|
+
system: options.system,
|
|
177
|
+
tools: definitions,
|
|
178
|
+
}).messages;
|
|
179
|
+
const result = await generateText({
|
|
180
|
+
model: options.model,
|
|
181
|
+
system: options.system,
|
|
182
|
+
tools: definitions,
|
|
183
|
+
toolChoice: "auto",
|
|
184
|
+
messages: [...fitted, { role: "user", content: [{ type: "text", text: COMPACTION_PROMPT }] }],
|
|
185
|
+
providerOptions: options.providerOptions,
|
|
186
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
187
|
+
abortSignal: options.signal,
|
|
188
|
+
});
|
|
189
|
+
const text = result.text.trim();
|
|
190
|
+
return result.finishReason === "stop" && result.toolCalls.length === 0 && text.length > 0 ? text : undefined;
|
|
191
|
+
}
|
|
192
|
+
function estimate(messages, options) {
|
|
193
|
+
return estimateRequestTokens(messages, { system: options.system, tools: options.tools });
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The conversation's size from the provider's count for its last step, plus an
|
|
197
|
+
* estimate of what came after it. Undefined when there is no count to go by:
|
|
198
|
+
* the request is then estimated whole, which errs high.
|
|
199
|
+
*/
|
|
200
|
+
function conversationTokens(history, carrier) {
|
|
201
|
+
const step = lastStepTokens(history, carrier);
|
|
202
|
+
if (step === undefined)
|
|
203
|
+
return undefined;
|
|
204
|
+
return step.tokens + sum(history.slice(step.index + 1).map(uiMessageTokens));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Input and output tokens of the conversation's last recorded step, when that
|
|
208
|
+
* step was sent after the latest compaction -- otherwise it measured messages
|
|
209
|
+
* the model no longer reads.
|
|
210
|
+
*/
|
|
211
|
+
function lastStepTokens(history, carrier) {
|
|
212
|
+
for (let index = history.length - 1; index > carrier; index -= 1) {
|
|
213
|
+
const message = history[index];
|
|
214
|
+
if (message?.role !== "assistant")
|
|
215
|
+
continue;
|
|
216
|
+
for (let i = message.parts.length - 1; i >= 0; i -= 1) {
|
|
217
|
+
const part = message.parts[i];
|
|
218
|
+
if (part?.type !== "data-step-metrics")
|
|
219
|
+
continue;
|
|
220
|
+
const usage = part.data.usage;
|
|
221
|
+
if (typeof usage?.inputTokens !== "number")
|
|
222
|
+
continue;
|
|
223
|
+
return { index, tokens: usage.inputTokens + (typeof usage.outputTokens === "number" ? usage.outputTokens : 0) };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The first message to keep: the earliest user message after `floor` such that
|
|
230
|
+
* it and everything up to the current user message fit `keepRecent` tokens.
|
|
231
|
+
* The current user message when no earlier turn fits. Undefined when nothing
|
|
232
|
+
* after `floor` would be summarized.
|
|
233
|
+
*/
|
|
234
|
+
function cutIndex(history, floor, keepRecent) {
|
|
235
|
+
const current = lastIndex(history, ({ role }) => role === "user");
|
|
236
|
+
if (current <= floor)
|
|
237
|
+
return undefined;
|
|
238
|
+
let cut = current;
|
|
239
|
+
let tokens = 0;
|
|
240
|
+
for (let i = current - 1; i > floor; i -= 1) {
|
|
241
|
+
tokens += uiMessageTokens(history[i]);
|
|
242
|
+
if (tokens > keepRecent)
|
|
243
|
+
break;
|
|
244
|
+
if (history[i].role === "user")
|
|
245
|
+
cut = i;
|
|
246
|
+
}
|
|
247
|
+
return cut;
|
|
248
|
+
}
|
|
249
|
+
/** Skills read successfully in these messages, in the order first read. */
|
|
250
|
+
function readSkills(messages) {
|
|
251
|
+
const names = new Set();
|
|
252
|
+
for (const message of messages) {
|
|
253
|
+
if (message.role !== "assistant")
|
|
254
|
+
continue;
|
|
255
|
+
for (const part of message.parts) {
|
|
256
|
+
const isReadSkill = part.type === "tool-read_skill" || (part.type === "dynamic-tool" && part.toolName === "read_skill");
|
|
257
|
+
if (!isReadSkill || part.state !== "output-available")
|
|
258
|
+
continue;
|
|
259
|
+
const skills = part.output?.skills;
|
|
260
|
+
if (!Array.isArray(skills))
|
|
261
|
+
continue;
|
|
262
|
+
for (const skill of skills) {
|
|
263
|
+
if (skill?.ok === true && typeof skill.name === "string")
|
|
264
|
+
names.add(skill.name);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return names;
|
|
269
|
+
}
|
|
270
|
+
/** A stored message's size, roughly: its JSON, with images at their model cost and data URLs left out. */
|
|
271
|
+
function uiMessageTokens(message) {
|
|
272
|
+
let images = 0;
|
|
273
|
+
const json = JSON.stringify(message.parts, (key, value) => {
|
|
274
|
+
if (typeof value === "string" && key === "url" && value.startsWith("data:"))
|
|
275
|
+
return "";
|
|
276
|
+
if (typeof value === "object" && value !== null) {
|
|
277
|
+
const item = value;
|
|
278
|
+
if (item.type === "file" && typeof item.mediaType === "string" && item.mediaType.startsWith("image/"))
|
|
279
|
+
images += 1;
|
|
280
|
+
if (item.kind === "image" && item.inspect !== false)
|
|
281
|
+
images += 1;
|
|
282
|
+
}
|
|
283
|
+
return value;
|
|
284
|
+
});
|
|
285
|
+
return Math.ceil((json?.length ?? 0) / CHARS_PER_TOKEN) + images * IMAGE_TOKENS;
|
|
286
|
+
}
|
|
287
|
+
function sum(values) {
|
|
288
|
+
return values.reduce((total, value) => total + value, 0);
|
|
289
|
+
}
|
|
290
|
+
function lastIndex(items, test) {
|
|
291
|
+
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
292
|
+
if (test(items[i]))
|
|
293
|
+
return i;
|
|
294
|
+
}
|
|
295
|
+
return -1;
|
|
296
|
+
}
|
package/dist/context-budget.d.ts
CHANGED
|
@@ -61,3 +61,5 @@ export type ContextBudgetResult = {
|
|
|
61
61
|
export declare function fitMessagesToContext(messages: ModelMessage[], options: ContextBudgetOptions): ContextBudgetResult;
|
|
62
62
|
/** The estimate for a list of messages, as `fitMessagesToContext` counts them. */
|
|
63
63
|
export declare function estimateMessagesTokens(messages: ModelMessage[]): number;
|
|
64
|
+
/** The estimate for a whole request: its messages, system prompt and tool definitions. */
|
|
65
|
+
export declare function estimateRequestTokens(messages: ModelMessage[], options: Pick<ContextBudgetOptions, "system" | "tools">): number;
|
package/dist/context-budget.js
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
// WHAT, in this order, until the estimate is back under the target:
|
|
15
15
|
//
|
|
16
16
|
// 1. Images before the current turn, oldest first -- the most tokens for the
|
|
17
|
-
// least text, and the ones a later step least needs to see again.
|
|
17
|
+
// least text, and the ones a later step least needs to see again. The
|
|
18
|
+
// agent's own screenshots go before the images the user attached, which
|
|
19
|
+
// are what the work is often built from.
|
|
18
20
|
// 2. Large tool results before the current turn, oldest first. The call and a
|
|
19
21
|
// result part stay, with a note in place of the output, so no provider is
|
|
20
22
|
// handed a call without its result.
|
|
@@ -155,12 +157,12 @@ export function fitMessagesToContext(messages, options) {
|
|
|
155
157
|
* replacement and roughly what that saves -- used only to stop partway through a
|
|
156
158
|
* message, which is then measured again.
|
|
157
159
|
*/
|
|
158
|
-
const rewriteEarlier = (rewrite) => {
|
|
160
|
+
const rewriteEarlier = (rewrite, only = () => true) => {
|
|
159
161
|
for (let i = 0; i < protectedFrom && total > target; i += 1) {
|
|
160
162
|
if (i === current)
|
|
161
163
|
continue;
|
|
162
164
|
const message = out[i];
|
|
163
|
-
if (message === undefined || !Array.isArray(message.content))
|
|
165
|
+
if (message === undefined || !Array.isArray(message.content) || !only(message))
|
|
164
166
|
continue;
|
|
165
167
|
let projected = total;
|
|
166
168
|
let changed = false;
|
|
@@ -183,8 +185,8 @@ export function fitMessagesToContext(messages, options) {
|
|
|
183
185
|
out[i] = next;
|
|
184
186
|
}
|
|
185
187
|
};
|
|
186
|
-
// 1. Earlier images.
|
|
187
|
-
|
|
188
|
+
// 1. Earlier images: the agent's own first, then those the user attached.
|
|
189
|
+
const omitImages = (part, projected) => {
|
|
188
190
|
if (isImage(part)) {
|
|
189
191
|
report.omittedImages += 1;
|
|
190
192
|
return { part: omittedImage(), saved: IMAGE_TOKENS };
|
|
@@ -201,7 +203,9 @@ export function fitMessagesToContext(messages, options) {
|
|
|
201
203
|
return omittedImage();
|
|
202
204
|
});
|
|
203
205
|
return saved === 0 ? { part, saved } : { part: { ...part, output: { ...part.output, value } }, saved };
|
|
204
|
-
}
|
|
206
|
+
};
|
|
207
|
+
rewriteEarlier(omitImages, ({ role }) => role !== "user");
|
|
208
|
+
rewriteEarlier(omitImages, ({ role }) => role === "user");
|
|
205
209
|
// 2. Earlier large tool results.
|
|
206
210
|
rewriteEarlier((part) => {
|
|
207
211
|
if (!isToolResult(part))
|
|
@@ -249,6 +253,10 @@ export function fitMessagesToContext(messages, options) {
|
|
|
249
253
|
export function estimateMessagesTokens(messages) {
|
|
250
254
|
return sum(messages.map(messageTokens));
|
|
251
255
|
}
|
|
256
|
+
/** The estimate for a whole request: its messages, system prompt and tool definitions. */
|
|
257
|
+
export function estimateRequestTokens(messages, options) {
|
|
258
|
+
return estimateMessagesTokens(messages) + textTokens(options.system ?? "") + toolsTokens(options.tools);
|
|
259
|
+
}
|
|
252
260
|
// ── estimation ──────────────────────────────────────────────────────────────
|
|
253
261
|
const estimates = new WeakMap();
|
|
254
262
|
function messageTokens(message) {
|
package/dist/goals.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type SparkGoalTry = {
|
|
|
22
22
|
/** In the order of the goal's conditions; a condition left to the user has no result. */
|
|
23
23
|
conditions: Pick<SparkGoalCondition, "met" | "reason">[];
|
|
24
24
|
};
|
|
25
|
-
/** The
|
|
25
|
+
/** The goal the agent proposes for a request, for the user to confirm before it starts. */
|
|
26
26
|
export declare const sparkGoalProposalSchema: z.ZodObject<{
|
|
27
27
|
title: z.ZodString;
|
|
28
28
|
conditions: z.ZodArray<z.ZodObject<{
|
|
@@ -31,6 +31,43 @@ export declare const sparkGoalProposalSchema: z.ZodObject<{
|
|
|
31
31
|
}, z.core.$strip>>;
|
|
32
32
|
}, z.core.$strip>;
|
|
33
33
|
export type SparkGoalProposal = z.infer<typeof sparkGoalProposalSchema>;
|
|
34
|
+
/**
|
|
35
|
+
* How the user answered a proposed goal: run it with these caps, try the request
|
|
36
|
+
* once, or nothing in the time the host waits.
|
|
37
|
+
*/
|
|
38
|
+
export declare const sparkGoalDecisionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
39
|
+
creditBudget: z.ZodOptional<z.ZodNumber>;
|
|
40
|
+
maxTurns: z.ZodOptional<z.ZodNumber>;
|
|
41
|
+
kind: z.ZodLiteral<"goal">;
|
|
42
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
43
|
+
kind: z.ZodLiteral<"once">;
|
|
44
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
45
|
+
kind: z.ZodLiteral<"unanswered">;
|
|
46
|
+
}, z.core.$strip>], "kind">;
|
|
47
|
+
export type SparkGoalDecision = z.infer<typeof sparkGoalDecisionSchema>;
|
|
48
|
+
/** What became of a proposal, as the agent is told: `active` when a goal was already running. */
|
|
49
|
+
export declare const SPARK_GOAL_OUTCOMES: readonly ["started", "declined", "unanswered", "active"];
|
|
50
|
+
export type SparkGoalOutcome = (typeof SPARK_GOAL_OUTCOMES)[number];
|
|
51
|
+
/**
|
|
52
|
+
* A proposal as its message stores it: the `propose_goal` call that made it, and
|
|
53
|
+
* its outcome once there is one. Without an outcome it is waiting for the user
|
|
54
|
+
* while its run is, and was left unanswered once that run has ended.
|
|
55
|
+
*/
|
|
56
|
+
export declare const sparkGoalProposalPartSchema: z.ZodObject<{
|
|
57
|
+
title: z.ZodString;
|
|
58
|
+
conditions: z.ZodArray<z.ZodObject<{
|
|
59
|
+
text: z.ZodString;
|
|
60
|
+
measurable: z.ZodBoolean;
|
|
61
|
+
}, z.core.$strip>>;
|
|
62
|
+
toolCallId: z.ZodString;
|
|
63
|
+
outcome: z.ZodOptional<z.ZodEnum<{
|
|
64
|
+
active: "active";
|
|
65
|
+
unanswered: "unanswered";
|
|
66
|
+
started: "started";
|
|
67
|
+
declined: "declined";
|
|
68
|
+
}>>;
|
|
69
|
+
}, z.core.$strip>;
|
|
70
|
+
export type SparkGoalProposalPart = z.infer<typeof sparkGoalProposalPartSchema>;
|
|
34
71
|
/** The caps a goal confirmation offers before the user changes them. */
|
|
35
72
|
export declare const GOAL_CONFIRMATION_DEFAULTS: {
|
|
36
73
|
readonly tries: 5;
|
|
@@ -61,20 +98,15 @@ export type SparkGoal = {
|
|
|
61
98
|
runId?: string;
|
|
62
99
|
};
|
|
63
100
|
export declare const sparkGoalCommandSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
101
|
+
creditBudget: z.ZodOptional<z.ZodNumber>;
|
|
102
|
+
maxTurns: z.ZodOptional<z.ZodNumber>;
|
|
64
103
|
action: z.ZodLiteral<"set">;
|
|
65
104
|
objective: z.ZodString;
|
|
66
|
-
|
|
67
|
-
conditions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
68
|
-
text: z.ZodString;
|
|
69
|
-
measurable: z.ZodBoolean;
|
|
70
|
-
}, z.core.$strip>>>;
|
|
105
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
71
106
|
creditBudget: z.ZodOptional<z.ZodNumber>;
|
|
72
107
|
maxTurns: z.ZodOptional<z.ZodNumber>;
|
|
73
|
-
}, z.core.$strip>, z.ZodObject<{
|
|
74
108
|
action: z.ZodLiteral<"update_budget">;
|
|
75
109
|
goalId: z.ZodString;
|
|
76
|
-
creditBudget: z.ZodOptional<z.ZodNumber>;
|
|
77
|
-
maxTurns: z.ZodOptional<z.ZodNumber>;
|
|
78
110
|
}, z.core.$strip>, z.ZodObject<{
|
|
79
111
|
action: z.ZodEnum<{
|
|
80
112
|
status: "status";
|
package/dist/goals.js
CHANGED
|
@@ -11,10 +11,34 @@ export const sparkGoalConditionSchema = z.object({
|
|
|
11
11
|
*/
|
|
12
12
|
measurable: z.boolean(),
|
|
13
13
|
});
|
|
14
|
-
|
|
14
|
+
const goalCaps = {
|
|
15
|
+
creditBudget: z.number().int().positive().max(1_000_000_000).optional(),
|
|
16
|
+
maxTurns: z.number().int().min(1).max(100).optional(),
|
|
17
|
+
};
|
|
18
|
+
/** The goal the agent proposes for a request, for the user to confirm before it starts. */
|
|
15
19
|
export const sparkGoalProposalSchema = z.object({
|
|
16
20
|
title: z.string().trim().min(1).max(80),
|
|
17
|
-
conditions: z.array(sparkGoalConditionSchema).max(MAX_GOAL_CONDITIONS),
|
|
21
|
+
conditions: z.array(sparkGoalConditionSchema).min(1).max(MAX_GOAL_CONDITIONS),
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* How the user answered a proposed goal: run it with these caps, try the request
|
|
25
|
+
* once, or nothing in the time the host waits.
|
|
26
|
+
*/
|
|
27
|
+
export const sparkGoalDecisionSchema = z.discriminatedUnion("kind", [
|
|
28
|
+
z.object({ kind: z.literal("goal"), ...goalCaps }),
|
|
29
|
+
z.object({ kind: z.literal("once") }),
|
|
30
|
+
z.object({ kind: z.literal("unanswered") }),
|
|
31
|
+
]);
|
|
32
|
+
/** What became of a proposal, as the agent is told: `active` when a goal was already running. */
|
|
33
|
+
export const SPARK_GOAL_OUTCOMES = ["started", "declined", "unanswered", "active"];
|
|
34
|
+
/**
|
|
35
|
+
* A proposal as its message stores it: the `propose_goal` call that made it, and
|
|
36
|
+
* its outcome once there is one. Without an outcome it is waiting for the user
|
|
37
|
+
* while its run is, and was left unanswered once that run has ended.
|
|
38
|
+
*/
|
|
39
|
+
export const sparkGoalProposalPartSchema = sparkGoalProposalSchema.extend({
|
|
40
|
+
toolCallId: z.string().min(1),
|
|
41
|
+
outcome: z.enum(SPARK_GOAL_OUTCOMES).optional(),
|
|
18
42
|
});
|
|
19
43
|
/** The caps a goal confirmation offers before the user changes them. */
|
|
20
44
|
export const GOAL_CONFIRMATION_DEFAULTS = { tries: 5, credits: 2_000 };
|
|
@@ -22,17 +46,13 @@ export const sparkGoalCommandSchema = z.discriminatedUnion("action", [
|
|
|
22
46
|
z.object({
|
|
23
47
|
action: z.literal("set"),
|
|
24
48
|
objective: z.string().trim().min(1).max(MAX_GOAL_LENGTH),
|
|
25
|
-
|
|
26
|
-
conditions: sparkGoalProposalSchema.shape.conditions.min(1).optional(),
|
|
27
|
-
creditBudget: z.number().int().positive().max(1_000_000_000).optional(),
|
|
28
|
-
maxTurns: z.number().int().min(1).max(100).optional(),
|
|
49
|
+
...goalCaps,
|
|
29
50
|
}),
|
|
30
51
|
// Raises or lowers a goal's caps; at least one of them.
|
|
31
52
|
z.object({
|
|
32
53
|
action: z.literal("update_budget"),
|
|
33
54
|
goalId: z.string().min(1),
|
|
34
|
-
|
|
35
|
-
maxTurns: z.number().int().min(1).max(100).optional(),
|
|
55
|
+
...goalCaps,
|
|
36
56
|
}),
|
|
37
57
|
z.object({ action: z.enum(["status", "pause", "resume", "clear"]) }),
|
|
38
58
|
]);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { runSparkAgent, type SparkAgentOptions, type SparkAgentResult, type SparkAgentRun, } from "./agent.js";
|
|
2
2
|
export { createSparkTools, type SparkPersistence, type SparkToolCall } from "./agent-tools.js";
|
|
3
3
|
export { convertSparkMessages } from "./model-history.js";
|
|
4
|
+
export type { SparkCompactionLimits } from "./compaction.js";
|
|
4
5
|
export * from "./context-budget.js";
|
|
5
6
|
export * from "./capabilities.js";
|
|
6
7
|
export { createSdkSceneEditor, createSdkSceneHost, type SdkSceneEditor, type SdkSceneEditorOptions, type SdkSceneEditIdentity, type SdkSceneServices, } from "./sdk-scene-host.js";
|
package/dist/model-history.d.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { type ModelMessage } from "ai";
|
|
2
2
|
import type { createSparkTools } from "./agent-tools.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import type { SparkSkill } from "./capabilities.js";
|
|
4
|
+
import { type SparkCompaction, type SparkUIMessage } from "./ui.js";
|
|
5
|
+
/**
|
|
6
|
+
* The stored conversation as model messages. UI artifact previews already
|
|
7
|
+
* represented by a tool result are not model output. The messages before the
|
|
8
|
+
* latest compaction's kept message -- or `options.compaction`'s -- are sent as
|
|
9
|
+
* its summary, with the current text of the skills it names from
|
|
10
|
+
* `options.skills` and the images the user attached to them (./compaction.ts).
|
|
11
|
+
*/
|
|
12
|
+
export declare function convertSparkMessages(messages: SparkUIMessage[], tools: ReturnType<typeof createSparkTools>, options?: {
|
|
13
|
+
compaction?: SparkCompaction;
|
|
14
|
+
skills?: readonly SparkSkill[];
|
|
15
|
+
}): Promise<ModelMessage[]>;
|
package/dist/model-history.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { asSchema, convertToModelMessages } from "ai";
|
|
2
|
+
import { applyCompaction } from "./compaction.js";
|
|
2
3
|
import { sparkDataPartSchemas } from "./ui.js";
|
|
3
4
|
function record(value) {
|
|
4
5
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
@@ -22,19 +23,15 @@ async function liveInput(tools, part) {
|
|
|
22
23
|
const validated = await asSchema(tool.inputSchema).validate?.(input);
|
|
23
24
|
return validated?.success ? { ...part, input: validated.value } : part;
|
|
24
25
|
}
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
/** UI artifact previews already represented by a tool result are not model output. */
|
|
36
|
-
export async function convertSparkMessages(messages, tools) {
|
|
37
|
-
const history = await Promise.all(messages.map(async (message) => {
|
|
26
|
+
/**
|
|
27
|
+
* The stored conversation as model messages. UI artifact previews already
|
|
28
|
+
* represented by a tool result are not model output. The messages before the
|
|
29
|
+
* latest compaction's kept message -- or `options.compaction`'s -- are sent as
|
|
30
|
+
* its summary, with the current text of the skills it names from
|
|
31
|
+
* `options.skills` and the images the user attached to them (./compaction.ts).
|
|
32
|
+
*/
|
|
33
|
+
export async function convertSparkMessages(messages, tools, options = {}) {
|
|
34
|
+
const history = await Promise.all(applyCompaction(messages, options).map(async (message) => {
|
|
38
35
|
if (message.role !== "assistant")
|
|
39
36
|
return message;
|
|
40
37
|
const previews = new Set();
|
|
@@ -63,10 +60,6 @@ export async function convertSparkMessages(messages, tools) {
|
|
|
63
60
|
return convertToModelMessages(history, {
|
|
64
61
|
tools,
|
|
65
62
|
convertDataPart: (part) => {
|
|
66
|
-
if (part.type === "data-goal") {
|
|
67
|
-
const goal = sparkDataPartSchemas.goal.safeParse(part.data);
|
|
68
|
-
return goal.success ? { type: "text", text: goalConditionsText(goal.data) } : undefined;
|
|
69
|
-
}
|
|
70
63
|
if (part.type !== "data-selection")
|
|
71
64
|
return undefined;
|
|
72
65
|
const selection = sparkDataPartSchemas.selection.safeParse(part.data);
|
|
@@ -8,6 +8,8 @@ export type ResolvedSparkModel = {
|
|
|
8
8
|
maxOutputTokens?: number;
|
|
9
9
|
};
|
|
10
10
|
export type SparkRunExecutorOptions = {
|
|
11
|
+
/** When a run's conversation is compacted; see `SparkAgentOptions.compaction`. */
|
|
12
|
+
compaction?: SparkAgentOptions["compaction"];
|
|
11
13
|
resolveModel(settings: SparkRunSettings): ResolvedSparkModel | Promise<ResolvedSparkModel>;
|
|
12
14
|
createHost(context: SparkRunExecutionContext): Promise<Pick<SparkAgentOptions, "capabilities" | "executor" | "artifacts" | "persistence"> & {
|
|
13
15
|
dispose(): void;
|
package/dist/server/executor.js
CHANGED
|
@@ -1,28 +1,21 @@
|
|
|
1
|
-
import { convertSparkMessages } from "../model-history.js";
|
|
2
1
|
import { runSparkAgent } from "../agent.js";
|
|
3
|
-
import { createSparkTools } from "../agent-tools.js";
|
|
4
2
|
export function createSparkRunExecutor(options) {
|
|
5
3
|
return async (context) => {
|
|
6
4
|
const resolved = await options.resolveModel(context.settings);
|
|
7
5
|
const host = await options.createHost(context);
|
|
8
6
|
try {
|
|
9
|
-
const tools = createSparkTools({
|
|
10
|
-
...host,
|
|
11
|
-
runId: context.runId,
|
|
12
|
-
controller: new AbortController(),
|
|
13
|
-
write: () => undefined,
|
|
14
|
-
});
|
|
15
|
-
const messages = await convertSparkMessages(context.uiMessages, tools);
|
|
16
7
|
const run = runSparkAgent({
|
|
17
8
|
...host,
|
|
18
|
-
|
|
9
|
+
history: context.uiMessages,
|
|
19
10
|
model: resolved.model,
|
|
20
11
|
modelId: resolved.modelId,
|
|
21
12
|
providerOptions: resolved.providerOptions,
|
|
22
13
|
maxOutputTokens: resolved.maxOutputTokens,
|
|
14
|
+
compaction: options.compaction,
|
|
23
15
|
runId: context.runId,
|
|
24
16
|
messageId: context.assistantMessageId,
|
|
25
17
|
signal: context.signal,
|
|
18
|
+
proposeGoal: context.proposeGoal,
|
|
26
19
|
});
|
|
27
20
|
return { stream: run.stream, result: run.result.finally(() => host.dispose()) };
|
|
28
21
|
}
|
package/dist/server/express.js
CHANGED
|
@@ -100,15 +100,6 @@ export function createSparkAgentRouter(options) {
|
|
|
100
100
|
const result = await options.service.goalCommand(ownerId, request.params.conversationId, body.command, body.settings, body.message);
|
|
101
101
|
response.status(result.run ? 202 : 200).json(result);
|
|
102
102
|
});
|
|
103
|
-
router.post("/conversations/:conversationId/goal/proposal", async (request, response) => {
|
|
104
|
-
const ownerId = await resolveOwnerId(request);
|
|
105
|
-
const body = z.object({ message: messageSchema, settings: settingsSchema }).parse(request.body);
|
|
106
|
-
const abortController = new AbortController();
|
|
107
|
-
response.on("close", () => { if (!response.writableEnded)
|
|
108
|
-
abortController.abort(); });
|
|
109
|
-
const proposal = await options.service.proposeGoal(ownerId, request.params.conversationId, body.message, body.settings, abortController.signal);
|
|
110
|
-
response.json({ proposal });
|
|
111
|
-
});
|
|
112
103
|
router.get("/conversations/:conversationId/active-run", async (request, response) => {
|
|
113
104
|
const ownerId = await resolveOwnerId(request);
|
|
114
105
|
const run = await options.service.getActiveRun(ownerId, request.params.conversationId);
|
|
@@ -2,6 +2,8 @@ import { z } from "zod";
|
|
|
2
2
|
import type { SparkGoal } from "../goals.js";
|
|
3
3
|
import type { SparkUIMessage } from "../ui.js";
|
|
4
4
|
import type { SparkRunExecutorOptions } from "./executor.js";
|
|
5
|
+
/** How many of the user's newest images an evaluation attaches as the reference. */
|
|
6
|
+
export declare const GOAL_REFERENCE_IMAGES = 4;
|
|
5
7
|
export declare const goalVerdictSchema: z.ZodObject<{
|
|
6
8
|
conditions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
7
9
|
index: z.ZodNumber;
|
|
@@ -19,6 +21,11 @@ export type SparkGoalEvaluation = z.infer<typeof goalVerdictSchema>;
|
|
|
19
21
|
export type SparkGoalEvaluator = (input: {
|
|
20
22
|
ownerId?: string;
|
|
21
23
|
goal: SparkGoal;
|
|
24
|
+
/**
|
|
25
|
+
* The conversation. The images the user attached to it are the evaluation's
|
|
26
|
+
* reference, read only from `data:` URLs: a host that stores them elsewhere
|
|
27
|
+
* passes the messages with those images inline.
|
|
28
|
+
*/
|
|
22
29
|
messages: SparkUIMessage[];
|
|
23
30
|
context?: Record<string, unknown>;
|
|
24
31
|
signal: AbortSignal;
|