@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.
Files changed (36) hide show
  1. package/.env.example +6 -6
  2. package/README.md +457 -436
  3. package/dist/agent-run.d.ts +1 -1
  4. package/dist/capabilities/flow.js +3 -3
  5. package/dist/capabilities/geometry.js +14 -14
  6. package/dist/capabilities/openscad-instructions.d.ts +1 -1
  7. package/dist/capabilities/openscad-instructions.js +2 -1
  8. package/dist/capabilities/operations.js +33 -32
  9. package/dist/capabilities/scene.js +74 -74
  10. package/dist/client.js +8 -0
  11. package/dist/geometry/schemas/backend-schema.json +3393 -3393
  12. package/dist/geometry/schemas/flowir-dialect.json +1023 -1023
  13. package/dist/geometry.js +6 -6
  14. package/dist/models.d.ts +12 -5
  15. package/dist/models.js +16 -7
  16. package/dist/pricing.js +15 -0
  17. package/dist/prompt.d.ts +1 -1
  18. package/dist/prompt.js +65 -70
  19. package/dist/server/express.js +9 -1
  20. package/dist/server/fixtures/materials/matte-blue.json +14 -14
  21. package/dist/server/fixtures/materials/polished-copper.json +14 -14
  22. package/dist/server/index.d.ts +2 -2
  23. package/dist/server/index.js +1 -1
  24. package/dist/server/message-fold.d.ts +59 -0
  25. package/dist/server/message-fold.js +345 -0
  26. package/dist/server/scene-initialization.d.ts +3 -0
  27. package/dist/server/scene-initialization.js +44 -0
  28. package/dist/server/service.d.ts +82 -5
  29. package/dist/server/service.js +294 -63
  30. package/dist/server/sqlite-store.d.ts +1 -0
  31. package/dist/server/sqlite-store.js +40 -37
  32. package/dist/server/standalone.js +3 -0
  33. package/dist/server/store.d.ts +1 -0
  34. package/dist/server/store.js +7 -0
  35. package/dist/server/types.d.ts +7 -0
  36. 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,3 @@
1
+ import { type KakapoAPI } from "@womp/kakapo-sdk";
2
+ /** Only for the standalone host's direct, atomic native Kakapo connection. */
3
+ export declare function initializeStandaloneScene(api: Pick<KakapoAPI, "rpc" | "refreshScene">, signal: AbortSignal): Promise<void>;
@@ -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
+ }
@@ -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
- constructor(options: {
22
- store: SparkAgentStore;
23
- executeRun: SparkRunExecutor;
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
- /** The host must establish exclusive ownership before recovering abandoned runs. */
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;