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/fx-core.wasm CHANGED
Binary file
package/fx-harness.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // fx as a harness of the durable core in durable.js: `createFxAgent` is that
2
2
  // core running fx engines. Everything the core needs to know about fx is
3
3
  // here; the core itself knows only the harness contract.
4
- import { coreAnswered, engineInternals, journalMarksWanted, normalizeInstructions, normalizeModelChoice } from "./fx-sdk.js";
4
+ import { checkPromptInput, coreAnswered, engineInternals, journalMarksWanted, normalizeInstructions, normalizeModelChoice, resumeOutputShown } from "./fx-sdk.js";
5
5
  import { createCredentialMemory, credentialMemory } from "./subscription.js";
6
6
 
7
7
  const encoder = new TextEncoder();
@@ -28,6 +28,11 @@ export function fxHarness({ createEngine, defaultApiKey = async () => undefined
28
28
  return {
29
29
  ...(checkpoint === undefined ? {} : { seed: snapshotOf(checkpointBytesOf(checkpoint)) }),
30
30
  settings: sessionSettings,
31
+ // What a session's prompt() refuses before storing anything: whatever
32
+ // this agent's engines would refuse.
33
+ checkPrompt(input) {
34
+ checkPromptInput(input, { resizeImage: engineOptions.resizeImage });
35
+ },
31
36
  async open({ sessionId, store, context, settings, durability, ready }) {
32
37
  const apiKey = engineOptions.apiKey ?? await defaultApiKey(durability);
33
38
  const engine = await createEngine({
@@ -147,7 +152,9 @@ function fxSession(engine, idempotent) {
147
152
  // A call left running reruns only when running it twice is safe, and
148
153
  // with `rerun: false` not even then. Any other call is never run again:
149
154
  // the model is told it may have partly run, and the turn goes on.
150
- resume: ({ rerun = true, ...options } = {}) => fxTurn(engine.resume({ ...options, onAmbiguous: (call) => (rerun && idempotent.has(call.name) ? "rerun" : undefined) }), internals.exited),
155
+ // The session's stream already holds what the turn sent before it
156
+ // stopped, so the resume does not send it again.
157
+ resume: ({ rerun = true, ...options } = {}) => fxTurn(engine.resume({ ...options, [resumeOutputShown]: true, onAmbiguous: (call) => (rerun && idempotent.has(call.name) ? "rerun" : undefined) }), internals.exited),
151
158
  get openTurn() {
152
159
  return internals.openTurn;
153
160
  },
package/fx-sdk.js CHANGED
@@ -37,7 +37,10 @@ const maxUnreadEvents = 256;
37
37
  // Prompt images travel as raw bytes beside the ACP frame and are base64
38
38
  // encoded only in the model request. An image may use 5 MiB of encoded
39
39
  // request data, and a prompt's images 8 MiB, so the raw limits are 3/4 of that.
40
- // The kernel still validates content and media type.
40
+ // The kernel still validates content and media type, and holds a prompt to
41
+ // these limits too: `max_libfx_prompt_images` and
42
+ // `max_libfx_prompt_images_bytes` in src/core/images/image_attachments.zig
43
+ // must match them.
41
44
  const maxPromptImages = 8;
42
45
  const maxPromptImageDataBytes = 5 * 1024 * 1024;
43
46
  const maxPromptImagesDataBytes = 8 * 1024 * 1024;
@@ -45,6 +48,7 @@ const maxPromptImageBytes = (maxPromptImageDataBytes / 4) * 3;
45
48
  const maxPromptImagesBytes = (maxPromptImagesDataBytes / 4) * 3;
46
49
  // Matches the native attachment table: one prompt's images or one checkpoint.
47
50
  const maxPendingAttachments = 8;
51
+ const maxPendingAttachmentBytes = 8 * 1024 * 1024;
48
52
  const maxOutboundAttachments = 4;
49
53
  // Matches the core's kernel checkpoint limit (max_checkpoint_bytes).
50
54
  const maxCheckpointBytes = 4 * 1024 * 1024;
@@ -199,8 +203,12 @@ export function normalizeAgentOptions(value) {
199
203
  if (options[name] !== undefined && typeof options[name] !== "function") throw new TypeError(`${name} must be a function`);
200
204
  }
201
205
  Object.assign(options, normalizeModelChoice(options));
202
- // fx's default model is a Gateway id; a custom provider names its own.
206
+ // fx's default model is a Gateway id. Codex, Grok, and a custom provider
207
+ // name their own models, so each needs one, as the fx CLI asks for.
203
208
  if (custom && options.model === undefined) throw new TypeError("a custom provider needs a model");
209
+ if (subscription && options.model === undefined) {
210
+ throw new TypeError(`provider "${options.provider}" needs a model; fx's default model is an AI Gateway id`);
211
+ }
204
212
  validateGatewayChatUrl(options.gatewayChatUrl);
205
213
  if (options.resizeImage !== undefined && typeof options.resizeImage !== "function") {
206
214
  throw new TypeError("resizeImage must be a function");
@@ -234,6 +242,14 @@ export function normalizeAgentOptions(value) {
234
242
  // public surface.
235
243
  export const engineInternals = Symbol.for("libfx.engineInternals");
236
244
  export const journalMarksWanted = Symbol.for("libfx.journalMarks");
245
+ // A persistence's image store, `{ put(name, bytes), get(name) }`, which keeps
246
+ // each image once, named by the SHA-256 of its bytes, so records and
247
+ // checkpoints only name it. Durable sessions give one to the engines they
248
+ // run; without it, images stay inline.
249
+ export const persistenceImages = Symbol.for("libfx.persistenceImages");
250
+ // `{ images, bytes }`: a smaller image window than the core's, the newest
251
+ // images a session with an image store keeps, so tests can reach its edge.
252
+ const imageWindowLimits = Symbol.for("libfx.imageWindow");
237
253
  // Marks an error the core answered a request with, as opposed to one from a
238
254
  // core that stopped answering.
239
255
  export const coreAnswered = Symbol.for("libfx.coreAnswered");
@@ -279,8 +295,26 @@ const journalOpenErrorCodes = new Map([
279
295
  ["libfx journal events are out of order", "FX_JOURNAL_INVALID"],
280
296
  ["libfx journal is too large", "FX_JOURNAL_TOO_LARGE"],
281
297
  ["libfx journal holds more than 1024 turns", "FX_JOURNAL_TOO_LARGE"],
298
+ ["libfx journal images are too large", "FX_JOURNAL_TOO_LARGE"],
299
+ ["Invalid libfx journal images", "FX_JOURNAL_INVALID"],
300
+ ["libfx image does not match its name", "FX_JOURNAL_IMAGE_MISSING"],
282
301
  ]);
283
302
 
303
+ // An image's name in a persistence's image store: the lowercase hex SHA-256
304
+ // of its bytes, as the core names it.
305
+ async function imageName(bytes) {
306
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
307
+ let name = "";
308
+ for (const byte of digest) name += byte.toString(16).padStart(2, "0");
309
+ return name;
310
+ }
311
+
312
+ function imageMissingError(name, cause) {
313
+ const error = journalLoadError(`libfx journal names an image the store does not hold: ${name}`, "FX_JOURNAL_IMAGE_MISSING", cause);
314
+ error.image = name;
315
+ return error;
316
+ }
317
+
284
318
  /**
285
319
  * A persistence store that keeps a session's records and its latest
286
320
  * checkpoint in memory, for tests and for hosts that copy them elsewhere.
@@ -327,6 +361,11 @@ const resumeTurn = Symbol("libfx.resume");
327
361
  const followUpInput = Symbol("libfx.followUp");
328
362
  // Carries the ids of the calls a resumed turn runs again.
329
363
  const rerunCallIds = Symbol("libfx.rerun");
364
+ // Marks a resume whose host already holds what the turn sent before it
365
+ // stopped, as a durable session's stream does, so the turn does not send it
366
+ // again. Any other resume runs in a host that saw none of it. Never a
367
+ // caller-visible option; `fx-harness.js` sets it.
368
+ export const resumeOutputShown = Symbol.for("libfx.resumeOutputShown");
330
369
 
331
370
  function journalAppendError(cause) {
332
371
  const error = new Error("libfx journal append failed", { cause });
@@ -470,6 +509,10 @@ async function loadPersistence(persistence) {
470
509
  return { checkpoint, events, head, tailBytes: bytes, lastSeq: events.at(-1)?.seq ?? covered };
471
510
  }
472
511
 
512
+ // Events someone waits on, so the record holding one is a barrier: a steer
513
+ // or withdrawal its caller awaits, and the end or yield a turn's result does.
514
+ const waitedForEvents = new Set(["input_accepted", "input_withdrawn", "turn_committed", "turn_progress_cleared", "turn_yielded"]);
515
+
473
516
  // What a record's events say about turns, in order, for a store that tracks
474
517
  // them without reading records: a turn started, ended, or yielded, an input
475
518
  // was accepted or handed back unplaced, or the step the open turn is at, as
@@ -1977,6 +2020,62 @@ function normalizePromptInput(input, { deferImageLimits = false } = {}) {
1977
2020
  return prompt;
1978
2021
  }
1979
2022
 
2023
+ // The image types libfx's core takes, each known by how its bytes start.
2024
+ const imageSignatures = [
2025
+ ["image/png", 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
2026
+ ["image/jpeg", 0, [0xff, 0xd8, 0xff]],
2027
+ ["image/gif", 0, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]],
2028
+ ["image/gif", 0, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]],
2029
+ ["image/webp", 0, [0x52, 0x49, 0x46, 0x46]],
2030
+ ];
2031
+ const imageTypes = new Set(imageSignatures.map(([type]) => type));
2032
+ const webpMarker = [0x57, 0x45, 0x42, 0x50];
2033
+
2034
+ // The image type `bytes` start as, or null.
2035
+ function imageTypeOf(bytes) {
2036
+ const startsWith = (offset, signature) => bytes.length >= offset + signature.length && signature.every((byte, index) => bytes[offset + index] === byte);
2037
+ for (const [type, offset, signature] of imageSignatures) {
2038
+ if (!startsWith(offset, signature)) continue;
2039
+ if (type === "image/webp" && !startsWith(8, webpMarker)) continue;
2040
+ return type;
2041
+ }
2042
+ return null;
2043
+ }
2044
+
2045
+ // Refuses image bytes that are not the type they declare, as libfx's core does.
2046
+ function checkImageBytesType(bytes, mimeType, index) {
2047
+ const type = imageTypeOf(bytes);
2048
+ if (type === null) throw new TypeError(`image prompt block ${index} is not a PNG, JPEG, GIF or WebP image`);
2049
+ if (type !== mimeType) throw new TypeError(`image prompt block ${index} holds ${type} data, not ${mimeType}`);
2050
+ }
2051
+
2052
+ /**
2053
+ * Checks a prompt's input as libfx takes it and returns it normalized:
2054
+ * every check `prompt()` makes, and the ones libfx's core makes on images,
2055
+ * which would otherwise refuse the prompt only once it reached the core: a
2056
+ * supported image type, and bytes or base64 that are that type. A Blob is not
2057
+ * read, so its bytes are checked once read (`checkImageBytesType`). With
2058
+ * `resizeImage`, image bytes are checked after it runs instead. Throws a
2059
+ * TypeError or RangeError.
2060
+ */
2061
+ export function checkPromptInput(input, { resizeImage } = {}) {
2062
+ const resized = resizeImage !== undefined;
2063
+ const normalized = normalizePromptInput(input, { deferImageLimits: resized });
2064
+ // The core takes no prompt without text or an image; a resource adds neither.
2065
+ if (!normalized.some((block) => block.type === "image" || (block.type === "text" && block.text.length > 0))) {
2066
+ throw new TypeError("prompt is empty");
2067
+ }
2068
+ // resizeImage decides the final image sizes, so they are counted after it runs.
2069
+ checkPromptFrameSize(normalized, !resized);
2070
+ normalized.forEach((block, index) => {
2071
+ if (block.type !== "image" || (resized && block.data !== undefined)) return;
2072
+ if (!imageTypes.has(block.mimeType)) throw new TypeError(`image prompt block ${index} mimeType must be image/png, image/jpeg, image/gif or image/webp`);
2073
+ if (block.source === "bytes") checkImageBytesType(block.data, block.mimeType, index);
2074
+ else if (block.source === "base64") checkImageBytesType(base64ToBytes(block.data.slice(0, 16)), block.mimeType, index);
2075
+ });
2076
+ return normalized;
2077
+ }
2078
+
1980
2079
  // Image bytes travel beside the frame as attachment references, but the model
1981
2080
  // request carries them base64 encoded and the native host caps that request at
1982
2081
  // 8 MiB. Images therefore count at their encoded size, the same budget they
@@ -2062,6 +2161,11 @@ async function materializePromptImages(blocks, isCancelled, resizeImage) {
2062
2161
  }
2063
2162
  if (promptFrameSize(prepared) > maxPromptFrameBytes) prepared = omitReferencedImageData(prepared);
2064
2163
  checkPromptFrameSize(prepared, true);
2164
+ // A file's bytes, or what resizeImage made, are checked once there are
2165
+ // some, after the size limits.
2166
+ prepared.forEach((image, index) => {
2167
+ if (image.type === "image" && image.bytes !== undefined) checkImageBytesType(image.bytes, image.mimeType, index);
2168
+ });
2065
2169
  return prepared;
2066
2170
  }
2067
2171
 
@@ -2106,7 +2210,6 @@ function normalizeHostTools(value) {
2106
2210
  } else {
2107
2211
  throw new TypeError("tools must be an array or an object");
2108
2212
  }
2109
- if (entries.length > 64) throw new RangeError("tools cannot contain more than 64 entries");
2110
2213
  const descriptors = [];
2111
2214
  const executors = new Map();
2112
2215
  const names = new Set();
@@ -2192,7 +2295,10 @@ export function normalizeInstructions(value) {
2192
2295
  return instructions;
2193
2296
  }
2194
2297
 
2195
- function hostToolContent(value) {
2298
+ // `stored` maps an image's base64 data to its name in the image store, for
2299
+ // the images stored before the result goes to the core: their blocks carry
2300
+ // the name, so the core names them too.
2301
+ function hostToolContent(value, stored = null) {
2196
2302
  if (value?.type === "libfx.tool-result") {
2197
2303
  if (typeof value.text !== "string" || !Array.isArray(value.images) || value.images.length > 8) {
2198
2304
  throw new TypeError("invalid typed tool result");
@@ -2204,11 +2310,13 @@ function hostToolContent(value) {
2204
2310
  if ((!referenceOnly && typeof image.data !== "string") || typeof image.mimeType !== "string" || image.mimeType.length === 0 || image.mimeType.length > 128 || (image.data?.length > maxPromptImageDataBytes && sourceRef === undefined)) {
2205
2311
  throw new TypeError("invalid tool image");
2206
2312
  }
2313
+ const data = !referenceOnly && image.data.length <= maxPromptImageDataBytes ? image.data : undefined;
2207
2314
  return {
2208
2315
  type: "image",
2209
- ...(!referenceOnly && image.data.length <= maxPromptImageDataBytes ? { data: image.data } : {}),
2316
+ ...(data === undefined ? {} : { data }),
2210
2317
  mimeType: image.mimeType,
2211
2318
  ...(sourceRef === undefined ? {} : { sourceRef }),
2319
+ ...(data !== undefined && stored?.has(data) ? { stored: stored.get(data) } : {}),
2212
2320
  };
2213
2321
  });
2214
2322
  if (promptImageDataBytes(images) > maxPromptImagesDataBytes) images = omitReferencedImageData(images);
@@ -2219,7 +2327,7 @@ function hostToolContent(value) {
2219
2327
  content = JSON.stringify({ text: value.text, images });
2220
2328
  }
2221
2329
  if (encoder.encode(content).length > maxPromptImagesDataBytes) throw new RangeError("typed tool result exceeds the result limit");
2222
- return { content, rich: true, isError: value.isError === true };
2330
+ return { content, rich: true, isError: value.isError === true, images };
2223
2331
  }
2224
2332
  if (typeof value === "string") return { content: value, rich: false };
2225
2333
  if (value === undefined) return { content: "null", rich: false };
@@ -2308,6 +2416,11 @@ export async function createFxEngine(options = {}) {
2308
2416
  // waits for it: the core before an action others can see, such as a tool
2309
2417
  // call with effects, or a caller for its input.
2310
2418
  const persistence = options.persistence ?? null;
2419
+ // A persistence with an image store keeps each prompt image once, by
2420
+ // name, and the core names it in records and checkpoints instead of
2421
+ // holding its bytes.
2422
+ const imageStorage = persistence?.[persistenceImages] ?? null;
2423
+ const imageStore = imageStorage !== null;
2311
2424
  const wantsMarks = persistence?.marks === true || persistence?.[journalMarksWanted] === true;
2312
2425
  const checkpointAfterBytes = options.checkpointAfterBytes ?? defaultCheckpointAfterBytes;
2313
2426
  // The store's last cursor, and the last seq its records hold.
@@ -2444,8 +2557,9 @@ export async function createFxEngine(options = {}) {
2444
2557
  return;
2445
2558
  }
2446
2559
  journalQueue.push(...events);
2447
- // A caller waits for its steer or withdrawal to be stored.
2448
- if (barrier || events.some((event) => event?.type === "input_accepted" || event?.type === "input_withdrawn")) journalBarrier = true;
2560
+ // A caller waits for its steer or withdrawal to be stored, and a turn's
2561
+ // result for the turn's end or yield.
2562
+ if (barrier || events.some((event) => waitedForEvents.has(event?.type))) journalBarrier = true;
2449
2563
  // A turn notes a new config right before its first progress, a barrier:
2450
2564
  // the two go out in one write instead of two in a row.
2451
2565
  if (events.every((event) => event?.type === "session_config")) return;
@@ -2615,6 +2729,30 @@ export async function createFxEngine(options = {}) {
2615
2729
  }
2616
2730
  throw new Error("transport retry exhausted");
2617
2731
  };
2732
+ // A typed tool result's images go in the image store by name before the
2733
+ // result reaches the core, as a prompt's do, so the session's records name
2734
+ // them. The tool has already run, so an image the store refuses is not
2735
+ // lost: it stays in the result, and the records hold it inline.
2736
+ const storedToolContent = async (value) => {
2737
+ const normalized = hostToolContent(value);
2738
+ if (!imageStore || !normalized.images?.some((image) => image.data !== undefined)) return normalized;
2739
+ const stored = new Map();
2740
+ for (const image of normalized.images) {
2741
+ if (image.data === undefined || stored.has(image.data)) continue;
2742
+ let bytes;
2743
+ // Data that is not base64 is the core's to refuse.
2744
+ try { bytes = base64ToBytes(image.data); } catch { continue; }
2745
+ const name = await imageName(bytes);
2746
+ try {
2747
+ await imageStorage.put(name, bytes);
2748
+ stored.set(image.data, name);
2749
+ } catch {
2750
+ // Not stored: the result keeps the image, and the records hold it
2751
+ // inline.
2752
+ }
2753
+ }
2754
+ return stored.size ? hostToolContent(value, stored) : normalized;
2755
+ };
2618
2756
  const executeHostTool = async (name, input, requestedSessionId, toolCallId) => {
2619
2757
  const execute = hostTools.executors.get(name);
2620
2758
  const issuerSessionId = requestedSessionId ?? sessionId;
@@ -2648,7 +2786,7 @@ export async function createFxEngine(options = {}) {
2648
2786
  });
2649
2787
  const value = await Promise.race([execution, aborted]);
2650
2788
  if (!controller.signal.aborted) {
2651
- const normalized = hostToolContent(value);
2789
+ const normalized = await storedToolContent(value);
2652
2790
  content = normalized.content;
2653
2791
  rich = normalized.rich;
2654
2792
  isError = normalized.isError === true;
@@ -2657,7 +2795,7 @@ export async function createFxEngine(options = {}) {
2657
2795
  isError = true;
2658
2796
  if (error?.toolResult?.type === "libfx.tool-result") {
2659
2797
  try {
2660
- const normalized = hostToolContent(error.toolResult);
2798
+ const normalized = await storedToolContent(error.toolResult);
2661
2799
  content = normalized.content;
2662
2800
  rich = normalized.rich;
2663
2801
  } catch {
@@ -2736,12 +2874,31 @@ export async function createFxEngine(options = {}) {
2736
2874
  if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) throw new Error("fx returned an invalid checkpoint");
2737
2875
  return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
2738
2876
  }
2739
- const sendPrompt = (blocks, resuming = false, followUp = null, turnId = null, rerun = [], yieldAt = undefined) => {
2877
+ // Stores each prompt image in the persistence's image store, by name,
2878
+ // before the prompt that carries it is sent: nothing the core writes may
2879
+ // name an image the store does not hold. Returns the blocks with their
2880
+ // names, or null when the prompt stopped meanwhile.
2881
+ async function storePromptImages(blocks, stopped) {
2882
+ const named = [];
2883
+ for (const block of blocks) {
2884
+ if (block.type !== "image" || block.bytes === undefined) {
2885
+ named.push(block);
2886
+ continue;
2887
+ }
2888
+ const name = await imageName(block.bytes);
2889
+ if (stopped()) return null;
2890
+ await imageStorage.put(name, block.bytes);
2891
+ if (stopped()) return null;
2892
+ named.push({ ...block, stored: name });
2893
+ }
2894
+ return named;
2895
+ }
2896
+ const sendPrompt = (blocks, resuming = false, followUp = null, turnId = null, rerun = [], yieldAt = undefined, outputShown = false) => {
2740
2897
  // A journaled turn given a time to stop at yields before its first model
2741
2898
  // request after it, keeping the turn open.
2742
2899
  const stopAt = journal && yieldAt !== undefined ? { yieldAt } : {};
2743
2900
  if (resuming) {
2744
- return request("session/prompt", { sessionId, prompt: [], _meta: { fx: { continueRecovery: true, ...(rerun.length ? { rerun } : {}), ...stopAt } } });
2901
+ return request("session/prompt", { sessionId, prompt: [], _meta: { fx: { continueRecovery: true, ...(outputShown ? {} : { freshOutput: true }), ...(rerun.length ? { rerun } : {}), ...stopAt } } });
2745
2902
  }
2746
2903
  const images = blocks.filter((block) => block.type === "image" && block.bytes !== undefined);
2747
2904
  const ids = images.length ? attachBytes(images.map((block) => block.bytes)) : [];
@@ -2750,7 +2907,8 @@ export async function createFxEngine(options = {}) {
2750
2907
  type: "image",
2751
2908
  mimeType: block.mimeType,
2752
2909
  ...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
2753
- ...(block.bytes === undefined ? {} : { _meta: { fx: { attachment: ids[next++] } } }),
2910
+ // An image the store holds carries its name, so the core names it too.
2911
+ ...(block.bytes === undefined ? {} : { _meta: { fx: { attachment: ids[next++], ...(block.stored === undefined ? {} : { stored: block.stored }) } } }),
2754
2912
  });
2755
2913
  // A follow-up's turn places it; the core records it as accepted first
2756
2914
  // when the host could not. A journaled turn's first progress records its id.
@@ -2868,24 +3026,62 @@ export async function createFxEngine(options = {}) {
2868
3026
  }
2869
3027
  if (journal) {
2870
3028
  const { events, checkpoint } = loaded;
2871
- const params = { sessionId, ...(options.inputsDurable === true ? { inputsDurable: true } : {}) };
2872
- // One call: attaching again would drop the first attachment.
2873
- const attachments = [];
2874
- if (events.length > 0) {
2875
- const bytes = encoder.encode(JSON.stringify(withoutSupersededProgress(events)));
2876
- if (bytes.byteLength > maxJournalBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
2877
- attachments.push(["journalAttachment", bytes]);
2878
- }
2879
- if (checkpoint) attachments.push(["snapshotAttachment", checkpoint]);
2880
- const ids = attachments.length ? attachBytes(attachments.map(([, bytes]) => bytes)) : [];
2881
- attachments.forEach(([name], index) => { params[name] = ids[index]; });
2882
- const opened = await request("libfx/journal_open", params).catch((error) => {
3029
+ const journalBytes = events.length > 0 ? encoder.encode(JSON.stringify(withoutSupersededProgress(events))) : null;
3030
+ if (journalBytes && journalBytes.byteLength > maxJournalBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
3031
+ const openFailed = (error) => {
2883
3032
  const message = error?.message ?? "";
2884
3033
  if (/newer fx/.test(message)) throw new FxJournalVersionError(message);
2885
3034
  const code = journalOpenErrorCodes.get(message);
2886
3035
  if (code) throw journalLoadError(message, code, error);
2887
3036
  throw error;
2888
- });
3037
+ };
3038
+ const openOnce = () => {
3039
+ const params = {
3040
+ sessionId,
3041
+ ...(options.inputsDurable === true ? { inputsDurable: true } : {}),
3042
+ ...(imageStore ? { imageStore: true } : {}),
3043
+ ...(imageStore && options[imageWindowLimits] ? { imageWindow: options[imageWindowLimits] } : {}),
3044
+ };
3045
+ // One call: attaching again would drop the first attachment.
3046
+ const attachments = [];
3047
+ if (journalBytes) attachments.push(["journalAttachment", journalBytes]);
3048
+ if (checkpoint) attachments.push(["snapshotAttachment", checkpoint]);
3049
+ const ids = attachments.length ? attachBytes(attachments.map(([, bytes]) => bytes)) : [];
3050
+ attachments.forEach(([name], index) => { params[name] = ids[index]; });
3051
+ return request("libfx/journal_open", params).catch(openFailed);
3052
+ };
3053
+ // Stages the images a history names, read from the store, in batches
3054
+ // the attachment table holds. A store that lacks one fails the open:
3055
+ // the history without it is not the one saved.
3056
+ const stageImages = async (names) => {
3057
+ let batch = [];
3058
+ let batchBytes = 0;
3059
+ const flush = async () => {
3060
+ if (batch.length === 0) return;
3061
+ const ids = attachBytes(batch.map(({ bytes }) => bytes));
3062
+ const images = batch.map(({ name }, index) => ({ name, attachment: ids[index] }));
3063
+ batch = [];
3064
+ batchBytes = 0;
3065
+ await request("libfx/journal_images", { sessionId, images }).catch(openFailed);
3066
+ };
3067
+ for (const name of new Set(names.map(String))) {
3068
+ const stored = imageStore ? await imageStorage.get(name) : undefined;
3069
+ if (stored === undefined || stored === null) throw imageMissingError(name);
3070
+ const bytes = persistenceBytes(stored, "an image the image store returns");
3071
+ if (batch.length === maxPendingAttachments || batchBytes + bytes.byteLength > maxPendingAttachmentBytes) await flush();
3072
+ batch.push({ name, bytes });
3073
+ batchBytes += bytes.byteLength;
3074
+ }
3075
+ await flush();
3076
+ };
3077
+ // A history that names images opens once they are staged from the
3078
+ // store: the core answers with the names it needs, all at once.
3079
+ let opened = await openOnce();
3080
+ if (Array.isArray(opened?.imagesNeeded)) {
3081
+ await stageImages(opened.imagesNeeded);
3082
+ opened = await openOnce();
3083
+ if (Array.isArray(opened?.imagesNeeded)) throw imageMissingError(String(opened.imagesNeeded[0]));
3084
+ }
2889
3085
  compareCheckpointMeta(opened?.checkpointMeta);
2890
3086
  resumable = opened?.resumable === true;
2891
3087
  openTurnId = resumable && validTurnId(opened?.openTurnId) ? opened.openTurnId : null;
@@ -3201,13 +3397,12 @@ export async function createFxEngine(options = {}) {
3201
3397
  const resizeImage = options.resizeImage;
3202
3398
  const resuming = promptOptions[resumeTurn] === true;
3203
3399
  const followUp = promptOptions[followUpInput] ?? null;
3204
- const normalized = resuming ? [] : normalizePromptInput(input, { deferImageLimits: resizeImage !== undefined });
3400
+ const normalized = resuming ? [] : checkPromptInput(input, { resizeImage });
3205
3401
  const hasPixels = normalized.some((block) => block.type === "image" && block.data !== undefined);
3206
- // Blob reads and resizeImage run before the prompt frame is sent.
3207
- const asyncImages = hasPixels && (resizeImage !== undefined ||
3402
+ // Blob reads, resizeImage and storing images run before the prompt
3403
+ // frame is sent.
3404
+ const asyncImages = hasPixels && (resizeImage !== undefined || imageStore ||
3208
3405
  normalized.some((block) => block.type === "image" && block.source === "blob"));
3209
- // resizeImage decides the final image sizes, so they are counted after it runs.
3210
- checkPromptFrameSize(normalized, resizeImage === undefined);
3211
3406
  // Snapshot caller-owned bytes, which could change before an async send.
3212
3407
  const prompt = asyncImages
3213
3408
  ? normalized.map((block) => block.type === "image" && block.source === "bytes" ? { ...block, data: block.data.slice() } : block)
@@ -3412,8 +3607,11 @@ export async function createFxEngine(options = {}) {
3412
3607
  }) : null;
3413
3608
  let response;
3414
3609
  if (asyncImages) {
3610
+ const stopped = () => cancelled || closing;
3415
3611
  response = Promise.race([
3416
- Promise.resolve().then(() => materializePromptImages(prompt, () => cancelled || closing, resizeImage)),
3612
+ Promise.resolve()
3613
+ .then(() => materializePromptImages(prompt, stopped, resizeImage))
3614
+ .then((prepared) => prepared && imageStore ? storePromptImages(prepared, stopped) : prepared),
3417
3615
  imagePrepCancelled,
3418
3616
  ]).then((prepared) => {
3419
3617
  cancelImagePrep = null;
@@ -3427,12 +3625,15 @@ export async function createFxEngine(options = {}) {
3427
3625
  return sendPrompt(prepared, false, followUp, turn.id, [], yieldAt);
3428
3626
  });
3429
3627
  } else {
3430
- try { response = sendPrompt(prompt, resuming, followUp, turn.id, promptOptions[rerunCallIds] ?? [], yieldAt); } catch (error) { response = Promise.reject(error); }
3628
+ try { response = sendPrompt(prompt, resuming, followUp, turn.id, promptOptions[rerunCallIds] ?? [], yieldAt, promptOptions[resumeOutputShown] === true); } catch (error) { response = Promise.reject(error); }
3431
3629
  }
3432
- // Appends after a turn's first progress are lazy, so the result does not
3433
- // wait for them; `close()` does. A failure already seen fails the turn.
3630
+ // Appends after a turn's first progress are lazy, so the turn runs ahead
3631
+ // of them, but its result waits until every record it wrote is stored:
3632
+ // a turn counts as done only once its end is durable, and a failure
3633
+ // among those records fails the turn.
3434
3634
  turn.result = response
3435
- .then((value) => {
3635
+ .then(async (value) => {
3636
+ if (journal) await journalSettled().catch(() => {});
3436
3637
  if (journalFailure) throw reportJournalFailure();
3437
3638
  if (value.stopReason === "yielded" && !cancelled) {
3438
3639
  // The core keeps the turn open: `resume()` continues it here, or
package/fx-term.wasm CHANGED
Binary file
Binary file
Binary file
Binary file
Binary file
package/mcp.js CHANGED
@@ -1,4 +1,4 @@
1
- const maxTools = 64;
1
+ const maxPages = 64;
2
2
  const maxInstructionsBytes = 64 * 1024;
3
3
 
4
4
  function contentText(content, mode = "tool") {
@@ -45,10 +45,10 @@ export async function createMcpAdapter(client, options = {}) {
45
45
  const cursors = new Set();
46
46
  let cursor;
47
47
  for (let page = 0; ; page += 1) {
48
- if (page >= maxTools) throw new RangeError("MCP tool pagination exceeded its page limit");
48
+ if (page >= maxPages) throw new RangeError("MCP tool pagination exceeded its page limit");
49
49
  const listed = await client.listTools(cursor === undefined ? undefined : { cursor });
50
50
  const tools = Array.isArray(listed) ? listed : listed?.tools;
51
- if (!Array.isArray(tools) || tools.length > maxTools - catalog.length) throw new TypeError("MCP listTools() returned an invalid tool catalog");
51
+ if (!Array.isArray(tools)) throw new TypeError("MCP listTools() returned an invalid tool catalog");
52
52
  catalog.push(...tools);
53
53
  cursor = Array.isArray(listed) ? undefined : listed.nextCursor;
54
54
  if (cursor === undefined || cursor === null) break;