libfx 0.0.15 → 0.0.17

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/engine.js ADDED
@@ -0,0 +1,2498 @@
1
+ // The fx engine: options, sessions, journals and checkpoints, prompts,
2
+ // tools, and errors. It runs on the runtime its caller supplies, the native
3
+ // addon or the WebAssembly host, and loads neither itself.
4
+ import { maxCoreMessageBytes } from "./core-output.js";
5
+ import { codexSubscription } from "./chatgpt.js";
6
+ import { customProviderDefinition, customProviderSpec } from "./custom-provider.js";
7
+ import { grokSubscription } from "./grok.js";
8
+ import { createSubscriptionAuthorizer, credentialMemory } from "./subscription.js";
9
+ import { toolObservation } from "./tool-observation.js";
10
+
11
+ // Providers that run on a user's plan, authorized by the host's credentials.
12
+ const subscriptionProviders = new Set(["codex", "grok"]);
13
+
14
+ const encoder = new TextEncoder();
15
+ const decoder = new TextDecoder();
16
+ const strictDecoder = new TextDecoder("utf-8", { fatal: true });
17
+ const maxInstructionsBytes = 64 * 1024;
18
+ const maxApiKeyBytes = 64 * 1024;
19
+ const maxModelBytes = 1024;
20
+ // Matches the kernel's ReasoningEffort.max_name_bytes.
21
+ const maxEffortBytes = 64;
22
+ const maxUrlBytes = 16 * 1024;
23
+ // The answer a resumed turn gives a call its crash left running; matches
24
+ // unfinished_tool_output in session_adapter.zig.
25
+ const unfinishedToolOutput = "fx stopped while this tool was running, so it may have partly run. Check its effects before running it again.";
26
+ const maxModelCatalogBytes = 4 * 1024 * 1024;
27
+ const maxModelCatalogEntries = 10_000;
28
+ const transportActivityIntervalMs = 250;
29
+ const maxUnreadEventBytes = 1024 * 1024;
30
+ const maxUnreadEvents = 256;
31
+ // Prompt images travel as raw bytes beside the ACP frame and are base64
32
+ // encoded only in the model request. An image may use 5 MiB of encoded
33
+ // request data, and a prompt's images 8 MiB, so the raw limits are 3/4 of that.
34
+ // The kernel still validates content and media type, and holds a prompt to
35
+ // these limits too: `max_libfx_prompt_images` and
36
+ // `max_libfx_prompt_images_bytes` in src/core/images/image_attachments.zig
37
+ // must match them.
38
+ const maxPromptImages = 8;
39
+ const maxPromptImageDataBytes = 5 * 1024 * 1024;
40
+ const maxPromptImagesDataBytes = 8 * 1024 * 1024;
41
+ const maxPromptImageBytes = (maxPromptImageDataBytes / 4) * 3;
42
+ const maxPromptImagesBytes = (maxPromptImagesDataBytes / 4) * 3;
43
+ // Matches the native attachment table: one prompt's images or one checkpoint.
44
+ const maxPendingAttachments = 8;
45
+ const maxPendingAttachmentBytes = 8 * 1024 * 1024;
46
+ // Matches the core's kernel checkpoint limit (max_checkpoint_bytes).
47
+ const maxCheckpointBytes = 4 * 1024 * 1024;
48
+ // Matches the core's journal load limit (journal.max_load_bytes).
49
+ const maxJournalBytes = 4 * 1024 * 1024;
50
+ // The core's ACP reader drops frames over 8 MiB without a request id to answer
51
+ // (jsonrpc frame_resource_byte_limit), so the SDK must never emit one. The
52
+ // envelope allowance covers the method key and request id.
53
+ const maxPromptFrameBytes = 8 * 1024 * 1024;
54
+ const promptFrameEnvelopeBytes = 128;
55
+ const maxSteeringMessageBytes = 64 * 1024;
56
+ const maxSteeringMessages = 64;
57
+ const maxSteeringQueueBytes = 1024 * 1024;
58
+ // tool_start events carry a bounded preview of the tool input; larger inputs
59
+ // are marked truncated instead of dropped or sent whole.
60
+ const maxToolStartInputBytes = 64 * 1024;
61
+
62
+ function boundedString(value, name, maxBytes, required) {
63
+ if (value === undefined && !required) return undefined;
64
+ if (typeof value !== "string" || value.length === 0) {
65
+ throw new TypeError(`${name} ${required ? "is required and " : ""}must be a non-empty string`);
66
+ }
67
+ if (encoder.encode(value).length > maxBytes) {
68
+ throw new RangeError(`${name} exceeds the ${maxBytes} byte libfx limit`);
69
+ }
70
+ return value;
71
+ }
72
+
73
+ function validateGatewayChatUrl(value) {
74
+ if (value === undefined) return;
75
+ boundedString(value, "gatewayChatUrl", maxUrlBytes, false);
76
+ let url;
77
+ try { url = new URL(value); } catch { throw new TypeError("gatewayChatUrl must be a valid URL"); }
78
+ if (url.username || url.password || url.hash) {
79
+ throw new TypeError("gatewayChatUrl must not contain credentials or a fragment");
80
+ }
81
+ if (url.href === "https://ai-gateway.vercel.sh/v4/ai/language-model") return;
82
+ if (url.href === "https://ai-gateway.vercel.sh/v3/ai/language-model") return;
83
+ const loopback = url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "localhost";
84
+ if (url.protocol !== "http:" || !loopback || !url.port) {
85
+ throw new TypeError("gatewayChatUrl must use the canonical Gateway or explicit loopback HTTP");
86
+ }
87
+ }
88
+
89
+ // Mirrors the kernel's ReasoningEffort.parse: "auto"/"adaptive"/"default" pick
90
+ // the model default; anything else must be a bounded effort name.
91
+ function normalizeEffort(value) {
92
+ if (value === undefined) return undefined;
93
+ boundedString(value, "effort", maxEffortBytes, false);
94
+ if (!/^[A-Za-z0-9._-]+$/.test(value)) {
95
+ throw new TypeError('effort must use only letters, digits, ".", "-", or "_"');
96
+ }
97
+ return value;
98
+ }
99
+
100
+ // Mirrors the CLI's --fast/--no-fast toggle: a strict boolean, with undefined
101
+ // leaving the model default in place.
102
+ function normalizeFast(value) {
103
+ if (value === undefined) return undefined;
104
+ if (typeof value !== "boolean") {
105
+ throw new TypeError("fast must be a boolean");
106
+ }
107
+ return value;
108
+ }
109
+
110
+ function normalizeUltrafast(value) {
111
+ if (value === undefined) return undefined;
112
+ if (typeof value !== "boolean") {
113
+ throw new TypeError("ultrafast must be a boolean");
114
+ }
115
+ return value;
116
+ }
117
+
118
+ // The model choice in `options`, a model object or a model ID with the
119
+ // deprecated top-level options, as `{ model, effort, fast, ultrafast }`.
120
+ export function normalizeModelChoice(options) {
121
+ if (options.model !== null && typeof options.model === "object" && !Array.isArray(options.model)) {
122
+ if (Object.hasOwn(options, "effort") || Object.hasOwn(options, "fast") || Object.hasOwn(options, "ultrafast")) {
123
+ throw new TypeError("model options cannot be mixed with top-level effort, fast, or ultrafast");
124
+ }
125
+ const model = options.model;
126
+ for (const name of Object.keys(model)) {
127
+ if (name !== "id" && name !== "effort" && name !== "fast" && name !== "ultrafast") {
128
+ throw new TypeError(`unsupported model option: ${name}`);
129
+ }
130
+ }
131
+ return {
132
+ model: boundedString(model.id, "model.id", maxModelBytes, true),
133
+ effort: normalizeEffort(model.effort),
134
+ fast: normalizeFast(model.fast),
135
+ ultrafast: normalizeUltrafast(model.ultrafast),
136
+ };
137
+ }
138
+ return {
139
+ model: boundedString(options.model, "model", maxModelBytes, false),
140
+ effort: normalizeEffort(options.effort),
141
+ fast: normalizeFast(options.fast),
142
+ ultrafast: normalizeUltrafast(options.ultrafast),
143
+ };
144
+ }
145
+
146
+ export function normalizeAgentOptions(value) {
147
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
148
+ throw new TypeError("createFxEngine() options must be an object");
149
+ }
150
+ const options = { ...value };
151
+ if (options.hooks !== undefined) {
152
+ const hooks = options.hooks;
153
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks) || typeof hooks.onEvent !== "function") {
154
+ throw new TypeError("hooks must be an object with onEvent()");
155
+ }
156
+ if (hooks.onError !== undefined && typeof hooks.onError !== "function") throw new TypeError("hooks.onError must be a function");
157
+ if (hooks.captureContent !== undefined && typeof hooks.captureContent !== "boolean") throw new TypeError("hooks.captureContent must be a boolean");
158
+ options.hooks = {
159
+ captureContent: hooks.captureContent === true,
160
+ onEvent: hooks.onEvent.bind(hooks),
161
+ ...(hooks.onError === undefined ? {} : { onError: hooks.onError.bind(hooks) }),
162
+ ...(hooks.parent === undefined ? {} : { parent: { ...hooks.parent } }),
163
+ };
164
+ if (options.hooks.parent !== undefined) {
165
+ for (const name of ["rootSessionId", "sessionId", "turnId", "callId"]) {
166
+ if (typeof options.hooks.parent[name] !== "string" || options.hooks.parent[name].length === 0 || options.hooks.parent[name].length > 1024) throw new TypeError(`hooks.parent.${name} must be a bounded identity`);
167
+ }
168
+ }
169
+ }
170
+ if (Object.hasOwn(options, "env")) {
171
+ throw new TypeError("createFxEngine() does not accept env; pass apiKey and model directly");
172
+ }
173
+ // Derived from the caller's provider every time, so normalizing again agrees.
174
+ const custom = options.provider !== null && typeof options.provider === "object"
175
+ ? customProviderDefinition(options.provider)
176
+ : undefined;
177
+ if (!custom && options.provider !== undefined && options.provider !== "gateway" && !subscriptionProviders.has(options.provider)) {
178
+ throw new TypeError("provider must be \"gateway\", \"codex\", \"grok\" or a custom provider { name, baseUrl }");
179
+ }
180
+ options.customProvider = custom;
181
+ options.providerName = custom ? custom.name : options.provider;
182
+ const subscription = subscriptionProviders.has(options.provider);
183
+ // Gateway needs its key; every other provider runs on the host's credentials.
184
+ options.apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, !subscription && !custom);
185
+ if (subscription && typeof options.credentials !== "function") {
186
+ throw new TypeError(`provider "${options.provider}" needs a credentials() function`);
187
+ }
188
+ if (!subscription && !custom && options.credentials !== undefined) {
189
+ throw new TypeError("credentials needs provider \"codex\", \"grok\" or a custom provider");
190
+ }
191
+ // A custom provider's key is never refreshed, so it has no onCredentials or refreshLock.
192
+ for (const name of ["onCredentials", "refreshLock"]) {
193
+ if (!subscription && options[name] !== undefined) throw new TypeError(`${name} needs provider "codex" or "grok"`);
194
+ }
195
+ for (const name of ["credentials", "onCredentials", "refreshLock"]) {
196
+ if (options[name] !== undefined && typeof options[name] !== "function") throw new TypeError(`${name} must be a function`);
197
+ }
198
+ Object.assign(options, normalizeModelChoice(options));
199
+ // fx's default model is a Gateway id. Codex, Grok, and a custom provider
200
+ // name their own models, so each needs one, as the fx CLI asks for.
201
+ if (custom && options.model === undefined) throw new TypeError("a custom provider needs a model");
202
+ if (subscription && options.model === undefined) {
203
+ throw new TypeError(`provider "${options.provider}" needs a model; fx's default model is an AI Gateway id`);
204
+ }
205
+ validateGatewayChatUrl(options.gatewayChatUrl);
206
+ if (options.resizeImage !== undefined && typeof options.resizeImage !== "function") {
207
+ throw new TypeError("resizeImage must be a function");
208
+ }
209
+ if (options.sessionId !== undefined && options.sessionId !== null && !validSessionId(options.sessionId)) {
210
+ throw new TypeError(`sessionId must be ${sessionIdRule}`);
211
+ }
212
+ // A separate key, so normalizing these options again reads the caller's value.
213
+ if (options.modelCatalog !== undefined) options.modelCatalogBody = modelCatalogBody(options.modelCatalog);
214
+ if (options.persistence !== undefined) {
215
+ const persistence = options.persistence;
216
+ if (!persistence || typeof persistence !== "object" ||
217
+ typeof persistence.load !== "function" || typeof persistence.append !== "function" ||
218
+ (persistence.saveCheckpoint !== undefined && typeof persistence.saveCheckpoint !== "function")) {
219
+ throw new TypeError("persistence must be an object with load(), append() and, optionally, saveCheckpoint()");
220
+ }
221
+ if (options.checkpoint !== undefined) {
222
+ throw new TypeError("persistence cannot be combined with checkpoint");
223
+ }
224
+ }
225
+ if (options.checkpointAfterBytes !== undefined) {
226
+ if (options.persistence === undefined) throw new TypeError("checkpointAfterBytes needs persistence");
227
+ if (!Number.isSafeInteger(options.checkpointAfterBytes) || options.checkpointAfterBytes < 0) {
228
+ throw new RangeError("checkpointAfterBytes must be a non-negative integer");
229
+ }
230
+ }
231
+ return options;
232
+ }
233
+
234
+ // Keys for libfx's own session worker; symbols keep them off the engine's
235
+ // public surface.
236
+ export const engineInternals = Symbol.for("libfx.engineInternals");
237
+ export const journalMarksWanted = Symbol.for("libfx.journalMarks");
238
+ // A persistence's image store, `{ put(name, bytes), get(name) }`, which keeps
239
+ // each image once, named by the SHA-256 of its bytes, so records and
240
+ // checkpoints only name it. Durable sessions give one to the engines they
241
+ // run; without it, images stay inline.
242
+ export const persistenceImages = Symbol.for("libfx.persistenceImages");
243
+ // `{ images, bytes }`: a smaller image window than the core's, the newest
244
+ // images a session with an image store keeps, so tests can reach its edge.
245
+ const imageWindowLimits = Symbol.for("libfx.imageWindow");
246
+ // Marks an error the core answered a request with, as opposed to one from a
247
+ // core that stopped answering.
248
+ export const coreAnswered = Symbol.for("libfx.coreAnswered");
249
+
250
+ /**
251
+ * Another writer took over the session after this agent loaded it. A
252
+ * persistence store rejects an append whose `expected` cursor is not its head
253
+ * with this error; the agent stops its turn at once and makes no further
254
+ * effect or write. Check `code === "FX_FENCED"` when the error may come from
255
+ * another copy of libfx.
256
+ */
257
+ export class FxFencedError extends Error {
258
+ constructor(message) {
259
+ super(message);
260
+ this.name = "FxFencedError";
261
+ this.code = "FX_FENCED";
262
+ }
263
+ }
264
+
265
+ /** The journal, or the `checkpoint` option, was written by a newer libfx. */
266
+ export class FxJournalVersionError extends Error {
267
+ constructor(message) {
268
+ super(message);
269
+ this.name = "FxJournalVersionError";
270
+ this.code = "FX_JOURNAL_VERSION";
271
+ }
272
+ }
273
+
274
+ // A journal this libfx cannot open: more than one load holds
275
+ // (FX_JOURNAL_TOO_LARGE), or events that do not fold (FX_JOURNAL_INVALID).
276
+ function journalLoadError(message, code, cause) {
277
+ const error = new Error(message, cause === undefined ? undefined : { cause });
278
+ error.code = code;
279
+ return error;
280
+ }
281
+
282
+ // The core's libfx/journal_open errors, by their exact message. The journal
283
+ // tests open a journal for each, so rewording one fails them, except the size
284
+ // limit: libfx refuses a journal over it before the core sees one, so that
285
+ // entry is a backstop.
286
+ const journalOpenErrorCodes = new Map([
287
+ ["Invalid libfx journal", "FX_JOURNAL_INVALID"],
288
+ ["libfx journal events are out of order", "FX_JOURNAL_INVALID"],
289
+ ["libfx journal is too large", "FX_JOURNAL_TOO_LARGE"],
290
+ ["libfx journal holds more than 1024 turns", "FX_JOURNAL_TOO_LARGE"],
291
+ ["libfx journal images are too large", "FX_JOURNAL_TOO_LARGE"],
292
+ ["Invalid libfx journal images", "FX_JOURNAL_INVALID"],
293
+ ["libfx image does not match its name", "FX_JOURNAL_IMAGE_MISSING"],
294
+ ]);
295
+
296
+ // An image's name in a persistence's image store: the lowercase hex SHA-256
297
+ // of its bytes, as the core names it.
298
+ async function imageName(bytes) {
299
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
300
+ let name = "";
301
+ for (const byte of digest) name += byte.toString(16).padStart(2, "0");
302
+ return name;
303
+ }
304
+
305
+ function imageMissingError(name, cause) {
306
+ const error = journalLoadError(`libfx journal names an image the store does not hold: ${name}`, "FX_JOURNAL_IMAGE_MISSING", cause);
307
+ error.image = name;
308
+ return error;
309
+ }
310
+
311
+ /**
312
+ * A persistence store that keeps a session's records and its latest
313
+ * checkpoint in memory, for tests and for hosts that copy them elsewhere.
314
+ * `records` is the stored list, oldest first. An append whose `expected`
315
+ * cursor is not the last record's is refused, so a second agent cannot write
316
+ * the same session, and an append that repeats a stored write's
317
+ * `idempotencyKey` returns that write's cursor.
318
+ */
319
+ export function createMemoryPersistence() {
320
+ const records = [];
321
+ let checkpoint = null;
322
+ const head = () => records.at(-1)?.cursor ?? null;
323
+ return {
324
+ records,
325
+ get checkpoint() {
326
+ return checkpoint;
327
+ },
328
+ async load() {
329
+ const after = checkpoint ? records.findIndex((record) => record.cursor === checkpoint.through) + 1 : 0;
330
+ return {
331
+ ...(checkpoint ? { checkpoint: { data: checkpoint.data.slice(), through: checkpoint.through } } : {}),
332
+ journal: records.slice(after).map(({ cursor, data }) => ({ cursor, data: data.slice() })),
333
+ };
334
+ },
335
+ async append({ expected, idempotencyKey, data }) {
336
+ const stored = records.find((record) => record.idempotencyKey === idempotencyKey);
337
+ if (stored) return { cursor: stored.cursor };
338
+ if (expected !== head()) {
339
+ throw new FxFencedError(`another agent appended to this session: expected ${expected}, head ${head()}`);
340
+ }
341
+ const cursor = String(records.length + 1);
342
+ records.push({ cursor, idempotencyKey, data: data.slice() });
343
+ return { cursor };
344
+ },
345
+ async saveCheckpoint({ through, data }) {
346
+ checkpoint = { through, data: data.slice() };
347
+ },
348
+ };
349
+ }
350
+
351
+ // Marks the turn `agent.resume()` starts; never a caller-visible option.
352
+ const resumeTurn = Symbol("libfx.resume");
353
+ // Carries the follow-up a turn runs: `{ id, accepted }`.
354
+ const followUpInput = Symbol("libfx.followUp");
355
+ // Carries the ids of the calls a resumed turn runs again.
356
+ const rerunCallIds = Symbol("libfx.rerun");
357
+ // Marks a resume whose host already holds what the turn sent before it
358
+ // stopped, as a durable session's stream does, so the turn does not send it
359
+ // again. Any other resume runs in a host that saw none of it. Never a
360
+ // caller-visible option; `fx-harness.js` sets it.
361
+ export const resumeOutputShown = Symbol.for("libfx.resumeOutputShown");
362
+
363
+ function journalAppendError(cause) {
364
+ const error = new Error("libfx journal append failed", { cause });
365
+ error.code = "FX_JOURNAL_APPEND_FAILED";
366
+ return error;
367
+ }
368
+
369
+ // Restoring an open turn needs only its newest progress, and the ones it
370
+ // extends when it carries only what is new back to one that carries all of
371
+ // it, and the results of calls no progress has answered yet. So an older
372
+ // progress's state, and an answered call's result, are left out of a load
373
+ // (data: null). A load then grows with the session's committed turns, not
374
+ // with every step of every turn.
375
+ function withoutSupersededProgress(events) {
376
+ const kept = new Array(events.length);
377
+ let superseded = false;
378
+ let answered = false;
379
+ for (let index = events.length - 1; index >= 0; index -= 1) {
380
+ const event = events[index];
381
+ const type = event?.type;
382
+ const dropped = event?.data != null && ((type === "turn_progress" && superseded) || (type === "tool_result" && answered));
383
+ kept[index] = dropped ? { ...event, data: null } : event;
384
+ if (type === "turn_committed" || type === "turn_progress_cleared") {
385
+ superseded = true;
386
+ answered = true;
387
+ } else if (type === "turn_progress") {
388
+ answered = true;
389
+ if (event.base === undefined) superseded = true;
390
+ }
391
+ }
392
+ return kept;
393
+ }
394
+
395
+ // A session id reaches gateway headers; the core applies the same rule.
396
+ function validSessionId(id) {
397
+ return typeof id === "string" && /^[A-Za-z0-9._-]{1,255}$/.test(id) &&
398
+ id !== "." && id !== ".." && id.toLowerCase() !== "v2";
399
+ }
400
+ const sessionIdRule = "1 to 255 letters, digits, '.', '_' or '-'";
401
+
402
+ // A record is one append's events as UTF-8 JSON. Its shape is libfx's; a
403
+ // store keeps the bytes as they are.
404
+ const journalRecordFormat = "libfx-journal-v1";
405
+ // Checkpoints after the journal since the last one reaches this many bytes.
406
+ const defaultCheckpointAfterBytes = 1024 * 1024;
407
+ // A turn id reaches the journal and tool calls; the core applies the same rule.
408
+ const turnIdRule = "1 to 128 letters, digits, '.', '_' or '-'";
409
+
410
+ function validTurnId(id) {
411
+ return typeof id === "string" && /^[A-Za-z0-9._-]{1,128}$/.test(id);
412
+ }
413
+
414
+ function checkOnAmbiguous(onAmbiguous) {
415
+ if (onAmbiguous !== undefined && typeof onAmbiguous !== "function") throw new TypeError("onAmbiguous must be a function");
416
+ }
417
+
418
+ // A call's input as the model wrote it: JSON when it parses, else the text.
419
+ function parsedToolInput(text) {
420
+ try { return JSON.parse(text); } catch { return text; }
421
+ }
422
+
423
+ function validCursor(cursor) {
424
+ return typeof cursor === "string" && cursor.length > 0 && cursor.length <= 1024;
425
+ }
426
+
427
+ function encodeJournalRecord(events) {
428
+ return encoder.encode(JSON.stringify({ format: journalRecordFormat, events }));
429
+ }
430
+
431
+ function persistenceBytes(value, what) {
432
+ if (value instanceof Uint8Array) return value;
433
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
434
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
435
+ throw new TypeError(`${what} must be bytes`);
436
+ }
437
+
438
+ function decodeJournalRecord(data) {
439
+ let value;
440
+ try { value = JSON.parse(decoder.decode(persistenceBytes(data, "a journal record's data"))); } catch (error) {
441
+ if (error instanceof TypeError && /must be bytes/.test(error.message)) throw error;
442
+ throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID", error);
443
+ }
444
+ if (value?.format !== journalRecordFormat || !Array.isArray(value.events)) {
445
+ if (typeof value?.format === "string" && value.format.startsWith("libfx-journal-v")) {
446
+ throw new FxJournalVersionError("libfx journal was written by a newer fx");
447
+ }
448
+ throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID");
449
+ }
450
+ return value.events;
451
+ }
452
+
453
+ // The last `seq` a checkpoint covers, from its header: `FXSN`, a version
454
+ // byte, then the seq as a little-endian u64. The core checks the rest.
455
+ function checkpointSeq(bytes) {
456
+ if (bytes.byteLength < 13 || decoder.decode(bytes.subarray(0, 4)) !== "FXSN") {
457
+ throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID");
458
+ }
459
+ return Number(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(5, true));
460
+ }
461
+
462
+ // Reads what `persistence.load()` returns: the latest checkpoint, if any, and
463
+ // the records after it, as the events the core folds after that checkpoint.
464
+ // Modeled in `tests/tla/durable/SnapshotTail.tla`.
465
+ async function loadPersistence(persistence) {
466
+ const loaded = await persistence.load();
467
+ if (!loaded || typeof loaded !== "object") {
468
+ throw new TypeError("persistence.load() must resolve to { checkpoint?, journal? }");
469
+ }
470
+ let checkpoint = null;
471
+ let covered = 0;
472
+ let head = null;
473
+ if (loaded.checkpoint != null) {
474
+ const { data, through } = loaded.checkpoint;
475
+ if (!validCursor(through)) throw new TypeError("persistence.load() checkpoint.through must be a cursor string");
476
+ checkpoint = persistenceBytes(data, "checkpoint.data").slice();
477
+ if (checkpoint.byteLength > maxCheckpointBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
478
+ covered = checkpointSeq(checkpoint);
479
+ head = through;
480
+ }
481
+ const records = loaded.journal ?? [];
482
+ if (typeof records[Symbol.iterator] !== "function" && typeof records[Symbol.asyncIterator] !== "function") {
483
+ throw new TypeError("persistence.load() journal must be an iterable of { cursor, data }");
484
+ }
485
+ const events = [];
486
+ let bytes = 0;
487
+ for await (const record of records) {
488
+ if (!record || !validCursor(record.cursor)) throw new TypeError("each journal record needs a cursor string");
489
+ // The load limit applies once an open turn's older progress is dropped.
490
+ const data = persistenceBytes(record.data, "a journal record's data");
491
+ bytes += data.byteLength;
492
+ // A store may return records the checkpoint already covers.
493
+ for (const event of decodeJournalRecord(data)) if (!(Number.isSafeInteger(event?.seq) && event.seq <= covered)) events.push(event);
494
+ head = record.cursor;
495
+ }
496
+ // A store whose log holds entries of its own between records names the
497
+ // entry the next record follows.
498
+ if (loaded.head !== undefined) {
499
+ if (loaded.head !== null && !validCursor(loaded.head)) throw new TypeError("persistence.load() head must be a cursor string or null");
500
+ head = loaded.head;
501
+ }
502
+ return { checkpoint, events, head, tailBytes: bytes, lastSeq: events.at(-1)?.seq ?? covered };
503
+ }
504
+
505
+ // Events someone waits on, so the record holding one is a barrier: a steer
506
+ // or withdrawal its caller awaits, and the end or yield a turn's result does.
507
+ const waitedForEvents = new Set(["input_accepted", "input_withdrawn", "turn_committed", "turn_progress_cleared", "turn_yielded"]);
508
+
509
+ // What a record's events say about turns, in order, for a store that tracks
510
+ // them without reading records: a turn started, ended, or yielded, an input
511
+ // was accepted or handed back unplaced, or the step the open turn is at, as
512
+ // its tool steps finished and the state of its calls.
513
+ function journalMarks(events) {
514
+ const marks = [];
515
+ for (const event of events) {
516
+ if (event?.type === "turn_progress") {
517
+ if (typeof event.turnId === "string") marks.push({ start: event.turnId });
518
+ // The inputs its model request carries for the first time.
519
+ for (const id of Array.isArray(event.inputs) ? event.inputs : []) if (typeof id === "string") marks.push({ placed: id });
520
+ // A progress that carries only what is new counts all the turn's steps.
521
+ const steps = Number.isSafeInteger(event.steps) ? event.steps : event.data?.execution?.tool_steps?.length;
522
+ if (Number.isSafeInteger(steps)) marks.push({ step: `${steps}:${String(event.data?.tool_state ?? "none")}` });
523
+ } else if (event?.type === "turn_committed" || event?.type === "turn_progress_cleared") marks.push({ end: true });
524
+ else if (event?.type === "turn_yielded") marks.push({ yield: true });
525
+ else if (event?.type === "input_accepted" && typeof event.data?.id === "string") marks.push({ accepted: event.data.id });
526
+ else if (event?.type === "input_withdrawn" && typeof event.data?.id === "string") marks.push({ withdrawn: event.data.id });
527
+ }
528
+ return marks;
529
+ }
530
+
531
+ function agentEnvironment(options) {
532
+ return {
533
+ ...(options.apiKey === undefined ? {} : { AI_GATEWAY_API_KEY: options.apiKey }),
534
+ ...(options.providerName === undefined ? {} : { FX_PROVIDER: options.providerName }),
535
+ ...(options.customProvider === undefined ? {} : { FX_CONFIGURED_PROVIDERS: options.customProvider.json }),
536
+ ...(options.model === undefined ? {} : { FX_MODEL: options.model }),
537
+ ...(options.effort === undefined ? {} : { FX_EFFORT: options.effort }),
538
+ ...(options.fast === undefined ? {} : { FX_FAST: options.fast ? "true" : "false" }),
539
+ ...(options.ultrafast === undefined ? {} : { FX_ULTRAFAST: options.ultrafast ? "true" : "false" }),
540
+ ...(options.gatewayChatUrl === undefined ? {} : { FX_GATEWAY_CHAT_URL: options.gatewayChatUrl }),
541
+ };
542
+ }
543
+
544
+ function agentRpcError(response) {
545
+ const error = new Error(response.message);
546
+ error[coreAnswered] = true;
547
+ const data = response.data;
548
+ if (data && ["LIBFX_MODEL_UNSUPPORTED_EFFORT", "LIBFX_MODEL_UNSUPPORTED_FAST", "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST"].includes(data.code) &&
549
+ typeof data.model === "string" &&
550
+ data.capability === (data.code === "LIBFX_MODEL_UNSUPPORTED_FAST" ? "fast" : data.code === "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST" ? "ultrafast" : "effort")) {
551
+ error.code = data.code;
552
+ error.model = data.model;
553
+ error.capability = data.capability;
554
+ }
555
+ return error;
556
+ }
557
+
558
+ async function cancelResponseBody(response) {
559
+ try {
560
+ await response.body?.cancel();
561
+ } catch {}
562
+ }
563
+
564
+ async function readBoundedResponseText(response, limit) {
565
+ const declared = Number(response.headers.get("content-length"));
566
+ if (Number.isFinite(declared) && declared > limit) {
567
+ await cancelResponseBody(response);
568
+ throw new RangeError(`model catalog exceeds the ${limit} byte libfx limit`);
569
+ }
570
+ if (!response.body) {
571
+ const bytes = new Uint8Array(await response.arrayBuffer());
572
+ if (bytes.length > limit) throw new RangeError(`model catalog exceeds the ${limit} byte libfx limit`);
573
+ return strictDecoder.decode(bytes);
574
+ }
575
+
576
+ const reader = response.body.getReader();
577
+ const chunks = [];
578
+ let total = 0;
579
+ for (;;) {
580
+ const { done, value } = await reader.read();
581
+ if (done) break;
582
+ if (!value?.length) continue;
583
+ total += value.length;
584
+ if (total > limit) {
585
+ try {
586
+ await reader.cancel();
587
+ } catch {}
588
+ throw new RangeError(`model catalog exceeds the ${limit} byte libfx limit`);
589
+ }
590
+ chunks.push(value);
591
+ }
592
+ const bytes = new Uint8Array(total);
593
+ let offset = 0;
594
+ for (const chunk of chunks) {
595
+ bytes.set(chunk, offset);
596
+ offset += chunk.length;
597
+ }
598
+ return strictDecoder.decode(bytes);
599
+ }
600
+
601
+ export async function listModels(options = {}) {
602
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
603
+ throw new TypeError("listModels() options must be an object");
604
+ }
605
+ const apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, true);
606
+ const fetchModels = options.fetch ?? globalThis.fetch?.bind(globalThis);
607
+ if (typeof fetchModels !== "function") throw new TypeError("fetch is unavailable");
608
+ const response = await fetchModels("https://ai-gateway.vercel.sh/coding-agent/v1/models", {
609
+ method: "GET",
610
+ headers: { authorization: `Bearer ${apiKey}` },
611
+ });
612
+ if (!response.ok) {
613
+ await cancelResponseBody(response);
614
+ throw new Error(`model catalog request failed with HTTP ${response.status}`);
615
+ }
616
+
617
+ let catalog;
618
+ try {
619
+ catalog = JSON.parse(await readBoundedResponseText(response, maxModelCatalogBytes));
620
+ } catch (error) {
621
+ if (error instanceof RangeError) throw error;
622
+ throw new TypeError("model catalog response is malformed");
623
+ }
624
+ if (!catalog || typeof catalog !== "object" || !Array.isArray(catalog.data)) {
625
+ throw new TypeError("model catalog response is malformed");
626
+ }
627
+ if (catalog.data.length > maxModelCatalogEntries) {
628
+ throw new RangeError(`model catalog exceeds the ${maxModelCatalogEntries} entry libfx limit`);
629
+ }
630
+
631
+ const ids = new Set();
632
+ for (const entry of catalog.data) {
633
+ if (!entry || typeof entry !== "object") continue;
634
+ if (typeof entry.type === "string" && entry.type.toLowerCase() !== "language") continue;
635
+ if (typeof entry.id !== "string" || entry.id.length === 0) continue;
636
+ if (encoder.encode(entry.id).length > maxModelBytes) continue;
637
+ ids.add(entry.id);
638
+ }
639
+ return [...ids].sort();
640
+ }
641
+
642
+ function utf8Prefix(value, limit) {
643
+ if (value.length <= limit) return value;
644
+ let end = limit;
645
+ while (end > 0 && (value[end] & 0xc0) === 0x80) end -= 1;
646
+ return value.subarray(0, end);
647
+ }
648
+
649
+ export const fxSdkApiVersion = 2;
650
+
651
+ export function supportsJspi() {
652
+ return typeof WebAssembly.Suspending === "function" &&
653
+ typeof WebAssembly.promising === "function";
654
+ }
655
+
656
+ function blobByteLength(value) {
657
+ if (typeof Blob === "undefined" || value == null) return null;
658
+ try {
659
+ return Object.getOwnPropertyDescriptor(Blob.prototype, "size").get.call(value);
660
+ } catch {
661
+ return null;
662
+ }
663
+ }
664
+
665
+ function normalizeImageSourceRef(value, name) {
666
+ boundedString(value, `${name} sourceRef`, 512, false);
667
+ if (value !== undefined && /[\x00-\x1f\x7f\uD800-\uDFFF]/u.test(value)) {
668
+ throw new TypeError(`${name} sourceRef must be valid UTF-8 without ASCII controls`);
669
+ }
670
+ return value;
671
+ }
672
+
673
+ function omitReferencedImageData(blocks) {
674
+ return blocks.map((block) => block.type !== "image" || block.sourceRef === undefined ? block : {
675
+ type: "image",
676
+ mimeType: block.mimeType,
677
+ sourceRef: block.sourceRef,
678
+ });
679
+ }
680
+
681
+ function promptImageDataBytes(prompt) {
682
+ return prompt.reduce((total, block) => {
683
+ if (block.type !== "image") return total;
684
+ if (typeof block.data === "string") return total + block.data.length;
685
+ const byteLength = block.bytes?.byteLength ?? block.byteLength ?? blobByteLength(block.data) ?? 0;
686
+ return total + Math.ceil(byteLength / 3) * 4;
687
+ }, 0);
688
+ }
689
+
690
+ // Returns a Uint8Array view of an ArrayBuffer or typed array, or null.
691
+ function byteView(value) {
692
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
693
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
694
+ return null;
695
+ }
696
+
697
+ const base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
698
+
699
+ // Returns the decoded length of canonical, unwrapped base64, or -1.
700
+ function canonicalBase64ByteLength(value) {
701
+ if (value.length === 0 || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return -1;
702
+ const groups = value.length / 4;
703
+ if (value.endsWith("==")) {
704
+ return (base64Alphabet.indexOf(value[value.length - 3]) & 0x0f) === 0 ? groups * 3 - 2 : -1;
705
+ }
706
+ if (value.endsWith("=")) {
707
+ return (base64Alphabet.indexOf(value[value.length - 2]) & 0x03) === 0 ? groups * 3 - 1 : -1;
708
+ }
709
+ return groups * 3;
710
+ }
711
+
712
+ function requireImageMimeType(mimeType, index) {
713
+ if (typeof mimeType !== "string" || mimeType.length === 0 || mimeType.length > 128) {
714
+ throw new TypeError(`image prompt block ${index} requires a mimeType`);
715
+ }
716
+ }
717
+
718
+ function checkImageByteLength(byteLength, index) {
719
+ if (byteLength > maxPromptImageBytes) {
720
+ throw new RangeError(`image prompt block ${index} exceeds the ${maxPromptImageBytes} byte per-image libfx limit`);
721
+ }
722
+ }
723
+
724
+ function checkPromptImagesByteLength(byteLength) {
725
+ if (byteLength > maxPromptImagesBytes) {
726
+ throw new RangeError(`prompt images exceed the ${maxPromptImagesBytes} byte libfx limit`);
727
+ }
728
+ }
729
+
730
+ // Pixel inputs carry a private source and byteLength until preparation;
731
+ // reference-only inputs remain public image descriptors without pixel data.
732
+ function normalizePromptImage(block, index) {
733
+ const sourceRef = normalizeImageSourceRef(block.sourceRef, `image prompt block ${index}`);
734
+ const reference = sourceRef === undefined ? {} : { sourceRef };
735
+ if (block.data === undefined && sourceRef !== undefined) {
736
+ requireImageMimeType(block.mimeType, index);
737
+ return { type: "image", mimeType: block.mimeType, ...reference };
738
+ }
739
+ const size = blobByteLength(block.data);
740
+ if (size !== null) {
741
+ const mimeType = block.data.type;
742
+ if (block.mimeType !== undefined && block.mimeType !== mimeType) {
743
+ throw new TypeError(`image prompt block ${index} mimeType disagrees with Blob.type`);
744
+ }
745
+ requireImageMimeType(mimeType, index);
746
+ if (!Number.isSafeInteger(size) || size <= 0) {
747
+ throw new TypeError(`image prompt block ${index} requires a non-empty Blob with a valid size`);
748
+ }
749
+ return { type: "image", source: "blob", data: block.data, mimeType, byteLength: size, ...reference };
750
+ }
751
+ if (typeof block.data === "string" && block.data.length > 0) {
752
+ requireImageMimeType(block.mimeType, index);
753
+ const byteLength = canonicalBase64ByteLength(block.data);
754
+ if (byteLength <= 0) throw new TypeError(`image prompt block ${index} requires canonical base64 data`);
755
+ return { type: "image", source: "base64", data: block.data, mimeType: block.mimeType, byteLength, ...reference };
756
+ }
757
+ const bytes = typeof block.data === "string" ? null : byteView(block.data);
758
+ if (!bytes || bytes.byteLength === 0) {
759
+ throw new TypeError(`image prompt block ${index} requires base64 data, bytes, or a Blob`);
760
+ }
761
+ requireImageMimeType(block.mimeType, index);
762
+ return { type: "image", source: "bytes", data: bytes, mimeType: block.mimeType, byteLength: bytes.byteLength, ...reference };
763
+ }
764
+
765
+ // With deferImageLimits, byte limits apply after resizeImage instead.
766
+ function normalizePromptInput(input, { deferImageLimits = false } = {}) {
767
+ if (typeof input === "string") return [{ type: "text", text: input }];
768
+ if (!Array.isArray(input)) throw new TypeError("prompt input must be a string or an array of prompt blocks");
769
+ let imageCount = 0;
770
+ let imageBytes = 0;
771
+ let prompt = input.map((block, index) => {
772
+ if (!block || typeof block !== "object") throw new TypeError(`prompt block ${index} must be an object`);
773
+ if (block.type === "image") {
774
+ const image = normalizePromptImage(block, index);
775
+ imageCount += 1;
776
+ if (imageCount > maxPromptImages) {
777
+ throw new RangeError(`prompt cannot contain more than ${maxPromptImages} images`);
778
+ }
779
+ if (!deferImageLimits) {
780
+ if (image.byteLength > maxPromptImageBytes && image.sourceRef !== undefined) {
781
+ return omitReferencedImageData([image])[0];
782
+ }
783
+ checkImageByteLength(image.byteLength ?? 0, index);
784
+ imageBytes += image.byteLength ?? 0;
785
+ }
786
+ return image;
787
+ }
788
+ if (block.type === "text") {
789
+ if (typeof block.text !== "string") throw new TypeError(`text prompt block ${index} requires text`);
790
+ return { type: "text", text: block.text };
791
+ }
792
+ if (block.type === "resource") {
793
+ const resource = block.resource || block;
794
+ if (typeof resource.uri !== "string") throw new TypeError(`resource prompt block ${index} requires uri`);
795
+ if (resource.text !== undefined && typeof resource.text !== "string") throw new TypeError(`resource prompt block ${index} text must be a string`);
796
+ return { type: "resource", resource: { uri: resource.uri, ...(resource.text === undefined ? {} : { text: resource.text }) } };
797
+ }
798
+ throw new TypeError(`unsupported prompt block type: ${String(block.type)}`);
799
+ });
800
+ if (!deferImageLimits) {
801
+ if (imageBytes > maxPromptImagesBytes) {
802
+ prompt = omitReferencedImageData(prompt);
803
+ imageBytes = prompt.reduce((total, block) => total + (block.type === "image" ? block.byteLength ?? 0 : 0), 0);
804
+ }
805
+ checkPromptImagesByteLength(imageBytes);
806
+ if (promptFrameSize(prompt) > maxPromptFrameBytes) prompt = omitReferencedImageData(prompt);
807
+ }
808
+ return prompt;
809
+ }
810
+
811
+ // The image types libfx's core takes, each known by how its bytes start.
812
+ const imageSignatures = [
813
+ ["image/png", 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
814
+ ["image/jpeg", 0, [0xff, 0xd8, 0xff]],
815
+ ["image/gif", 0, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]],
816
+ ["image/gif", 0, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]],
817
+ ["image/webp", 0, [0x52, 0x49, 0x46, 0x46]],
818
+ ];
819
+ const imageTypes = new Set(imageSignatures.map(([type]) => type));
820
+ const webpMarker = [0x57, 0x45, 0x42, 0x50];
821
+
822
+ // The image type `bytes` start as, or null.
823
+ function imageTypeOf(bytes) {
824
+ const startsWith = (offset, signature) => bytes.length >= offset + signature.length && signature.every((byte, index) => bytes[offset + index] === byte);
825
+ for (const [type, offset, signature] of imageSignatures) {
826
+ if (!startsWith(offset, signature)) continue;
827
+ if (type === "image/webp" && !startsWith(8, webpMarker)) continue;
828
+ return type;
829
+ }
830
+ return null;
831
+ }
832
+
833
+ // Refuses image bytes that are not the type they declare, as libfx's core does.
834
+ function checkImageBytesType(bytes, mimeType, index) {
835
+ const type = imageTypeOf(bytes);
836
+ if (type === null) throw new TypeError(`image prompt block ${index} is not a PNG, JPEG, GIF or WebP image`);
837
+ if (type !== mimeType) throw new TypeError(`image prompt block ${index} holds ${type} data, not ${mimeType}`);
838
+ }
839
+
840
+ /**
841
+ * Checks a prompt's input as libfx takes it and returns it normalized:
842
+ * every check `prompt()` makes, and the ones libfx's core makes on images,
843
+ * which would otherwise refuse the prompt only once it reached the core: a
844
+ * supported image type, and bytes or base64 that are that type. A Blob is not
845
+ * read, so its bytes are checked once read (`checkImageBytesType`). With
846
+ * `resizeImage`, image bytes are checked after it runs instead. Throws a
847
+ * TypeError or RangeError.
848
+ */
849
+ export function checkPromptInput(input, { resizeImage } = {}) {
850
+ const resized = resizeImage !== undefined;
851
+ const normalized = normalizePromptInput(input, { deferImageLimits: resized });
852
+ // The core takes no prompt without text or an image; a resource adds neither.
853
+ if (!normalized.some((block) => block.type === "image" || (block.type === "text" && block.text.length > 0))) {
854
+ throw new TypeError("prompt is empty");
855
+ }
856
+ // resizeImage decides the final image sizes, so they are counted after it runs.
857
+ checkPromptFrameSize(normalized, !resized);
858
+ normalized.forEach((block, index) => {
859
+ if (block.type !== "image" || (resized && block.data !== undefined)) return;
860
+ if (!imageTypes.has(block.mimeType)) throw new TypeError(`image prompt block ${index} mimeType must be image/png, image/jpeg, image/gif or image/webp`);
861
+ if (block.source === "bytes") checkImageBytesType(block.data, block.mimeType, index);
862
+ else if (block.source === "base64") checkImageBytesType(base64ToBytes(block.data.slice(0, 16)), block.mimeType, index);
863
+ });
864
+ return normalized;
865
+ }
866
+
867
+ // Image bytes travel beside the frame as attachment references, but the model
868
+ // request carries them base64 encoded and the native host caps that request at
869
+ // 8 MiB. Images therefore count at their encoded size, the same budget they
870
+ // had inside the frame. With countImages false, only the frame counts.
871
+ function promptFrameSize(prompt, countImages = true) {
872
+ const encodedImageBytes = countImages ? promptImageDataBytes(prompt) : 0;
873
+ const projected = prompt.map((block) => block.type !== "image" ? block : {
874
+ type: "image",
875
+ mimeType: block.mimeType,
876
+ ...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
877
+ ...(block.data === undefined && block.bytes === undefined ? {} : { _meta: { fx: { attachment: 0xffffffff } } }),
878
+ });
879
+ return encoder.encode(JSON.stringify({ sessionId: "", prompt: projected })).length +
880
+ encodedImageBytes + promptFrameEnvelopeBytes;
881
+ }
882
+
883
+ function checkPromptFrameSize(prompt, countImages) {
884
+ if (promptFrameSize(prompt, countImages) > maxPromptFrameBytes) {
885
+ throw new RangeError(`prompt exceeds the ${maxPromptFrameBytes} byte libfx frame limit`);
886
+ }
887
+ }
888
+
889
+ // Returns prompt blocks whose images carry { mimeType, bytes }. Synchronous
890
+ // sources only: base64 is decoded and caller bytes are used in place.
891
+ function preparePromptImages(blocks) {
892
+ return blocks.map((block) => block.type !== "image" || block.data === undefined ? block : {
893
+ type: "image",
894
+ mimeType: block.mimeType,
895
+ bytes: block.source === "base64" ? base64ToBytes(block.data) : block.data,
896
+ ...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
897
+ });
898
+ }
899
+
900
+ function resizedPromptImage(value, index) {
901
+ const bytes = byteView(value?.bytes);
902
+ if (!bytes || bytes.byteLength === 0 || typeof value.mimeType !== "string" ||
903
+ value.mimeType.length === 0 || value.mimeType.length > 128) {
904
+ throw new TypeError(`resizeImage must return non-empty bytes and a mimeType for image prompt block ${index}`);
905
+ }
906
+ // Copied because a hook may reuse its output buffer for the next image.
907
+ return { bytes: bytes.slice(), mimeType: value.mimeType };
908
+ }
909
+
910
+ // Reads Blob images and applies resizeImage, then checks the final byte and
911
+ // frame limits. Returns null when the turn is cancelled first.
912
+ async function materializePromptImages(blocks, isCancelled, resizeImage) {
913
+ let prepared = [];
914
+ let imageBytes = 0;
915
+ for (let index = 0; index < blocks.length; index++) {
916
+ if (isCancelled()) return null;
917
+ const block = blocks[index];
918
+ if (block.type !== "image" || block.data === undefined) {
919
+ prepared.push(block);
920
+ continue;
921
+ }
922
+ let image = block.source === "blob"
923
+ ? { mimeType: block.mimeType, bytes: new Uint8Array(await block.data.arrayBuffer()) }
924
+ : preparePromptImages([block])[0];
925
+ if (isCancelled()) return null;
926
+ if (resizeImage) {
927
+ image = resizedPromptImage(await resizeImage({ bytes: image.bytes, mimeType: image.mimeType }), index);
928
+ if (isCancelled()) return null;
929
+ }
930
+ image = {
931
+ type: "image",
932
+ mimeType: image.mimeType,
933
+ bytes: image.bytes,
934
+ ...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
935
+ };
936
+ if (image.bytes.byteLength > maxPromptImageBytes && image.sourceRef !== undefined) {
937
+ image = omitReferencedImageData([image])[0];
938
+ }
939
+ checkImageByteLength(image.bytes?.byteLength ?? 0, index);
940
+ imageBytes += image.bytes?.byteLength ?? 0;
941
+ prepared.push(image);
942
+ if (imageBytes > maxPromptImagesBytes) {
943
+ prepared = omitReferencedImageData(prepared);
944
+ // Do not read more referenced Blobs after the actual aggregate overflows.
945
+ blocks = omitReferencedImageData(blocks);
946
+ imageBytes = prepared.reduce((total, entry) => total + (entry.bytes?.byteLength ?? 0), 0);
947
+ }
948
+ checkPromptImagesByteLength(imageBytes);
949
+ }
950
+ if (promptFrameSize(prepared) > maxPromptFrameBytes) prepared = omitReferencedImageData(prepared);
951
+ checkPromptFrameSize(prepared, true);
952
+ // A file's bytes, or what resizeImage made, are checked once there are
953
+ // some, after the size limits.
954
+ prepared.forEach((image, index) => {
955
+ if (image.type === "image" && image.bytes !== undefined) checkImageBytesType(image.bytes, image.mimeType, index);
956
+ });
957
+ return prepared;
958
+ }
959
+
960
+ function normalizeSteeringInput(input) {
961
+ const blocks = normalizePromptInput(input);
962
+ if (blocks.some((block) => block.type !== "text")) {
963
+ throw new TypeError("steering accepts only text blocks");
964
+ }
965
+ const text = blocks.map((block) => block.text).join("\n");
966
+ if (text.length === 0) throw new TypeError("steering text cannot be empty");
967
+ if (encoder.encode(text).length > maxSteeringMessageBytes) {
968
+ throw new RangeError(`steering text exceeds the ${maxSteeringMessageBytes} byte libfx limit`);
969
+ }
970
+ return text;
971
+ }
972
+
973
+ // A tool set's shape, so a checkpoint can tell when the tools it continues
974
+ // with changed since it was saved.
975
+ function toolSchemaHash(descriptors) {
976
+ const shape = JSON.stringify([...descriptors].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)));
977
+ let hash = 0x811c9dc5;
978
+ for (const byte of new TextEncoder().encode(shape)) hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
979
+ return hash.toString(16).padStart(8, "0");
980
+ }
981
+
982
+ // `tools` is an array of descriptors, or an object of descriptors keyed by
983
+ // name. What `idempotent` and `writes` mean after a crash and for calls that
984
+ // run together is modeled in `tests/tla/durable/ToolReplay.tla`, whose
985
+ // `replay: "safe"` is an idempotent tool that does not write, and in
986
+ // `tests/tla/durable/ParallelBatch.tla`.
987
+ function normalizeHostTools(value) {
988
+ if (value === undefined) return { descriptors: [], executors: new Map() };
989
+ let entries;
990
+ if (Array.isArray(value)) {
991
+ entries = value;
992
+ } else if (value && typeof value === "object") {
993
+ entries = Object.entries(value).map(([key, tool]) => {
994
+ if (!tool || typeof tool !== "object") throw new TypeError(`tool ${key} must be an object`);
995
+ if (tool.name !== undefined && tool.name !== key) throw new TypeError(`tool ${key} has a different name: ${tool.name}`);
996
+ return { ...tool, name: key };
997
+ });
998
+ } else {
999
+ throw new TypeError("tools must be an array or an object");
1000
+ }
1001
+ const descriptors = [];
1002
+ const executors = new Map();
1003
+ const names = new Set();
1004
+ for (const [index, tool] of entries.entries()) {
1005
+ if (!tool || typeof tool !== "object") throw new TypeError(`tool ${index} must be an object`);
1006
+ const { name, description, inputSchema, execute, providerExecuted, writes, idempotent } = tool;
1007
+ if (typeof name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
1008
+ throw new TypeError(`tool ${index} has an invalid name`);
1009
+ }
1010
+ if (names.has(name)) throw new TypeError(`duplicate tool name: ${name}`);
1011
+ names.add(name);
1012
+ if (providerExecuted !== undefined && typeof providerExecuted !== "boolean") {
1013
+ throw new TypeError(`tool ${name} providerExecuted must be a boolean`);
1014
+ }
1015
+ if (providerExecuted === true) {
1016
+ if (execute !== undefined) throw new TypeError(`provider-executed tool ${name} must not define execute()`);
1017
+ descriptors.push({ name, providerExecuted: true });
1018
+ continue;
1019
+ }
1020
+ if (typeof description !== "string") throw new TypeError(`tool ${name} requires a description`);
1021
+ if (typeof execute !== "function") throw new TypeError(`tool ${name} requires execute()`);
1022
+ if (writes !== undefined && typeof writes !== "boolean") throw new TypeError(`tool ${name} writes must be a boolean`);
1023
+ if (idempotent !== undefined && typeof idempotent !== "boolean") throw new TypeError(`tool ${name} idempotent must be a boolean`);
1024
+ if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
1025
+ throw new TypeError(`tool ${name} requires an object inputSchema`);
1026
+ }
1027
+ let schema;
1028
+ try { schema = JSON.parse(JSON.stringify(inputSchema)); } catch {
1029
+ throw new TypeError(`tool ${name} inputSchema must be JSON-serializable`);
1030
+ }
1031
+ descriptors.push({
1032
+ name,
1033
+ description,
1034
+ inputSchema: schema,
1035
+ ...(writes === undefined ? {} : { writes }),
1036
+ // The engine reads `idempotent`: such a tool runs beside others unless
1037
+ // it `writes`, and one that does not write starts before its intent is
1038
+ // stored. It is also in the shape a checkpoint records, so a change to
1039
+ // it counts as a changed tool set.
1040
+ ...(idempotent === true ? { idempotent: true } : {}),
1041
+ });
1042
+ executors.set(name, execute);
1043
+ }
1044
+ return { descriptors, executors };
1045
+ }
1046
+
1047
+ // The model catalog a host supplies, as the response body the core would
1048
+ // otherwise fetch: `{ data }` as AI Gateway returns it, or the entries alone.
1049
+ function modelCatalogBody(value) {
1050
+ const entries = Array.isArray(value) ? value : value?.data;
1051
+ if (!Array.isArray(entries)) throw new TypeError("modelCatalog must be an array of catalog entries or { data: [...] }");
1052
+ if (entries.length > maxModelCatalogEntries) {
1053
+ throw new RangeError(`modelCatalog exceeds the ${maxModelCatalogEntries} entry libfx limit`);
1054
+ }
1055
+ for (const entry of entries) {
1056
+ if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || entry.id === "") {
1057
+ throw new TypeError("each modelCatalog entry needs a string id");
1058
+ }
1059
+ }
1060
+ let body;
1061
+ try { body = JSON.stringify({ object: "list", data: entries }); } catch {
1062
+ throw new TypeError("modelCatalog must be JSON-serializable");
1063
+ }
1064
+ if (encoder.encode(body).length > maxModelCatalogBytes) {
1065
+ throw new RangeError(`modelCatalog exceeds the ${maxModelCatalogBytes} byte libfx limit`);
1066
+ }
1067
+ return body;
1068
+ }
1069
+
1070
+ export function normalizeInstructions(value) {
1071
+ let instructions;
1072
+ if (value === undefined) instructions = "";
1073
+ else if (typeof value === "string") instructions = value;
1074
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
1075
+ instructions = value.filter(Boolean).join("\n\n");
1076
+ }
1077
+ if (instructions === undefined) {
1078
+ throw new TypeError("instructions must be a string or an array of strings");
1079
+ }
1080
+ if (encoder.encode(instructions).length > maxInstructionsBytes) {
1081
+ throw new RangeError(`instructions exceed the ${maxInstructionsBytes} byte libfx limit`);
1082
+ }
1083
+ return instructions;
1084
+ }
1085
+
1086
+ // `stored` maps an image's base64 data to its name in the image store, for
1087
+ // the images stored before the result goes to the core: their blocks carry
1088
+ // the name, so the core names them too.
1089
+ function hostToolContent(value, stored = null) {
1090
+ if (value?.type === "libfx.tool-result") {
1091
+ if (typeof value.text !== "string" || !Array.isArray(value.images) || value.images.length > 8) {
1092
+ throw new TypeError("invalid typed tool result");
1093
+ }
1094
+ let images = value.images.map((image) => {
1095
+ if (image?.type !== "image") throw new TypeError("invalid tool image");
1096
+ const sourceRef = normalizeImageSourceRef(image.sourceRef, "tool image");
1097
+ const referenceOnly = image.data === undefined && sourceRef !== undefined;
1098
+ if ((!referenceOnly && typeof image.data !== "string") || typeof image.mimeType !== "string" || image.mimeType.length === 0 || image.mimeType.length > 128 || (image.data?.length > maxPromptImageDataBytes && sourceRef === undefined)) {
1099
+ throw new TypeError("invalid tool image");
1100
+ }
1101
+ const data = !referenceOnly && image.data.length <= maxPromptImageDataBytes ? image.data : undefined;
1102
+ return {
1103
+ type: "image",
1104
+ ...(data === undefined ? {} : { data }),
1105
+ mimeType: image.mimeType,
1106
+ ...(sourceRef === undefined ? {} : { sourceRef }),
1107
+ ...(data !== undefined && stored?.has(data) ? { stored: stored.get(data) } : {}),
1108
+ };
1109
+ });
1110
+ if (promptImageDataBytes(images) > maxPromptImagesDataBytes) images = omitReferencedImageData(images);
1111
+ if (promptImageDataBytes(images) > maxPromptImagesDataBytes) throw new RangeError("tool images exceed the result limit");
1112
+ let content = JSON.stringify({ text: value.text, images });
1113
+ if (encoder.encode(content).length > maxPromptImagesDataBytes) {
1114
+ images = omitReferencedImageData(images);
1115
+ content = JSON.stringify({ text: value.text, images });
1116
+ }
1117
+ if (encoder.encode(content).length > maxPromptImagesDataBytes) throw new RangeError("typed tool result exceeds the result limit");
1118
+ return { content, rich: true, isError: value.isError === true, images };
1119
+ }
1120
+ if (typeof value === "string") return { content: value, rich: false };
1121
+ if (value === undefined) return { content: "null", rich: false };
1122
+ const encoded = JSON.stringify(value);
1123
+ return { content: encoded === undefined ? "null" : encoded, rich: false };
1124
+ }
1125
+
1126
+ function checkpointBytes(value) {
1127
+ if (value === undefined) return null;
1128
+ if (value instanceof Uint8Array) return value.slice();
1129
+ if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0));
1130
+ if (ArrayBuffer.isView(value)) {
1131
+ return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
1132
+ }
1133
+ throw new TypeError("checkpoint must be an ArrayBuffer or typed array");
1134
+ }
1135
+
1136
+ function base64ToBytes(value) {
1137
+ const binary = atob(value);
1138
+ const bytes = new Uint8Array(binary.length);
1139
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
1140
+ return bytes;
1141
+ }
1142
+
1143
+ /**
1144
+ * One session on one fx core, with no durability of its own: `createFxAgent`
1145
+ * runs each durable session's turns on one of these, and a host with its own
1146
+ * storage may pass `persistence`. The core runs on `options.runtimeFactory`
1147
+ * or else `defaultRuntimeFactory`, the native addon or the WebAssembly host,
1148
+ * which the caller supplies so that this module loads neither.
1149
+ */
1150
+ export async function createFxEngine(options = {}, defaultRuntimeFactory = undefined) {
1151
+ options = normalizeAgentOptions(options);
1152
+ const runtimeFactory = options.runtimeFactory || defaultRuntimeFactory;
1153
+ if (typeof runtimeFactory !== "function") throw new TypeError("createFxEngine() needs a runtimeFactory");
1154
+ const hostTools = normalizeHostTools(options.tools);
1155
+ const instructions = normalizeInstructions(options.instructions);
1156
+ // Recorded in every checkpoint this agent saves, so a later one can tell
1157
+ // it continues with another libfx, other tools, or another model.
1158
+ const checkpointMeta = {
1159
+ libfxVersion: String(fxSdkApiVersion),
1160
+ toolSchemaHash: toolSchemaHash(hostTools.descriptors),
1161
+ model: options.model ?? "",
1162
+ };
1163
+ const initialCheckpoint = checkpointBytes(options.checkpoint);
1164
+ // Checked before the core starts, and with the core's message, so both
1165
+ // backends report it the same way.
1166
+ if (initialCheckpoint && initialCheckpoint.byteLength > maxCheckpointBytes) {
1167
+ throw new Error("libfx checkpoint is too large");
1168
+ }
1169
+ const pending = new Map();
1170
+ let nextId = 1;
1171
+ let sessionId = null;
1172
+ const hookScopes = new Map();
1173
+ // Observation never participates in effects, journal barriers, or retries.
1174
+ // Contain both throws and rejected promises, including an error handler's.
1175
+ const hookError = (error) => {
1176
+ try { Promise.resolve(options.hooks?.onError?.(error)).catch(() => {}); } catch {}
1177
+ };
1178
+ const hook = (event) => {
1179
+ if (!options.hooks) return;
1180
+ if (options.hooks.parent) event = {
1181
+ ...event, rootSessionId: options.hooks.parent.rootSessionId,
1182
+ ...(event.sessionId === sessionId && event.parentSessionId == null ? {
1183
+ parentSessionId: options.hooks.parent.sessionId, parentTurnId: options.hooks.parent.turnId, parentCallId: options.hooks.parent.callId,
1184
+ } : {}),
1185
+ };
1186
+ if (event.type === "tool.start") {
1187
+ const { data, type, ...scope } = event;
1188
+ hookScopes.set(`${event.sessionId}:${data.call.callId}`, scope);
1189
+ } else if (event.type === "tool.end") hookScopes.delete(`${event.sessionId}:${event.data.call.callId}`);
1190
+ else if (event.type === "turn.end") {
1191
+ for (const [key, scope] of hookScopes) if (scope.executionId === event.executionId) hookScopes.delete(key);
1192
+ } else if (event.type === "journal.error" && event.data.fenced) hookScopes.clear();
1193
+ try { Promise.resolve(options.hooks.onEvent(event)).catch(hookError); } catch (error) { hookError(error); }
1194
+ };
1195
+ const journalHook = (type, data) => hook({
1196
+ version: 1, type, sessionId, rootSessionId: sessionId, parentSessionId: null, sourceSessionId: null, workId: null, turnId: null, executionId: null, stepIndex: 0,
1197
+ timestamp: Date.now(), contentCaptured: false, contentTruncated: false, data,
1198
+ });
1199
+ let activeTurn = null;
1200
+ let closing = false;
1201
+ let coreExitError = null;
1202
+ const isCurrentTurn = (turn) => turn && activeTurn === turn && !turn.cancelled && !closing;
1203
+ // Events go to the store in the order the core sends them, one append at
1204
+ // a time: events that arrive together become one record, and so do all
1205
+ // the events that arrive while an append is in flight. Each record's append
1206
+ // names the cursor of the one before it, so a store refuses a write that
1207
+ // does not continue what it holds, and says with `barrier` whether anyone
1208
+ // waits for it: the core before an action others can see, such as a tool
1209
+ // call with effects, or a caller for its input.
1210
+ const persistence = options.persistence ?? null;
1211
+ // A persistence with an image store keeps each prompt image once, by
1212
+ // name, and the core names it in records and checkpoints instead of
1213
+ // holding its bytes.
1214
+ const imageStorage = persistence?.[persistenceImages] ?? null;
1215
+ const imageStore = imageStorage !== null;
1216
+ const wantsMarks = persistence?.marks === true || persistence?.[journalMarksWanted] === true;
1217
+ const checkpointAfterBytes = options.checkpointAfterBytes ?? defaultCheckpointAfterBytes;
1218
+ // The store's last cursor, and the last seq its records hold.
1219
+ let journalHead = null;
1220
+ let appendedSeq = 0;
1221
+ // Record bytes stored since the last checkpoint.
1222
+ let bytesSinceCheckpoint = 0;
1223
+ let appendTail = Promise.resolve();
1224
+ // One per agent, so a store can tell a repeated write from another writer's.
1225
+ const writerId = crypto.randomUUID();
1226
+ // Pipelined appends and barriers are modeled in `tests/tla/durable/Journal.tla`,
1227
+ // and two processes appending to one log in `tests/tla/durable/Fencing.tla`.
1228
+ const journal = persistence && {
1229
+ append(events, barrier) {
1230
+ const data = encodeJournalRecord(events);
1231
+ const lastSeq = events.at(-1)?.seq;
1232
+ // A store that tracks turns without reading records asks for marks.
1233
+ const marks = wantsMarks ? { marks: journalMarks(events) } : {};
1234
+ const appended = appendTail.then(async () => {
1235
+ const result = await persistence.append({ expected: journalHead, idempotencyKey: `${writerId}:${events[0]?.seq}`, data, barrier, ...marks });
1236
+ if (!validCursor(result?.cursor)) throw new TypeError("persistence.append() must resolve to { cursor }");
1237
+ journalHead = result.cursor;
1238
+ if (Number.isSafeInteger(lastSeq)) appendedSeq = lastSeq;
1239
+ bytesSinceCheckpoint += data.byteLength;
1240
+ journalHook("journal.commit", { cursor: result.cursor, firstSeq: Number.isSafeInteger(events[0]?.seq) ? events[0].seq : null, lastSeq: Number.isSafeInteger(lastSeq) ? lastSeq : null, eventCount: events.length });
1241
+ });
1242
+ // A failed append fails every later one: they would not continue it.
1243
+ appendTail = appended;
1244
+ return appended;
1245
+ },
1246
+ };
1247
+ let resumable = false;
1248
+ // The host's ids for the turn a crash left open and the last turn that
1249
+ // ended, so a retried prompt continues or skips its turn.
1250
+ let openTurnId = null;
1251
+ let lastTurnId = null;
1252
+ // The calls the open turn left running: `{ id, name, arguments }`, and
1253
+ // what the resume tells the model about each one it does not run again.
1254
+ let ambiguousCalls = [];
1255
+ // Asks `onAmbiguous` about each of them, once. Returns the ids of the
1256
+ // calls to run again, and the rest, which the resume answers as possibly
1257
+ // run.
1258
+ const settleAmbiguous = (onAmbiguous) => {
1259
+ const calls = ambiguousCalls;
1260
+ ambiguousCalls = [];
1261
+ const rerun = onAmbiguous === undefined
1262
+ ? []
1263
+ : calls.filter((call) => onAmbiguous({ executionId: call.id, name: call.name, input: parsedToolInput(call.arguments) }) === "rerun").map((call) => call.id);
1264
+ return { rerun, answered: calls.filter((call) => !rerun.includes(call.id)) };
1265
+ };
1266
+ // Continues the open turn: the calls it left running run again or are
1267
+ // answered as possibly run, and each answered one ends for the turn's
1268
+ // viewers as it does for the model. Modeled in
1269
+ // `tests/tla/durable/Resumption.tla`.
1270
+ const resumeOpenTurn = (promptOptions) => {
1271
+ const { rerun, answered } = settleAmbiguous(promptOptions.onAmbiguous);
1272
+ return normalizeTurn(startTurn(null, { ...promptOptions, [resumeTurn]: true, [rerunCallIds]: rerun }), answered);
1273
+ };
1274
+ // Follow-ups waiting for the turn ahead of them, oldest first.
1275
+ const followUps = [];
1276
+ // A follow-up this agent queued starts when the turn ahead of it ends, ahead
1277
+ // of any the journal held: those have no caller, so each waits for
1278
+ // `resume()` to start it, oldest first.
1279
+ const runNextFollowUp = (resuming = false) => {
1280
+ if (activeTurn || closing || journalFailure) return;
1281
+ const index = followUps.findIndex((entry) => (entry.held === true) === resuming);
1282
+ if (index < 0) return;
1283
+ const [next] = followUps.splice(index, 1);
1284
+ let turn;
1285
+ try {
1286
+ turn = normalizeTurn(startTurn(next.text, { turnId: next.id, [followUpInput]: { id: next.id, accepted: next.accepted } }));
1287
+ } catch (error) {
1288
+ next.rejectTurn?.(error);
1289
+ return;
1290
+ }
1291
+ next.resolveTurn?.(turn);
1292
+ };
1293
+ let journalQueue = [];
1294
+ let journalFlushScheduled = false;
1295
+ const journalPending = new Set();
1296
+ // The append in flight, and whether anyone waits for the events queued
1297
+ // behind it.
1298
+ let journalInFlight = null;
1299
+ let journalBarrier = false;
1300
+ let journalFailure = null;
1301
+ // Set by `turn.cancel({ reason: "handoff" })`: the journal keeps the open
1302
+ // turn for another agent, so this one stores nothing more.
1303
+ let handedOff = false;
1304
+ const handedOffError = () => new Error("this agent handed its session off; open it again to continue");
1305
+ // Input events (`accepted:<id>`, `withdrawn:<id>`) once stored, and the
1306
+ // steer and withdraw calls waiting for them, which resolve only then
1307
+ // (`tests/tla/durable/Inbox.tla`).
1308
+ const durableInputs = new Set();
1309
+ const inputWaiters = new Map();
1310
+ const waitForInput = (key) => {
1311
+ if (durableInputs.delete(key)) return Promise.resolve();
1312
+ if (journalFailure) return Promise.reject(journalFailure);
1313
+ return new Promise((resolve, reject) => {
1314
+ const waiters = inputWaiters.get(key) ?? [];
1315
+ waiters.push({ resolve, reject });
1316
+ inputWaiters.set(key, waiters);
1317
+ });
1318
+ };
1319
+ const settleInputs = (batch) => {
1320
+ for (const event of batch) {
1321
+ if (event.type !== "input_accepted" && event.type !== "input_withdrawn") continue;
1322
+ const key = `${event.type === "input_accepted" ? "accepted" : "withdrawn"}:${event.data?.id}`;
1323
+ durableInputs.add(key);
1324
+ for (const waiter of inputWaiters.get(key) ?? []) waiter.resolve();
1325
+ inputWaiters.delete(key);
1326
+ }
1327
+ };
1328
+ const rejectInputWaiters = (keys, error) => {
1329
+ for (const key of keys) {
1330
+ for (const waiter of inputWaiters.get(key) ?? []) waiter.reject(error);
1331
+ inputWaiters.delete(key);
1332
+ }
1333
+ };
1334
+ // close() reports a failure no turn or prompt call has reported yet.
1335
+ let journalFailureReported = false;
1336
+ const reportJournalFailure = () => {
1337
+ journalFailureReported = true;
1338
+ return journalFailure;
1339
+ };
1340
+ // `barrier`: the core waits for these events before it goes on.
1341
+ const queueJournalEvents = (events, barrier = false) => {
1342
+ if (journalFailure || !Array.isArray(events) || events.length === 0) return;
1343
+ if (handedOff) {
1344
+ // The agent that opens the session next decides about these inputs.
1345
+ const dropped = events
1346
+ .filter((event) => event?.type === "input_accepted" || event?.type === "input_withdrawn")
1347
+ .map((event) => `${event.type === "input_accepted" ? "accepted" : "withdrawn"}:${event.data?.id}`);
1348
+ rejectInputWaiters(dropped, handedOffError());
1349
+ return;
1350
+ }
1351
+ journalQueue.push(...events);
1352
+ // A caller waits for its steer or withdrawal to be stored, and a turn's
1353
+ // result for the turn's end or yield.
1354
+ if (barrier || events.some((event) => waitedForEvents.has(event?.type))) journalBarrier = true;
1355
+ // A turn notes a new config right before its first progress, a barrier:
1356
+ // the two go out in one write instead of two in a row.
1357
+ if (events.every((event) => event?.type === "session_config")) return;
1358
+ if (journalFlushScheduled) return;
1359
+ journalFlushScheduled = true;
1360
+ queueMicrotask(flushJournal);
1361
+ };
1362
+ // Sends the queued events as one record, unless an append is in flight:
1363
+ // then they wait for it, with any that arrive meanwhile, and go out
1364
+ // together once it lands.
1365
+ function flushJournal() {
1366
+ journalFlushScheduled = false;
1367
+ if (journalFailure || journalInFlight || journalQueue.length === 0) return;
1368
+ const batch = journalQueue;
1369
+ journalQueue = [];
1370
+ const barrier = journalBarrier;
1371
+ journalBarrier = false;
1372
+ emit("journal.append", { events: batch.length, barrier });
1373
+ let appended;
1374
+ try {
1375
+ appended = Promise.resolve(journal.append(batch, barrier));
1376
+ } catch (error) {
1377
+ appended = Promise.reject(error);
1378
+ }
1379
+ const settled = appended.then(() => {
1380
+ settleInputs(batch);
1381
+ }, (error) => {
1382
+ // Later events would leave a gap after the failed batch.
1383
+ journalQueue = [];
1384
+ if (journalFailure) return;
1385
+ journalFailure = journalAppendError(error);
1386
+ emit("journal.error", { error: error instanceof Error ? error.name : "Error" });
1387
+ journalHook("journal.error", { error: error instanceof Error ? error.name : "Error", fenced: error?.code === "FX_FENCED" || error?.cause?.code === "FX_FENCED" });
1388
+ rejectInputWaiters([...inputWaiters.keys()], journalFailure);
1389
+ // Nothing the turn does from here can be saved, and after a fence the
1390
+ // session has another owner: stop the turn and its effects now.
1391
+ activeTurn?.cancel();
1392
+ }).finally(() => {
1393
+ journalPending.delete(settled);
1394
+ journalInFlight = null;
1395
+ flushJournal();
1396
+ });
1397
+ journalInFlight = settled;
1398
+ journalPending.add(settled);
1399
+ }
1400
+ // The journal's own work, such as a heartbeat, ends with the agent: once,
1401
+ // after the last append settles.
1402
+ let journalClosed = null;
1403
+ function closeJournal() {
1404
+ journalClosed ??= journalSettled().catch(() => {}).then(() => checkpointing);
1405
+ return journalClosed;
1406
+ }
1407
+ async function journalSettled() {
1408
+ for (;;) {
1409
+ // This caller waits for every event queued so far.
1410
+ if (journalQueue.length > 0) journalBarrier = true;
1411
+ if (journalFlushScheduled || journalQueue.length > 0) flushJournal();
1412
+ if (journalPending.size === 0) break;
1413
+ await Promise.all([...journalPending]);
1414
+ }
1415
+ if (journalFailure) throw journalFailure;
1416
+ }
1417
+ const emit = (type, detail = {}) => {
1418
+ try { options.onEvent?.({ type, timestamp: performance.now(), ...detail }); } catch {}
1419
+ };
1420
+ // A checkpoint saved by another libfx, with other tools, or for another
1421
+ // model still restores; the host hears what changed.
1422
+ const compareCheckpointMeta = (saved) => {
1423
+ if (!saved || typeof saved !== "object") return;
1424
+ const changed = Object.keys(checkpointMeta).filter((key) => typeof saved[key] === "string" && saved[key] !== "" && saved[key] !== checkpointMeta[key]);
1425
+ if (changed.length) emit("checkpoint.mismatch", { sessionId, changed, saved, current: checkpointMeta });
1426
+ };
1427
+ // Saves a checkpoint once the records since the last one reach
1428
+ // `checkpointAfterBytes`, or with `force` whatever their size, at a point
1429
+ // no turn is running: a turn's end, or where it yielded. A failed save
1430
+ // leaves the journal as it was; the next turn's end tries again. Returns
1431
+ // the save in flight, which never rejects.
1432
+ let checkpointing = null;
1433
+ const maybeCheckpoint = (force = false) => {
1434
+ if (checkpointing) return checkpointing;
1435
+ if (!persistence?.saveCheckpoint || closing || handedOff || journalFailure) return Promise.resolve();
1436
+ checkpointing = (async () => {
1437
+ // The turn's last records may still be in flight; count them first.
1438
+ await journalSettled();
1439
+ // A save begun before close() finishes; close() waits for it.
1440
+ if (activeTurn) return;
1441
+ if (bytesSinceCheckpoint === 0 || (!force && bytesSinceCheckpoint < checkpointAfterBytes)) return;
1442
+ const response = await request("libfx/snapshot", { sessionId, checkpointMeta });
1443
+ const id = response?.snapshotAttachment;
1444
+ if (!Number.isSafeInteger(id) || id <= 0) return;
1445
+ const bytes = runtime.takeAttachment?.(id);
1446
+ // The core took it after every event this agent stored, and no later.
1447
+ if (!(bytes instanceof Uint8Array) || response.atSeq !== appendedSeq) return;
1448
+ const through = journalHead;
1449
+ const covered = bytesSinceCheckpoint;
1450
+ await persistence.saveCheckpoint({ through, data: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) });
1451
+ bytesSinceCheckpoint -= covered;
1452
+ emit("checkpoint.save", { through, bytes: bytes.byteLength });
1453
+ })().catch((error) => {
1454
+ emit("checkpoint.error", { error: error instanceof Error ? error.name : "Error", message: error?.message ?? "" });
1455
+ }).finally(() => { checkpointing = null; });
1456
+ return checkpointing;
1457
+ };
1458
+ const hostFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
1459
+ // Adds the host's credentials to the provider's own requests.
1460
+ const providerAuth = subscriptionProviders.has(options.provider)
1461
+ ? createSubscriptionAuthorizer([codexSubscription, grokSubscription], { credentials: options.credentials, onCredentials: options.onCredentials, refreshLock: options.refreshLock, fetch: hostFetch, memory: options[credentialMemory] })
1462
+ : options.customProvider && options.credentials
1463
+ ? createSubscriptionAuthorizer([customProviderSpec(options.customProvider, () => sessionId)], { credentials: options.credentials, fetch: hostFetch })
1464
+ : null;
1465
+ const transportFetch = async (input, init = {}) => {
1466
+ const method = String(init.method ?? input?.method ?? "GET").toUpperCase();
1467
+ let endpoint = String(input?.url ?? input);
1468
+ let path = null;
1469
+ try {
1470
+ const url = new URL(endpoint);
1471
+ endpoint = `${url.origin}${url.pathname}`;
1472
+ path = url.pathname;
1473
+ } catch {}
1474
+ // A supplied catalog answers the core's catalog request without the network.
1475
+ if (options.modelCatalogBody && method === "GET" && path === "/coding-agent/v1/models") {
1476
+ return new Response(options.modelCatalogBody, { status: 200, headers: { "content-type": "application/json" } });
1477
+ }
1478
+ for (let attemptIndex = 0; attemptIndex < 2; attemptIndex++) {
1479
+ const startedAt = performance.now();
1480
+ const attempt = activeTurn ? ++activeTurn.transportAttempts : attemptIndex + 1;
1481
+ if (activeTurn) {
1482
+ activeTurn.transportBytes = 0;
1483
+ activeTurn.lastTransportActivityAt = null;
1484
+ }
1485
+ emit("transport.start", { attempt, method, endpoint, model: options.model });
1486
+ try {
1487
+ if (activeTurn?.cancelled) {
1488
+ runtime.abortHostEffects();
1489
+ throw new DOMException("Aborted", "AbortError");
1490
+ }
1491
+ if (!hostFetch) throw new TypeError("fetch is unavailable");
1492
+ const spec = providerAuth?.specFor(endpoint);
1493
+ const response = spec
1494
+ ? await providerAuth.fetch(spec, endpoint, hostFetch, input, init)
1495
+ : await hostFetch(input, init);
1496
+ const headers = response.headers;
1497
+ emit("transport.response", {
1498
+ attempt,
1499
+ status: response.status,
1500
+ elapsedMs: performance.now() - startedAt,
1501
+ requestId: headers.get("x-vercel-id"),
1502
+ generationId: headers.get("x-generation-id"),
1503
+ model: headers.get("x-model-id") ?? options.model,
1504
+ provider: headers.get("x-vercel-ai-gateway-provider") ?? headers.get("x-ai-gateway-provider"),
1505
+ });
1506
+ return response;
1507
+ } catch (error) {
1508
+ const errorName = error instanceof Error ? error.name : "Error";
1509
+ const elapsedMs = performance.now() - startedAt;
1510
+ emit("transport.error", { attempt, elapsedMs, error: errorName });
1511
+ if (init.signal?.aborted) throw new DOMException("Aborted", "AbortError");
1512
+ if (attemptIndex === 1) throw error;
1513
+ emit("transport.retry", {
1514
+ attempt,
1515
+ nextAttempt: attempt + 1,
1516
+ elapsedMs,
1517
+ error: errorName,
1518
+ });
1519
+ if (init.signal?.aborted) throw new DOMException("Aborted", "AbortError");
1520
+ }
1521
+ }
1522
+ throw new Error("transport retry exhausted");
1523
+ };
1524
+ // A typed tool result's images go in the image store by name before the
1525
+ // result reaches the core, as a prompt's do, so the session's records name
1526
+ // them. The tool has already run, so an image the store refuses is not
1527
+ // lost: it stays in the result, and the records hold it inline.
1528
+ const storedToolContent = async (value) => {
1529
+ const normalized = hostToolContent(value);
1530
+ if (!imageStore || !normalized.images?.some((image) => image.data !== undefined)) return normalized;
1531
+ const stored = new Map();
1532
+ for (const image of normalized.images) {
1533
+ if (image.data === undefined || stored.has(image.data)) continue;
1534
+ let bytes;
1535
+ // Data that is not base64 is the core's to refuse.
1536
+ try { bytes = base64ToBytes(image.data); } catch { continue; }
1537
+ const name = await imageName(bytes);
1538
+ try {
1539
+ await imageStorage.put(name, bytes);
1540
+ stored.set(image.data, name);
1541
+ } catch {
1542
+ // Not stored: the result keeps the image, and the records hold it
1543
+ // inline.
1544
+ }
1545
+ }
1546
+ return stored.size ? hostToolContent(value, stored) : normalized;
1547
+ };
1548
+ const executeHostTool = async (name, input, requestedSessionId, toolCallId) => {
1549
+ const execute = hostTools.executors.get(name);
1550
+ const issuerSessionId = requestedSessionId ?? sessionId;
1551
+ const scope = hookScopes.get(`${issuerSessionId}:${toolCallId}`);
1552
+ // Hook metadata is not authority to execute a foreign session's tool.
1553
+ const turn = issuerSessionId === sessionId
1554
+ ? activeTurn
1555
+ : null;
1556
+ if (!isCurrentTurn(turn)) return { content: "", isError: true, cancelled: true };
1557
+ const controller = new AbortController();
1558
+ turn.toolControllers.add(controller);
1559
+ let onAbort;
1560
+ const aborted = new Promise((resolve) => { onAbort = () => resolve(); });
1561
+ controller.signal.addEventListener("abort", onAbort, { once: true });
1562
+ let content = "";
1563
+ let rich = false;
1564
+ let isError = false;
1565
+ try {
1566
+ if (!execute) throw new Error(`unknown host tool: ${String(name)}`);
1567
+ const execution = Promise.resolve().then(() => {
1568
+ if (controller.signal.aborted || !isCurrentTurn(turn)) return;
1569
+ // The model's call id is stable across restores of a persisted
1570
+ // session, so a tool can key its own retries on it.
1571
+ return execute(input, {
1572
+ signal: controller.signal,
1573
+ executionId: String(toolCallId ?? ""),
1574
+ sessionId: issuerSessionId,
1575
+ ...(options.hooks && scope ? { observation: toolObservation(scope, String(toolCallId), options.hooks, hook, controller.signal) } : {}),
1576
+ ...(options.toolContext === undefined ? {} : { context: options.toolContext }),
1577
+ });
1578
+ });
1579
+ const value = await Promise.race([execution, aborted]);
1580
+ if (!controller.signal.aborted) {
1581
+ const normalized = await storedToolContent(value);
1582
+ content = normalized.content;
1583
+ rich = normalized.rich;
1584
+ isError = normalized.isError === true;
1585
+ }
1586
+ } catch (error) {
1587
+ isError = true;
1588
+ if (error?.toolResult?.type === "libfx.tool-result") {
1589
+ try {
1590
+ const normalized = await storedToolContent(error.toolResult);
1591
+ content = normalized.content;
1592
+ rich = normalized.rich;
1593
+ } catch {
1594
+ content = error instanceof Error ? error.message : String(error);
1595
+ }
1596
+ } else {
1597
+ content = error instanceof Error ? error.message : String(error);
1598
+ }
1599
+ } finally {
1600
+ controller.signal.removeEventListener("abort", onAbort);
1601
+ turn.toolControllers.delete(controller);
1602
+ }
1603
+ // Providers reject an empty error result, which would end the session.
1604
+ if (isError && content === "") content = `Tool ${String(name)} failed without a message`;
1605
+ return { content, isError, rich, cancelled: controller.signal.aborted || !isCurrentTurn(turn) };
1606
+ };
1607
+ emit("runtime.start");
1608
+ const runtimeOptions = {
1609
+ ...options,
1610
+ fetch: transportFetch,
1611
+ args: ["acp"],
1612
+ env: agentEnvironment(options),
1613
+ hostToolExecutor: executeHostTool,
1614
+ journalFlush: () => journalSettled(),
1615
+ onTransportChunk(byteLength) {
1616
+ const turn = activeTurn;
1617
+ if (!isCurrentTurn(turn) || !Number.isSafeInteger(byteLength) || byteLength <= 0) return;
1618
+ turn.transportBytes += byteLength;
1619
+ const now = performance.now();
1620
+ if (turn.lastTransportActivityAt !== null && now - turn.lastTransportActivityAt < transportActivityIntervalMs) return;
1621
+ turn.lastTransportActivityAt = now;
1622
+ emit("transport.activity", {
1623
+ attempt: turn.transportAttempts,
1624
+ chunkBytes: byteLength,
1625
+ totalBytes: turn.transportBytes,
1626
+ });
1627
+ },
1628
+ };
1629
+ const runtime = await runtimeFactory(runtimeOptions);
1630
+ emit("runtime.ready");
1631
+ const send = (message) => {
1632
+ if (closing) throw new Error("fx agent is closing");
1633
+ emit("acp.send", { message });
1634
+ if (message.method === "session/prompt" && activeTurn?.cancelled) throw new Error("Cancelled");
1635
+ runtime.write(`${JSON.stringify(message)}\n`);
1636
+ if (message.method === "session/prompt") activeTurn?.promptWritten();
1637
+ };
1638
+ const request = (method, params = {}) => new Promise((resolve, reject) => {
1639
+ const id = nextId++;
1640
+ pending.set(id, { resolve, reject });
1641
+ try { send({ jsonrpc: "2.0", id, method, params }); } catch (error) { pending.delete(id); reject(error); }
1642
+ });
1643
+ // Raw payloads ride beside the next frame instead of inside it. Payloads
1644
+ // left by an earlier frame that never reached the core are dropped first.
1645
+ let nextAttachmentId = 1;
1646
+ const attachBytes = (payloads) => {
1647
+ if (typeof runtime.writeAttachment !== "function") throw new Error("fx runtime does not accept binary attachments");
1648
+ runtime.discardAttachments?.();
1649
+ return payloads.map((bytes) => {
1650
+ const id = nextAttachmentId;
1651
+ nextAttachmentId = id === 0x7fffffff ? 1 : id + 1;
1652
+ runtime.writeAttachment(id, bytes);
1653
+ return id;
1654
+ });
1655
+ };
1656
+ let checkpointTail = null;
1657
+ let pendingCheckpoints = 0;
1658
+ async function takeCheckpoint() {
1659
+ if (closing) throw new Error("fx agent is closed");
1660
+ if (activeTurn) throw new Error("cannot checkpoint while a prompt is active");
1661
+ const response = await request("libfx/checkpoint", { sessionId, checkpointMeta });
1662
+ const id = response?.checkpointAttachment;
1663
+ const bytes = Number.isSafeInteger(id) && id > 0 ? runtime.takeAttachment?.(id) : null;
1664
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) throw new Error("fx returned an invalid checkpoint");
1665
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1666
+ }
1667
+ // Stores each prompt image in the persistence's image store, by name,
1668
+ // before the prompt that carries it is sent: nothing the core writes may
1669
+ // name an image the store does not hold. Returns the blocks with their
1670
+ // names, or null when the prompt stopped meanwhile.
1671
+ async function storePromptImages(blocks, stopped) {
1672
+ const named = [];
1673
+ for (const block of blocks) {
1674
+ if (block.type !== "image" || block.bytes === undefined) {
1675
+ named.push(block);
1676
+ continue;
1677
+ }
1678
+ const name = await imageName(block.bytes);
1679
+ if (stopped()) return null;
1680
+ await imageStorage.put(name, block.bytes);
1681
+ if (stopped()) return null;
1682
+ named.push({ ...block, stored: name });
1683
+ }
1684
+ return named;
1685
+ }
1686
+ const sendPrompt = (blocks, resuming = false, followUp = null, turnId = null, rerun = [], yieldAt = undefined, outputShown = false) => {
1687
+ // A journaled turn given a time to stop at yields before its first model
1688
+ // request after it, keeping the turn open.
1689
+ const stopAt = journal && yieldAt !== undefined ? { yieldAt } : {};
1690
+ if (resuming) {
1691
+ return request("session/prompt", { sessionId, prompt: [], _meta: { fx: { continueRecovery: true, ...(outputShown ? {} : { freshOutput: true }), ...(rerun.length ? { rerun } : {}), ...stopAt } } });
1692
+ }
1693
+ const images = blocks.filter((block) => block.type === "image" && block.bytes !== undefined);
1694
+ const ids = images.length ? attachBytes(images.map((block) => block.bytes)) : [];
1695
+ let next = 0;
1696
+ const prompt = blocks.map((block) => block.type !== "image" ? block : {
1697
+ type: "image",
1698
+ mimeType: block.mimeType,
1699
+ ...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
1700
+ // An image the store holds carries its name, so the core names it too.
1701
+ ...(block.bytes === undefined ? {} : { _meta: { fx: { attachment: ids[next++], ...(block.stored === undefined ? {} : { stored: block.stored }) } } }),
1702
+ });
1703
+ // A follow-up's turn places it; the core records it as accepted first
1704
+ // when the host could not. A journaled turn's first progress records its id.
1705
+ const fx = {
1706
+ ...(followUp ? { inputId: followUp.id, inputAccepted: followUp.accepted } : {}),
1707
+ ...((journal || options.hooks) && turnId ? { turnId } : {}),
1708
+ ...stopAt,
1709
+ };
1710
+ return request("session/prompt", { sessionId, prompt, ...(Object.keys(fx).length ? { _meta: { fx } } : {}) });
1711
+ };
1712
+ runtime.exited.then((code) => {
1713
+ hookScopes.clear();
1714
+ closing = true;
1715
+ // An agent whose core exited writes nothing more.
1716
+ if (journal) void closeJournal().catch(() => {});
1717
+ const error = runtime.error ?? new Error(`fx-core exited with code ${code} before completing the ACP request`);
1718
+ coreExitError = error;
1719
+ activeTurn?.failImagePrep(error);
1720
+ for (const waiter of pending.values()) waiter.reject(error);
1721
+ pending.clear();
1722
+ emit("runtime.exit", { code });
1723
+ });
1724
+ runtime.setLineHandler((message, size) => {
1725
+ if (message.method === "libfx/hook") {
1726
+ if (message.params?.rootSessionId === sessionId && message.params?.version === 1) {
1727
+ if (message.params.type === "turn.end" && handedOff) message.params.data.handoff = true;
1728
+ hook(message.params);
1729
+ }
1730
+ return;
1731
+ }
1732
+ emit("acp.receive", { message });
1733
+ if (message.method === "libfx/journal_append") {
1734
+ if (journal && message.params?.sessionId === sessionId) {
1735
+ queueJournalEvents(message.params.events, message.params.barrier === true);
1736
+ }
1737
+ return;
1738
+ }
1739
+ if (message.method === "session/update") {
1740
+ if (message.params.sessionId === sessionId) return activeTurn?.push(message.params.update, size);
1741
+ return;
1742
+ }
1743
+ void handleControlMessage(message).catch((error) => runtime.abort(error));
1744
+ });
1745
+ async function handleControlMessage(message) {
1746
+ if (message.method === "session/request_permission") {
1747
+ const turn = activeTurn;
1748
+ if (!isCurrentTurn(turn)) return;
1749
+ emit("permission.request", { request: message.params });
1750
+ if (!isCurrentTurn(turn)) return;
1751
+ let optionId = null;
1752
+ try { optionId = await options.onPermission?.(message.params); } catch {}
1753
+ if (!isCurrentTurn(turn)) return;
1754
+ emit("permission.resolve", { optionId });
1755
+ if (!isCurrentTurn(turn)) return;
1756
+ send({ jsonrpc: "2.0", id: message.id, result: optionId ? { outcome: { outcome: "selected", optionId } } : { outcome: { outcome: "cancelled" } } });
1757
+ return;
1758
+ }
1759
+ if (message.method === "libfx/journal_flush") {
1760
+ // Every append the core sent before this request is already queued.
1761
+ let failure = null;
1762
+ try { await journalSettled(); } catch (error) { failure = error; }
1763
+ if (closing) return;
1764
+ send(failure
1765
+ ? { jsonrpc: "2.0", id: message.id, error: { code: -32000, message: failure.message } }
1766
+ : { jsonrpc: "2.0", id: message.id, result: {} });
1767
+ return;
1768
+ }
1769
+ if (message.method === "libfx/tool_call") {
1770
+ const { content, isError, rich, cancelled } = await executeHostTool(
1771
+ message.params?.name,
1772
+ message.params?.input,
1773
+ message.params?.sessionId,
1774
+ message.params?.toolCallId,
1775
+ );
1776
+ if (cancelled || closing) return;
1777
+ const response = { jsonrpc: "2.0", id: message.id, result: { content, isError, ...(rich ? { contentType: "rich" } : {}) } };
1778
+ if (rich && encoder.encode(JSON.stringify(response)).length + 1 > maxPromptFrameBytes) {
1779
+ const result = JSON.parse(content);
1780
+ response.result.content = JSON.stringify({ ...result, images: omitReferencedImageData(result.images) });
1781
+ }
1782
+ if (encoder.encode(JSON.stringify(response)).length + 1 > 8 * 1024 * 1024) {
1783
+ response.result = { content: "Host tool result exceeded the response frame limit", isError: true };
1784
+ }
1785
+ send(response);
1786
+ return;
1787
+ }
1788
+ const waiter = pending.get(message.id); if (!waiter) return; pending.delete(message.id);
1789
+ if (message.error) waiter.reject(agentRpcError(message.error)); else waiter.resolve(message.result);
1790
+ }
1791
+ try {
1792
+ await request("initialize", {
1793
+ protocolVersion: 1,
1794
+ clientCapabilities: {
1795
+ ...(hostTools.descriptors.length || instructions
1796
+ ? { libfx: { tools: hostTools.descriptors, instructions } }
1797
+ : {}),
1798
+ },
1799
+ });
1800
+
1801
+ const loaded = persistence ? await loadPersistence(persistence) : null;
1802
+ const requestedSessionId = options.sessionId ?? null;
1803
+ const sessionResult = await request("libfx/new", {
1804
+ ...(requestedSessionId === null ? {} : { sessionId: requestedSessionId }),
1805
+ ...(options.hooks ? { hooks: { captureContent: options.hooks.captureContent === true } } : {}),
1806
+ });
1807
+ sessionId = sessionResult.sessionId;
1808
+ if (options.hooks && sessionResult?._meta?.fx?.hooksVersion !== 1) throw new Error("Semantic hooks require a matching libfx core with hooks version 1");
1809
+ if (initialCheckpoint) {
1810
+ const [checkpointAttachment] = attachBytes([initialCheckpoint]);
1811
+ const restored = await request("libfx/restore", { sessionId, checkpointAttachment }).catch((error) => {
1812
+ if (/newer fx/.test(error?.message ?? "")) throw new FxJournalVersionError(error.message);
1813
+ throw error;
1814
+ });
1815
+ compareCheckpointMeta(restored?.checkpointMeta);
1816
+ }
1817
+ if (journal) {
1818
+ const { events, checkpoint } = loaded;
1819
+ const journalBytes = events.length > 0 ? encoder.encode(JSON.stringify(withoutSupersededProgress(events))) : null;
1820
+ if (journalBytes && journalBytes.byteLength > maxJournalBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
1821
+ const openFailed = (error) => {
1822
+ const message = error?.message ?? "";
1823
+ if (/newer fx/.test(message)) throw new FxJournalVersionError(message);
1824
+ const code = journalOpenErrorCodes.get(message);
1825
+ if (code) throw journalLoadError(message, code, error);
1826
+ throw error;
1827
+ };
1828
+ const openOnce = () => {
1829
+ const params = {
1830
+ sessionId,
1831
+ ...(options.inputsDurable === true ? { inputsDurable: true } : {}),
1832
+ ...(imageStore ? { imageStore: true } : {}),
1833
+ ...(imageStore && options[imageWindowLimits] ? { imageWindow: options[imageWindowLimits] } : {}),
1834
+ };
1835
+ // One call: attaching again would drop the first attachment.
1836
+ const attachments = [];
1837
+ if (journalBytes) attachments.push(["journalAttachment", journalBytes]);
1838
+ if (checkpoint) attachments.push(["snapshotAttachment", checkpoint]);
1839
+ const ids = attachments.length ? attachBytes(attachments.map(([, bytes]) => bytes)) : [];
1840
+ attachments.forEach(([name], index) => { params[name] = ids[index]; });
1841
+ return request("libfx/journal_open", params).catch(openFailed);
1842
+ };
1843
+ // Stages the images a history names, read from the store, in batches
1844
+ // the attachment table holds. A store that lacks one fails the open:
1845
+ // the history without it is not the one saved.
1846
+ const stageImages = async (names) => {
1847
+ let batch = [];
1848
+ let batchBytes = 0;
1849
+ const flush = async () => {
1850
+ if (batch.length === 0) return;
1851
+ const ids = attachBytes(batch.map(({ bytes }) => bytes));
1852
+ const images = batch.map(({ name }, index) => ({ name, attachment: ids[index] }));
1853
+ batch = [];
1854
+ batchBytes = 0;
1855
+ await request("libfx/journal_images", { sessionId, images }).catch(openFailed);
1856
+ };
1857
+ for (const name of new Set(names.map(String))) {
1858
+ const stored = imageStore ? await imageStorage.get(name) : undefined;
1859
+ if (stored === undefined || stored === null) throw imageMissingError(name);
1860
+ const bytes = persistenceBytes(stored, "an image the image store returns");
1861
+ if (batch.length === maxPendingAttachments || batchBytes + bytes.byteLength > maxPendingAttachmentBytes) await flush();
1862
+ batch.push({ name, bytes });
1863
+ batchBytes += bytes.byteLength;
1864
+ }
1865
+ await flush();
1866
+ };
1867
+ // A history that names images opens once they are staged from the
1868
+ // store: the core answers with the names it needs, all at once.
1869
+ let opened = await openOnce();
1870
+ if (Array.isArray(opened?.imagesNeeded)) {
1871
+ await stageImages(opened.imagesNeeded);
1872
+ opened = await openOnce();
1873
+ if (Array.isArray(opened?.imagesNeeded)) throw imageMissingError(String(opened.imagesNeeded[0]));
1874
+ }
1875
+ compareCheckpointMeta(opened?.checkpointMeta);
1876
+ resumable = opened?.resumable === true;
1877
+ openTurnId = resumable && validTurnId(opened?.openTurnId) ? opened.openTurnId : null;
1878
+ lastTurnId = validTurnId(opened?.lastTurnId) ? opened.lastTurnId : null;
1879
+ ambiguousCalls = resumable && Array.isArray(opened?.runningCalls)
1880
+ ? opened.runningCalls.filter((call) => typeof call?.id === "string" && typeof call.name === "string")
1881
+ : [];
1882
+ journalHead = loaded.head;
1883
+ appendedSeq = loaded.lastSeq;
1884
+ bytesSinceCheckpoint = loaded.tailBytes;
1885
+ // Follow-ups the journal holds, as accepted; each waits for `resume()`.
1886
+ for (const held of Array.isArray(opened?.followUps) ? opened.followUps : []) {
1887
+ followUps.push({ id: held.id, text: held.text, accepted: true, held: true });
1888
+ }
1889
+ emit("journal.open", { events: events.length, checkpoint: checkpoint !== null, turns: opened?.turns, resumable });
1890
+ journalHook("journal.restore", { cursor: loaded.head, lastSeq: loaded.lastSeq, resumable, openTurnId, lastTurnId });
1891
+ }
1892
+ } catch (error) {
1893
+ closing = true;
1894
+ try { runtime.abortHostEffects(); } catch {}
1895
+ try { runtime.closeStdin(); } catch {}
1896
+ try { await runtime.exited; } catch {}
1897
+ throw error;
1898
+ }
1899
+
1900
+ const agent = {
1901
+ get sessionId() {
1902
+ return sessionId;
1903
+ },
1904
+ // What libfx's session worker needs beyond the public surface.
1905
+ [engineInternals]: {
1906
+ // Resolves once every record the session started has landed: a
1907
+ // turn's result does not wait for its last ones.
1908
+ settled() {
1909
+ return journal ? journalSettled() : Promise.resolve();
1910
+ },
1911
+ // Saves a checkpoint now, at a turn's end or where it yielded, whatever
1912
+ // has accumulated since the last; resolves once it is stored or skipped.
1913
+ checkpoint() {
1914
+ return maybeCheckpoint(true);
1915
+ },
1916
+ // Resolves when the core this agent runs on exits.
1917
+ exited: runtime.exited,
1918
+ // The turn the journal left open, if any, and the calls it left
1919
+ // running, as `{ executionId, name, input }`.
1920
+ get openTurn() {
1921
+ if (!resumable) return null;
1922
+ return {
1923
+ id: openTurnId,
1924
+ calls: ambiguousCalls.map((call) => ({ executionId: call.id, name: call.name, input: parsedToolInput(call.arguments) })),
1925
+ };
1926
+ },
1927
+ },
1928
+ /**
1929
+ * Starts a turn. With `turnId`, a retried call is the same turn: when the
1930
+ * journal left that turn open, this continues it as `resume()` does, and
1931
+ * when that turn already ended, this returns a turn that has ended too,
1932
+ * without running it again.
1933
+ */
1934
+ prompt(input, promptOptions = {}) {
1935
+ if (closing) throw new Error("fx agent is closed");
1936
+ if (journalFailure) throw reportJournalFailure();
1937
+ if (handedOff) throw handedOffError();
1938
+ if (activeTurn) throw new Error("a prompt is already in progress for this session");
1939
+ const turnId = promptOptions.turnId;
1940
+ if (turnId !== undefined && !validTurnId(turnId)) throw new TypeError(`turnId must be ${turnIdRule}`);
1941
+ checkOnAmbiguous(promptOptions.onAmbiguous);
1942
+ if (turnId !== undefined && journal) {
1943
+ if (resumable && turnId === openTurnId) {
1944
+ resumable = false;
1945
+ return resumeOpenTurn(promptOptions);
1946
+ }
1947
+ if (turnId === lastTurnId) return endedTurn(turnId);
1948
+ }
1949
+ // A new prompt ends a turn the last process left open as interrupted.
1950
+ resumable = false;
1951
+ ambiguousCalls = [];
1952
+ return normalizeTurn(startTurn(input, { ...promptOptions, turnId: turnId ?? `turn_${crypto.randomUUID()}` }));
1953
+ },
1954
+ /**
1955
+ * Continues the turn the last process left open, with no new input, and
1956
+ * returns it; returns null when the journal holds no open turn. The
1957
+ * model is told the session was interrupted; calls that were running
1958
+ * come back answered as possibly run, for the model to decide about,
1959
+ * unless `onAmbiguous(call)` returns "rerun" for them.
1960
+ */
1961
+ resume(promptOptions = {}) {
1962
+ if (closing) throw new Error("fx agent is closed");
1963
+ if (journalFailure) throw reportJournalFailure();
1964
+ if (handedOff) throw handedOffError();
1965
+ if (activeTurn) throw new Error("a prompt is already in progress for this session");
1966
+ checkOnAmbiguous(promptOptions.onAmbiguous);
1967
+ if (resumable) {
1968
+ resumable = false;
1969
+ return resumeOpenTurn(promptOptions);
1970
+ }
1971
+ // With no open turn, the follow-ups the journal held are the work left.
1972
+ const next = followUps.find((entry) => entry.held);
1973
+ if (!next) return null;
1974
+ let started = null;
1975
+ const resolveTurn = next.resolveTurn;
1976
+ next.resolveTurn = (turn) => {
1977
+ started = turn;
1978
+ resolveTurn?.(turn);
1979
+ };
1980
+ runNextFollowUp(true);
1981
+ return started;
1982
+ },
1983
+ /**
1984
+ * Queues `input` to run as its own turn after the current one, or at once
1985
+ * when no turn is running. Returns a promise for that turn, carrying the
1986
+ * follow-up's `id` and `accepted`, which resolves `{ id }` once a
1987
+ * journal holds it.
1988
+ */
1989
+ followUp(input) {
1990
+ if (closing) return Promise.reject(new Error("fx agent is closed"));
1991
+ if (journalFailure) return Promise.reject(reportJournalFailure());
1992
+ if (handedOff) return Promise.reject(handedOffError());
1993
+ let text;
1994
+ try { text = normalizeSteeringInput(input); } catch (error) { return Promise.reject(error); }
1995
+ const id = `in_${crypto.randomUUID()}`;
1996
+ const entry = { id, text, accepted: false };
1997
+ const started = new Promise((resolve, reject) => { entry.resolveTurn = resolve; entry.rejectTurn = reject; });
1998
+ let accepted;
1999
+ if (journal && activeTurn && typeof runtime.steer !== "function") {
2000
+ // The native core records it now, ahead of the turn that will run it.
2001
+ entry.accepted = true;
2002
+ accepted = request("libfx/follow_up", { sessionId, id, text })
2003
+ .then(() => waitForInput(`accepted:${id}`))
2004
+ .then(() => ({ id }), (error) => {
2005
+ const index = followUps.indexOf(entry);
2006
+ if (index >= 0) followUps.splice(index, 1);
2007
+ entry.rejectTurn(error);
2008
+ throw error;
2009
+ });
2010
+ } else {
2011
+ // Its own turn records it with the first progress, a barrier; it is
2012
+ // accepted once that is stored.
2013
+ accepted = started.then(() => (journal ? waitForInput(`accepted:${id}`) : undefined)).then(() => ({ id }));
2014
+ }
2015
+ followUps.push(entry);
2016
+ void accepted.catch(() => {});
2017
+ queueMicrotask(runNextFollowUp);
2018
+ started.id = id;
2019
+ started.accepted = accepted;
2020
+ return started;
2021
+ },
2022
+ /**
2023
+ * The session's state as opaque bytes, when no prompt is running; pass
2024
+ * them as `checkpoint` to restore it in a new agent.
2025
+ */
2026
+ checkpoint() {
2027
+ // One checkpoint runs at a time: each holds an outbound attachment until
2028
+ // it is taken, and the native table holds only a few. An idle call still
2029
+ // sends its request before returning, ahead of a later prompt(). The slot
2030
+ // is claimed before that send, so a call from an event handler during it
2031
+ // still waits. The count drops before the caller's own reaction to `run`,
2032
+ // so a call made right after awaiting the previous one is idle.
2033
+ const previous = pendingCheckpoints > 0 ? checkpointTail : null;
2034
+ pendingCheckpoints++;
2035
+ let release;
2036
+ checkpointTail = new Promise((resolve) => { release = resolve; });
2037
+ const run = previous ? previous.then(takeCheckpoint) : takeCheckpoint();
2038
+ const settle = () => { pendingCheckpoints--; release(); };
2039
+ run.then(settle, settle);
2040
+ return run;
2041
+ },
2042
+ async close() {
2043
+ hookScopes.clear();
2044
+ if (closing) {
2045
+ await runtime.exited;
2046
+ if (journal) await closeJournal();
2047
+ return;
2048
+ }
2049
+ const turn = activeTurn;
2050
+ turn?.cancel();
2051
+ if (turn) await turn.result.catch(() => {});
2052
+ // A checkpoint being taken needs the core; it finishes first.
2053
+ await checkpointing;
2054
+ closing = true;
2055
+ // A journal keeps these; the next `resume()` runs them.
2056
+ for (const entry of followUps.splice(0)) entry.rejectTurn?.(new Error("fx agent closed before the follow-up ran"));
2057
+ runtime.closeStdin();
2058
+ await runtime.exited;
2059
+ // Every append the session started has landed, or close reports why.
2060
+ if (journal) {
2061
+ let failure = null;
2062
+ try {
2063
+ await journalSettled();
2064
+ } catch {
2065
+ if (!journalFailureReported) failure = reportJournalFailure();
2066
+ }
2067
+ await closeJournal();
2068
+ if (failure) throw failure;
2069
+ }
2070
+ },
2071
+ };
2072
+ return agent;
2073
+
2074
+ // `answered`: calls a resumed turn answers as possibly run before it goes
2075
+ // on, whose `tool_end` comes first.
2076
+ function normalizeTurn(rawTurn, answered = []) {
2077
+ const toolNames = new Map();
2078
+ const started = new Set();
2079
+ const eventFor = (update) => {
2080
+ if (update.sessionUpdate === "agent_message_chunk") {
2081
+ const delta = update.content?.text;
2082
+ if (!delta || delta.startsWith("[context]")) return null;
2083
+ return { type: "text_delta", delta };
2084
+ }
2085
+ if (update.sessionUpdate === "agent_thought_chunk") {
2086
+ const delta = update.content?.text;
2087
+ return delta ? { type: "reasoning_delta", delta } : null;
2088
+ }
2089
+ if (update.sessionUpdate === "user_message_chunk" && update.content?.type === "text") {
2090
+ // A steer shows where it joined the turn, with the id it was sent with.
2091
+ const id = update._meta?.fx?.steering?.inputId;
2092
+ return { type: "user_message", text: update.content.text, ...(typeof id === "string" ? { id } : {}) };
2093
+ }
2094
+ if (update.sessionUpdate === "tool_call") {
2095
+ toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool");
2096
+ if (started.has(update.toolCallId)) return null;
2097
+ started.add(update.toolCallId);
2098
+ return {
2099
+ type: "tool_start",
2100
+ id: update.toolCallId,
2101
+ name: toolNames.get(update.toolCallId),
2102
+ ...toolStartInput(update.rawInput),
2103
+ };
2104
+ }
2105
+ if (update.sessionUpdate === "tool_call_update" &&
2106
+ (update.status === "completed" || update.status === "failed")) {
2107
+ const content = update.content?.find((entry) => entry.content?.type === "text")?.content?.text;
2108
+ return {
2109
+ type: "tool_end",
2110
+ id: update.toolCallId,
2111
+ name: toolNames.get(update.toolCallId) || "tool",
2112
+ ...(content === undefined ? {} : { content }),
2113
+ isError: update.status === "failed",
2114
+ };
2115
+ }
2116
+ return null;
2117
+ };
2118
+ const result = rawTurn.result.then((value) => ({
2119
+ stopReason: value.stopReason,
2120
+ usage: normalizeTurnUsage(value.usage),
2121
+ }));
2122
+ void result.catch(() => {});
2123
+ return {
2124
+ id: rawTurn.id ?? null,
2125
+ cancel(cancelOptions) { rawTurn.cancel(cancelOptions); },
2126
+ steer(input, id) {
2127
+ if (id !== undefined && !validTurnId(id)) throw new TypeError(`steer id must be ${turnIdRule}`);
2128
+ return rawTurn.steer(normalizeSteeringInput(input), id);
2129
+ },
2130
+ withdraw(id) { return rawTurn.withdraw(id); },
2131
+ [engineInternals]: rawTurn[engineInternals],
2132
+ [Symbol.asyncIterator]() {
2133
+ const iterator = (async function* () {
2134
+ for (const call of answered) {
2135
+ yield { type: "tool_end", id: call.id, name: call.name, content: unfinishedToolOutput, isError: true };
2136
+ }
2137
+ for await (const update of rawTurn) {
2138
+ const event = eventFor(update);
2139
+ if (event) yield event;
2140
+ }
2141
+ })();
2142
+ return {
2143
+ next(value) { return iterator.next(value); },
2144
+ return(value) { rawTurn.cancel(); return iterator.return(value); },
2145
+ throw(error) { rawTurn.cancel(); return iterator.throw(error); },
2146
+ [Symbol.asyncIterator]() { return this; },
2147
+ };
2148
+ },
2149
+ result,
2150
+ };
2151
+ }
2152
+
2153
+ // A turn the journal shows already ended, returned for a retried prompt.
2154
+ function endedTurn(id) {
2155
+ return {
2156
+ id,
2157
+ result: Promise.resolve({ stopReason: "end_turn", usage: {} }),
2158
+ cancel() {},
2159
+ steer() { return Promise.reject(new Error("no prompt is running")); },
2160
+ withdraw() { return Promise.resolve("already_placed"); },
2161
+ async *[Symbol.asyncIterator]() {},
2162
+ };
2163
+ }
2164
+
2165
+ function normalizeTurnUsage(usage) {
2166
+ const result = {};
2167
+ if (Number.isSafeInteger(usage?.inputTokens)) result.inputTokens = usage.inputTokens;
2168
+ if (Number.isSafeInteger(usage?.outputTokens)) result.outputTokens = usage.outputTokens;
2169
+ if (Number.isSafeInteger(usage?.cacheReadTokens)) result.cacheReadTokens = usage.cacheReadTokens;
2170
+ if (Number.isSafeInteger(usage?.cacheWriteTokens)) result.cacheWriteTokens = usage.cacheWriteTokens;
2171
+ if (Number.isSafeInteger(usage?.reasoningTokens)) result.reasoningTokens = usage.reasoningTokens;
2172
+ return result;
2173
+ }
2174
+
2175
+ // The input object rides the event when it fits the preview budget; larger
2176
+ // inputs become a bounded JSON prefix plus an explicit marker.
2177
+ function toolStartInput(rawInput) {
2178
+ if (rawInput === undefined || rawInput === null) return {};
2179
+ const serialized = JSON.stringify(rawInput);
2180
+ if (serialized === undefined) return {};
2181
+ const bytes = encoder.encode(serialized);
2182
+ if (bytes.length <= maxToolStartInputBytes) return { input: rawInput };
2183
+ return { inputTruncated: true, inputPreview: decoder.decode(utf8Prefix(bytes, maxToolStartInputBytes)) };
2184
+ }
2185
+
2186
+ function startTurn(input, promptOptions) {
2187
+ const resizeImage = options.resizeImage;
2188
+ const resuming = promptOptions[resumeTurn] === true;
2189
+ const followUp = promptOptions[followUpInput] ?? null;
2190
+ const normalized = resuming ? [] : checkPromptInput(input, { resizeImage });
2191
+ const hasPixels = normalized.some((block) => block.type === "image" && block.data !== undefined);
2192
+ // Blob reads, resizeImage and storing images run before the prompt
2193
+ // frame is sent.
2194
+ const asyncImages = hasPixels && (resizeImage !== undefined || imageStore ||
2195
+ normalized.some((block) => block.type === "image" && block.source === "blob"));
2196
+ // Snapshot caller-owned bytes, which could change before an async send.
2197
+ const prompt = asyncImages
2198
+ ? normalized.map((block) => block.type === "image" && block.source === "bytes" ? { ...block, data: block.data.slice() } : block)
2199
+ : preparePromptImages(normalized);
2200
+ const signal = promptOptions.signal;
2201
+ if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal");
2202
+ const yieldAt = promptOptions.yieldAt;
2203
+ if (yieldAt !== undefined && !(Number.isSafeInteger(yieldAt) && yieldAt > 0)) throw new TypeError("yieldAt must be a time in milliseconds since the epoch");
2204
+ const queue = [];
2205
+ const waiters = [];
2206
+ let queuedBytes = 0;
2207
+ let resumeOutput;
2208
+ let iteratorTaken = false;
2209
+ let terminalError;
2210
+ let reportedPressure = false;
2211
+ let discardedBytes = 0;
2212
+ let cancelImagePrep = null;
2213
+ let rejectImagePrep = null;
2214
+ let resolvePromptStart = null;
2215
+ const promptStarted = asyncImages ? new Promise((resolve) => { resolvePromptStart = resolve; }) : null;
2216
+ let pendingSteeringCount = 0;
2217
+ let pendingSteeringBytes = 0;
2218
+ // This turn's steers: whether each reached the core, or was withdrawn first.
2219
+ const steers = new Map();
2220
+ const withdrawnSteer = (id) => Object.assign(new Error("steer was withdrawn"), { code: "FX_STEER_WITHDRAWN", id });
2221
+ const startSteer = (id, text) => {
2222
+ if (finished || cancelled || activeTurn !== turn) {
2223
+ return Promise.reject(new Error("no prompt is running"));
2224
+ }
2225
+ if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
2226
+ const local = { sent: false, withdrawn: false };
2227
+ steers.set(id, local);
2228
+ const apply = () => {
2229
+ if (local.withdrawn) return Promise.resolve({ id, withdrawn: true });
2230
+ if (finished || cancelled || activeTurn !== turn) {
2231
+ return Promise.reject(new Error("no prompt is running"));
2232
+ }
2233
+ if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
2234
+ try {
2235
+ local.sent = true;
2236
+ const accepted = () => (journal ? waitForInput(`accepted:${id}`) : Promise.resolve());
2237
+ if (typeof runtime.steer === "function") {
2238
+ runtime.steer(text, id);
2239
+ // The web core accepts it when it takes it at the next boundary,
2240
+ // and shows where it joined the turn then.
2241
+ return accepted().then(() => ({ id }), (error) => (error?.code === "FX_STEER_WITHDRAWN" ? { id, withdrawn: true } : Promise.reject(error)));
2242
+ }
2243
+ return request("libfx/steer", { sessionId, text, id }).then(accepted).then(() => ({ id }));
2244
+ } catch (error) {
2245
+ return Promise.reject(error);
2246
+ }
2247
+ };
2248
+ if (!resolvePromptStart) return apply();
2249
+ const bytes = encoder.encode(text).length;
2250
+ if (pendingSteeringCount >= maxSteeringMessages || bytes > maxSteeringQueueBytes - pendingSteeringBytes) {
2251
+ return Promise.reject(new Error("steering queue is full"));
2252
+ }
2253
+ pendingSteeringCount++;
2254
+ pendingSteeringBytes += bytes;
2255
+ return promptStarted.then((started) => {
2256
+ if (coreExitError && !cancelled) throw coreExitError;
2257
+ return started === true ? apply() : Promise.reject(started instanceof Error ? started : new Error("no prompt is running"));
2258
+ }).finally(() => { pendingSteeringCount--; pendingSteeringBytes -= bytes; });
2259
+ };
2260
+ const toolControllers = new Set();
2261
+ let finished = false;
2262
+ let cancelled = false;
2263
+ const turn = {
2264
+ // The host's id: the one it gave, or for a resumed turn, the one the
2265
+ // turn started with.
2266
+ id: resuming ? openTurnId : promptOptions.turnId ?? null,
2267
+ push(update, size = encoder.encode(JSON.stringify(update)).length) {
2268
+ if (cancelled || finished) { discardedBytes += size; return; }
2269
+ if (size > maxCoreMessageBytes) throw new RangeError("core output message exceeds 64 MiB");
2270
+ if (queue.length && (queue.length >= maxUnreadEvents || size > maxUnreadEventBytes - queuedBytes)) {
2271
+ const capacity = new Promise((resolveCapacity) => { resumeOutput = resolveCapacity; });
2272
+ if (!reportedPressure) {
2273
+ reportedPressure = true;
2274
+ emit("output.backpressure", { bufferedBytes: queuedBytes, bufferedEvents: queue.length });
2275
+ }
2276
+ return capacity.then(() => turn.push(update, size));
2277
+ }
2278
+ const waiter = waiters.shift();
2279
+ if (waiter) waiter.resolve({ value: update, done: false });
2280
+ else { queue.push({ update, size }); queuedBytes += size; }
2281
+ },
2282
+ toolControllers,
2283
+ transportAttempts: 0,
2284
+ transportBytes: 0,
2285
+ lastTransportActivityAt: null,
2286
+ get cancelled() { return cancelled; },
2287
+ failImagePrep(error) {
2288
+ rejectImagePrep?.(error);
2289
+ rejectImagePrep = null;
2290
+ cancelImagePrep = null;
2291
+ },
2292
+ promptWritten() {
2293
+ resolvePromptStart?.(true);
2294
+ resolvePromptStart = null;
2295
+ },
2296
+ // Resolves `{ id }` once the steer is accepted: in a journaled session,
2297
+ // once its acceptance is stored. The promise carries `id` at once.
2298
+ steer(text, id = `in_${crypto.randomUUID()}`) {
2299
+ const steered = startSteer(id, text).then((value) => value ?? { id });
2300
+ steered.id = id;
2301
+ return steered;
2302
+ },
2303
+ // 'withdrawn' when the model never saw the steer, else 'already_placed'.
2304
+ withdraw(id) {
2305
+ const local = steers.get(id);
2306
+ if (!local) return Promise.resolve("already_placed");
2307
+ if (!local.sent) {
2308
+ local.withdrawn = true;
2309
+ return Promise.resolve("withdrawn");
2310
+ }
2311
+ if (typeof runtime.withdrawSteering === "function") {
2312
+ if (!runtime.withdrawSteering(id)) return Promise.resolve("already_placed");
2313
+ local.withdrawn = true;
2314
+ rejectInputWaiters([`accepted:${id}`], withdrawnSteer(id));
2315
+ return Promise.resolve("withdrawn");
2316
+ }
2317
+ return request("libfx/withdraw", { sessionId, id }).then(async (response) => {
2318
+ if (response?.result !== "withdrawn") return "already_placed";
2319
+ if (journal) await waitForInput(`withdrawn:${id}`);
2320
+ return "withdrawn";
2321
+ });
2322
+ },
2323
+ // For libfx's session worker: a steer that cannot join the turn, such
2324
+ // as one with images, ends the turn at its next safe point, at once
2325
+ // while the model streams a reply, so that steer can run next.
2326
+ [engineInternals]: {
2327
+ endForSteer() {
2328
+ const send = () => {
2329
+ if (finished || cancelled || activeTurn !== turn || closing) return undefined;
2330
+ if (typeof runtime.handOffSteering === "function") {
2331
+ runtime.handOffSteering();
2332
+ return undefined;
2333
+ }
2334
+ return request("libfx/handoff", { sessionId }).then(() => undefined);
2335
+ };
2336
+ const sent = promptStarted && resolvePromptStart ? promptStarted.then((started) => (started === true ? send() : undefined)) : Promise.resolve().then(send);
2337
+ return sent.catch(() => {});
2338
+ },
2339
+ },
2340
+ cancel(cancelOptions = {}) {
2341
+ const reason = cancelOptions?.reason;
2342
+ if (reason !== undefined && reason !== "handoff") throw new TypeError('cancel() reason must be "handoff" when given');
2343
+ if (reason === "handoff" && !journal) throw new TypeError("a handoff needs persistence to keep the turn in");
2344
+ if (finished || cancelled) return;
2345
+ if (reason === "handoff") {
2346
+ handedOff = true;
2347
+ // The journal keeps queued follow-ups for the next agent.
2348
+ for (const entry of followUps.splice(0)) entry.rejectTurn?.(handedOffError());
2349
+ }
2350
+ cancelled = true;
2351
+ cancelImagePrep?.();
2352
+ cancelImagePrep = null;
2353
+ rejectImagePrep = null;
2354
+ resolvePromptStart?.(false);
2355
+ resolvePromptStart = null;
2356
+ runtime.closeSteering?.();
2357
+ resumeOutput?.();
2358
+ resumeOutput = null;
2359
+ if (!closing) send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
2360
+ for (const controller of toolControllers) controller.abort();
2361
+ runtime.abortHostEffects();
2362
+ },
2363
+ [Symbol.asyncIterator]() {
2364
+ if (iteratorTaken) throw new Error("a turn has only one event consumer");
2365
+ iteratorTaken = true;
2366
+ return {
2367
+ next() {
2368
+ if (queue.length) {
2369
+ const { update, size } = queue.shift();
2370
+ queuedBytes -= size;
2371
+ resumeOutput?.();
2372
+ resumeOutput = null;
2373
+ return Promise.resolve({ value: update, done: false });
2374
+ }
2375
+ if (terminalError) return Promise.reject(terminalError);
2376
+ if (finished) return Promise.resolve({ done: true });
2377
+ return new Promise((resolve, reject) => waiters.push({ resolve, reject }));
2378
+ },
2379
+ return() { turn.cancel(); return Promise.resolve({ done: true }); },
2380
+ };
2381
+ },
2382
+ };
2383
+ if (signal?.aborted) {
2384
+ resolvePromptStart?.(false);
2385
+ resolvePromptStart = null;
2386
+ finished = true;
2387
+ turn.result = Promise.resolve({ stopReason: "cancelled" });
2388
+ return turn;
2389
+ }
2390
+ activeTurn = turn;
2391
+ runtime.openSteering?.();
2392
+ const abort = () => turn.cancel();
2393
+ signal?.addEventListener("abort", abort, { once: true });
2394
+ const imagePrepCancelled = asyncImages ? new Promise((resolve, reject) => {
2395
+ cancelImagePrep = () => resolve(null);
2396
+ rejectImagePrep = reject;
2397
+ }) : null;
2398
+ let response;
2399
+ if (asyncImages) {
2400
+ const stopped = () => cancelled || closing;
2401
+ response = Promise.race([
2402
+ Promise.resolve()
2403
+ .then(() => materializePromptImages(prompt, stopped, resizeImage))
2404
+ .then((prepared) => prepared && imageStore ? storePromptImages(prepared, stopped) : prepared),
2405
+ imagePrepCancelled,
2406
+ ]).then((prepared) => {
2407
+ cancelImagePrep = null;
2408
+ rejectImagePrep = null;
2409
+ if (coreExitError && !cancelled) throw coreExitError;
2410
+ if (prepared === null || cancelled || closing) {
2411
+ resolvePromptStart?.(false);
2412
+ resolvePromptStart = null;
2413
+ return { stopReason: "cancelled" };
2414
+ }
2415
+ return sendPrompt(prepared, false, followUp, turn.id, [], yieldAt);
2416
+ });
2417
+ } else {
2418
+ try { response = sendPrompt(prompt, resuming, followUp, turn.id, promptOptions[rerunCallIds] ?? [], yieldAt, promptOptions[resumeOutputShown] === true); } catch (error) { response = Promise.reject(error); }
2419
+ }
2420
+ // Appends after a turn's first progress are lazy, so the turn runs ahead
2421
+ // of them, but its result waits until every record it wrote is stored:
2422
+ // a turn counts as done only once its end is durable, and a failure
2423
+ // among those records fails the turn.
2424
+ turn.result = response
2425
+ .then(async (value) => {
2426
+ if (journal) await journalSettled().catch(() => {});
2427
+ if (journalFailure) throw reportJournalFailure();
2428
+ if (value.stopReason === "yielded" && !cancelled) {
2429
+ // The core keeps the turn open: `resume()` continues it here, or
2430
+ // a later agent continues it from the journal.
2431
+ resumable = true;
2432
+ openTurnId = turn.id;
2433
+ ambiguousCalls = [];
2434
+ } else if (!handedOff) {
2435
+ // A handed-off turn stays open for the next agent.
2436
+ if (turn.id) lastTurnId = turn.id;
2437
+ openTurnId = null;
2438
+ }
2439
+ return { stopReason: cancelled ? "cancelled" : value.stopReason, usage: value.usage };
2440
+ })
2441
+ .catch(async (error) => {
2442
+ cancelImagePrep = null;
2443
+ rejectImagePrep = null;
2444
+ resolvePromptStart?.(error);
2445
+ resolvePromptStart = null;
2446
+ // A failed append is why the core stopped, including when the turn
2447
+ // was cancelled because of it; report the host's error.
2448
+ if (journalFailure) {
2449
+ terminalError = reportJournalFailure();
2450
+ throw terminalError;
2451
+ }
2452
+ if (cancelled && error.message === "Cancelled") return { stopReason: "cancelled" };
2453
+ terminalError = error;
2454
+ throw terminalError;
2455
+ })
2456
+ .finally(() => {
2457
+ finished = true;
2458
+ resumeOutput?.();
2459
+ resumeOutput = null;
2460
+ signal?.removeEventListener("abort", abort);
2461
+ if (activeTurn === turn) activeTurn = null;
2462
+ // A steer still queued when the turn ends never reached the model.
2463
+ if (typeof runtime.withdrawSteering === "function") {
2464
+ const untaken = [...steers.keys()].filter((id) => runtime.withdrawSteering(id));
2465
+ rejectInputWaiters(untaken.map((id) => `accepted:${id}`), new Error("the turn ended before it took the steer"));
2466
+ }
2467
+ for (const id of steers.keys()) {
2468
+ durableInputs.delete(`accepted:${id}`);
2469
+ durableInputs.delete(`withdrawn:${id}`);
2470
+ }
2471
+ queueMicrotask(runNextFollowUp);
2472
+ queueMicrotask(() => { void maybeCheckpoint(); });
2473
+ runtime.closeSteering?.();
2474
+ toolControllers.clear();
2475
+ if (discardedBytes) emit("output.discarded", { reason: "cancelled", bytes: discardedBytes });
2476
+ for (const waiter of waiters.splice(0)) {
2477
+ if (terminalError) waiter.reject(terminalError);
2478
+ else waiter.resolve({ done: true });
2479
+ }
2480
+ });
2481
+ if (signal?.aborted) turn.cancel();
2482
+ void turn.result.catch(() => {});
2483
+ return turn;
2484
+ }
2485
+ }
2486
+
2487
+ // The WebAssembly host shares these with the engine.
2488
+ export {
2489
+ cancelResponseBody,
2490
+ decoder,
2491
+ encoder,
2492
+ maxPendingAttachments,
2493
+ maxSteeringMessageBytes,
2494
+ maxSteeringMessages,
2495
+ maxSteeringQueueBytes,
2496
+ strictDecoder,
2497
+ utf8Prefix,
2498
+ };