libfx 0.0.13 → 0.0.14-dev.1534.g6afa7c33dac5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fx-sdk.js CHANGED
@@ -1,18 +1,32 @@
1
1
  import { CoreOutput, maxCoreMessageBytes } from "./core-output.js";
2
+ import { codexSubscription } from "./chatgpt.js";
3
+ import { customProviderDefinition, customProviderSpec } from "./custom-provider.js";
4
+ import { grokSubscription } from "./grok.js";
5
+ import { createSubscriptionAuthorizer, credentialMemory } from "./subscription.js";
2
6
  import { loadModule } from "./wasm-module.js";
3
7
 
8
+ // Providers that run on a user's plan, authorized by the host's credentials.
9
+ const subscriptionProviders = new Set(["codex", "grok"]);
10
+
4
11
  const encoder = new TextEncoder();
5
12
  const decoder = new TextDecoder();
6
13
  const strictDecoder = new TextDecoder("utf-8", { fatal: true });
7
14
  const workspaceInfoLimit = 4 * 1024;
8
15
  const workspaceCommandLimit = 64 * 1024;
9
16
  const workspaceOutputLimit = 64 * 1024;
17
+ // Matches max_instruction_file_bytes and max_instruction_path_bytes in js_host_workspace.zig.
18
+ const workspaceInstructionFileLimit = 1024 * 1024;
19
+ const workspaceInstructionPathLimit = 4 * 1024;
20
+ const workspaceReadTimeoutMs = 10_000;
10
21
  const maxInstructionsBytes = 64 * 1024;
11
22
  const maxApiKeyBytes = 64 * 1024;
12
23
  const maxModelBytes = 1024;
13
24
  // Matches the kernel's ReasoningEffort.max_name_bytes.
14
25
  const maxEffortBytes = 64;
15
26
  const maxUrlBytes = 16 * 1024;
27
+ // The answer a resumed turn gives a call its crash left running; matches
28
+ // unfinished_tool_output in session_adapter.zig.
29
+ const unfinishedToolOutput = "fx stopped while this tool was running, so it may have partly run. Check its effects before running it again.";
16
30
  const maxModelCatalogBytes = 4 * 1024 * 1024;
17
31
  const maxModelCatalogEntries = 10_000;
18
32
  const streamReadsPerTaskYield = 32;
@@ -33,6 +47,8 @@ const maxPendingAttachments = 8;
33
47
  const maxOutboundAttachments = 4;
34
48
  // Matches the core's kernel checkpoint limit (max_checkpoint_bytes).
35
49
  const maxCheckpointBytes = 4 * 1024 * 1024;
50
+ // Matches the core's journal load limit (journal.max_load_bytes).
51
+ const maxJournalBytes = 4 * 1024 * 1024;
36
52
  // The core's ACP reader drops frames over 8 MiB without a request id to answer
37
53
  // (jsonrpc frame_resource_byte_limit), so the SDK must never emit one. The
38
54
  // envelope allowance covers the method key and request id.
@@ -101,15 +117,9 @@ function normalizeUltrafast(value) {
101
117
  return value;
102
118
  }
103
119
 
104
- export function normalizeAgentOptions(value) {
105
- if (!value || typeof value !== "object" || Array.isArray(value)) {
106
- throw new TypeError("createFxAgent() options must be an object");
107
- }
108
- const options = { ...value };
109
- if (Object.hasOwn(options, "env")) {
110
- throw new TypeError("createFxAgent() does not accept env; pass apiKey and model directly");
111
- }
112
- options.apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, true);
120
+ // The model choice in `options`, a model object or a model ID with the
121
+ // deprecated top-level options, as `{ model, effort, fast, ultrafast }`.
122
+ export function normalizeModelChoice(options) {
113
123
  if (options.model !== null && typeof options.model === "object" && !Array.isArray(options.model)) {
114
124
  if (Object.hasOwn(options, "effort") || Object.hasOwn(options, "fast") || Object.hasOwn(options, "ultrafast")) {
115
125
  throw new TypeError("model options cannot be mixed with top-level effort, fast, or ultrafast");
@@ -120,26 +130,352 @@ export function normalizeAgentOptions(value) {
120
130
  throw new TypeError(`unsupported model option: ${name}`);
121
131
  }
122
132
  }
123
- options.model = boundedString(model.id, "model.id", maxModelBytes, true);
124
- options.effort = normalizeEffort(model.effort);
125
- options.fast = normalizeFast(model.fast);
126
- options.ultrafast = normalizeUltrafast(model.ultrafast);
127
- } else {
128
- options.model = boundedString(options.model, "model", maxModelBytes, false);
129
- options.effort = normalizeEffort(options.effort);
130
- options.fast = normalizeFast(options.fast);
131
- options.ultrafast = normalizeUltrafast(options.ultrafast);
133
+ return {
134
+ model: boundedString(model.id, "model.id", maxModelBytes, true),
135
+ effort: normalizeEffort(model.effort),
136
+ fast: normalizeFast(model.fast),
137
+ ultrafast: normalizeUltrafast(model.ultrafast),
138
+ };
139
+ }
140
+ return {
141
+ model: boundedString(options.model, "model", maxModelBytes, false),
142
+ effort: normalizeEffort(options.effort),
143
+ fast: normalizeFast(options.fast),
144
+ ultrafast: normalizeUltrafast(options.ultrafast),
145
+ };
146
+ }
147
+
148
+ export function normalizeAgentOptions(value) {
149
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
150
+ throw new TypeError("createFxEngine() options must be an object");
132
151
  }
152
+ const options = { ...value };
153
+ if (Object.hasOwn(options, "env")) {
154
+ throw new TypeError("createFxEngine() does not accept env; pass apiKey and model directly");
155
+ }
156
+ // Derived from the caller's provider every time, so normalizing again agrees.
157
+ const custom = options.provider !== null && typeof options.provider === "object"
158
+ ? customProviderDefinition(options.provider)
159
+ : undefined;
160
+ if (!custom && options.provider !== undefined && options.provider !== "gateway" && !subscriptionProviders.has(options.provider)) {
161
+ throw new TypeError("provider must be \"gateway\", \"codex\", \"grok\" or a custom provider { name, baseUrl }");
162
+ }
163
+ options.customProvider = custom;
164
+ options.providerName = custom ? custom.name : options.provider;
165
+ const subscription = subscriptionProviders.has(options.provider);
166
+ // Gateway needs its key; every other provider runs on the host's credentials.
167
+ options.apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, !subscription && !custom);
168
+ if (subscription && typeof options.credentials !== "function") {
169
+ throw new TypeError(`provider "${options.provider}" needs a credentials() function`);
170
+ }
171
+ if (!subscription && !custom && options.credentials !== undefined) {
172
+ throw new TypeError("credentials needs provider \"codex\", \"grok\" or a custom provider");
173
+ }
174
+ // A custom provider's key is never refreshed, so it has no onCredentials or refreshLock.
175
+ for (const name of ["onCredentials", "refreshLock"]) {
176
+ if (!subscription && options[name] !== undefined) throw new TypeError(`${name} needs provider "codex" or "grok"`);
177
+ }
178
+ for (const name of ["credentials", "onCredentials", "refreshLock"]) {
179
+ if (options[name] !== undefined && typeof options[name] !== "function") throw new TypeError(`${name} must be a function`);
180
+ }
181
+ Object.assign(options, normalizeModelChoice(options));
182
+ // fx's default model is a Gateway id; a custom provider names its own.
183
+ if (custom && options.model === undefined) throw new TypeError("a custom provider needs a model");
133
184
  validateGatewayChatUrl(options.gatewayChatUrl);
134
185
  if (options.resizeImage !== undefined && typeof options.resizeImage !== "function") {
135
186
  throw new TypeError("resizeImage must be a function");
136
187
  }
188
+ if (options.sessionId !== undefined && options.sessionId !== null && !validSessionId(options.sessionId)) {
189
+ throw new TypeError(`sessionId must be ${sessionIdRule}`);
190
+ }
191
+ // A separate key, so normalizing these options again reads the caller's value.
192
+ if (options.modelCatalog !== undefined) options.modelCatalogBody = modelCatalogBody(options.modelCatalog);
193
+ if (options.persistence !== undefined) {
194
+ const persistence = options.persistence;
195
+ if (!persistence || typeof persistence !== "object" ||
196
+ typeof persistence.load !== "function" || typeof persistence.append !== "function" ||
197
+ (persistence.saveCheckpoint !== undefined && typeof persistence.saveCheckpoint !== "function")) {
198
+ throw new TypeError("persistence must be an object with load(), append() and, optionally, saveCheckpoint()");
199
+ }
200
+ if (options.checkpoint !== undefined) {
201
+ throw new TypeError("persistence cannot be combined with checkpoint");
202
+ }
203
+ }
204
+ if (options.checkpointAfterBytes !== undefined) {
205
+ if (options.persistence === undefined) throw new TypeError("checkpointAfterBytes needs persistence");
206
+ if (!Number.isSafeInteger(options.checkpointAfterBytes) || options.checkpointAfterBytes < 0) {
207
+ throw new RangeError("checkpointAfterBytes must be a non-negative integer");
208
+ }
209
+ }
137
210
  return options;
138
211
  }
139
212
 
213
+ // Keys for libfx's own session worker; symbols keep them off the engine's
214
+ // public surface.
215
+ export const engineInternals = Symbol.for("libfx.engineInternals");
216
+ export const journalMarksWanted = Symbol.for("libfx.journalMarks");
217
+ // Marks an error the core answered a request with, as opposed to one from a
218
+ // core that stopped answering.
219
+ export const coreAnswered = Symbol.for("libfx.coreAnswered");
220
+
221
+ /**
222
+ * Another writer took over the session after this agent loaded it. A
223
+ * persistence store rejects an append whose `expected` cursor is not its head
224
+ * with this error; the agent stops its turn at once and makes no further
225
+ * effect or write. Check `code === "FX_FENCED"` when the error may come from
226
+ * another copy of libfx.
227
+ */
228
+ export class FxFencedError extends Error {
229
+ constructor(message) {
230
+ super(message);
231
+ this.name = "FxFencedError";
232
+ this.code = "FX_FENCED";
233
+ }
234
+ }
235
+
236
+ /** The journal, or the `checkpoint` option, was written by a newer libfx. */
237
+ export class FxJournalVersionError extends Error {
238
+ constructor(message) {
239
+ super(message);
240
+ this.name = "FxJournalVersionError";
241
+ this.code = "FX_JOURNAL_VERSION";
242
+ }
243
+ }
244
+
245
+ // A journal this libfx cannot open: more than one load holds
246
+ // (FX_JOURNAL_TOO_LARGE), or events that do not fold (FX_JOURNAL_INVALID).
247
+ function journalLoadError(message, code, cause) {
248
+ const error = new Error(message, cause === undefined ? undefined : { cause });
249
+ error.code = code;
250
+ return error;
251
+ }
252
+
253
+ // The core's libfx/journal_open errors, by their exact message. The journal
254
+ // tests open a journal for each, so rewording one fails them, except the size
255
+ // limit: libfx refuses a journal over it before the core sees one, so that
256
+ // entry is a backstop.
257
+ const journalOpenErrorCodes = new Map([
258
+ ["Invalid libfx journal", "FX_JOURNAL_INVALID"],
259
+ ["libfx journal events are out of order", "FX_JOURNAL_INVALID"],
260
+ ["libfx journal is too large", "FX_JOURNAL_TOO_LARGE"],
261
+ ["libfx journal holds more than 1024 turns", "FX_JOURNAL_TOO_LARGE"],
262
+ ]);
263
+
264
+ /**
265
+ * A persistence store that keeps a session's records and its latest
266
+ * checkpoint in memory, for tests and for hosts that copy them elsewhere.
267
+ * `records` is the stored list, oldest first. An append whose `expected`
268
+ * cursor is not the last record's is refused, so a second agent cannot write
269
+ * the same session, and an append that repeats a stored write's
270
+ * `idempotencyKey` returns that write's cursor.
271
+ */
272
+ export function createMemoryPersistence() {
273
+ const records = [];
274
+ let checkpoint = null;
275
+ const head = () => records.at(-1)?.cursor ?? null;
276
+ return {
277
+ records,
278
+ get checkpoint() {
279
+ return checkpoint;
280
+ },
281
+ async load() {
282
+ const after = checkpoint ? records.findIndex((record) => record.cursor === checkpoint.through) + 1 : 0;
283
+ return {
284
+ ...(checkpoint ? { checkpoint: { data: checkpoint.data.slice(), through: checkpoint.through } } : {}),
285
+ journal: records.slice(after).map(({ cursor, data }) => ({ cursor, data: data.slice() })),
286
+ };
287
+ },
288
+ async append({ expected, idempotencyKey, data }) {
289
+ const stored = records.find((record) => record.idempotencyKey === idempotencyKey);
290
+ if (stored) return { cursor: stored.cursor };
291
+ if (expected !== head()) {
292
+ throw new FxFencedError(`another agent appended to this session: expected ${expected}, head ${head()}`);
293
+ }
294
+ const cursor = String(records.length + 1);
295
+ records.push({ cursor, idempotencyKey, data: data.slice() });
296
+ return { cursor };
297
+ },
298
+ async saveCheckpoint({ through, data }) {
299
+ checkpoint = { through, data: data.slice() };
300
+ },
301
+ };
302
+ }
303
+
304
+ // Marks the turn `agent.resume()` starts; never a caller-visible option.
305
+ const resumeTurn = Symbol("libfx.resume");
306
+ // Carries the follow-up a turn runs: `{ id, accepted }`.
307
+ const followUpInput = Symbol("libfx.followUp");
308
+ // Carries the ids of the calls a resumed turn runs again.
309
+ const rerunCallIds = Symbol("libfx.rerun");
310
+
311
+ function journalAppendError(cause) {
312
+ const error = new Error("libfx journal append failed", { cause });
313
+ error.code = "FX_JOURNAL_APPEND_FAILED";
314
+ return error;
315
+ }
316
+
317
+ // Restoring an open turn needs only its newest progress, and the ones it
318
+ // extends when it carries only what is new back to one that carries all of
319
+ // it, and the results of calls no progress has answered yet. So an older
320
+ // progress's state, and an answered call's result, are left out of a load
321
+ // (data: null). A load then grows with the session's committed turns, not
322
+ // with every step of every turn.
323
+ function withoutSupersededProgress(events) {
324
+ const kept = new Array(events.length);
325
+ let superseded = false;
326
+ let answered = false;
327
+ for (let index = events.length - 1; index >= 0; index -= 1) {
328
+ const event = events[index];
329
+ const type = event?.type;
330
+ const dropped = event?.data != null && ((type === "turn_progress" && superseded) || (type === "tool_result" && answered));
331
+ kept[index] = dropped ? { ...event, data: null } : event;
332
+ if (type === "turn_committed" || type === "turn_progress_cleared") {
333
+ superseded = true;
334
+ answered = true;
335
+ } else if (type === "turn_progress") {
336
+ answered = true;
337
+ if (event.base === undefined) superseded = true;
338
+ }
339
+ }
340
+ return kept;
341
+ }
342
+
343
+ // A session id reaches gateway headers; the core applies the same rule.
344
+ function validSessionId(id) {
345
+ return typeof id === "string" && /^[A-Za-z0-9._-]{1,255}$/.test(id) &&
346
+ id !== "." && id !== ".." && id.toLowerCase() !== "v2";
347
+ }
348
+ const sessionIdRule = "1 to 255 letters, digits, '.', '_' or '-'";
349
+
350
+ // A record is one append's events as UTF-8 JSON. Its shape is libfx's; a
351
+ // store keeps the bytes as they are.
352
+ const journalRecordFormat = "libfx-journal-v1";
353
+ // Checkpoints after the journal since the last one reaches this many bytes.
354
+ const defaultCheckpointAfterBytes = 1024 * 1024;
355
+ // A turn id reaches the journal and tool calls; the core applies the same rule.
356
+ const turnIdRule = "1 to 128 letters, digits, '.', '_' or '-'";
357
+
358
+ function validTurnId(id) {
359
+ return typeof id === "string" && /^[A-Za-z0-9._-]{1,128}$/.test(id);
360
+ }
361
+
362
+ function checkOnAmbiguous(onAmbiguous) {
363
+ if (onAmbiguous !== undefined && typeof onAmbiguous !== "function") throw new TypeError("onAmbiguous must be a function");
364
+ }
365
+
366
+ // A call's input as the model wrote it: JSON when it parses, else the text.
367
+ function parsedToolInput(text) {
368
+ try { return JSON.parse(text); } catch { return text; }
369
+ }
370
+
371
+ function validCursor(cursor) {
372
+ return typeof cursor === "string" && cursor.length > 0 && cursor.length <= 1024;
373
+ }
374
+
375
+ function encodeJournalRecord(events) {
376
+ return encoder.encode(JSON.stringify({ format: journalRecordFormat, events }));
377
+ }
378
+
379
+ function persistenceBytes(value, what) {
380
+ if (value instanceof Uint8Array) return value;
381
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
382
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
383
+ throw new TypeError(`${what} must be bytes`);
384
+ }
385
+
386
+ function decodeJournalRecord(data) {
387
+ let value;
388
+ try { value = JSON.parse(decoder.decode(persistenceBytes(data, "a journal record's data"))); } catch (error) {
389
+ if (error instanceof TypeError && /must be bytes/.test(error.message)) throw error;
390
+ throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID", error);
391
+ }
392
+ if (value?.format !== journalRecordFormat || !Array.isArray(value.events)) {
393
+ if (typeof value?.format === "string" && value.format.startsWith("libfx-journal-v")) {
394
+ throw new FxJournalVersionError("libfx journal was written by a newer fx");
395
+ }
396
+ throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID");
397
+ }
398
+ return value.events;
399
+ }
400
+
401
+ // The last `seq` a checkpoint covers, from its header: `FXSN`, a version
402
+ // byte, then the seq as a little-endian u64. The core checks the rest.
403
+ function checkpointSeq(bytes) {
404
+ if (bytes.byteLength < 13 || decoder.decode(bytes.subarray(0, 4)) !== "FXSN") {
405
+ throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID");
406
+ }
407
+ return Number(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(5, true));
408
+ }
409
+
410
+ // Reads what `persistence.load()` returns: the latest checkpoint, if any, and
411
+ // the records after it, as the events the core folds after that checkpoint.
412
+ async function loadPersistence(persistence) {
413
+ const loaded = await persistence.load();
414
+ if (!loaded || typeof loaded !== "object") {
415
+ throw new TypeError("persistence.load() must resolve to { checkpoint?, journal? }");
416
+ }
417
+ let checkpoint = null;
418
+ let covered = 0;
419
+ let head = null;
420
+ if (loaded.checkpoint != null) {
421
+ const { data, through } = loaded.checkpoint;
422
+ if (!validCursor(through)) throw new TypeError("persistence.load() checkpoint.through must be a cursor string");
423
+ checkpoint = persistenceBytes(data, "checkpoint.data").slice();
424
+ if (checkpoint.byteLength > maxCheckpointBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
425
+ covered = checkpointSeq(checkpoint);
426
+ head = through;
427
+ }
428
+ const records = loaded.journal ?? [];
429
+ if (typeof records[Symbol.iterator] !== "function" && typeof records[Symbol.asyncIterator] !== "function") {
430
+ throw new TypeError("persistence.load() journal must be an iterable of { cursor, data }");
431
+ }
432
+ const events = [];
433
+ let bytes = 0;
434
+ for await (const record of records) {
435
+ if (!record || !validCursor(record.cursor)) throw new TypeError("each journal record needs a cursor string");
436
+ // The load limit applies once an open turn's older progress is dropped.
437
+ const data = persistenceBytes(record.data, "a journal record's data");
438
+ bytes += data.byteLength;
439
+ // A store may return records the checkpoint already covers.
440
+ for (const event of decodeJournalRecord(data)) if (!(Number.isSafeInteger(event?.seq) && event.seq <= covered)) events.push(event);
441
+ head = record.cursor;
442
+ }
443
+ // A store whose log holds entries of its own between records names the
444
+ // entry the next record follows.
445
+ if (loaded.head !== undefined) {
446
+ if (loaded.head !== null && !validCursor(loaded.head)) throw new TypeError("persistence.load() head must be a cursor string or null");
447
+ head = loaded.head;
448
+ }
449
+ return { checkpoint, events, head, tailBytes: bytes, lastSeq: events.at(-1)?.seq ?? covered };
450
+ }
451
+
452
+ // What a record's events say about turns, in order, for a store that tracks
453
+ // them without reading records: a turn started, ended, or yielded, an input
454
+ // was accepted or handed back unplaced, or the step the open turn is at, as
455
+ // its tool steps finished and the state of its calls.
456
+ function journalMarks(events) {
457
+ const marks = [];
458
+ for (const event of events) {
459
+ if (event?.type === "turn_progress") {
460
+ if (typeof event.turnId === "string") marks.push({ start: event.turnId });
461
+ // The inputs its model request carries for the first time.
462
+ for (const id of Array.isArray(event.inputs) ? event.inputs : []) if (typeof id === "string") marks.push({ placed: id });
463
+ // A progress that carries only what is new counts all the turn's steps.
464
+ const steps = Number.isSafeInteger(event.steps) ? event.steps : event.data?.execution?.tool_steps?.length;
465
+ if (Number.isSafeInteger(steps)) marks.push({ step: `${steps}:${String(event.data?.tool_state ?? "none")}` });
466
+ } else if (event?.type === "turn_committed" || event?.type === "turn_progress_cleared") marks.push({ end: true });
467
+ else if (event?.type === "turn_yielded") marks.push({ yield: true });
468
+ else if (event?.type === "input_accepted" && typeof event.data?.id === "string") marks.push({ accepted: event.data.id });
469
+ else if (event?.type === "input_withdrawn" && typeof event.data?.id === "string") marks.push({ withdrawn: event.data.id });
470
+ }
471
+ return marks;
472
+ }
473
+
140
474
  function agentEnvironment(options) {
141
475
  return {
142
- AI_GATEWAY_API_KEY: options.apiKey,
476
+ ...(options.apiKey === undefined ? {} : { AI_GATEWAY_API_KEY: options.apiKey }),
477
+ ...(options.providerName === undefined ? {} : { FX_PROVIDER: options.providerName }),
478
+ ...(options.customProvider === undefined ? {} : { FX_CONFIGURED_PROVIDERS: options.customProvider.json }),
143
479
  ...(options.model === undefined ? {} : { FX_MODEL: options.model }),
144
480
  ...(options.effort === undefined ? {} : { FX_EFFORT: options.effort }),
145
481
  ...(options.fast === undefined ? {} : { FX_FAST: options.fast ? "true" : "false" }),
@@ -150,6 +486,7 @@ function agentEnvironment(options) {
150
486
 
151
487
  function agentRpcError(response) {
152
488
  const error = new Error(response.message);
489
+ error[coreAnswered] = true;
153
490
  const data = response.data;
154
491
  if (data && ["LIBFX_MODEL_UNSUPPORTED_EFFORT", "LIBFX_MODEL_UNSUPPORTED_FAST", "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST"].includes(data.code) &&
155
492
  typeof data.model === "string" &&
@@ -253,12 +590,24 @@ function validWorkspacePath(path) {
253
590
  return path.slice(1).split("/").every((part) => part && part !== "." && part !== "..");
254
591
  }
255
592
 
593
+ function workspacePathInside(root, path) {
594
+ return root === "/" ? path.startsWith("/") : path === root || path.startsWith(`${root}/`);
595
+ }
596
+
597
+ // fx only reads AGENTS.md files below the workspace root or the host home.
598
+ function workspaceInstructionPathAllowed(info, path) {
599
+ return validWorkspacePath(path) && path.endsWith("/AGENTS.md") &&
600
+ (workspacePathInside(info.root, path) || workspacePathInside(info.home, path));
601
+ }
602
+
256
603
  function prepareWorkspaceAdapter(workspace) {
257
604
  if (workspace == null) return { present: false, valid: false };
258
605
  try {
259
606
  const info = workspace.info;
260
607
  const permission = workspace.permission;
608
+ const readFile = workspace.readFile;
261
609
  if (!info || typeof workspace.exec !== "function" || info.version !== 1 ||
610
+ (readFile !== undefined && typeof readFile !== "function") ||
262
611
  !validWorkspacePath(info.root) || !validWorkspacePath(info.cwd) ||
263
612
  !validWorkspacePath(info.home) || info.cwd !== info.root ||
264
613
  info.gitAvailable !== false || info.ephemeral !== true ||
@@ -273,6 +622,7 @@ function prepareWorkspaceAdapter(workspace) {
273
622
  git: false,
274
623
  ephemeral: true,
275
624
  permission,
625
+ instruction_files: typeof readFile === "function",
276
626
  };
277
627
  const encoded = encoder.encode(JSON.stringify(value));
278
628
  if (encoded.length > workspaceInfoLimit) return { present: true, valid: false };
@@ -488,12 +838,14 @@ function createRuntime(options) {
488
838
  const steering = [];
489
839
  let steeringBytes = 0;
490
840
  let steeringOpen = false;
841
+ // A steer that cannot join the running turn waits to run next.
842
+ let steeringHandoff = false;
491
843
  // Raw payloads beside ACP frames: the core copies inbound bytes into its
492
844
  // own memory and publishes outbound bytes for the agent to take.
493
845
  const inboundAttachments = new Map();
494
846
  const outboundAttachments = new Map();
495
847
  let nextOutboundAttachment = 1;
496
- const workspaceExecs = new Set();
848
+ const workspaceOperations = new Set();
497
849
  const workspace = prepareWorkspaceAdapter(options.workspace);
498
850
  const args = ["fx", ...(options.args || [])];
499
851
  const env = Object.entries(options.env || {}).map(([key, value]) => `${key}=${value}`);
@@ -762,9 +1114,10 @@ function createRuntime(options) {
762
1114
  function clearSteering() {
763
1115
  steering.length = 0;
764
1116
  steeringBytes = 0;
1117
+ steeringHandoff = false;
765
1118
  }
766
1119
 
767
- function queueSteering(text) {
1120
+ function queueSteering(text, id = "") {
768
1121
  if (!steeringOpen) throw new Error("no prompt is running");
769
1122
  const value = encoder.encode(text);
770
1123
  if (value.length === 0) throw new TypeError("steering text cannot be empty");
@@ -774,19 +1127,33 @@ function createRuntime(options) {
774
1127
  if (steering.length >= maxSteeringMessages || value.length > maxSteeringQueueBytes - steeringBytes) {
775
1128
  throw new Error("steering queue is full");
776
1129
  }
777
- steering.push(value);
1130
+ steering.push({ id, value });
778
1131
  steeringBytes += value.length;
779
1132
  }
780
1133
 
1134
+ // The core takes queued steering at a boundary; until then it can be withdrawn.
1135
+ function withdrawSteering(id) {
1136
+ const index = steering.findIndex((entry) => entry.id === id);
1137
+ if (index < 0) return false;
1138
+ steeringBytes -= steering[index].value.length;
1139
+ steering.splice(index, 1);
1140
+ return true;
1141
+ }
1142
+
1143
+ // Each message is its input id, a newline, then the text.
781
1144
  function steeringTake(outputPtr, outputCap) {
782
- const value = steering[0];
783
- if (!value) return 0;
1145
+ const entry = steering[0];
1146
+ if (!entry) return 0;
784
1147
  const output = checkedBytes(outputPtr, outputCap);
785
- if (!output || value.length > output.length) return -1;
786
- output.subarray(0, value.length).set(value);
1148
+ const id = encoder.encode(entry.id);
1149
+ const length = id.length + 1 + entry.value.length;
1150
+ if (!output || length > output.length) return -1;
1151
+ output.set(id, 0);
1152
+ output[id.length] = 10;
1153
+ output.set(entry.value, id.length + 1);
787
1154
  steering.shift();
788
- steeringBytes -= value.length;
789
- return value.length;
1155
+ steeringBytes -= entry.value.length;
1156
+ return length;
790
1157
  }
791
1158
 
792
1159
  function writeAttachment(id, data) {
@@ -821,14 +1188,20 @@ function createRuntime(options) {
821
1188
  return id;
822
1189
  }
823
1190
 
1191
+ // Resolves once the agent's journal holds every event the core sent.
1192
+ function journalFlush() {
1193
+ if (typeof options.journalFlush !== "function") return -1;
1194
+ return Promise.resolve().then(() => options.journalFlush()).then(() => 0, () => -1);
1195
+ }
1196
+
824
1197
  let pendingHostToolResult = null;
825
- function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr) {
1198
+ function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr, callIdPtr, callIdLen) {
826
1199
  pendingHostToolResult = null;
827
1200
  if (typeof options.hostToolExecutor !== "function") return -1;
828
1201
  if (options.traceWasi) console.error("fx host tool call start");
829
1202
  let input;
830
1203
  try { input = JSON.parse(text(argumentsPtr, argumentsLen)); } catch { return -1; }
831
- return Promise.resolve(options.hostToolExecutor(text(namePtr, nameLen), input)).then((result) => {
1204
+ return Promise.resolve(options.hostToolExecutor(text(namePtr, nameLen), input, undefined, text(callIdPtr, callIdLen))).then((result) => {
832
1205
  if (options.traceWasi) console.error("fx host tool call settled", result.cancelled, result.isError);
833
1206
  if (result.cancelled) return -2;
834
1207
  const output = encoder.encode(result.content);
@@ -1047,32 +1420,13 @@ function createRuntime(options) {
1047
1420
  }
1048
1421
  if (command.includes("\0")) return Promise.resolve(-4);
1049
1422
 
1050
- const controller = new AbortController();
1051
- let resolveAbort;
1052
- const aborted = new Promise((resolve) => { resolveAbort = resolve; });
1053
- const state = {
1054
- controller,
1055
- status: null,
1056
- abort(status) {
1057
- if (this.status !== null) return;
1058
- this.status = status;
1059
- resolveAbort(status);
1060
- controller.abort(new DOMException(
1061
- status === -5 ? "workspace command timed out" : "workspace command aborted",
1062
- status === -5 ? "TimeoutError" : "AbortError",
1063
- ));
1064
- },
1065
- };
1066
- workspaceExecs.add(state);
1067
- const timer = setTimeout(() => state.abort(-5), timeoutMs);
1068
- const execution = Promise.resolve().then(() => workspace.adapter.exec({
1423
+ return runWorkspaceOperation(timeoutMs, "workspace command", (signal) => workspace.adapter.exec({
1069
1424
  command,
1070
1425
  cwd: workspace.info.cwd,
1071
- signal: controller.signal,
1426
+ signal,
1072
1427
  timeoutMs,
1073
1428
  outputLimitBytes: workspaceOutputLimit,
1074
- })).then((value) => {
1075
- if (state.status !== null) return state.status;
1429
+ }), (value) => {
1076
1430
  if (!value || !Number.isInteger(value.exitCode) || value.exitCode < -0x80000000 ||
1077
1431
  value.exitCode > 0x7fffffff || typeof value.stdout !== "string" ||
1078
1432
  typeof value.stderr !== "string") return -1;
@@ -1106,6 +1460,81 @@ function createRuntime(options) {
1106
1460
  view.setUint32(24, stderr.length, true);
1107
1461
  view.setUint32(28, copied < stdout.length + stderr.length ? 1 : 0, true);
1108
1462
  return 0;
1463
+ });
1464
+ }
1465
+
1466
+ function workspaceReadFile(pathPtr, pathLen, outPtr, outCap, resultPtr) {
1467
+ if (!workspace.present || (workspace.valid && typeof workspace.adapter.readFile !== "function")) {
1468
+ return Promise.resolve(-2);
1469
+ }
1470
+ if (!workspace.valid) return Promise.resolve(-4);
1471
+ const pathBytes = checkedBytes(pathPtr, pathLen);
1472
+ if (!pathBytes || !checkedBytes(outPtr, outCap) || !checkedBytes(resultPtr, 8) ||
1473
+ pathLen > workspaceInstructionPathLimit || outCap > workspaceInstructionFileLimit) {
1474
+ return Promise.resolve(-4);
1475
+ }
1476
+ let path;
1477
+ try {
1478
+ path = strictDecoder.decode(pathBytes);
1479
+ } catch {
1480
+ return Promise.resolve(-4);
1481
+ }
1482
+ if (!workspaceInstructionPathAllowed(workspace.info, path)) return Promise.resolve(-4);
1483
+
1484
+ return runWorkspaceOperation(workspaceReadTimeoutMs, "workspace read", (signal) => workspace.adapter.readFile({
1485
+ path,
1486
+ signal,
1487
+ }), (value) => {
1488
+ if (value == null) return -6;
1489
+ let content;
1490
+ if (typeof value === "string") {
1491
+ content = encoder.encode(value);
1492
+ } else if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
1493
+ content = value instanceof ArrayBuffer
1494
+ ? new Uint8Array(value)
1495
+ : new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
1496
+ try {
1497
+ strictDecoder.decode(content);
1498
+ } catch {
1499
+ return -7;
1500
+ }
1501
+ } else {
1502
+ return -1;
1503
+ }
1504
+ if (content.length > 0xffffffff) return -1;
1505
+ const copied = Math.min(content.length, outCap);
1506
+ // Reacquire views: memory may have grown while the host read was pending.
1507
+ bytes(outPtr, copied).set(content.subarray(0, copied));
1508
+ const view = new DataView(memory().buffer, resultPtr, 8);
1509
+ view.setUint32(0, content.length, true);
1510
+ view.setUint32(4, copied, true);
1511
+ return 0;
1512
+ });
1513
+ }
1514
+
1515
+ // Runs one host workspace effect under a deadline and the shared Ctrl+C
1516
+ // abort path. `finish` maps the host value to an ABI status.
1517
+ function runWorkspaceOperation(timeoutMs, label, start, finish) {
1518
+ const controller = new AbortController();
1519
+ let resolveAbort;
1520
+ const aborted = new Promise((resolve) => { resolveAbort = resolve; });
1521
+ const state = {
1522
+ status: null,
1523
+ abort(status) {
1524
+ if (this.status !== null) return;
1525
+ this.status = status;
1526
+ resolveAbort(status);
1527
+ controller.abort(new DOMException(
1528
+ status === -5 ? `${label} timed out` : `${label} aborted`,
1529
+ status === -5 ? "TimeoutError" : "AbortError",
1530
+ ));
1531
+ },
1532
+ };
1533
+ workspaceOperations.add(state);
1534
+ const timer = setTimeout(() => state.abort(-5), timeoutMs);
1535
+ const execution = Promise.resolve().then(() => start(controller.signal)).then((value) => {
1536
+ if (state.status !== null) return state.status;
1537
+ return finish(value);
1109
1538
  }).catch((error) => {
1110
1539
  if (state.status !== null) return state.status;
1111
1540
  if (error?.name === "TimeoutError") return -5;
@@ -1114,7 +1543,7 @@ function createRuntime(options) {
1114
1543
  });
1115
1544
  return Promise.race([execution, aborted]).finally(() => {
1116
1545
  clearTimeout(timer);
1117
- workspaceExecs.delete(state);
1546
+ workspaceOperations.delete(state);
1118
1547
  });
1119
1548
  }
1120
1549
 
@@ -1122,7 +1551,7 @@ function createRuntime(options) {
1122
1551
  pendingHostToolResult = null;
1123
1552
  streams.forEach((state) => state.controller.abort(abortReason));
1124
1553
  httpRequests.forEach((controller) => controller.abort(abortReason));
1125
- workspaceExecs.forEach((state) => state.abort(-3));
1554
+ workspaceOperations.forEach((state) => state.abort(-3));
1126
1555
  }
1127
1556
 
1128
1557
  const unavailable = () => 52;
@@ -1177,12 +1606,14 @@ function createRuntime(options) {
1177
1606
  fx_workspace_available() { return workspace.present ? 1 : 0; },
1178
1607
  fx_workspace_info: workspaceInfo,
1179
1608
  fx_workspace_exec: new WebAssembly.Suspending(workspaceExec),
1609
+ fx_workspace_read_file: new WebAssembly.Suspending(workspaceReadFile),
1180
1610
  fx_http_stream_open: streamOpen,
1181
1611
  fx_http_stream_status: new WebAssembly.Suspending(streamStatus),
1182
1612
  fx_http_stream_next: new WebAssembly.Suspending(streamNext),
1183
1613
  fx_http_stream_close(handle) { const state = streams.get(handle); state?.controller.abort(abortReason); streams.delete(handle); },
1184
1614
  fx_http_request: new WebAssembly.Suspending(httpRequest),
1185
1615
  fx_host_tool_call: new WebAssembly.Suspending(hostToolCall),
1616
+ fx_journal_flush: new WebAssembly.Suspending(journalFlush),
1186
1617
  fx_host_tool_result_read(offset, ptr, cap) {
1187
1618
  if (!pendingHostToolResult || offset < 0 || offset > pendingHostToolResult.length) return -1;
1188
1619
  const chunk = pendingHostToolResult.subarray(offset, offset + cap);
@@ -1192,6 +1623,9 @@ function createRuntime(options) {
1192
1623
  fx_host_tool_result_release() { pendingHostToolResult = null; },
1193
1624
  fx_steering_take: steeringTake,
1194
1625
  fx_steering_close() { steeringOpen = false; clearSteering(); },
1626
+ // -1 once the turn takes no more steering, else twice how many steers
1627
+ // wait, plus one when a handoff does.
1628
+ fx_steering_state() { return steeringOpen ? steering.length * 2 + (steeringHandoff ? 1 : 0) : -1; },
1195
1629
  fx_attachment_size: attachmentSize,
1196
1630
  fx_attachment_take: attachmentTake,
1197
1631
  fx_attachment_put: attachmentPut,
@@ -1232,6 +1666,13 @@ function createRuntime(options) {
1232
1666
  closeStdin() { steeringOpen = false; clearSteering(); stdin.close(); },
1233
1667
  openSteering() { clearSteering(); steeringOpen = true; },
1234
1668
  steer: queueSteering,
1669
+ withdrawSteering,
1670
+ // Ends the running turn at its next safe point; false when none runs.
1671
+ handOffSteering() {
1672
+ if (!steeringOpen) return false;
1673
+ steeringHandoff = true;
1674
+ return true;
1675
+ },
1235
1676
  closeSteering() { steeringOpen = false; clearSteering(); },
1236
1677
  abortHostEffects,
1237
1678
  abort(error) {
@@ -1616,16 +2057,38 @@ function normalizeSteeringInput(input) {
1616
2057
  return text;
1617
2058
  }
1618
2059
 
2060
+ // A tool set's shape, so a checkpoint can tell when the tools it continues
2061
+ // with changed since it was saved.
2062
+ function toolSchemaHash(descriptors) {
2063
+ const shape = JSON.stringify([...descriptors].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)));
2064
+ let hash = 0x811c9dc5;
2065
+ for (const byte of new TextEncoder().encode(shape)) hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
2066
+ return hash.toString(16).padStart(8, "0");
2067
+ }
2068
+
2069
+ // `tools` is an array of descriptors, or an object of descriptors keyed by
2070
+ // name.
1619
2071
  function normalizeHostTools(value) {
1620
2072
  if (value === undefined) return { descriptors: [], executors: new Map() };
1621
- if (!Array.isArray(value)) throw new TypeError("tools must be an array");
1622
- if (value.length > 64) throw new RangeError("tools cannot contain more than 64 entries");
2073
+ let entries;
2074
+ if (Array.isArray(value)) {
2075
+ entries = value;
2076
+ } else if (value && typeof value === "object") {
2077
+ entries = Object.entries(value).map(([key, tool]) => {
2078
+ if (!tool || typeof tool !== "object") throw new TypeError(`tool ${key} must be an object`);
2079
+ if (tool.name !== undefined && tool.name !== key) throw new TypeError(`tool ${key} has a different name: ${tool.name}`);
2080
+ return { ...tool, name: key };
2081
+ });
2082
+ } else {
2083
+ throw new TypeError("tools must be an array or an object");
2084
+ }
2085
+ if (entries.length > 64) throw new RangeError("tools cannot contain more than 64 entries");
1623
2086
  const descriptors = [];
1624
2087
  const executors = new Map();
1625
2088
  const names = new Set();
1626
- for (const [index, tool] of value.entries()) {
2089
+ for (const [index, tool] of entries.entries()) {
1627
2090
  if (!tool || typeof tool !== "object") throw new TypeError(`tool ${index} must be an object`);
1628
- const { name, description, inputSchema, execute, providerExecuted } = tool;
2091
+ const { name, description, inputSchema, execute, providerExecuted, writes, idempotent } = tool;
1629
2092
  if (typeof name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
1630
2093
  throw new TypeError(`tool ${index} has an invalid name`);
1631
2094
  }
@@ -1641,6 +2104,8 @@ function normalizeHostTools(value) {
1641
2104
  }
1642
2105
  if (typeof description !== "string") throw new TypeError(`tool ${name} requires a description`);
1643
2106
  if (typeof execute !== "function") throw new TypeError(`tool ${name} requires execute()`);
2107
+ if (writes !== undefined && typeof writes !== "boolean") throw new TypeError(`tool ${name} writes must be a boolean`);
2108
+ if (idempotent !== undefined && typeof idempotent !== "boolean") throw new TypeError(`tool ${name} idempotent must be a boolean`);
1644
2109
  if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
1645
2110
  throw new TypeError(`tool ${name} requires an object inputSchema`);
1646
2111
  }
@@ -1648,13 +2113,46 @@ function normalizeHostTools(value) {
1648
2113
  try { schema = JSON.parse(JSON.stringify(inputSchema)); } catch {
1649
2114
  throw new TypeError(`tool ${name} inputSchema must be JSON-serializable`);
1650
2115
  }
1651
- descriptors.push({ name, description, inputSchema: schema });
2116
+ descriptors.push({
2117
+ name,
2118
+ description,
2119
+ inputSchema: schema,
2120
+ ...(writes === undefined ? {} : { writes }),
2121
+ // The engine reads `idempotent`: such a tool runs beside others unless
2122
+ // it `writes`, and one that does not write starts before its intent is
2123
+ // stored. It is also in the shape a checkpoint records, so a change to
2124
+ // it counts as a changed tool set.
2125
+ ...(idempotent === true ? { idempotent: true } : {}),
2126
+ });
1652
2127
  executors.set(name, execute);
1653
2128
  }
1654
2129
  return { descriptors, executors };
1655
2130
  }
1656
2131
 
1657
- function normalizeInstructions(value) {
2132
+ // The model catalog a host supplies, as the response body the core would
2133
+ // otherwise fetch: `{ data }` as AI Gateway returns it, or the entries alone.
2134
+ function modelCatalogBody(value) {
2135
+ const entries = Array.isArray(value) ? value : value?.data;
2136
+ if (!Array.isArray(entries)) throw new TypeError("modelCatalog must be an array of catalog entries or { data: [...] }");
2137
+ if (entries.length > maxModelCatalogEntries) {
2138
+ throw new RangeError(`modelCatalog exceeds the ${maxModelCatalogEntries} entry libfx limit`);
2139
+ }
2140
+ for (const entry of entries) {
2141
+ if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || entry.id === "") {
2142
+ throw new TypeError("each modelCatalog entry needs a string id");
2143
+ }
2144
+ }
2145
+ let body;
2146
+ try { body = JSON.stringify({ object: "list", data: entries }); } catch {
2147
+ throw new TypeError("modelCatalog must be JSON-serializable");
2148
+ }
2149
+ if (encoder.encode(body).length > maxModelCatalogBytes) {
2150
+ throw new RangeError(`modelCatalog exceeds the ${maxModelCatalogBytes} byte libfx limit`);
2151
+ }
2152
+ return body;
2153
+ }
2154
+
2155
+ export function normalizeInstructions(value) {
1658
2156
  let instructions;
1659
2157
  if (value === undefined) instructions = "";
1660
2158
  else if (typeof value === "string") instructions = value;
@@ -1722,10 +2220,22 @@ function base64ToBytes(value) {
1722
2220
  return bytes;
1723
2221
  }
1724
2222
 
1725
- export async function createFxAgent(options = {}) {
2223
+ /**
2224
+ * One session on one fx core, with no durability of its own: `createFxAgent`
2225
+ * runs each durable session's turns on one of these, and a host with its own
2226
+ * storage may pass `persistence`.
2227
+ */
2228
+ export async function createFxEngine(options = {}) {
1726
2229
  options = normalizeAgentOptions(options);
1727
2230
  const hostTools = normalizeHostTools(options.tools);
1728
2231
  const instructions = normalizeInstructions(options.instructions);
2232
+ // Recorded in every checkpoint this agent saves, so a later one can tell
2233
+ // it continues with another libfx, other tools, or another model.
2234
+ const checkpointMeta = {
2235
+ libfxVersion: String(fxSdkApiVersion),
2236
+ toolSchemaHash: toolSchemaHash(hostTools.descriptors),
2237
+ model: options.model ?? "",
2238
+ };
1729
2239
  const initialCheckpoint = checkpointBytes(options.checkpoint);
1730
2240
  // Checked before the core starts, and with the core's message, so both
1731
2241
  // backends report it the same way.
@@ -1739,17 +2249,269 @@ export async function createFxAgent(options = {}) {
1739
2249
  let closing = false;
1740
2250
  let coreExitError = null;
1741
2251
  const isCurrentTurn = (turn) => turn && activeTurn === turn && !turn.cancelled && !closing;
2252
+ // Events go to the store in the order the core sends them, one append at
2253
+ // a time: events that arrive together become one record, and so do all
2254
+ // the events that arrive while an append is in flight. Each record's append
2255
+ // names the cursor of the one before it, so a store refuses a write that
2256
+ // does not continue what it holds, and says with `barrier` whether anyone
2257
+ // waits for it: the core before an action others can see, such as a tool
2258
+ // call with effects, or a caller for its input.
2259
+ const persistence = options.persistence ?? null;
2260
+ const wantsMarks = persistence?.marks === true || persistence?.[journalMarksWanted] === true;
2261
+ const checkpointAfterBytes = options.checkpointAfterBytes ?? defaultCheckpointAfterBytes;
2262
+ // The store's last cursor, and the last seq its records hold.
2263
+ let journalHead = null;
2264
+ let appendedSeq = 0;
2265
+ // Record bytes stored since the last checkpoint.
2266
+ let bytesSinceCheckpoint = 0;
2267
+ let appendTail = Promise.resolve();
2268
+ // One per agent, so a store can tell a repeated write from another writer's.
2269
+ const writerId = crypto.randomUUID();
2270
+ const journal = persistence && {
2271
+ append(events, barrier) {
2272
+ const data = encodeJournalRecord(events);
2273
+ const lastSeq = events.at(-1)?.seq;
2274
+ // A store that tracks turns without reading records asks for marks.
2275
+ const marks = wantsMarks ? { marks: journalMarks(events) } : {};
2276
+ const appended = appendTail.then(async () => {
2277
+ const result = await persistence.append({ expected: journalHead, idempotencyKey: `${writerId}:${events[0]?.seq}`, data, barrier, ...marks });
2278
+ if (!validCursor(result?.cursor)) throw new TypeError("persistence.append() must resolve to { cursor }");
2279
+ journalHead = result.cursor;
2280
+ if (Number.isSafeInteger(lastSeq)) appendedSeq = lastSeq;
2281
+ bytesSinceCheckpoint += data.byteLength;
2282
+ });
2283
+ // A failed append fails every later one: they would not continue it.
2284
+ appendTail = appended;
2285
+ return appended;
2286
+ },
2287
+ };
2288
+ let resumable = false;
2289
+ // The host's ids for the turn a crash left open and the last turn that
2290
+ // ended, so a retried prompt continues or skips its turn.
2291
+ let openTurnId = null;
2292
+ let lastTurnId = null;
2293
+ // The calls the open turn left running: `{ id, name, arguments }`, and
2294
+ // what the resume tells the model about each one it does not run again.
2295
+ let ambiguousCalls = [];
2296
+ // Asks `onAmbiguous` about each of them, once. Returns the ids of the
2297
+ // calls to run again, and the rest, which the resume answers as possibly
2298
+ // run.
2299
+ const settleAmbiguous = (onAmbiguous) => {
2300
+ const calls = ambiguousCalls;
2301
+ ambiguousCalls = [];
2302
+ const rerun = onAmbiguous === undefined
2303
+ ? []
2304
+ : calls.filter((call) => onAmbiguous({ executionId: call.id, name: call.name, input: parsedToolInput(call.arguments) }) === "rerun").map((call) => call.id);
2305
+ return { rerun, answered: calls.filter((call) => !rerun.includes(call.id)) };
2306
+ };
2307
+ // Continues the open turn: the calls it left running run again or are
2308
+ // answered as possibly run, and each answered one ends for the turn's
2309
+ // viewers as it does for the model.
2310
+ const resumeOpenTurn = (promptOptions) => {
2311
+ const { rerun, answered } = settleAmbiguous(promptOptions.onAmbiguous);
2312
+ return normalizeTurn(startTurn(null, { ...promptOptions, [resumeTurn]: true, [rerunCallIds]: rerun }), answered);
2313
+ };
2314
+ // Follow-ups waiting for the turn ahead of them, oldest first.
2315
+ const followUps = [];
2316
+ // A follow-up this agent queued starts when the turn ahead of it ends, ahead
2317
+ // of any the journal held: those have no caller, so each waits for
2318
+ // `resume()` to start it, oldest first.
2319
+ const runNextFollowUp = (resuming = false) => {
2320
+ if (activeTurn || closing || journalFailure) return;
2321
+ const index = followUps.findIndex((entry) => (entry.held === true) === resuming);
2322
+ if (index < 0) return;
2323
+ const [next] = followUps.splice(index, 1);
2324
+ let turn;
2325
+ try {
2326
+ turn = normalizeTurn(startTurn(next.text, { turnId: next.id, [followUpInput]: { id: next.id, accepted: next.accepted } }));
2327
+ } catch (error) {
2328
+ next.rejectTurn?.(error);
2329
+ return;
2330
+ }
2331
+ next.resolveTurn?.(turn);
2332
+ };
2333
+ let journalQueue = [];
2334
+ let journalFlushScheduled = false;
2335
+ const journalPending = new Set();
2336
+ // The append in flight, and whether anyone waits for the events queued
2337
+ // behind it.
2338
+ let journalInFlight = null;
2339
+ let journalBarrier = false;
2340
+ let journalFailure = null;
2341
+ // Set by `turn.cancel({ reason: "handoff" })`: the journal keeps the open
2342
+ // turn for another agent, so this one stores nothing more.
2343
+ let handedOff = false;
2344
+ const handedOffError = () => new Error("this agent handed its session off; open it again to continue");
2345
+ // Input events (`accepted:<id>`, `withdrawn:<id>`) once stored, and the
2346
+ // steer and withdraw calls waiting for them.
2347
+ const durableInputs = new Set();
2348
+ const inputWaiters = new Map();
2349
+ const waitForInput = (key) => {
2350
+ if (durableInputs.delete(key)) return Promise.resolve();
2351
+ if (journalFailure) return Promise.reject(journalFailure);
2352
+ return new Promise((resolve, reject) => {
2353
+ const waiters = inputWaiters.get(key) ?? [];
2354
+ waiters.push({ resolve, reject });
2355
+ inputWaiters.set(key, waiters);
2356
+ });
2357
+ };
2358
+ const settleInputs = (batch) => {
2359
+ for (const event of batch) {
2360
+ if (event.type !== "input_accepted" && event.type !== "input_withdrawn") continue;
2361
+ const key = `${event.type === "input_accepted" ? "accepted" : "withdrawn"}:${event.data?.id}`;
2362
+ durableInputs.add(key);
2363
+ for (const waiter of inputWaiters.get(key) ?? []) waiter.resolve();
2364
+ inputWaiters.delete(key);
2365
+ }
2366
+ };
2367
+ const rejectInputWaiters = (keys, error) => {
2368
+ for (const key of keys) {
2369
+ for (const waiter of inputWaiters.get(key) ?? []) waiter.reject(error);
2370
+ inputWaiters.delete(key);
2371
+ }
2372
+ };
2373
+ // close() reports a failure no turn or prompt call has reported yet.
2374
+ let journalFailureReported = false;
2375
+ const reportJournalFailure = () => {
2376
+ journalFailureReported = true;
2377
+ return journalFailure;
2378
+ };
2379
+ // `barrier`: the core waits for these events before it goes on.
2380
+ const queueJournalEvents = (events, barrier = false) => {
2381
+ if (journalFailure || !Array.isArray(events) || events.length === 0) return;
2382
+ if (handedOff) {
2383
+ // The agent that opens the session next decides about these inputs.
2384
+ const dropped = events
2385
+ .filter((event) => event?.type === "input_accepted" || event?.type === "input_withdrawn")
2386
+ .map((event) => `${event.type === "input_accepted" ? "accepted" : "withdrawn"}:${event.data?.id}`);
2387
+ rejectInputWaiters(dropped, handedOffError());
2388
+ return;
2389
+ }
2390
+ journalQueue.push(...events);
2391
+ // A caller waits for its steer or withdrawal to be stored.
2392
+ if (barrier || events.some((event) => event?.type === "input_accepted" || event?.type === "input_withdrawn")) journalBarrier = true;
2393
+ // A turn notes a new config right before its first progress, a barrier:
2394
+ // the two go out in one write instead of two in a row.
2395
+ if (events.every((event) => event?.type === "session_config")) return;
2396
+ if (journalFlushScheduled) return;
2397
+ journalFlushScheduled = true;
2398
+ queueMicrotask(flushJournal);
2399
+ };
2400
+ // Sends the queued events as one record, unless an append is in flight:
2401
+ // then they wait for it, with any that arrive meanwhile, and go out
2402
+ // together once it lands.
2403
+ function flushJournal() {
2404
+ journalFlushScheduled = false;
2405
+ if (journalFailure || journalInFlight || journalQueue.length === 0) return;
2406
+ const batch = journalQueue;
2407
+ journalQueue = [];
2408
+ const barrier = journalBarrier;
2409
+ journalBarrier = false;
2410
+ emit("journal.append", { events: batch.length, barrier });
2411
+ let appended;
2412
+ try {
2413
+ appended = Promise.resolve(journal.append(batch, barrier));
2414
+ } catch (error) {
2415
+ appended = Promise.reject(error);
2416
+ }
2417
+ const settled = appended.then(() => {
2418
+ settleInputs(batch);
2419
+ }, (error) => {
2420
+ // Later events would leave a gap after the failed batch.
2421
+ journalQueue = [];
2422
+ if (journalFailure) return;
2423
+ journalFailure = journalAppendError(error);
2424
+ emit("journal.error", { error: error instanceof Error ? error.name : "Error" });
2425
+ rejectInputWaiters([...inputWaiters.keys()], journalFailure);
2426
+ // Nothing the turn does from here can be saved, and after a fence the
2427
+ // session has another owner: stop the turn and its effects now.
2428
+ activeTurn?.cancel();
2429
+ }).finally(() => {
2430
+ journalPending.delete(settled);
2431
+ journalInFlight = null;
2432
+ flushJournal();
2433
+ });
2434
+ journalInFlight = settled;
2435
+ journalPending.add(settled);
2436
+ }
2437
+ // The journal's own work, such as a heartbeat, ends with the agent: once,
2438
+ // after the last append settles.
2439
+ let journalClosed = null;
2440
+ function closeJournal() {
2441
+ journalClosed ??= journalSettled().catch(() => {}).then(() => checkpointing);
2442
+ return journalClosed;
2443
+ }
2444
+ async function journalSettled() {
2445
+ for (;;) {
2446
+ // This caller waits for every event queued so far.
2447
+ if (journalQueue.length > 0) journalBarrier = true;
2448
+ if (journalFlushScheduled || journalQueue.length > 0) flushJournal();
2449
+ if (journalPending.size === 0) break;
2450
+ await Promise.all([...journalPending]);
2451
+ }
2452
+ if (journalFailure) throw journalFailure;
2453
+ }
1742
2454
  const emit = (type, detail = {}) => {
1743
2455
  try { options.onEvent?.({ type, timestamp: performance.now(), ...detail }); } catch {}
1744
2456
  };
2457
+ // A checkpoint saved by another libfx, with other tools, or for another
2458
+ // model still restores; the host hears what changed.
2459
+ const compareCheckpointMeta = (saved) => {
2460
+ if (!saved || typeof saved !== "object") return;
2461
+ const changed = Object.keys(checkpointMeta).filter((key) => typeof saved[key] === "string" && saved[key] !== "" && saved[key] !== checkpointMeta[key]);
2462
+ if (changed.length) emit("checkpoint.mismatch", { sessionId, changed, saved, current: checkpointMeta });
2463
+ };
2464
+ // Saves a checkpoint once the records since the last one reach
2465
+ // `checkpointAfterBytes`, or with `force` whatever their size, at a point
2466
+ // no turn is running: a turn's end, or where it yielded. A failed save
2467
+ // leaves the journal as it was; the next turn's end tries again. Returns
2468
+ // the save in flight, which never rejects.
2469
+ let checkpointing = null;
2470
+ const maybeCheckpoint = (force = false) => {
2471
+ if (checkpointing) return checkpointing;
2472
+ if (!persistence?.saveCheckpoint || closing || handedOff || journalFailure) return Promise.resolve();
2473
+ checkpointing = (async () => {
2474
+ // The turn's last records may still be in flight; count them first.
2475
+ await journalSettled();
2476
+ // A save begun before close() finishes; close() waits for it.
2477
+ if (activeTurn) return;
2478
+ if (bytesSinceCheckpoint === 0 || (!force && bytesSinceCheckpoint < checkpointAfterBytes)) return;
2479
+ const response = await request("libfx/snapshot", { sessionId, checkpointMeta });
2480
+ const id = response?.snapshotAttachment;
2481
+ if (!Number.isSafeInteger(id) || id <= 0) return;
2482
+ const bytes = runtime.takeAttachment?.(id);
2483
+ // The core took it after every event this agent stored, and no later.
2484
+ if (!(bytes instanceof Uint8Array) || response.atSeq !== appendedSeq) return;
2485
+ const through = journalHead;
2486
+ const covered = bytesSinceCheckpoint;
2487
+ await persistence.saveCheckpoint({ through, data: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) });
2488
+ bytesSinceCheckpoint -= covered;
2489
+ emit("checkpoint.save", { through, bytes: bytes.byteLength });
2490
+ })().catch((error) => {
2491
+ emit("checkpoint.error", { error: error instanceof Error ? error.name : "Error", message: error?.message ?? "" });
2492
+ }).finally(() => { checkpointing = null; });
2493
+ return checkpointing;
2494
+ };
1745
2495
  const hostFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
2496
+ // Adds the host's credentials to the provider's own requests.
2497
+ const providerAuth = subscriptionProviders.has(options.provider)
2498
+ ? createSubscriptionAuthorizer([codexSubscription, grokSubscription], { credentials: options.credentials, onCredentials: options.onCredentials, refreshLock: options.refreshLock, fetch: hostFetch, memory: options[credentialMemory] })
2499
+ : options.customProvider && options.credentials
2500
+ ? createSubscriptionAuthorizer([customProviderSpec(options.customProvider, () => sessionId)], { credentials: options.credentials, fetch: hostFetch })
2501
+ : null;
1746
2502
  const transportFetch = async (input, init = {}) => {
1747
2503
  const method = String(init.method ?? input?.method ?? "GET").toUpperCase();
1748
2504
  let endpoint = String(input?.url ?? input);
2505
+ let path = null;
1749
2506
  try {
1750
2507
  const url = new URL(endpoint);
1751
2508
  endpoint = `${url.origin}${url.pathname}`;
2509
+ path = url.pathname;
1752
2510
  } catch {}
2511
+ // A supplied catalog answers the core's catalog request without the network.
2512
+ if (options.modelCatalogBody && method === "GET" && path === "/coding-agent/v1/models") {
2513
+ return new Response(options.modelCatalogBody, { status: 200, headers: { "content-type": "application/json" } });
2514
+ }
1753
2515
  for (let attemptIndex = 0; attemptIndex < 2; attemptIndex++) {
1754
2516
  const startedAt = performance.now();
1755
2517
  const attempt = activeTurn ? ++activeTurn.transportAttempts : attemptIndex + 1;
@@ -1764,7 +2526,10 @@ export async function createFxAgent(options = {}) {
1764
2526
  throw new DOMException("Aborted", "AbortError");
1765
2527
  }
1766
2528
  if (!hostFetch) throw new TypeError("fetch is unavailable");
1767
- const response = await hostFetch(input, init);
2529
+ const spec = providerAuth?.specFor(endpoint);
2530
+ const response = spec
2531
+ ? await providerAuth.fetch(spec, endpoint, hostFetch, input, init)
2532
+ : await hostFetch(input, init);
1768
2533
  const headers = response.headers;
1769
2534
  emit("transport.response", {
1770
2535
  attempt,
@@ -1793,7 +2558,7 @@ export async function createFxAgent(options = {}) {
1793
2558
  }
1794
2559
  throw new Error("transport retry exhausted");
1795
2560
  };
1796
- const executeHostTool = async (name, input, requestedSessionId) => {
2561
+ const executeHostTool = async (name, input, requestedSessionId, toolCallId) => {
1797
2562
  const execute = hostTools.executors.get(name);
1798
2563
  const turn = requestedSessionId === undefined || requestedSessionId === sessionId
1799
2564
  ? activeTurn
@@ -1811,7 +2576,14 @@ export async function createFxAgent(options = {}) {
1811
2576
  if (!execute) throw new Error(`unknown host tool: ${String(name)}`);
1812
2577
  const execution = Promise.resolve().then(() => {
1813
2578
  if (controller.signal.aborted || !isCurrentTurn(turn)) return;
1814
- return execute(input, { signal: controller.signal });
2579
+ // The model's call id is stable across restores of a persisted
2580
+ // session, so a tool can key its own retries on it.
2581
+ return execute(input, {
2582
+ signal: controller.signal,
2583
+ executionId: String(toolCallId ?? ""),
2584
+ sessionId,
2585
+ ...(options.toolContext === undefined ? {} : { context: options.toolContext }),
2586
+ });
1815
2587
  });
1816
2588
  const value = await Promise.race([execution, aborted]);
1817
2589
  if (!controller.signal.aborted) {
@@ -1837,6 +2609,8 @@ export async function createFxAgent(options = {}) {
1837
2609
  controller.signal.removeEventListener("abort", onAbort);
1838
2610
  turn.toolControllers.delete(controller);
1839
2611
  }
2612
+ // Providers reject an empty error result, which would end the session.
2613
+ if (isError && content === "") content = `Tool ${String(name)} failed without a message`;
1840
2614
  return { content, isError, rich, cancelled: controller.signal.aborted || !isCurrentTurn(turn) };
1841
2615
  };
1842
2616
  emit("runtime.start");
@@ -1846,6 +2620,7 @@ export async function createFxAgent(options = {}) {
1846
2620
  args: ["acp"],
1847
2621
  env: agentEnvironment(options),
1848
2622
  hostToolExecutor: executeHostTool,
2623
+ journalFlush: () => journalSettled(),
1849
2624
  onTransportChunk(byteLength) {
1850
2625
  const turn = activeTurn;
1851
2626
  if (!isCurrentTurn(turn) || !Number.isSafeInteger(byteLength) || byteLength <= 0) return;
@@ -1894,13 +2669,19 @@ export async function createFxAgent(options = {}) {
1894
2669
  async function takeCheckpoint() {
1895
2670
  if (closing) throw new Error("fx agent is closed");
1896
2671
  if (activeTurn) throw new Error("cannot checkpoint while a prompt is active");
1897
- const response = await request("libfx/checkpoint", { sessionId });
2672
+ const response = await request("libfx/checkpoint", { sessionId, checkpointMeta });
1898
2673
  const id = response?.checkpointAttachment;
1899
2674
  const bytes = Number.isSafeInteger(id) && id > 0 ? runtime.takeAttachment?.(id) : null;
1900
2675
  if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) throw new Error("fx returned an invalid checkpoint");
1901
2676
  return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1902
2677
  }
1903
- const sendPrompt = (blocks) => {
2678
+ const sendPrompt = (blocks, resuming = false, followUp = null, turnId = null, rerun = [], yieldAt = undefined) => {
2679
+ // A journaled turn given a time to stop at yields before its first model
2680
+ // request after it, keeping the turn open.
2681
+ const stopAt = journal && yieldAt !== undefined ? { yieldAt } : {};
2682
+ if (resuming) {
2683
+ return request("session/prompt", { sessionId, prompt: [], _meta: { fx: { continueRecovery: true, ...(rerun.length ? { rerun } : {}), ...stopAt } } });
2684
+ }
1904
2685
  const images = blocks.filter((block) => block.type === "image" && block.bytes !== undefined);
1905
2686
  const ids = images.length ? attachBytes(images.map((block) => block.bytes)) : [];
1906
2687
  let next = 0;
@@ -1910,10 +2691,19 @@ export async function createFxAgent(options = {}) {
1910
2691
  ...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
1911
2692
  ...(block.bytes === undefined ? {} : { _meta: { fx: { attachment: ids[next++] } } }),
1912
2693
  });
1913
- return request("session/prompt", { sessionId, prompt });
2694
+ // A follow-up's turn places it; the core records it as accepted first
2695
+ // when the host could not. A journaled turn's first progress records its id.
2696
+ const fx = {
2697
+ ...(followUp ? { inputId: followUp.id, inputAccepted: followUp.accepted } : {}),
2698
+ ...(journal && turnId ? { turnId } : {}),
2699
+ ...stopAt,
2700
+ };
2701
+ return request("session/prompt", { sessionId, prompt, ...(Object.keys(fx).length ? { _meta: { fx } } : {}) });
1914
2702
  };
1915
2703
  runtime.exited.then((code) => {
1916
2704
  closing = true;
2705
+ // An agent whose core exited writes nothing more.
2706
+ if (journal) void closeJournal().catch(() => {});
1917
2707
  const error = runtime.error ?? new Error(`fx-core exited with code ${code} before completing the ACP request`);
1918
2708
  coreExitError = error;
1919
2709
  activeTurn?.failImagePrep(error);
@@ -1923,6 +2713,12 @@ export async function createFxAgent(options = {}) {
1923
2713
  });
1924
2714
  runtime.setLineHandler((message, size) => {
1925
2715
  emit("acp.receive", { message });
2716
+ if (message.method === "libfx/journal_append") {
2717
+ if (journal && message.params?.sessionId === sessionId) {
2718
+ queueJournalEvents(message.params.events, message.params.barrier === true);
2719
+ }
2720
+ return;
2721
+ }
1926
2722
  if (message.method === "session/update") {
1927
2723
  if (message.params.sessionId === sessionId) return activeTurn?.push(message.params.update, size);
1928
2724
  return;
@@ -1943,11 +2739,22 @@ export async function createFxAgent(options = {}) {
1943
2739
  send({ jsonrpc: "2.0", id: message.id, result: optionId ? { outcome: { outcome: "selected", optionId } } : { outcome: { outcome: "cancelled" } } });
1944
2740
  return;
1945
2741
  }
2742
+ if (message.method === "libfx/journal_flush") {
2743
+ // Every append the core sent before this request is already queued.
2744
+ let failure = null;
2745
+ try { await journalSettled(); } catch (error) { failure = error; }
2746
+ if (closing) return;
2747
+ send(failure
2748
+ ? { jsonrpc: "2.0", id: message.id, error: { code: -32000, message: failure.message } }
2749
+ : { jsonrpc: "2.0", id: message.id, result: {} });
2750
+ return;
2751
+ }
1946
2752
  if (message.method === "libfx/tool_call") {
1947
2753
  const { content, isError, rich, cancelled } = await executeHostTool(
1948
2754
  message.params?.name,
1949
2755
  message.params?.input,
1950
2756
  message.params?.sessionId,
2757
+ message.params?.toolCallId,
1951
2758
  );
1952
2759
  if (cancelled || closing) return;
1953
2760
  const response = { jsonrpc: "2.0", id: message.id, result: { content, isError, ...(rich ? { contentType: "rich" } : {}) } };
@@ -1974,11 +2781,53 @@ export async function createFxAgent(options = {}) {
1974
2781
  },
1975
2782
  });
1976
2783
 
1977
- const sessionResult = await request("libfx/new");
2784
+ const loaded = persistence ? await loadPersistence(persistence) : null;
2785
+ const requestedSessionId = options.sessionId ?? null;
2786
+ const sessionResult = await request("libfx/new", requestedSessionId === null ? {} : { sessionId: requestedSessionId });
1978
2787
  sessionId = sessionResult.sessionId;
1979
2788
  if (initialCheckpoint) {
1980
2789
  const [checkpointAttachment] = attachBytes([initialCheckpoint]);
1981
- await request("libfx/restore", { sessionId, checkpointAttachment });
2790
+ const restored = await request("libfx/restore", { sessionId, checkpointAttachment }).catch((error) => {
2791
+ if (/newer fx/.test(error?.message ?? "")) throw new FxJournalVersionError(error.message);
2792
+ throw error;
2793
+ });
2794
+ compareCheckpointMeta(restored?.checkpointMeta);
2795
+ }
2796
+ if (journal) {
2797
+ const { events, checkpoint } = loaded;
2798
+ const params = { sessionId, ...(options.inputsDurable === true ? { inputsDurable: true } : {}) };
2799
+ // One call: attaching again would drop the first attachment.
2800
+ const attachments = [];
2801
+ if (events.length > 0) {
2802
+ const bytes = encoder.encode(JSON.stringify(withoutSupersededProgress(events)));
2803
+ if (bytes.byteLength > maxJournalBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
2804
+ attachments.push(["journalAttachment", bytes]);
2805
+ }
2806
+ if (checkpoint) attachments.push(["snapshotAttachment", checkpoint]);
2807
+ const ids = attachments.length ? attachBytes(attachments.map(([, bytes]) => bytes)) : [];
2808
+ attachments.forEach(([name], index) => { params[name] = ids[index]; });
2809
+ const opened = await request("libfx/journal_open", params).catch((error) => {
2810
+ const message = error?.message ?? "";
2811
+ if (/newer fx/.test(message)) throw new FxJournalVersionError(message);
2812
+ const code = journalOpenErrorCodes.get(message);
2813
+ if (code) throw journalLoadError(message, code, error);
2814
+ throw error;
2815
+ });
2816
+ compareCheckpointMeta(opened?.checkpointMeta);
2817
+ resumable = opened?.resumable === true;
2818
+ openTurnId = resumable && validTurnId(opened?.openTurnId) ? opened.openTurnId : null;
2819
+ lastTurnId = validTurnId(opened?.lastTurnId) ? opened.lastTurnId : null;
2820
+ ambiguousCalls = resumable && Array.isArray(opened?.runningCalls)
2821
+ ? opened.runningCalls.filter((call) => typeof call?.id === "string" && typeof call.name === "string")
2822
+ : [];
2823
+ journalHead = loaded.head;
2824
+ appendedSeq = loaded.lastSeq;
2825
+ bytesSinceCheckpoint = loaded.tailBytes;
2826
+ // Follow-ups the journal holds, as accepted; each waits for `resume()`.
2827
+ for (const held of Array.isArray(opened?.followUps) ? opened.followUps : []) {
2828
+ followUps.push({ id: held.id, text: held.text, accepted: true, held: true });
2829
+ }
2830
+ emit("journal.open", { events: events.length, checkpoint: checkpoint !== null, turns: opened?.turns, resumable });
1982
2831
  }
1983
2832
  } catch (error) {
1984
2833
  closing = true;
@@ -1989,11 +2838,131 @@ export async function createFxAgent(options = {}) {
1989
2838
  }
1990
2839
 
1991
2840
  const agent = {
2841
+ get sessionId() {
2842
+ return sessionId;
2843
+ },
2844
+ // What libfx's session worker needs beyond the public surface.
2845
+ [engineInternals]: {
2846
+ // Resolves once every record the session started has landed: a
2847
+ // turn's result does not wait for its last ones.
2848
+ settled() {
2849
+ return journal ? journalSettled() : Promise.resolve();
2850
+ },
2851
+ // Saves a checkpoint now, at a turn's end or where it yielded, whatever
2852
+ // has accumulated since the last; resolves once it is stored or skipped.
2853
+ checkpoint() {
2854
+ return maybeCheckpoint(true);
2855
+ },
2856
+ // Resolves when the core this agent runs on exits.
2857
+ exited: runtime.exited,
2858
+ // The turn the journal left open, if any, and the calls it left
2859
+ // running, as `{ executionId, name, input }`.
2860
+ get openTurn() {
2861
+ if (!resumable) return null;
2862
+ return {
2863
+ id: openTurnId,
2864
+ calls: ambiguousCalls.map((call) => ({ executionId: call.id, name: call.name, input: parsedToolInput(call.arguments) })),
2865
+ };
2866
+ },
2867
+ },
2868
+ /**
2869
+ * Starts a turn. With `turnId`, a retried call is the same turn: when the
2870
+ * journal left that turn open, this continues it as `resume()` does, and
2871
+ * when that turn already ended, this returns a turn that has ended too,
2872
+ * without running it again.
2873
+ */
1992
2874
  prompt(input, promptOptions = {}) {
1993
2875
  if (closing) throw new Error("fx agent is closed");
2876
+ if (journalFailure) throw reportJournalFailure();
2877
+ if (handedOff) throw handedOffError();
1994
2878
  if (activeTurn) throw new Error("a prompt is already in progress for this session");
1995
- return normalizeTurn(startTurn(input, promptOptions));
2879
+ const turnId = promptOptions.turnId;
2880
+ if (turnId !== undefined && !validTurnId(turnId)) throw new TypeError(`turnId must be ${turnIdRule}`);
2881
+ checkOnAmbiguous(promptOptions.onAmbiguous);
2882
+ if (turnId !== undefined && journal) {
2883
+ if (resumable && turnId === openTurnId) {
2884
+ resumable = false;
2885
+ return resumeOpenTurn(promptOptions);
2886
+ }
2887
+ if (turnId === lastTurnId) return endedTurn(turnId);
2888
+ }
2889
+ // A new prompt ends a turn the last process left open as interrupted.
2890
+ resumable = false;
2891
+ ambiguousCalls = [];
2892
+ return normalizeTurn(startTurn(input, { ...promptOptions, turnId: turnId ?? `turn_${crypto.randomUUID()}` }));
1996
2893
  },
2894
+ /**
2895
+ * Continues the turn the last process left open, with no new input, and
2896
+ * returns it; returns null when the journal holds no open turn. The
2897
+ * model is told the session was interrupted; calls that were running
2898
+ * come back answered as possibly run, for the model to decide about,
2899
+ * unless `onAmbiguous(call)` returns "rerun" for them.
2900
+ */
2901
+ resume(promptOptions = {}) {
2902
+ if (closing) throw new Error("fx agent is closed");
2903
+ if (journalFailure) throw reportJournalFailure();
2904
+ if (handedOff) throw handedOffError();
2905
+ if (activeTurn) throw new Error("a prompt is already in progress for this session");
2906
+ checkOnAmbiguous(promptOptions.onAmbiguous);
2907
+ if (resumable) {
2908
+ resumable = false;
2909
+ return resumeOpenTurn(promptOptions);
2910
+ }
2911
+ // With no open turn, the follow-ups the journal held are the work left.
2912
+ const next = followUps.find((entry) => entry.held);
2913
+ if (!next) return null;
2914
+ let started = null;
2915
+ const resolveTurn = next.resolveTurn;
2916
+ next.resolveTurn = (turn) => {
2917
+ started = turn;
2918
+ resolveTurn?.(turn);
2919
+ };
2920
+ runNextFollowUp(true);
2921
+ return started;
2922
+ },
2923
+ /**
2924
+ * Queues `input` to run as its own turn after the current one, or at once
2925
+ * when no turn is running. Returns a promise for that turn, carrying the
2926
+ * follow-up's `id` and `accepted`, which resolves `{ id }` once a
2927
+ * journal holds it.
2928
+ */
2929
+ followUp(input) {
2930
+ if (closing) return Promise.reject(new Error("fx agent is closed"));
2931
+ if (journalFailure) return Promise.reject(reportJournalFailure());
2932
+ if (handedOff) return Promise.reject(handedOffError());
2933
+ let text;
2934
+ try { text = normalizeSteeringInput(input); } catch (error) { return Promise.reject(error); }
2935
+ const id = `in_${crypto.randomUUID()}`;
2936
+ const entry = { id, text, accepted: false };
2937
+ const started = new Promise((resolve, reject) => { entry.resolveTurn = resolve; entry.rejectTurn = reject; });
2938
+ let accepted;
2939
+ if (journal && activeTurn && typeof runtime.steer !== "function") {
2940
+ // The native core records it now, ahead of the turn that will run it.
2941
+ entry.accepted = true;
2942
+ accepted = request("libfx/follow_up", { sessionId, id, text })
2943
+ .then(() => waitForInput(`accepted:${id}`))
2944
+ .then(() => ({ id }), (error) => {
2945
+ const index = followUps.indexOf(entry);
2946
+ if (index >= 0) followUps.splice(index, 1);
2947
+ entry.rejectTurn(error);
2948
+ throw error;
2949
+ });
2950
+ } else {
2951
+ // Its own turn records it with the first progress, a barrier; it is
2952
+ // accepted once that is stored.
2953
+ accepted = started.then(() => (journal ? waitForInput(`accepted:${id}`) : undefined)).then(() => ({ id }));
2954
+ }
2955
+ followUps.push(entry);
2956
+ void accepted.catch(() => {});
2957
+ queueMicrotask(runNextFollowUp);
2958
+ started.id = id;
2959
+ started.accepted = accepted;
2960
+ return started;
2961
+ },
2962
+ /**
2963
+ * The session's state as opaque bytes, when no prompt is running; pass
2964
+ * them as `checkpoint` to restore it in a new agent.
2965
+ */
1997
2966
  checkpoint() {
1998
2967
  // One checkpoint runs at a time: each holds an outbound attachment until
1999
2968
  // it is taken, and the native table holds only a few. An idle call still
@@ -2011,18 +2980,39 @@ export async function createFxAgent(options = {}) {
2011
2980
  return run;
2012
2981
  },
2013
2982
  async close() {
2014
- if (closing) { await runtime.exited; return; }
2983
+ if (closing) {
2984
+ await runtime.exited;
2985
+ if (journal) await closeJournal();
2986
+ return;
2987
+ }
2015
2988
  const turn = activeTurn;
2016
2989
  turn?.cancel();
2017
2990
  if (turn) await turn.result.catch(() => {});
2991
+ // A checkpoint being taken needs the core; it finishes first.
2992
+ await checkpointing;
2018
2993
  closing = true;
2994
+ // A journal keeps these; the next `resume()` runs them.
2995
+ for (const entry of followUps.splice(0)) entry.rejectTurn?.(new Error("fx agent closed before the follow-up ran"));
2019
2996
  runtime.closeStdin();
2020
2997
  await runtime.exited;
2998
+ // Every append the session started has landed, or close reports why.
2999
+ if (journal) {
3000
+ let failure = null;
3001
+ try {
3002
+ await journalSettled();
3003
+ } catch {
3004
+ if (!journalFailureReported) failure = reportJournalFailure();
3005
+ }
3006
+ await closeJournal();
3007
+ if (failure) throw failure;
3008
+ }
2021
3009
  },
2022
3010
  };
2023
3011
  return agent;
2024
3012
 
2025
- function normalizeTurn(rawTurn) {
3013
+ // `answered`: calls a resumed turn answers as possibly run before it goes
3014
+ // on, whose `tool_end` comes first.
3015
+ function normalizeTurn(rawTurn, answered = []) {
2026
3016
  const toolNames = new Map();
2027
3017
  const started = new Set();
2028
3018
  const eventFor = (update) => {
@@ -2036,7 +3026,9 @@ export async function createFxAgent(options = {}) {
2036
3026
  return delta ? { type: "reasoning_delta", delta } : null;
2037
3027
  }
2038
3028
  if (update.sessionUpdate === "user_message_chunk" && update.content?.type === "text") {
2039
- return { type: "user_message", text: update.content.text };
3029
+ // A steer shows where it joined the turn, with the id it was sent with.
3030
+ const id = update._meta?.fx?.steering?.inputId;
3031
+ return { type: "user_message", text: update.content.text, ...(typeof id === "string" ? { id } : {}) };
2040
3032
  }
2041
3033
  if (update.sessionUpdate === "tool_call") {
2042
3034
  toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool");
@@ -2068,10 +3060,19 @@ export async function createFxAgent(options = {}) {
2068
3060
  }));
2069
3061
  void result.catch(() => {});
2070
3062
  return {
2071
- cancel() { rawTurn.cancel(); },
2072
- steer(input) { return rawTurn.steer(normalizeSteeringInput(input)); },
3063
+ id: rawTurn.id ?? null,
3064
+ cancel(cancelOptions) { rawTurn.cancel(cancelOptions); },
3065
+ steer(input, id) {
3066
+ if (id !== undefined && !validTurnId(id)) throw new TypeError(`steer id must be ${turnIdRule}`);
3067
+ return rawTurn.steer(normalizeSteeringInput(input), id);
3068
+ },
3069
+ withdraw(id) { return rawTurn.withdraw(id); },
3070
+ [engineInternals]: rawTurn[engineInternals],
2073
3071
  [Symbol.asyncIterator]() {
2074
3072
  const iterator = (async function* () {
3073
+ for (const call of answered) {
3074
+ yield { type: "tool_end", id: call.id, name: call.name, content: unfinishedToolOutput, isError: true };
3075
+ }
2075
3076
  for await (const update of rawTurn) {
2076
3077
  const event = eventFor(update);
2077
3078
  if (event) yield event;
@@ -2088,6 +3089,18 @@ export async function createFxAgent(options = {}) {
2088
3089
  };
2089
3090
  }
2090
3091
 
3092
+ // A turn the journal shows already ended, returned for a retried prompt.
3093
+ function endedTurn(id) {
3094
+ return {
3095
+ id,
3096
+ result: Promise.resolve({ stopReason: "end_turn", usage: {} }),
3097
+ cancel() {},
3098
+ steer() { return Promise.reject(new Error("no prompt is running")); },
3099
+ withdraw() { return Promise.resolve("already_placed"); },
3100
+ async *[Symbol.asyncIterator]() {},
3101
+ };
3102
+ }
3103
+
2091
3104
  function normalizeTurnUsage(usage) {
2092
3105
  const result = {};
2093
3106
  if (Number.isSafeInteger(usage?.inputTokens)) result.inputTokens = usage.inputTokens;
@@ -2111,7 +3124,9 @@ export async function createFxAgent(options = {}) {
2111
3124
 
2112
3125
  function startTurn(input, promptOptions) {
2113
3126
  const resizeImage = options.resizeImage;
2114
- const normalized = normalizePromptInput(input, { deferImageLimits: resizeImage !== undefined });
3127
+ const resuming = promptOptions[resumeTurn] === true;
3128
+ const followUp = promptOptions[followUpInput] ?? null;
3129
+ const normalized = resuming ? [] : normalizePromptInput(input, { deferImageLimits: resizeImage !== undefined });
2115
3130
  const hasPixels = normalized.some((block) => block.type === "image" && block.data !== undefined);
2116
3131
  // Blob reads and resizeImage run before the prompt frame is sent.
2117
3132
  const asyncImages = hasPixels && (resizeImage !== undefined ||
@@ -2124,6 +3139,8 @@ export async function createFxAgent(options = {}) {
2124
3139
  : preparePromptImages(normalized);
2125
3140
  const signal = promptOptions.signal;
2126
3141
  if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal");
3142
+ const yieldAt = promptOptions.yieldAt;
3143
+ if (yieldAt !== undefined && !(Number.isSafeInteger(yieldAt) && yieldAt > 0)) throw new TypeError("yieldAt must be a time in milliseconds since the epoch");
2127
3144
  const queue = [];
2128
3145
  const waiters = [];
2129
3146
  let queuedBytes = 0;
@@ -2138,10 +3155,55 @@ export async function createFxAgent(options = {}) {
2138
3155
  const promptStarted = asyncImages ? new Promise((resolve) => { resolvePromptStart = resolve; }) : null;
2139
3156
  let pendingSteeringCount = 0;
2140
3157
  let pendingSteeringBytes = 0;
3158
+ // This turn's steers: whether each reached the core, or was withdrawn first.
3159
+ const steers = new Map();
3160
+ const withdrawnSteer = (id) => Object.assign(new Error("steer was withdrawn"), { code: "FX_STEER_WITHDRAWN", id });
3161
+ const startSteer = (id, text) => {
3162
+ if (finished || cancelled || activeTurn !== turn) {
3163
+ return Promise.reject(new Error("no prompt is running"));
3164
+ }
3165
+ if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
3166
+ const local = { sent: false, withdrawn: false };
3167
+ steers.set(id, local);
3168
+ const apply = () => {
3169
+ if (local.withdrawn) return Promise.resolve({ id, withdrawn: true });
3170
+ if (finished || cancelled || activeTurn !== turn) {
3171
+ return Promise.reject(new Error("no prompt is running"));
3172
+ }
3173
+ if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
3174
+ try {
3175
+ local.sent = true;
3176
+ const accepted = () => (journal ? waitForInput(`accepted:${id}`) : Promise.resolve());
3177
+ if (typeof runtime.steer === "function") {
3178
+ runtime.steer(text, id);
3179
+ // The web core accepts it when it takes it at the next boundary,
3180
+ // and shows where it joined the turn then.
3181
+ return accepted().then(() => ({ id }), (error) => (error?.code === "FX_STEER_WITHDRAWN" ? { id, withdrawn: true } : Promise.reject(error)));
3182
+ }
3183
+ return request("libfx/steer", { sessionId, text, id }).then(accepted).then(() => ({ id }));
3184
+ } catch (error) {
3185
+ return Promise.reject(error);
3186
+ }
3187
+ };
3188
+ if (!resolvePromptStart) return apply();
3189
+ const bytes = encoder.encode(text).length;
3190
+ if (pendingSteeringCount >= maxSteeringMessages || bytes > maxSteeringQueueBytes - pendingSteeringBytes) {
3191
+ return Promise.reject(new Error("steering queue is full"));
3192
+ }
3193
+ pendingSteeringCount++;
3194
+ pendingSteeringBytes += bytes;
3195
+ return promptStarted.then((started) => {
3196
+ if (coreExitError && !cancelled) throw coreExitError;
3197
+ return started === true ? apply() : Promise.reject(started instanceof Error ? started : new Error("no prompt is running"));
3198
+ }).finally(() => { pendingSteeringCount--; pendingSteeringBytes -= bytes; });
3199
+ };
2141
3200
  const toolControllers = new Set();
2142
3201
  let finished = false;
2143
3202
  let cancelled = false;
2144
3203
  const turn = {
3204
+ // The host's id: the one it gave, or for a resumed turn, the one the
3205
+ // turn started with.
3206
+ id: resuming ? openTurnId : promptOptions.turnId ?? null,
2145
3207
  push(update, size = encoder.encode(JSON.stringify(update)).length) {
2146
3208
  if (cancelled || finished) { discardedBytes += size; return; }
2147
3209
  if (size > maxCoreMessageBytes) throw new RangeError("core output message exceeds 64 MiB");
@@ -2171,44 +3233,60 @@ export async function createFxAgent(options = {}) {
2171
3233
  resolvePromptStart?.(true);
2172
3234
  resolvePromptStart = null;
2173
3235
  },
2174
- steer(text) {
2175
- if (finished || cancelled || activeTurn !== turn) {
2176
- return Promise.reject(new Error("no prompt is running"));
3236
+ // Resolves `{ id }` once the steer is accepted: in a journaled session,
3237
+ // once its acceptance is stored. The promise carries `id` at once.
3238
+ steer(text, id = `in_${crypto.randomUUID()}`) {
3239
+ const steered = startSteer(id, text).then((value) => value ?? { id });
3240
+ steered.id = id;
3241
+ return steered;
3242
+ },
3243
+ // 'withdrawn' when the model never saw the steer, else 'already_placed'.
3244
+ withdraw(id) {
3245
+ const local = steers.get(id);
3246
+ if (!local) return Promise.resolve("already_placed");
3247
+ if (!local.sent) {
3248
+ local.withdrawn = true;
3249
+ return Promise.resolve("withdrawn");
2177
3250
  }
2178
- if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
2179
- const apply = () => {
2180
- if (finished || cancelled || activeTurn !== turn) {
2181
- return Promise.reject(new Error("no prompt is running"));
2182
- }
2183
- if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
2184
- try {
2185
- if (typeof runtime.steer === "function") {
2186
- runtime.steer(text);
2187
- void turn.push({
2188
- sessionUpdate: "user_message_chunk",
2189
- content: { type: "text", text },
2190
- });
2191
- return Promise.resolve();
2192
- }
2193
- return request("libfx/steer", { sessionId, text });
2194
- } catch (error) {
2195
- return Promise.reject(error);
2196
- }
2197
- };
2198
- if (!resolvePromptStart) return apply();
2199
- const bytes = encoder.encode(text).length;
2200
- if (pendingSteeringCount >= maxSteeringMessages || bytes > maxSteeringQueueBytes - pendingSteeringBytes) {
2201
- return Promise.reject(new Error("steering queue is full"));
3251
+ if (typeof runtime.withdrawSteering === "function") {
3252
+ if (!runtime.withdrawSteering(id)) return Promise.resolve("already_placed");
3253
+ local.withdrawn = true;
3254
+ rejectInputWaiters([`accepted:${id}`], withdrawnSteer(id));
3255
+ return Promise.resolve("withdrawn");
2202
3256
  }
2203
- pendingSteeringCount++;
2204
- pendingSteeringBytes += bytes;
2205
- return promptStarted.then((started) => {
2206
- if (coreExitError && !cancelled) throw coreExitError;
2207
- return started === true ? apply() : Promise.reject(started instanceof Error ? started : new Error("no prompt is running"));
2208
- }).finally(() => { pendingSteeringCount--; pendingSteeringBytes -= bytes; });
3257
+ return request("libfx/withdraw", { sessionId, id }).then(async (response) => {
3258
+ if (response?.result !== "withdrawn") return "already_placed";
3259
+ if (journal) await waitForInput(`withdrawn:${id}`);
3260
+ return "withdrawn";
3261
+ });
3262
+ },
3263
+ // For libfx's session worker: a steer that cannot join the turn, such
3264
+ // as one with images, ends the turn at its next safe point, at once
3265
+ // while the model streams a reply, so that steer can run next.
3266
+ [engineInternals]: {
3267
+ endForSteer() {
3268
+ const send = () => {
3269
+ if (finished || cancelled || activeTurn !== turn || closing) return undefined;
3270
+ if (typeof runtime.handOffSteering === "function") {
3271
+ runtime.handOffSteering();
3272
+ return undefined;
3273
+ }
3274
+ return request("libfx/handoff", { sessionId }).then(() => undefined);
3275
+ };
3276
+ const sent = promptStarted && resolvePromptStart ? promptStarted.then((started) => (started === true ? send() : undefined)) : Promise.resolve().then(send);
3277
+ return sent.catch(() => {});
3278
+ },
2209
3279
  },
2210
- cancel() {
3280
+ cancel(cancelOptions = {}) {
3281
+ const reason = cancelOptions?.reason;
3282
+ if (reason !== undefined && reason !== "handoff") throw new TypeError('cancel() reason must be "handoff" when given');
3283
+ if (reason === "handoff" && !journal) throw new TypeError("a handoff needs persistence to keep the turn in");
2211
3284
  if (finished || cancelled) return;
3285
+ if (reason === "handoff") {
3286
+ handedOff = true;
3287
+ // The journal keeps queued follow-ups for the next agent.
3288
+ for (const entry of followUps.splice(0)) entry.rejectTurn?.(handedOffError());
3289
+ }
2212
3290
  cancelled = true;
2213
3291
  cancelImagePrep?.();
2214
3292
  cancelImagePrep = null;
@@ -2271,21 +3349,43 @@ export async function createFxAgent(options = {}) {
2271
3349
  resolvePromptStart = null;
2272
3350
  return { stopReason: "cancelled" };
2273
3351
  }
2274
- return sendPrompt(prepared);
3352
+ return sendPrompt(prepared, false, followUp, turn.id, [], yieldAt);
2275
3353
  });
2276
3354
  } else {
2277
- try { response = sendPrompt(prompt); } catch (error) { response = Promise.reject(error); }
3355
+ try { response = sendPrompt(prompt, resuming, followUp, turn.id, promptOptions[rerunCallIds] ?? [], yieldAt); } catch (error) { response = Promise.reject(error); }
2278
3356
  }
3357
+ // Appends after a turn's first progress are lazy, so the result does not
3358
+ // wait for them; `close()` does. A failure already seen fails the turn.
2279
3359
  turn.result = response
2280
- .then((value) => ({ stopReason: cancelled ? "cancelled" : value.stopReason, usage: value.usage }))
2281
- .catch((error) => {
3360
+ .then((value) => {
3361
+ if (journalFailure) throw reportJournalFailure();
3362
+ if (value.stopReason === "yielded" && !cancelled) {
3363
+ // The core keeps the turn open: `resume()` continues it here, or
3364
+ // a later agent continues it from the journal.
3365
+ resumable = true;
3366
+ openTurnId = turn.id;
3367
+ ambiguousCalls = [];
3368
+ } else if (!handedOff) {
3369
+ // A handed-off turn stays open for the next agent.
3370
+ if (turn.id) lastTurnId = turn.id;
3371
+ openTurnId = null;
3372
+ }
3373
+ return { stopReason: cancelled ? "cancelled" : value.stopReason, usage: value.usage };
3374
+ })
3375
+ .catch(async (error) => {
2282
3376
  cancelImagePrep = null;
2283
3377
  rejectImagePrep = null;
2284
3378
  resolvePromptStart?.(error);
2285
3379
  resolvePromptStart = null;
3380
+ // A failed append is why the core stopped, including when the turn
3381
+ // was cancelled because of it; report the host's error.
3382
+ if (journalFailure) {
3383
+ terminalError = reportJournalFailure();
3384
+ throw terminalError;
3385
+ }
2286
3386
  if (cancelled && error.message === "Cancelled") return { stopReason: "cancelled" };
2287
3387
  terminalError = error;
2288
- throw error;
3388
+ throw terminalError;
2289
3389
  })
2290
3390
  .finally(() => {
2291
3391
  finished = true;
@@ -2293,6 +3393,17 @@ export async function createFxAgent(options = {}) {
2293
3393
  resumeOutput = null;
2294
3394
  signal?.removeEventListener("abort", abort);
2295
3395
  if (activeTurn === turn) activeTurn = null;
3396
+ // A steer still queued when the turn ends never reached the model.
3397
+ if (typeof runtime.withdrawSteering === "function") {
3398
+ const untaken = [...steers.keys()].filter((id) => runtime.withdrawSteering(id));
3399
+ rejectInputWaiters(untaken.map((id) => `accepted:${id}`), new Error("the turn ended before it took the steer"));
3400
+ }
3401
+ for (const id of steers.keys()) {
3402
+ durableInputs.delete(`accepted:${id}`);
3403
+ durableInputs.delete(`withdrawn:${id}`);
3404
+ }
3405
+ queueMicrotask(runNextFollowUp);
3406
+ queueMicrotask(() => { void maybeCheckpoint(); });
2296
3407
  runtime.closeSteering?.();
2297
3408
  toolControllers.clear();
2298
3409
  if (discardedBytes) emit("output.discarded", { reason: "cancelled", bytes: discardedBytes });