libfx 0.0.14-dev.1561.g1a070af32cdf → 0.0.15

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/durable.js CHANGED
@@ -75,14 +75,129 @@ const recentIdsKept = 256;
75
75
  // The longest a message waits for a live holder before it is delivered again.
76
76
  const maxBackstopSeconds = 300;
77
77
 
78
+ // Base64 of a Uint8Array: the runtime's own encoder where it has one, and
79
+ // otherwise four letters from the table for each three bytes. Both are fast
80
+ // enough for a large image; btoa over String.fromCharCode is not.
81
+ const base64Letters = encoder.encode("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/");
78
82
  const toBase64 = (bytes) => {
79
- let text = "";
80
- for (let index = 0; index < bytes.length; index += 0x8000) {
81
- text += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
83
+ if (typeof bytes.toBase64 === "function") return bytes.toBase64();
84
+ const letters = new Uint8Array(Math.ceil(bytes.length / 3) * 4);
85
+ const whole = bytes.length - (bytes.length % 3);
86
+ let at = 0;
87
+ for (let index = 0; index < whole; index += 3) {
88
+ const group = (bytes[index] << 16) | (bytes[index + 1] << 8) | bytes[index + 2];
89
+ letters[at] = base64Letters[group >> 18];
90
+ letters[at + 1] = base64Letters[(group >> 12) & 63];
91
+ letters[at + 2] = base64Letters[(group >> 6) & 63];
92
+ letters[at + 3] = base64Letters[group & 63];
93
+ at += 4;
82
94
  }
83
- return btoa(text);
95
+ if (whole < bytes.length) {
96
+ // One or two bytes left: two or three letters, then "=" padding.
97
+ const two = whole + 1 < bytes.length;
98
+ const group = (bytes[whole] << 16) | (two ? bytes[whole + 1] << 8 : 0);
99
+ letters[at] = base64Letters[group >> 18];
100
+ letters[at + 1] = base64Letters[(group >> 12) & 63];
101
+ letters[at + 2] = two ? base64Letters[(group >> 6) & 63] : 61;
102
+ letters[at + 3] = 61;
103
+ }
104
+ return decoder.decode(letters);
105
+ };
106
+ const fromBase64 = (text) => {
107
+ if (typeof Uint8Array.fromBase64 === "function") return Uint8Array.fromBase64(text);
108
+ const binary = atob(text);
109
+ const bytes = new Uint8Array(binary.length);
110
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
111
+ return bytes;
84
112
  };
85
- const fromBase64 = (text) => Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
113
+
114
+ // A session keeps each prompt image once, in its image store, named by the
115
+ // lowercase hex SHA-256 of its bytes, as libfx's core names it; the log only
116
+ // names it.
117
+ async function imageName(bytes) {
118
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
119
+ let name = "";
120
+ for (const byte of digest) name += byte.toString(16).padStart(2, "0");
121
+ return name;
122
+ }
123
+ const base64url = (text) => toBase64(encoder.encode(text)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
124
+ // The key a harness's store gives its image store under, `{ put, get }`:
125
+ // `persistenceImages` in fx-sdk.js, which engines read it from.
126
+ const persistenceImages = Symbol.for("libfx.persistenceImages");
127
+
128
+ // A view of the bytes in an ArrayBuffer or typed array, or null.
129
+ const byteView = (value) => (value instanceof ArrayBuffer ? new Uint8Array(value)
130
+ : ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : null);
131
+
132
+ // A prompt's input as the queue and the log hold it: JSON, so image data is
133
+ // base64 text. With `named`, the session stores each image in its image
134
+ // store and the log names it (`storePromptImages`); `images` says whether any
135
+ // image has data to store.
136
+ //
137
+ // Each image block is copied once and only the copy is read, since an
138
+ // accessor can return something else the next time it is read, and the log
139
+ // must hold what was checked. The blocks are walked by index for the same
140
+ // reason: an Array subclass can make map() return anything. Pure.
141
+ function promptInputForLog(input, { named = false } = {}) {
142
+ let images = false;
143
+ const blocks = [];
144
+ for (let index = 0; index < input.length; index += 1) {
145
+ const block = input[index];
146
+ if (block?.type !== "image") {
147
+ blocks.push(block);
148
+ continue;
149
+ }
150
+ const image = { ...block };
151
+ if (image.data !== undefined && typeof image.data !== "string") throw new TypeError("a durable prompt carries image data as a base64 string");
152
+ if (typeof image.data === "string") images = named;
153
+ blocks.push(image);
154
+ }
155
+ return { input: blocks, images };
156
+ }
157
+
158
+ // Stores each image a prompt's input holds in the session's image store,
159
+ // before the input that names it is stored, and returns the input naming
160
+ // them: `{ type: "image", mimeType, sourceRef?, data: { sha256 } }`. An older
161
+ // libfx refuses data it can't read, so a worker that predates names fails
162
+ // the turn rather than run it without its images. A store that fails rejects
163
+ // with its own error. With `images` null it only names them, for a prompt the
164
+ // log already holds.
165
+ async function storePromptImages(images, input) {
166
+ const blocks = [];
167
+ for (const block of input) {
168
+ if (block?.type !== "image" || block.data === undefined) {
169
+ blocks.push(block);
170
+ continue;
171
+ }
172
+ const { data, ...rest } = block;
173
+ const bytes = fromBase64(data);
174
+ const sha256 = await imageName(bytes);
175
+ if (images) await images.put(sha256, bytes);
176
+ blocks.push({ ...rest, data: { sha256 } });
177
+ }
178
+ return blocks;
179
+ }
180
+
181
+ // A prompt's input with each named image read back from the session's image
182
+ // store, as base64, which is how a harness has always received image data.
183
+ // `missing` names the first image the store does not hold.
184
+ async function resolvePromptImages(images, input) {
185
+ if (!Array.isArray(input)) return { input };
186
+ const blocks = [];
187
+ for (const block of input) {
188
+ const name = block?.type === "image" ? block.data?.sha256 : undefined;
189
+ if (typeof name !== "string") {
190
+ blocks.push(block);
191
+ continue;
192
+ }
193
+ const bytes = await images.get(name);
194
+ if (!bytes) return { missing: name };
195
+ // Bytes that aren't the ones named are not the image its caller sent.
196
+ if (await imageName(bytes) !== name) return { missing: name, corrupt: true };
197
+ blocks.push({ ...block, data: toBase64(bytes) });
198
+ }
199
+ return { input: blocks };
200
+ }
86
201
 
87
202
  /**
88
203
  * The durability that keeps sessions in this process only. Agents given the
@@ -451,7 +566,7 @@ function createMemoryBackend({ maxDurationMs, reserveMs } = {}) {
451
566
  const sessionFor = (id) => {
452
567
  let session = sessions.get(id);
453
568
  if (!session) {
454
- session = { entries: [], head: null, ui: [], uiWaiters: new Set(), watchers: new Set() };
569
+ session = { entries: [], head: null, ui: [], uiWaiters: new Set(), watchers: new Set(), images: new Map() };
455
570
  sessions.set(id, session);
456
571
  }
457
572
  return session;
@@ -530,6 +645,14 @@ function createMemoryBackend({ maxDurationMs, reserveMs } = {}) {
530
645
  session.watchers.add(fn);
531
646
  return () => session.watchers.delete(fn);
532
647
  },
648
+ images: {
649
+ async put(name, bytes) {
650
+ if (!session.images.has(name)) session.images.set(name, bytes.slice());
651
+ },
652
+ async get(name) {
653
+ return session.images.get(name)?.slice();
654
+ },
655
+ },
533
656
  ui: {
534
657
  async write(lines) {
535
658
  session.ui.push(...lines);
@@ -580,6 +703,9 @@ const logStep = "fx.log";
580
703
  // the run's id, then `_user_` and the base64url of the namespace
581
704
  // "libfx-ui".
582
705
  const uiStreamOf = (runId) => `${runId.replace(/^wrun_/, "strm_")}_user_bGliZngtdWk`;
706
+ // The stream that holds an image, named as the UI stream is, with the
707
+ // namespace "libfx-image-" and the image's name.
708
+ const imageStreamOf = (runId, name) => `${runId.replace(/^wrun_/, "strm_")}_user_${base64url(`libfx-image-${name}`)}`;
583
709
  const queuePrefix = "__libfx_wkf_workflow_";
584
710
  // The name of an agent created without one, and of whatever a message or a
585
711
  // session from before names belonged to.
@@ -636,6 +762,49 @@ const streamReadRetries = 5;
636
762
  const missingStreamPollMs = 50;
637
763
  const missingStreamFastMs = 2000;
638
764
 
765
+ // Runs whose log views one backend keeps, and the characters of JSON they
766
+ // may hold together, least recently read dropped first.
767
+ const maxLogViews = 32;
768
+ const maxLogViewChars = 32 * 1024 * 1024;
769
+
770
+ // Whether a newest-first listing showed every slot from the lowest it
771
+ // listed to the newest. Pure.
772
+ const gapless = (read) => read.listed === 0 || read.newest - read.lowest + 1 === read.listed;
773
+
774
+ // The slot of a run's first event: a World numbers each run's events densely
775
+ // from 1.
776
+ const firstEventSlot = 1;
777
+
778
+ // Whether a newest-first listing reached slot `floor`, or the checkpoint it
779
+ // stopped at, which stands for every slot through its `through`. Pure.
780
+ const listedDownTo = (read, floor) => read.lowest === floor || (read.through !== null && read.lowest <= read.through);
781
+
782
+ /**
783
+ * A run's log view extended by the entries listed after it, as a full read
784
+ * returns the log: every entry after the newest checkpoint's `through`.
785
+ * Null when that checkpoint covers less than the view starts after, which
786
+ * only a full read can answer. `head` is the chain's head as a full read
787
+ * notes it: the newest chained entry, or the checkpoint's `through`. Pure.
788
+ */
789
+ export function extendLogView(view, newer) {
790
+ const combined = [...view.entries, ...newer];
791
+ let through = view.through;
792
+ for (let index = combined.length - 1; index >= 0; index -= 1) {
793
+ const entry = combined[index].entry;
794
+ if (entry?.k === "checkpoint") {
795
+ through = entry.through === null || entry.through === undefined ? 0 : Number(entry.through);
796
+ break;
797
+ }
798
+ }
799
+ if (through < view.through) return null;
800
+ const entries = combined.filter((item) => Number(item.cursor) > through);
801
+ let head = null;
802
+ for (let index = entries.length - 1; index >= 0 && head === null; index -= 1) {
803
+ if (chainedKind(entries[index].entry?.k)) head = entries[index].cursor;
804
+ }
805
+ return { entries, through, head: head ?? (through ? String(through) : "null") };
806
+ }
807
+
639
808
  // Events a session-log read lists per page: the first page, then the rest.
640
809
  const firstPageEvents = 10;
641
810
  const laterPageEvents = 100;
@@ -782,6 +951,35 @@ function createWorldBackend(world, durability) {
782
951
  // poll.
783
952
  const watchers = new Map();
784
953
  const changed = (runId) => { for (const watcher of watchers.get(runId) ?? []) watcher(); };
954
+ // Each run's log as this process last read it: the entries read() returned,
955
+ // the newest slot seen, and the checkpoint's `through` they start after.
956
+ // The log only appends, so a later read lists only slots after `known`.
957
+ // The entries are kept as the JSON the World stores, so every read hands
958
+ // out its own copy, as reading the World does, and the views' size is
959
+ // bounded in characters as well as in runs.
960
+ const logViews = new Map();
961
+ let logViewChars = 0;
962
+ const forgetView = (runId) => {
963
+ const view = logViews.get(runId);
964
+ if (!view) return;
965
+ logViewChars -= view.text.length;
966
+ logViews.delete(runId);
967
+ };
968
+ const rememberView = (runId, entries, known, through) => {
969
+ forgetView(runId);
970
+ const text = JSON.stringify(entries);
971
+ if (text.length > maxLogViewChars) return;
972
+ logViews.set(runId, { text, known, through });
973
+ logViewChars += text.length;
974
+ for (const oldest of logViews.keys()) {
975
+ if (logViews.size <= maxLogViews && logViewChars <= maxLogViewChars) break;
976
+ forgetView(oldest);
977
+ }
978
+ };
979
+ const viewOf = (runId) => {
980
+ const view = logViews.get(runId);
981
+ return view && { entries: JSON.parse(view.text), known: view.known, through: view.through };
982
+ };
785
983
  let handler = null;
786
984
  const route = typeof world.registerHandler !== "function";
787
985
  const listEvents = (runId, cursor, limit) => world.events.list({
@@ -867,6 +1065,8 @@ function createWorldBackend(world, durability) {
867
1065
  async session(runId) {
868
1066
  // The newest slot this worker has seen.
869
1067
  let known = 0;
1068
+ // The images this worker knows the run's image store holds.
1069
+ const storedImages = new Set();
870
1070
  // Where the World has the in-band writer fence, chained entries are
871
1071
  // written in-band, each expecting the World's count of in-band writes
872
1072
  // at the entry it continues, so the World refuses one that continues a
@@ -925,11 +1125,17 @@ function createWorldBackend(world, durability) {
925
1125
  // The newest chained entry read: the chain's head, unless the read
926
1126
  // stopped before reaching any.
927
1127
  let newestChained;
1128
+ // The newest and lowest slots this read listed above `stopBelow`,
1129
+ // entries or not, and how many. Unlike `known`, which this object's
1130
+ // own writes also raise, these say only what the listing showed.
1131
+ let newest = stopBelow;
1132
+ let lowest = Infinity;
1133
+ let listed = 0;
928
1134
  // Ends the read. `head` is where the chain's head is when the read
929
1135
  // found no chained entry, or undefined when the read does not show.
930
1136
  const done = (head) => {
931
1137
  noteSnapshot(snapshot, newestChained ?? head);
932
- return { events, through };
1138
+ return { events, through, snapshot, newest, lowest, listed };
933
1139
  };
934
1140
  // A session's newest checkpoint is usually among its last few
935
1141
  // entries, so a read starts with a small page and fetches more only
@@ -943,13 +1149,16 @@ function createWorldBackend(world, durability) {
943
1149
  if (!missingRun(error)) throw error;
944
1150
  // The run's creation starts its count.
945
1151
  if (stopBelow === 0) noteSnapshot({ seq: 0, seqInBand: inBandAtRunCreation }, "null");
946
- return { events, through };
1152
+ return { events, through, newest, lowest, listed, missing: true };
947
1153
  }
948
1154
  snapshot ??= page.snapshot;
949
1155
  for (const event of page.data) {
950
1156
  const slot = slotOf(event);
951
1157
  if (slot > known) known = slot;
952
1158
  if (slot <= stopBelow) return done(undefined);
1159
+ if (slot > newest) newest = slot;
1160
+ if (slot < lowest) lowest = slot;
1161
+ listed += 1;
953
1162
  // The checkpoint stands for the entries through its `through`,
954
1163
  // the last of them the chain's head.
955
1164
  if (through !== null && slot <= through) return done(through === 0 ? "null" : String(through));
@@ -966,10 +1175,50 @@ function createWorldBackend(world, durability) {
966
1175
  cursor = page.cursor;
967
1176
  }
968
1177
  };
1178
+ // The newest slot the last read() returned every entry through, or
1179
+ // null when that read could not show it had them all.
1180
+ let covered = null;
1181
+ const fullRead = async () => {
1182
+ const read = await newestFirst(0, true);
1183
+ const entries = read.events.reverse();
1184
+ // A listing that skipped a slot may show a later write before an
1185
+ // earlier one lands. The entries go back as they are, as every read
1186
+ // returned them before, but no view keeps the gap for later reads.
1187
+ // The listing must also reach the run's first event or the checkpoint
1188
+ // it stopped at, so no slot below what it listed is missing.
1189
+ if (!read.missing && listedDownTo(read, firstEventSlot) && gapless(read)) {
1190
+ rememberView(runId, entries, read.newest, read.through ?? 0);
1191
+ covered = read.newest;
1192
+ } else {
1193
+ forgetView(runId);
1194
+ covered = null;
1195
+ }
1196
+ return entries;
1197
+ };
969
1198
  return {
1199
+ newest: () => covered,
970
1200
  async read() {
971
- const { events } = await newestFirst(0, true);
972
- return events.reverse();
1201
+ const view = viewOf(runId);
1202
+ if (!view) return fullRead();
1203
+ if (view.known > known) known = view.known;
1204
+ const since = await newestFirst(view.known, true);
1205
+ if (since.missing) {
1206
+ forgetView(runId);
1207
+ covered = null;
1208
+ return [];
1209
+ }
1210
+ // The newer listing must have skipped no slot and must reach the
1211
+ // view or a checkpoint that stands for what lies between; and a
1212
+ // checkpoint covering less than the view starts after is not one
1213
+ // the view can stand for. Otherwise read the log whole.
1214
+ const reaches = since.listed === 0 || listedDownTo(since, view.known + 1);
1215
+ const next = reaches && gapless(since) ? extendLogView(view, since.events.reverse()) : null;
1216
+ if (!next) return fullRead();
1217
+ // The in-band count at the chain's head, as a full read notes it.
1218
+ noteSnapshot(since.snapshot, next.head);
1219
+ rememberView(runId, next.entries, since.newest, next.through);
1220
+ covered = since.newest;
1221
+ return next.entries;
973
1222
  },
974
1223
  async readSince(cursor) {
975
1224
  const { events } = await newestFirst(Number(cursor), false);
@@ -1047,6 +1296,44 @@ function createWorldBackend(world, durability) {
1047
1296
  set.add(fn);
1048
1297
  return () => set.delete(fn);
1049
1298
  },
1299
+ // Each image is a stream of the run, named after it, so it is read by
1300
+ // name wherever checkpoints fall. Two writers of one image each add
1301
+ // its bytes once; a read takes the first chunk that matches the name.
1302
+ images: {
1303
+ async put(name, bytes) {
1304
+ if (storedImages.has(name)) return;
1305
+ // Refused here, before the prompt is stored, rather than by every
1306
+ // worker that reads the image back.
1307
+ if (typeof world.streams.getChunks !== "function") throw new TypeError("a World needs streams.getChunks() to keep a session's images");
1308
+ await ensureRun();
1309
+ const stream = imageStreamOf(runId, name);
1310
+ let present = false;
1311
+ try {
1312
+ present = Number((await world.streams.getInfo(runId, stream))?.tailIndex ?? -1) >= 0;
1313
+ } catch (error) {
1314
+ if (!missingRun(error)) throw error;
1315
+ }
1316
+ if (!present) await world.streams.write(runId, stream, bytes);
1317
+ storedImages.add(name);
1318
+ },
1319
+ async get(name) {
1320
+ let page;
1321
+ try {
1322
+ page = await world.streams.getChunks(runId, imageStreamOf(runId, name), { limit: 8 });
1323
+ } catch (error) {
1324
+ if (missingRun(error)) return undefined;
1325
+ throw error;
1326
+ }
1327
+ for (const chunk of page?.data ?? []) {
1328
+ const bytes = byteView(chunk?.data);
1329
+ if (bytes && bytes.byteLength > 0 && await imageName(bytes) === name) {
1330
+ storedImages.add(name);
1331
+ return bytes.slice();
1332
+ }
1333
+ }
1334
+ return undefined;
1335
+ },
1336
+ },
1050
1337
  ui: {
1051
1338
  async write(lines) {
1052
1339
  await ensureRun();
@@ -1293,14 +1580,33 @@ const messageIdInUse = (message) => Object.assign(
1293
1580
  { code: "FX_MESSAGE_ID_IN_USE" },
1294
1581
  );
1295
1582
 
1296
- // Writes what a message adds to its session, in order, and returns how many
1297
- // entries it wrote. Only the sender calls it, before it queues the message.
1583
+ // Writes what a message adds to its session, in order, and returns each
1584
+ // entry it wrote with the slot it took. Only the sender calls it, before it
1585
+ // queues the message.
1298
1586
  // A message that reuses another input's id writes nothing and throws.
1299
1587
  async function recordMessage(log, state, message) {
1300
1588
  if (idInUse(state, message)) throw messageIdInUse(message);
1301
1589
  const entries = messageEntries(state, message);
1302
- for (const entry of entries) await log.append(entry);
1303
- return entries.length;
1590
+ const appended = [];
1591
+ for (const entry of entries) appended.push({ cursor: await log.append(entry), entry });
1592
+ return appended;
1593
+ }
1594
+
1595
+ /**
1596
+ * The log a sender read and then extended with its own entries, as the
1597
+ * worker it starts in the same request may use it instead of reading the
1598
+ * log again. Only when the sender's read showed every entry through slot
1599
+ * `newestSeen`, and every entry the sender then wrote took the next slot:
1600
+ * then nothing landed in between, so the view holds every input that comes
1601
+ * before this message's. The sender's entries go on as the JSON the World
1602
+ * stored, not as the objects it wrote. Null otherwise. Pure.
1603
+ */
1604
+ export function handoffEntries(read, newestSeen, appended) {
1605
+ if (!Number.isSafeInteger(newestSeen) || appended.length === 0) return null;
1606
+ for (const [index, item] of appended.entries()) {
1607
+ if (Number(item.cursor) !== newestSeen + index + 1) return null;
1608
+ }
1609
+ return [...read, ...appended.map(({ cursor, entry }) => ({ cursor, entry: JSON.parse(JSON.stringify(entry)) }))];
1304
1610
  }
1305
1611
 
1306
1612
  /**
@@ -1385,9 +1691,12 @@ class SessionWorker {
1385
1691
  // (`tests/tla/sdk/TenantIsolation.tla` OnlySentRuns). Then it runs the
1386
1692
  // session when no live worker holds it. Returns `{ timeoutSeconds }` to
1387
1693
  // have the same message delivered again later.
1388
- async consume(message, delivery = null) {
1389
- const log = await this.backend.session(this.sessionId);
1390
- let entries = await log.read();
1694
+ // `handoff` is the sender's own session object and the log it read and
1695
+ // extended, when the request that took the prompt runs it (see
1696
+ // handoffEntries); the worker starts from them instead of reading again.
1697
+ async consume(message, delivery = null, handoff = null) {
1698
+ const log = handoff?.log ?? await this.backend.session(this.sessionId);
1699
+ let entries = handoff?.entries ?? await log.read();
1391
1700
  let state = this.fold(entries, delivery);
1392
1701
  if (!knownMessage(state, message)) {
1393
1702
  this.agent.emit("session.refused", { sessionId: this.sessionId, messageId: message.messageId, reason: "unknown message" });
@@ -1589,6 +1898,8 @@ class SessionWorker {
1589
1898
  const ui = uiWriter(log, (error) => agent.emit("ui.error", { sessionId: this.sessionId, error: error?.name ?? "Error" }), lease.epoch);
1590
1899
  // When the lease runs out, as this worker last renewed it.
1591
1900
  let leaseExpiresAt = lease.expiresAt;
1901
+ // An image read that threw while the engine opened.
1902
+ let imageReadFailed = null;
1592
1903
  const store = {
1593
1904
  load: async () => ({
1594
1905
  ...(state.checkpoint?.data ? { checkpoint: { through: state.checkpoint.through ?? "0", data: fromBase64(state.checkpoint.data) } } : {}),
@@ -1597,6 +1908,20 @@ class SessionWorker {
1597
1908
  head: head ?? state.head,
1598
1909
  }),
1599
1910
  append: ({ idempotencyKey, data, marks }) => chained({ k: "record", key: idempotencyKey, data: toBase64(data), marks }).then((cursor) => ({ cursor })),
1911
+ // The engine stores each prompt image before it sends it; a prompt
1912
+ // from the log names images already here.
1913
+ [persistenceImages]: {
1914
+ put: (name, bytes) => log.images.put(name, bytes),
1915
+ get: async (name) => {
1916
+ try {
1917
+ return await log.images.get(name);
1918
+ } catch (error) {
1919
+ // The store failed to answer, which says nothing of the image.
1920
+ imageReadFailed = error;
1921
+ throw error;
1922
+ }
1923
+ },
1924
+ },
1600
1925
  saveCheckpoint: async ({ through, data }) => {
1601
1926
  // What a fold that starts at this checkpoint would otherwise miss:
1602
1927
  // inputs before it that no turn took or that the open turn holds
@@ -1686,6 +2011,10 @@ class SessionWorker {
1686
2011
  harnessRun = runOf(state.openTurn ?? state.pending[0]);
1687
2012
  harness = await agent.openHarness(this.sessionId, store, harnessRun, ready);
1688
2013
  } catch (error) {
2014
+ // The image store failed to answer while the engine read the
2015
+ // session's images: as when the log can't be read, the delivery
2016
+ // fails and its message runs again; nothing failed the turn.
2017
+ if (imageReadFailed) throw imageReadFailed;
1689
2018
  // A harness that cannot start fails the turn waiting on it, once:
1690
2019
  // the open turn, or after it failed, the next prompt.
1691
2020
  const waiting = state.openTurn && !state.openFailed ? state.openTurn.id : state.pending[0]?.messageId;
@@ -1763,8 +2092,21 @@ class SessionWorker {
1763
2092
  harness = await agent.openHarness(this.sessionId, store, wanted, ready);
1764
2093
  harnessRun = wanted;
1765
2094
  }
2095
+ // A prompt names the images it stored; the engine gets their bytes.
2096
+ const withImages = await resolvePromptImages(log.images, next.input);
2097
+ if (withImages.missing) {
2098
+ // A history without the image is not the one its caller sent.
2099
+ await log.append({ k: "ended", messageId });
2100
+ const message = withImages.corrupt
2101
+ ? `the session's image store holds other bytes under image ${withImages.missing}`
2102
+ : `the session's image store does not hold image ${withImages.missing}`;
2103
+ ui.push({ type: "turn_end", messageId, stopReason: "error", usage: {}, error: { name: "Error", message } });
2104
+ await ui.settle();
2105
+ state = this.fold(await log.read());
2106
+ continue;
2107
+ }
1766
2108
  try {
1767
- turn = harness.prompt(next.input ?? "", { turnId: messageId, yieldAt });
2109
+ turn = harness.prompt(withImages.input ?? "", { turnId: messageId, yieldAt });
1768
2110
  } catch (error) {
1769
2111
  await log.append({ k: "stopped", messageId });
1770
2112
  return unusable(error);
@@ -2227,6 +2569,9 @@ function turnView({ messageId, resumeRequest = null, start }) {
2227
2569
  * sets over the agent's options for the turns it starts, as JSON the core
2228
2570
  * stores with each prompt and passes to `open()`, or undefined for none.
2229
2571
  * It throws a TypeError for options it refuses.
2572
+ * - `checkPrompt(input)` (optional): throws a TypeError or RangeError for
2573
+ * a prompt input the harness would refuse. A session's `prompt()` calls it
2574
+ * before it stores anything, so a prompt it refuses is never stored.
2230
2575
  *
2231
2576
  * A harness session has `prompt(input, { turnId, yieldAt })` and
2232
2577
  * `resume({ yieldAt, rerun })`, each returning a turn, where `rerun: false`
@@ -2244,7 +2589,8 @@ function turnView({ messageId, resumeRequest = null, start }) {
2244
2589
  * end. Either way the steer runs as the next turn. A turn stops before a
2245
2590
  * model request once `yieldAt` has passed, with the stop reason `yielded`.
2246
2591
  *
2247
- * An input the harness refuses is a turn that ends with an error.
2592
+ * An input the harness refuses only once a turn runs it is a turn that ends
2593
+ * with an error.
2248
2594
  * `prompt()` and `resume()` throw only when the harness cannot run a turn,
2249
2595
  * and a turn whose harness stopped under it rejects with a
2250
2596
  * `FX_HARNESS_STOPPED` code; either way the same message runs the session
@@ -2344,14 +2690,14 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2344
2690
  // One delivery of `message` to the session's worker here: from the
2345
2691
  // queue, with `delivery` naming the queue's message and attempt, or with
2346
2692
  // `inRequest` from the request that took the prompt.
2347
- const receive = (created, message, delivery, { inRequest = false } = {}) => {
2693
+ const receive = (created, message, delivery, { inRequest = false, handoff = null } = {}) => {
2348
2694
  let worker = workers.get(message.sessionId);
2349
2695
  if (!worker) {
2350
2696
  worker = new SessionWorker(agent, created, message.sessionId);
2351
2697
  workers.set(message.sessionId, worker);
2352
2698
  }
2353
2699
  worker.deliveries += 1;
2354
- let work = worker.consume(message, delivery);
2700
+ let work = worker.consume(message, delivery, handoff);
2355
2701
  if (inRequest) {
2356
2702
  // No queue delivered this message, so none delivers it again: a turn
2357
2703
  // that must go on later continues in a fresh message. One whose
@@ -2417,7 +2763,9 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2417
2763
  // OnlySentRuns), and a queue that ends with the process loses no
2418
2764
  // input.
2419
2765
  const log = await created.session(sessionId);
2420
- await recordMessage(log, foldSessionLog(await log.read()), full);
2766
+ const read = await log.read();
2767
+ const newestSeen = log.newest?.();
2768
+ const appended = await recordMessage(log, foldSessionLog(read), full);
2421
2769
  const queued = queuedMessage(full, created.store);
2422
2770
  const idempotencyKey = `${sessionId}:${message.type}:${message.messageId}`;
2423
2771
  if (inRequest && created.runInRequest) {
@@ -2434,7 +2782,9 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2434
2782
  seeded = true;
2435
2783
  const delivery = backstop ? { messageId: backstop, attempt: 0 } : null;
2436
2784
  // receive() reports a failure to the host; the backstop retries it.
2437
- const work = receive(created, queued, delivery, { inRequest: true }).then(() => {}, () => {});
2785
+ const entries = handoffEntries(read, newestSeen, appended);
2786
+ const handoff = entries ? { log, entries } : null;
2787
+ const work = receive(created, queued, delivery, { inRequest: true, handoff }).then(() => {}, () => {});
2438
2788
  created.waitUntil?.(work);
2439
2789
  return;
2440
2790
  }
@@ -2442,7 +2792,16 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2442
2792
  await created.queue.send(queued, { idempotencyKey });
2443
2793
  seeded = true;
2444
2794
  };
2445
- const startTurn = (type, messageId, input, retried = false, aborted = false) => {
2795
+ // Settles once the latest prompt from this session object with images
2796
+ // to store is stored or has failed; null while none has. Each later
2797
+ // prompt waits for it, so they are stored in the order made.
2798
+ let storing = null;
2799
+ // `ready`, when given, resolves the input once it can be stored; only a
2800
+ // prompt that is stored calls it.
2801
+ // `aborted()` says whether the prompt's signal has aborted. One that
2802
+ // aborted before the prompt's send began never stores it; `progress.sent`
2803
+ // is set as the send begins, after which an abort cancels the prompt.
2804
+ const startTurn = (type, messageId, input, retried = false, aborted = () => false, ready = null, progress = null) => {
2446
2805
  let accept;
2447
2806
  const accepted = new Promise((resolve, reject) => { accept = { resolve, reject }; });
2448
2807
  void accepted.catch(() => {});
@@ -2451,20 +2810,27 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2451
2810
  resumeRequest: type === "resume" ? messageId : null,
2452
2811
  start: async () => {
2453
2812
  try {
2813
+ const read = input === undefined || ready === null || aborted() ? input : await ready();
2814
+ const message = { type, messageId, ...(input === undefined ? {} : { input: read }) };
2815
+ const queue = () => {
2816
+ if (progress) progress.sent = true;
2817
+ return send(message, { inRequest: type === "prompt" });
2818
+ };
2454
2819
  const { created, id: sessionId } = await resolved;
2455
2820
  const log = await created.session(sessionId);
2456
2821
  let attaching = type === "resume";
2457
2822
  let from = null;
2458
- if (retried && !aborted) {
2459
- // The viewer's start is read before the message is queued, so
2460
- // every line this message's own turn writes comes after it,
2461
- // and an end for this id before it can only be from a turn
2462
- // this id already ran. The log, for that answer, is read
2463
- // alongside the one queue send.
2464
- from = await log.ui.length();
2823
+ // The viewer's start is read before the message is queued, so
2824
+ // every line this message's own turn writes comes after it, and
2825
+ // an end for this id before it can only be from a turn this id
2826
+ // already ran.
2827
+ if (retried) from = await log.ui.length();
2828
+ if (retried && !aborted()) {
2829
+ // The log, for that answer, is read alongside the one queue
2830
+ // send.
2465
2831
  const [state] = await Promise.all([
2466
2832
  log.read().then(foldSessionLog),
2467
- send({ type, messageId, ...(input === undefined ? {} : { input }) }, { inRequest: type === "prompt" }).then(() => accept.resolve({ messageId, sessionId })),
2833
+ queue().then(() => accept.resolve({ messageId, sessionId })),
2468
2834
  ]);
2469
2835
  // One the log does not know yet is this send's own. When the
2470
2836
  // log says it ran and nothing shows how, either this send's
@@ -2477,9 +2843,8 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2477
2843
  }
2478
2844
  if (retried) {
2479
2845
  // A turn this id already ran answers from the log, without
2480
- // queueing it again. The viewer's start is read first, so a
2846
+ // queueing it again. The viewer's start was read first, so a
2481
2847
  // turn that ends after it has its end in view.
2482
- from = await log.ui.length();
2483
2848
  const outcome = await retryOutcome(log, foldSessionLog(await log.read()), messageId, from, { sent: false });
2484
2849
  if (outcome.follow) attaching = true;
2485
2850
  else if (!outcome.fresh) {
@@ -2488,15 +2853,17 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2488
2853
  return { log, from, attaching: true, endWithinMs: retryEndWaitMs };
2489
2854
  }
2490
2855
  }
2491
- if (aborted && !attaching) {
2492
- // A new prompt whose signal had aborted is never stored.
2493
- accept.resolve({ messageId, sessionId });
2494
- return { settled: { messageId, stopReason: "cancelled", usage: {} } };
2495
- }
2496
2856
  // The viewer starts where the stream is now, before the
2497
2857
  // message can produce anything.
2498
2858
  from ??= await log.ui.length();
2499
- await send({ type, messageId, ...(input === undefined ? {} : { input }) }, { inRequest: type === "prompt" });
2859
+ if (aborted()) {
2860
+ // A prompt whose signal aborted before its send began is never
2861
+ // stored; a retry of one the log holds only follows its turn.
2862
+ accept.resolve({ messageId, sessionId });
2863
+ if (!attaching) return { settled: { messageId, stopReason: "cancelled", usage: {} } };
2864
+ return { log, from, attaching };
2865
+ }
2866
+ await queue();
2500
2867
  accept.resolve({ messageId, sessionId });
2501
2868
  return { log, from, attaching };
2502
2869
  } catch (error) {
@@ -2513,25 +2880,83 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2513
2880
  * Queues `input` as the session's next turn and returns a view of it.
2514
2881
  * With `messageId`, a retried call is the same turn: it continues a
2515
2882
  * turn still running and answers one that ended, without running it
2516
- * again.
2883
+ * again. Each image is stored once in the session's image store and
2884
+ * the log names it. A later prompt from this session object waits for
2885
+ * an earlier one's images. An input the harness would refuse throws
2886
+ * here and is never stored. A prompt whose signal aborts before it is
2887
+ * stored is never stored either: it ends cancelled.
2517
2888
  */
2518
2889
  prompt(input, promptOptions = {}) {
2519
2890
  if (closed) throw new Error(`${label} is closed`);
2520
2891
  const messageId = promptOptions.messageId ?? newId("msg");
2521
2892
  if (!validId(messageId)) throw new TypeError(`messageId must be ${idRule}`);
2522
2893
  if (typeof input !== "string" && !Array.isArray(input)) throw new TypeError("prompt input must be a string or an array of content blocks");
2523
- // The input travels through the queue and the log as JSON.
2524
- if (Array.isArray(input) && input.some((block) => block?.type === "image" && block.data !== undefined && typeof block.data !== "string")) {
2525
- throw new TypeError("a durable prompt carries image data as a base64 string");
2526
- }
2527
- admit(input);
2528
2894
  const signal = promptOptions.signal;
2529
2895
  if (signal !== undefined && typeof signal?.addEventListener !== "function") throw new TypeError("prompt signal must be an AbortSignal");
2530
- const turn = startTurn("prompt", messageId, input, promptOptions.messageId !== undefined, signal?.aborted === true);
2896
+ const aborted = signal?.aborted === true;
2897
+ const retried = promptOptions.messageId !== undefined;
2898
+ // The input travels through the queue and the log as JSON. Its
2899
+ // images go to the session's image store first and the log names
2900
+ // them; a prompt aborted before the call stores nothing, so it
2901
+ // keeps them as base64.
2902
+ const named = !aborted;
2903
+ const stored = Array.isArray(input) ? promptInputForLog(input, { named }) : { input, images: false };
2904
+ // The copy the log will hold is what is checked: a getter can't
2905
+ // show the check one value and the log another.
2906
+ admit(stored.input);
2907
+ plugged.checkPrompt?.(stored.input);
2908
+ const before = storing;
2909
+ const ready = before || stored.images ? async () => {
2910
+ // The prompt waits for an earlier one and its image writes only
2911
+ // until its signal aborts; what it waited for may still land, but
2912
+ // the prompt is not stored.
2913
+ let release = () => {};
2914
+ const abortion = signal === undefined ? null : new Promise((resolve) => {
2915
+ signal.addEventListener("abort", resolve, { once: true });
2916
+ release = () => signal.removeEventListener("abort", resolve);
2917
+ });
2918
+ const unlessAborted = (promise) => {
2919
+ if (abortion === null) return promise;
2920
+ void promise.catch(() => {});
2921
+ return Promise.race([promise, abortion]);
2922
+ };
2923
+ try {
2924
+ await unlessAborted(before ?? Promise.resolve());
2925
+ if (signal?.aborted) return null;
2926
+ let read = stored.input;
2927
+ if (stored.images) {
2928
+ const { created, id: sessionId } = await resolved;
2929
+ const log = await created.session(sessionId);
2930
+ // A retry of a prompt the log already holds is answered from
2931
+ // the log: its images are named, not written again.
2932
+ const known = retried && knownMessage(foldSessionLog(await log.read()), { type: "prompt", messageId });
2933
+ if (signal?.aborted) return null;
2934
+ read = await unlessAborted(storePromptImages(known ? null : log.images, read));
2935
+ if (signal?.aborted) return null;
2936
+ }
2937
+ // An agent closed meanwhile stores nothing more.
2938
+ if (closed) throw new Error(`${label} is closed`);
2939
+ return read;
2940
+ } finally {
2941
+ release();
2942
+ }
2943
+ } : null;
2944
+ const progress = { sent: false };
2945
+ const turn = startTurn("prompt", messageId, stored.input, retried, () => signal?.aborted === true, ready, progress);
2946
+ if (stored.images && !aborted) {
2947
+ // A later prompt waits for this one and every one before it: one
2948
+ // withdrawn while it waited settles before them.
2949
+ const settled = Promise.all([before, turn.accepted.then(() => {}, () => {})]).then(() => {});
2950
+ storing = settled;
2951
+ void settled.then(() => { if (storing === settled) storing = null; });
2952
+ }
2531
2953
  if (signal !== undefined && !signal.aborted) {
2532
2954
  // The prompt's own cancel: it stops this turn whether it runs or
2533
- // still waits, and never a later one.
2534
- const stop = () => { void send({ type: "cancel", messageId: newId("cnl"), target: messageId }).catch(() => {}); };
2955
+ // still waits, and never a later one. A prompt whose send had not
2956
+ // begun is never stored, so it needs none.
2957
+ const stop = () => {
2958
+ if (progress.sent) void send({ type: "cancel", messageId: newId("cnl"), target: messageId }).catch(() => {});
2959
+ };
2535
2960
  signal.addEventListener("abort", stop, { once: true });
2536
2961
  void turn.result.finally(() => signal.removeEventListener("abort", stop)).catch(() => {});
2537
2962
  }
@@ -2633,7 +3058,16 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2633
3058
  const messageId = steerOptions.messageId ?? newId("str");
2634
3059
  if (!validId(messageId)) throw new TypeError(`messageId must be ${idRule}`);
2635
3060
  admit(input);
2636
- await send({ type: "steer", messageId, input });
3061
+ // A steer's images go in the session's image store before the steer
3062
+ // that names them, as a prompt's do; storing one again under its
3063
+ // name is harmless, so a retry needs no check.
3064
+ let named = input;
3065
+ if (Array.isArray(input) && input.some((block) => block?.type === "image" && typeof block.data === "string")) {
3066
+ const { created, id: sessionId } = await resolved;
3067
+ named = await storePromptImages((await created.session(sessionId)).images, input);
3068
+ if (closed) throw new Error(`${label} is closed`);
3069
+ }
3070
+ await send({ type: "steer", messageId, input: named });
2637
3071
  return { messageId };
2638
3072
  },
2639
3073
  /** Cancels the running turn; dropping a turn's view does not. */
@@ -2652,6 +3086,12 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2652
3086
  head: state.head,
2653
3087
  }),
2654
3088
  append: async () => { throw new Error("a checkpoint reads the session without changing it"); },
3089
+ // The records name the session's images; the engine reads them
3090
+ // from its image store to export them.
3091
+ [persistenceImages]: {
3092
+ get: (name) => log.images.get(name),
3093
+ put: async () => { throw new Error("a checkpoint reads the session without changing it"); },
3094
+ },
2655
3095
  };
2656
3096
  const harness = await agent.openHarness(sessionId, readOnly, null);
2657
3097
  try {