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/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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2872
|
-
|
|
2873
|
-
const
|
|
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 ? [] :
|
|
3400
|
+
const normalized = resuming ? [] : checkPromptInput(input, { resizeImage });
|
|
3205
3401
|
const hasPixels = normalized.some((block) => block.type === "image" && block.data !== undefined);
|
|
3206
|
-
// Blob reads and
|
|
3207
|
-
|
|
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()
|
|
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
|
|
3433
|
-
//
|
|
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
|
package/libfx.darwin-arm64.node
CHANGED
|
Binary file
|
package/libfx.darwin-x64.node
CHANGED
|
Binary file
|
package/libfx.linux-arm64.node
CHANGED
|
Binary file
|
package/libfx.linux-x64.node
CHANGED
|
Binary file
|
package/mcp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const
|
|
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 >=
|
|
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)
|
|
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;
|