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/README.md +9 -6
- package/durable.js +490 -50
- package/fx-core.wasm +0 -0
- package/fx-harness.js +9 -2
- package/fx-sdk.js +237 -36
- package/fx-term.wasm +0 -0
- package/libfx.darwin-arm64.node +0 -0
- package/libfx.darwin-x64.node +0 -0
- package/libfx.linux-arm64.node +0 -0
- package/libfx.linux-x64.node +0 -0
- package/mcp.js +3 -3
- package/node.cjs +567 -75
- package/package.json +1 -1
- package/types/durable-world.d.ts +4 -1
- package/types/libfx.d.cts +10 -4
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
972
|
-
return
|
|
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
|
|
1297
|
-
//
|
|
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
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|