@womp/spark-agent 4.2.1 → 4.3.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/.env.example +6 -6
- package/README.md +457 -436
- package/dist/agent-run.d.ts +1 -1
- package/dist/capabilities/flow.js +3 -3
- package/dist/capabilities/geometry.js +14 -14
- package/dist/capabilities/openscad-instructions.d.ts +1 -1
- package/dist/capabilities/openscad-instructions.js +2 -1
- package/dist/capabilities/operations.js +33 -32
- package/dist/capabilities/scene.js +74 -74
- package/dist/client.js +8 -0
- package/dist/geometry/schemas/backend-schema.json +3393 -3393
- package/dist/geometry/schemas/flowir-dialect.json +1023 -1023
- package/dist/geometry.js +6 -6
- package/dist/models.d.ts +12 -5
- package/dist/models.js +16 -7
- package/dist/pricing.js +15 -0
- package/dist/prompt.d.ts +1 -1
- package/dist/prompt.js +65 -70
- package/dist/server/express.js +9 -1
- package/dist/server/fixtures/materials/matte-blue.json +14 -14
- package/dist/server/fixtures/materials/polished-copper.json +14 -14
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +1 -1
- package/dist/server/message-fold.d.ts +59 -0
- package/dist/server/message-fold.js +345 -0
- package/dist/server/scene-initialization.d.ts +3 -0
- package/dist/server/scene-initialization.js +44 -0
- package/dist/server/service.d.ts +82 -5
- package/dist/server/service.js +294 -63
- package/dist/server/sqlite-store.d.ts +1 -0
- package/dist/server/sqlite-store.js +40 -37
- package/dist/server/standalone.js +3 -0
- package/dist/server/store.d.ts +1 -0
- package/dist/server/store.js +7 -0
- package/dist/server/types.d.ts +7 -0
- package/package.json +139 -139
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// A run's assistant message, rebuilt from its stored chunks a step at a time.
|
|
2
|
+
//
|
|
3
|
+
// WHY. The AI SDK's `readUIMessageStream` deep-copies (`structuredClone`) the
|
|
4
|
+
// whole message on every chunk it writes, so replaying a long run in one pass
|
|
5
|
+
// costs chunks x message size, all of it in already-resolved promises that
|
|
6
|
+
// never yield. `SparkAgentService.finishRun` did exactly that once per run: a
|
|
7
|
+
// 150-step run with 20 KB tool outputs held the process's event loop for
|
|
8
|
+
// 10-13 s as it ended, and a host that serves other requests (crow) went
|
|
9
|
+
// silent for everyone while a thread write in flight timed out.
|
|
10
|
+
//
|
|
11
|
+
// HOW. The fold keeps a SETTLED message: the result of every chunk up to a
|
|
12
|
+
// sequence at which a new step starts and no text, reasoning or tool input is
|
|
13
|
+
// open. Each read folds only the chunks after that sequence onto it, with the
|
|
14
|
+
// same SDK reader, and moves the settled point to the last such step start it
|
|
15
|
+
// sees. `foldAllChunks` reads a whole run that way, one step start at a time,
|
|
16
|
+
// and yields to the event loop as it goes.
|
|
17
|
+
//
|
|
18
|
+
// A PART LEFT OPEN ACROSS STEPS (a provider that never ends a text, reasoning
|
|
19
|
+
// or tool input) means nothing settles until it closes. `foldAllChunks` then
|
|
20
|
+
// reads only where a step starts with nothing open, so the rest of the run is
|
|
21
|
+
// folded in one pass, as a full replay is; a live reader (`read` after every
|
|
22
|
+
// chunk) re-folds a long unsettled tail only after it has grown by a quarter
|
|
23
|
+
// (`UNSETTLED_TAIL_THROTTLE`); and the reader is fed through a stream that
|
|
24
|
+
// gives the event loop back every `FOLD_YIELD_MS`, so even that one pass never
|
|
25
|
+
// holds the loop for its whole length.
|
|
26
|
+
//
|
|
27
|
+
// WHY THE RESULT IS THE SAME AS A FULL REPLAY. The SDK reader cannot resume
|
|
28
|
+
// inside an open part: the open text, reasoning and tool input ids live in
|
|
29
|
+
// reader state, not in the message. At a `start-step` chunk with nothing open
|
|
30
|
+
// that state is empty in a full replay too, and the reader's "current step"
|
|
31
|
+
// (the parts after the last `step-start` part) begins at that chunk in both.
|
|
32
|
+
// The only other things a chunk can reach in earlier parts are:
|
|
33
|
+
// - a tool part by `toolCallId` (an output for a call from an earlier step),
|
|
34
|
+
// - a tool part by approval id (`tool-approval-response`),
|
|
35
|
+
// - a data part with the same type and id (it is updated in place),
|
|
36
|
+
// - the message metadata (merged).
|
|
37
|
+
// So each read hands the reader the settled metadata plus a copy of exactly
|
|
38
|
+
// the earlier parts the new chunks name, then puts the updated copies back at
|
|
39
|
+
// their places and appends the new parts. A seeded property test
|
|
40
|
+
// (test/message-fold.test.ts) holds it to a full replay after every chunk.
|
|
41
|
+
import { readUIMessageStream, UIMessageStreamError } from "ai";
|
|
42
|
+
/** One of a chunk's string fields, or `undefined`. */
|
|
43
|
+
function stringField(chunk, name) {
|
|
44
|
+
const value = chunk[name];
|
|
45
|
+
return typeof value === "string" ? value : undefined;
|
|
46
|
+
}
|
|
47
|
+
function isToolPart(part) {
|
|
48
|
+
return "toolCallId" in part && typeof part.toolCallId === "string";
|
|
49
|
+
}
|
|
50
|
+
function approvalIdOf(part) {
|
|
51
|
+
if (!("approval" in part))
|
|
52
|
+
return undefined;
|
|
53
|
+
const approval = part.approval;
|
|
54
|
+
if (approval === null || typeof approval !== "object")
|
|
55
|
+
return undefined;
|
|
56
|
+
const id = approval.id;
|
|
57
|
+
return typeof id === "string" ? id : undefined;
|
|
58
|
+
}
|
|
59
|
+
/** A chunk type the reader treats as a data part. */
|
|
60
|
+
function isDataChunk(chunk) {
|
|
61
|
+
return chunk.type.startsWith("data-");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The indexes of the settled parts that `chunks` may reach, chosen the way the
|
|
65
|
+
* reader looks them up: a tool part by call id takes the LAST match (the reader
|
|
66
|
+
* scans the whole message backwards once the current step has none), a data
|
|
67
|
+
* part the FIRST (`parts.find`), a tool part by approval id the first.
|
|
68
|
+
*/
|
|
69
|
+
function reachedParts(parts, chunks) {
|
|
70
|
+
// EVERY call id the chunks name, including one a `tool-input-start` in these
|
|
71
|
+
// chunks opens again: if that new part is later removed (`reset-step`), the
|
|
72
|
+
// reader's backward scan finds the earlier step's part for the same id.
|
|
73
|
+
const callIds = new Set();
|
|
74
|
+
const approvalIds = new Set();
|
|
75
|
+
const dataKeys = new Set();
|
|
76
|
+
for (const chunk of chunks) {
|
|
77
|
+
const callId = stringField(chunk, "toolCallId");
|
|
78
|
+
if (callId !== undefined)
|
|
79
|
+
callIds.add(callId);
|
|
80
|
+
if (chunk.type === "tool-approval-response") {
|
|
81
|
+
const approvalId = stringField(chunk, "approvalId");
|
|
82
|
+
if (approvalId !== undefined)
|
|
83
|
+
approvalIds.add(approvalId);
|
|
84
|
+
}
|
|
85
|
+
const id = stringField(chunk, "id");
|
|
86
|
+
if (isDataChunk(chunk) && id !== undefined && chunk.transient !== true) {
|
|
87
|
+
dataKeys.add(`${chunk.type}\u0000${id}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (callIds.size === 0 && approvalIds.size === 0 && dataKeys.size === 0)
|
|
91
|
+
return [];
|
|
92
|
+
const reached = new Set();
|
|
93
|
+
const lastByCall = new Map();
|
|
94
|
+
const firstByData = new Map();
|
|
95
|
+
parts.forEach((part, index) => {
|
|
96
|
+
if (isToolPart(part) && callIds.has(part.toolCallId))
|
|
97
|
+
lastByCall.set(part.toolCallId, index);
|
|
98
|
+
const approvalId = approvalIdOf(part);
|
|
99
|
+
if (isToolPart(part) && approvalId !== undefined && approvalIds.has(approvalId))
|
|
100
|
+
reached.add(index);
|
|
101
|
+
if ("id" in part && "data" in part && typeof part.id === "string") {
|
|
102
|
+
const key = `${part.type}\u0000${part.id}`;
|
|
103
|
+
if (dataKeys.has(key) && !firstByData.has(key))
|
|
104
|
+
firstByData.set(key, index);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
lastByCall.forEach((index) => reached.add(index));
|
|
108
|
+
firstByData.forEach((index) => reached.add(index));
|
|
109
|
+
return Array.from(reached).sort((left, right) => left - right);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The last index in `chunks` at which a step starts with no text, reasoning or
|
|
113
|
+
* tool input open, or 0 for none. The chunks before it can be settled.
|
|
114
|
+
*/
|
|
115
|
+
function lastCut(chunks) {
|
|
116
|
+
const open = new Set();
|
|
117
|
+
let cut = 0;
|
|
118
|
+
chunks.forEach((chunk, index) => {
|
|
119
|
+
if (chunk.type === "start-step" && open.size === 0 && index > 0)
|
|
120
|
+
cut = index;
|
|
121
|
+
trackOpenParts(open, chunk);
|
|
122
|
+
});
|
|
123
|
+
return cut;
|
|
124
|
+
}
|
|
125
|
+
/** Updates `open`, the text, reasoning and tool-input parts open, for one chunk. */
|
|
126
|
+
function trackOpenParts(open, chunk) {
|
|
127
|
+
const id = stringField(chunk, "id");
|
|
128
|
+
const callId = stringField(chunk, "toolCallId");
|
|
129
|
+
switch (chunk.type) {
|
|
130
|
+
case "text-start":
|
|
131
|
+
case "reasoning-start":
|
|
132
|
+
if (id !== undefined)
|
|
133
|
+
open.add(`${chunk.type.slice(0, -6)}\u0000${id}`);
|
|
134
|
+
break;
|
|
135
|
+
case "text-end":
|
|
136
|
+
case "reasoning-end":
|
|
137
|
+
if (id !== undefined)
|
|
138
|
+
open.delete(`${chunk.type.slice(0, -4)}\u0000${id}`);
|
|
139
|
+
break;
|
|
140
|
+
case "tool-input-start":
|
|
141
|
+
if (callId !== undefined)
|
|
142
|
+
open.add(`tool\u0000${callId}`);
|
|
143
|
+
break;
|
|
144
|
+
case "tool-input-available":
|
|
145
|
+
case "tool-input-error":
|
|
146
|
+
if (callId !== undefined)
|
|
147
|
+
open.delete(`tool\u0000${callId}`);
|
|
148
|
+
break;
|
|
149
|
+
case "reset-step":
|
|
150
|
+
open.clear();
|
|
151
|
+
break;
|
|
152
|
+
default:
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The chunks as a stream the reader pulls one at a time, giving the event loop
|
|
158
|
+
* back every `FOLD_YIELD_MS` of reading. The reader copies its whole message on
|
|
159
|
+
* every chunk it writes, so a pass over a long unsettled tail (a part left
|
|
160
|
+
* open across steps) is still expensive; this spreads it across turns rather
|
|
161
|
+
* than holding the loop for its whole length.
|
|
162
|
+
*/
|
|
163
|
+
/** How many chunks the reader is handed per pull. */
|
|
164
|
+
const CHUNKS_PER_PULL = 16;
|
|
165
|
+
function chunkStream(chunks) {
|
|
166
|
+
let next = 0;
|
|
167
|
+
let worked = performance.now();
|
|
168
|
+
return new ReadableStream({
|
|
169
|
+
async pull(controller) {
|
|
170
|
+
// Pulled once the reader has taken the previous chunk, so the time
|
|
171
|
+
// since the last yield includes its work on the chunks before.
|
|
172
|
+
if (performance.now() - worked >= FOLD_YIELD_MS) {
|
|
173
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
174
|
+
worked = performance.now();
|
|
175
|
+
}
|
|
176
|
+
// A few at a time: a pull per chunk costs more than a short pass.
|
|
177
|
+
const end = Math.min(next + CHUNKS_PER_PULL, chunks.length);
|
|
178
|
+
for (; next < end; next += 1) {
|
|
179
|
+
const chunk = chunks[next];
|
|
180
|
+
if (chunk !== undefined)
|
|
181
|
+
controller.enqueue(chunk);
|
|
182
|
+
}
|
|
183
|
+
if (next >= chunks.length)
|
|
184
|
+
controller.close();
|
|
185
|
+
},
|
|
186
|
+
}, { highWaterMark: 1 });
|
|
187
|
+
}
|
|
188
|
+
/** `base` with the reached parts replaced by the first of `folded`'s parts, and
|
|
189
|
+
* the rest of `folded`'s parts appended. */
|
|
190
|
+
function merge(base, reached, folded) {
|
|
191
|
+
const parts = base.slice();
|
|
192
|
+
reached.forEach((index, position) => {
|
|
193
|
+
const part = folded.parts[position];
|
|
194
|
+
if (part !== undefined)
|
|
195
|
+
parts[index] = part;
|
|
196
|
+
});
|
|
197
|
+
parts.push(...folded.parts.slice(reached.length));
|
|
198
|
+
return { ...folded, parts };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Folds `chunks` onto `base` with the SDK reader. `base` is never mutated: the
|
|
202
|
+
* reader is given copies of the parts it may reach, and the result shares the
|
|
203
|
+
* other parts with `base`.
|
|
204
|
+
*/
|
|
205
|
+
async function fold(base, chunks) {
|
|
206
|
+
const parts = base?.state.parts ?? [];
|
|
207
|
+
const reached = reachedParts(parts, chunks);
|
|
208
|
+
// The reader keeps THIS object as its message and mutates it in place, so
|
|
209
|
+
// once the stream is read it holds the state after the last chunk.
|
|
210
|
+
// In the reader's own key order (`createStreamingUIMessageState`), so the
|
|
211
|
+
// message serialises byte for byte as a full replay's does.
|
|
212
|
+
const working = {
|
|
213
|
+
id: base?.state.id ?? "",
|
|
214
|
+
metadata: base?.state.metadata === undefined ? undefined : structuredClone(base.state.metadata),
|
|
215
|
+
role: "assistant",
|
|
216
|
+
parts: reached.flatMap((index) => {
|
|
217
|
+
const part = parts[index];
|
|
218
|
+
return part === undefined ? [] : [structuredClone(part)];
|
|
219
|
+
}),
|
|
220
|
+
};
|
|
221
|
+
// A chunk the reader cannot apply ends its stream, and it reports that
|
|
222
|
+
// through `onError`, as it reports an `error` chunk. An `error` chunk comes as
|
|
223
|
+
// a plain `Error`; a chunk it cannot apply as a `UIMessageStreamError` (or,
|
|
224
|
+
// for a fault inside the reader, some other error class). Counting `error`
|
|
225
|
+
// chunks instead would miss a stop followed by an `error` chunk the reader
|
|
226
|
+
// never reached.
|
|
227
|
+
let stopped = false;
|
|
228
|
+
let lastWritten;
|
|
229
|
+
for await (const current of readUIMessageStream({
|
|
230
|
+
message: working,
|
|
231
|
+
stream: chunkStream(chunks),
|
|
232
|
+
terminateOnError: false,
|
|
233
|
+
onError: (error) => {
|
|
234
|
+
if (UIMessageStreamError.isInstance(error) ||
|
|
235
|
+
!(error instanceof Error) ||
|
|
236
|
+
Object.getPrototypeOf(error) !== Error.prototype) {
|
|
237
|
+
stopped = true;
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
})) {
|
|
241
|
+
lastWritten = current;
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
state: merge(parts, reached, working),
|
|
245
|
+
written: lastWritten === undefined ? base?.written : merge(parts, reached, lastWritten),
|
|
246
|
+
stopped: (base?.stopped ?? false) || stopped,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** How many step starts may pass with a part still open before it is logged. */
|
|
250
|
+
export const UNSETTLED_STEPS_LOGGED = 10;
|
|
251
|
+
/** An unsettled tail this long is re-folded only after it grows by a quarter. */
|
|
252
|
+
export const UNSETTLED_TAIL_THROTTLE = 400;
|
|
253
|
+
export function createRunMessageFold(options = {}) {
|
|
254
|
+
let unsettledLogged = false;
|
|
255
|
+
// The last fold of the unsettled tail, and how long that tail was: reset
|
|
256
|
+
// whenever the settled point moves.
|
|
257
|
+
let tailFold;
|
|
258
|
+
let settled;
|
|
259
|
+
let settledSequence = 0;
|
|
260
|
+
let folded = 0;
|
|
261
|
+
return {
|
|
262
|
+
async read(list, readOptions = {}) {
|
|
263
|
+
const stored = await list(settledSequence);
|
|
264
|
+
// A reader that stopped at a chunk it could not apply ignores every later
|
|
265
|
+
// chunk in a full replay too, so nothing after it changes the message.
|
|
266
|
+
if (settled?.stopped === true)
|
|
267
|
+
return settled.written;
|
|
268
|
+
const chunks = stored.map(({ chunk }) => chunk);
|
|
269
|
+
const cut = lastCut(chunks);
|
|
270
|
+
let base = settled;
|
|
271
|
+
if (cut > 0) {
|
|
272
|
+
base = await fold(settled, chunks.slice(0, cut));
|
|
273
|
+
folded += cut;
|
|
274
|
+
settled = base;
|
|
275
|
+
settledSequence = stored[cut - 1]?.sequence ?? settledSequence;
|
|
276
|
+
tailFold = undefined;
|
|
277
|
+
if (base.stopped)
|
|
278
|
+
return base.written;
|
|
279
|
+
}
|
|
280
|
+
const tail = chunks.slice(cut);
|
|
281
|
+
if (!unsettledLogged && options.onUnsettled !== undefined) {
|
|
282
|
+
const steps = tail.filter((chunk) => chunk.type === "start-step").length;
|
|
283
|
+
if (steps >= UNSETTLED_STEPS_LOGGED) {
|
|
284
|
+
unsettledLogged = true;
|
|
285
|
+
options.onUnsettled({ steps, chunks: tail.length });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (tail.length === 0)
|
|
289
|
+
return base?.written;
|
|
290
|
+
if (readOptions.complete !== true &&
|
|
291
|
+
tailFold !== undefined &&
|
|
292
|
+
tail.length >= UNSETTLED_TAIL_THROTTLE &&
|
|
293
|
+
tail.length < tailFold.length * 1.25) {
|
|
294
|
+
return tailFold.written ?? base?.written;
|
|
295
|
+
}
|
|
296
|
+
const current = await fold(base, tail);
|
|
297
|
+
folded += tail.length;
|
|
298
|
+
tailFold = { length: tail.length, written: current.written };
|
|
299
|
+
return current.written;
|
|
300
|
+
},
|
|
301
|
+
foldedChunks: () => folded,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/** How long `foldAllChunks` works before it lets other callbacks run. */
|
|
305
|
+
export const FOLD_YIELD_MS = 10;
|
|
306
|
+
/**
|
|
307
|
+
* The message a full replay of `chunks` builds (the last message
|
|
308
|
+
* `readUIMessageStream` yields, with `terminateOnError: false`), folded a step
|
|
309
|
+
* at a time and giving the event loop back every `FOLD_YIELD_MS`. The reader
|
|
310
|
+
* only ever sees about one step's chunks and the parts they name; with a part
|
|
311
|
+
* left open across steps, one pass over the rest of the run.
|
|
312
|
+
*/
|
|
313
|
+
export async function foldAllChunks(chunks, options = {}) {
|
|
314
|
+
const yieldMs = options.yieldMs ?? FOLD_YIELD_MS;
|
|
315
|
+
// Sequence n is index n - 1, so "after sequence s" is `slice(s)`.
|
|
316
|
+
const stored = chunks.map((chunk, index) => ({
|
|
317
|
+
sequence: index + 1,
|
|
318
|
+
conversationId: "",
|
|
319
|
+
runId: "",
|
|
320
|
+
createdAt: "",
|
|
321
|
+
chunk,
|
|
322
|
+
}));
|
|
323
|
+
const fold = options.fold ?? createRunMessageFold();
|
|
324
|
+
// Only where a step starts with nothing open does a read settle anything
|
|
325
|
+
// (`lastCut`'s rule); a read anywhere else would fold the whole unsettled
|
|
326
|
+
// tail again. So a part left open makes this one pass over the rest of the
|
|
327
|
+
// run, as a full replay is, and never one pass per step.
|
|
328
|
+
const open = new Set();
|
|
329
|
+
let worked = performance.now();
|
|
330
|
+
for (let index = 0; index < stored.length; index += 1) {
|
|
331
|
+
const chunk = stored[index]?.chunk;
|
|
332
|
+
if (chunk === undefined)
|
|
333
|
+
continue;
|
|
334
|
+
if (chunk.type === "start-step" && index > 0 && open.size === 0) {
|
|
335
|
+
const end = index + 1;
|
|
336
|
+
await fold.read(async (after) => stored.slice(after, end));
|
|
337
|
+
if (performance.now() - worked >= yieldMs) {
|
|
338
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
339
|
+
worked = performance.now();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
trackOpenParts(open, chunk);
|
|
343
|
+
}
|
|
344
|
+
return fold.read(async (after) => stored.slice(after), { complete: true });
|
|
345
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { KakapoSceneCommitError } from "@womp/kakapo-sdk";
|
|
2
|
+
/** Only for the standalone host's direct, atomic native Kakapo connection. */
|
|
3
|
+
export async function initializeStandaloneScene(api, signal) {
|
|
4
|
+
signal.throwIfAborted();
|
|
5
|
+
const response = await api.rpc("scene_state_get");
|
|
6
|
+
signal.throwIfAborted();
|
|
7
|
+
const snapshot = typeof response === "string" ? JSON.parse(response) : response;
|
|
8
|
+
if (snapshot === null || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
|
9
|
+
throw new Error("Kakapo returned an invalid scene snapshot during scene initialization.");
|
|
10
|
+
}
|
|
11
|
+
const additions = [];
|
|
12
|
+
if (snapshot.ext === undefined)
|
|
13
|
+
additions.push({ op: "add", path: "/ext", value: {} });
|
|
14
|
+
for (const key of ["materials", "shaders"]) {
|
|
15
|
+
if (snapshot.storage[key] === undefined) {
|
|
16
|
+
additions.push({ op: "add", path: `/storage/${key}`, value: {} });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (snapshot.storage.openscad === undefined) {
|
|
20
|
+
additions.push({ op: "add", path: "/storage/openscad", value: { scripts: {} } });
|
|
21
|
+
}
|
|
22
|
+
else if (snapshot.storage.openscad.scripts === undefined) {
|
|
23
|
+
additions.push({ op: "add", path: "/storage/openscad/scripts", value: {} });
|
|
24
|
+
}
|
|
25
|
+
if (additions.length === 0)
|
|
26
|
+
return;
|
|
27
|
+
// The SDK requires /ext; native resource updates require per-ID patch paths.
|
|
28
|
+
// Initialize missing collections so later edits address individual resources.
|
|
29
|
+
// The whole-scene test rejects concurrent changes before editing begins.
|
|
30
|
+
let result;
|
|
31
|
+
try {
|
|
32
|
+
result = await api.rpc("scene_state_patch", [JSON.stringify([
|
|
33
|
+
{ op: "test", path: "", value: snapshot },
|
|
34
|
+
...additions,
|
|
35
|
+
])]);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new KakapoSceneCommitError("unknown", error);
|
|
39
|
+
}
|
|
40
|
+
if (result !== undefined && result !== null && result !== "") {
|
|
41
|
+
throw new KakapoSceneCommitError("unknown", new Error(`Scene initialization was not acknowledged: ${JSON.stringify(result)}`));
|
|
42
|
+
}
|
|
43
|
+
await api.refreshScene();
|
|
44
|
+
}
|
package/dist/server/service.d.ts
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { type SparkUIMessage, type StoredSparkChunk } from "../ui.js";
|
|
2
2
|
import type { SparkAgentStore, SparkConversation, SparkConversationSummary, SparkRun, SparkRunExecutor, SparkRunSettings } from "./types.js";
|
|
3
|
+
/** Why a run ended when its progress could not be stored. */
|
|
4
|
+
export declare const RUN_NOT_SAVED_ERROR = "This run was stopped because its progress could not be saved.";
|
|
5
|
+
/** Why a run ended that no execution in this process was running any more. */
|
|
6
|
+
export declare const RUN_INTERRUPTED_ERROR = "This run stopped unexpectedly and could not be finished.";
|
|
7
|
+
/** How long to wait before each further attempt to store a run's ending. About 5 minutes in all. */
|
|
8
|
+
export declare const DEFAULT_ENDING_RETRY_DELAYS_MS: readonly number[];
|
|
9
|
+
export type SparkAgentServiceOptions = {
|
|
10
|
+
store: SparkAgentStore;
|
|
11
|
+
executeRun: SparkRunExecutor;
|
|
12
|
+
/**
|
|
13
|
+
* Ends, with `RUN_INTERRUPTED_ERROR`, an unfinished active run that no
|
|
14
|
+
* execution in this process is running, when a message is submitted to its
|
|
15
|
+
* conversation or it is cancelled. Only for a host that is the only process
|
|
16
|
+
* running runs from its store: another process's live run would be ended.
|
|
17
|
+
*/
|
|
18
|
+
endOrphanedRuns?: boolean;
|
|
19
|
+
/** Delays between the attempts to store an ending that could not be stored. */
|
|
20
|
+
endingRetryDelaysMs?: readonly number[];
|
|
21
|
+
};
|
|
3
22
|
export declare class SparkServiceError extends Error {
|
|
4
23
|
readonly statusCode: number;
|
|
5
24
|
constructor(statusCode: number, message: string);
|
|
@@ -18,10 +37,15 @@ export declare class SparkAgentService {
|
|
|
18
37
|
private readonly controllers;
|
|
19
38
|
private readonly listeners;
|
|
20
39
|
private readonly executions;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
40
|
+
/** Runs this process started and has not finished ending. */
|
|
41
|
+
private readonly live;
|
|
42
|
+
/** Runs whose execution is over but whose ending is still being stored. */
|
|
43
|
+
private readonly ending;
|
|
44
|
+
private readonly endOrphanedRuns;
|
|
45
|
+
/** Per conversation: the tail of its read-modify-write sections in this process. */
|
|
46
|
+
private readonly conversationTurns;
|
|
47
|
+
private readonly endingRetryDelaysMs;
|
|
48
|
+
constructor(options: SparkAgentServiceOptions);
|
|
25
49
|
createConversation(ownerId: string, input?: {
|
|
26
50
|
id?: string;
|
|
27
51
|
title?: string;
|
|
@@ -32,13 +56,66 @@ export declare class SparkAgentService {
|
|
|
32
56
|
deleteConversation(ownerId: string, id: string): Promise<void>;
|
|
33
57
|
getRun(ownerId: string, id: string): Promise<SparkRun>;
|
|
34
58
|
getActiveRun(ownerId: string, conversationId: string): Promise<SparkRun | undefined>;
|
|
35
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* The host must establish exclusive ownership before recovering abandoned runs.
|
|
61
|
+
*
|
|
62
|
+
* The ending this writes spans event-loop turns: the run's message is folded
|
|
63
|
+
* a step at a time and yields as it goes (`finishRun`). The recovered run is
|
|
64
|
+
* not one this process is running, so nothing here keeps a submit to its
|
|
65
|
+
* conversation out meanwhile; `finishRun` then saves the conversation as it
|
|
66
|
+
* read it, over anything that submit wrote. Recover before accepting
|
|
67
|
+
* requests, as the standalone server does.
|
|
68
|
+
*/
|
|
36
69
|
recoverInterruptedRun(runId: string): Promise<void>;
|
|
37
70
|
close(): Promise<void>;
|
|
38
71
|
submitMessage(input: SubmitSparkMessageInput): Promise<SparkRun>;
|
|
72
|
+
private submitInTurn;
|
|
39
73
|
cancelRun(ownerId: string, runId: string): Promise<SparkRun>;
|
|
40
74
|
streamRun(ownerId: string, runId: string, afterSequence: number, signal: AbortSignal): AsyncGenerator<StoredSparkChunk>;
|
|
41
75
|
private runInBackground;
|
|
76
|
+
/** Ends a run whose execution, or whose own bookkeeping, failed. */
|
|
77
|
+
private endAfterError;
|
|
78
|
+
/**
|
|
79
|
+
* The last resort when a run's ending could not be written in full: the run
|
|
80
|
+
* row gets its terminal status and the conversation lets go of the run, so
|
|
81
|
+
* it is not left unfinished. Its stored chunks may lack the ending. A store
|
|
82
|
+
* that is still failing is retried in the background with backoff
|
|
83
|
+
* (`endingRetryDelaysMs`); until then, a message or a Stop for the
|
|
84
|
+
* conversation ends the run at once (`endIfNotRunning`).
|
|
85
|
+
*/
|
|
86
|
+
private endWithoutHistory;
|
|
87
|
+
/** `storeEnding`, answering whether it was stored rather than throwing. */
|
|
88
|
+
private tryStoreEnding;
|
|
89
|
+
/**
|
|
90
|
+
* Runs `action` after every earlier section for the conversation in this
|
|
91
|
+
* process has finished, so a read-modify-write of it (a submit, a released
|
|
92
|
+
* run) never saves back a copy another one has since replaced.
|
|
93
|
+
*/
|
|
94
|
+
private inConversationTurn;
|
|
95
|
+
/**
|
|
96
|
+
* Writes a run's terminal status unless the stored run has already ended,
|
|
97
|
+
* then clears the conversation's active run while it still names the run.
|
|
98
|
+
* Read back first, so a late write never replaces a newer ending or a
|
|
99
|
+
* newer active run.
|
|
100
|
+
*/
|
|
101
|
+
private storeEnding;
|
|
102
|
+
/** Clears the conversation's active run while it is still `runId`, and nothing else. */
|
|
103
|
+
private releaseRun;
|
|
104
|
+
/**
|
|
105
|
+
* Ends an unfinished run that is not being executed: one whose ending this
|
|
106
|
+
* process is still retrying, or, with `endOrphanedRuns`, one no execution in
|
|
107
|
+
* this process is running at all (its process stopped). Answers whether it
|
|
108
|
+
* was ended; throws when the store is still failing.
|
|
109
|
+
*/
|
|
110
|
+
private endIfNotRunning;
|
|
111
|
+
/**
|
|
112
|
+
* Writes a run's ending: its message, rebuilt from every stored chunk, onto
|
|
113
|
+
* the conversation, then the run's terminal state. The rebuild is folded a
|
|
114
|
+
* step at a time and gives the event loop back as it goes (./message-fold.ts),
|
|
115
|
+
* so the ending spans event-loop turns; for a run this process is running, it
|
|
116
|
+
* is still in `live` throughout, so a submit to its conversation is refused
|
|
117
|
+
* until it has ended.
|
|
118
|
+
*/
|
|
42
119
|
private finishRun;
|
|
43
120
|
private materializeMessage;
|
|
44
121
|
private appendStatus;
|