libfx 0.0.13 → 0.0.14-dev.1561.g1a070af32cdf

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,5 +1,13 @@
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";
7
+ import { toolObservation } from "./tool-observation.js";
8
+
9
+ // Providers that run on a user's plan, authorized by the host's credentials.
10
+ const subscriptionProviders = new Set(["codex", "grok"]);
3
11
 
4
12
  const encoder = new TextEncoder();
5
13
  const decoder = new TextDecoder();
@@ -7,12 +15,19 @@ const strictDecoder = new TextDecoder("utf-8", { fatal: true });
7
15
  const workspaceInfoLimit = 4 * 1024;
8
16
  const workspaceCommandLimit = 64 * 1024;
9
17
  const workspaceOutputLimit = 64 * 1024;
18
+ // Matches max_instruction_file_bytes and max_instruction_path_bytes in js_host_workspace.zig.
19
+ const workspaceInstructionFileLimit = 1024 * 1024;
20
+ const workspaceInstructionPathLimit = 4 * 1024;
21
+ const workspaceReadTimeoutMs = 10_000;
10
22
  const maxInstructionsBytes = 64 * 1024;
11
23
  const maxApiKeyBytes = 64 * 1024;
12
24
  const maxModelBytes = 1024;
13
25
  // Matches the kernel's ReasoningEffort.max_name_bytes.
14
26
  const maxEffortBytes = 64;
15
27
  const maxUrlBytes = 16 * 1024;
28
+ // The answer a resumed turn gives a call its crash left running; matches
29
+ // unfinished_tool_output in session_adapter.zig.
30
+ const unfinishedToolOutput = "fx stopped while this tool was running, so it may have partly run. Check its effects before running it again.";
16
31
  const maxModelCatalogBytes = 4 * 1024 * 1024;
17
32
  const maxModelCatalogEntries = 10_000;
18
33
  const streamReadsPerTaskYield = 32;
@@ -33,6 +48,8 @@ const maxPendingAttachments = 8;
33
48
  const maxOutboundAttachments = 4;
34
49
  // Matches the core's kernel checkpoint limit (max_checkpoint_bytes).
35
50
  const maxCheckpointBytes = 4 * 1024 * 1024;
51
+ // Matches the core's journal load limit (journal.max_load_bytes).
52
+ const maxJournalBytes = 4 * 1024 * 1024;
36
53
  // The core's ACP reader drops frames over 8 MiB without a request id to answer
37
54
  // (jsonrpc frame_resource_byte_limit), so the SDK must never emit one. The
38
55
  // envelope allowance covers the method key and request id.
@@ -101,15 +118,9 @@ function normalizeUltrafast(value) {
101
118
  return value;
102
119
  }
103
120
 
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);
121
+ // The model choice in `options`, a model object or a model ID with the
122
+ // deprecated top-level options, as `{ model, effort, fast, ultrafast }`.
123
+ export function normalizeModelChoice(options) {
113
124
  if (options.model !== null && typeof options.model === "object" && !Array.isArray(options.model)) {
114
125
  if (Object.hasOwn(options, "effort") || Object.hasOwn(options, "fast") || Object.hasOwn(options, "ultrafast")) {
115
126
  throw new TypeError("model options cannot be mixed with top-level effort, fast, or ultrafast");
@@ -120,26 +131,372 @@ export function normalizeAgentOptions(value) {
120
131
  throw new TypeError(`unsupported model option: ${name}`);
121
132
  }
122
133
  }
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);
134
+ return {
135
+ model: boundedString(model.id, "model.id", maxModelBytes, true),
136
+ effort: normalizeEffort(model.effort),
137
+ fast: normalizeFast(model.fast),
138
+ ultrafast: normalizeUltrafast(model.ultrafast),
139
+ };
132
140
  }
141
+ return {
142
+ model: boundedString(options.model, "model", maxModelBytes, false),
143
+ effort: normalizeEffort(options.effort),
144
+ fast: normalizeFast(options.fast),
145
+ ultrafast: normalizeUltrafast(options.ultrafast),
146
+ };
147
+ }
148
+
149
+ export function normalizeAgentOptions(value) {
150
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
151
+ throw new TypeError("createFxEngine() options must be an object");
152
+ }
153
+ const options = { ...value };
154
+ if (options.hooks !== undefined) {
155
+ const hooks = options.hooks;
156
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks) || typeof hooks.onEvent !== "function") {
157
+ throw new TypeError("hooks must be an object with onEvent()");
158
+ }
159
+ if (hooks.onError !== undefined && typeof hooks.onError !== "function") throw new TypeError("hooks.onError must be a function");
160
+ if (hooks.captureContent !== undefined && typeof hooks.captureContent !== "boolean") throw new TypeError("hooks.captureContent must be a boolean");
161
+ options.hooks = {
162
+ captureContent: hooks.captureContent === true,
163
+ onEvent: hooks.onEvent.bind(hooks),
164
+ ...(hooks.onError === undefined ? {} : { onError: hooks.onError.bind(hooks) }),
165
+ ...(hooks.parent === undefined ? {} : { parent: { ...hooks.parent } }),
166
+ };
167
+ if (options.hooks.parent !== undefined) {
168
+ for (const name of ["rootSessionId", "sessionId", "turnId", "callId"]) {
169
+ 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`);
170
+ }
171
+ }
172
+ }
173
+ if (Object.hasOwn(options, "env")) {
174
+ throw new TypeError("createFxEngine() does not accept env; pass apiKey and model directly");
175
+ }
176
+ // Derived from the caller's provider every time, so normalizing again agrees.
177
+ const custom = options.provider !== null && typeof options.provider === "object"
178
+ ? customProviderDefinition(options.provider)
179
+ : undefined;
180
+ if (!custom && options.provider !== undefined && options.provider !== "gateway" && !subscriptionProviders.has(options.provider)) {
181
+ throw new TypeError("provider must be \"gateway\", \"codex\", \"grok\" or a custom provider { name, baseUrl }");
182
+ }
183
+ options.customProvider = custom;
184
+ options.providerName = custom ? custom.name : options.provider;
185
+ const subscription = subscriptionProviders.has(options.provider);
186
+ // Gateway needs its key; every other provider runs on the host's credentials.
187
+ options.apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, !subscription && !custom);
188
+ if (subscription && typeof options.credentials !== "function") {
189
+ throw new TypeError(`provider "${options.provider}" needs a credentials() function`);
190
+ }
191
+ if (!subscription && !custom && options.credentials !== undefined) {
192
+ throw new TypeError("credentials needs provider \"codex\", \"grok\" or a custom provider");
193
+ }
194
+ // A custom provider's key is never refreshed, so it has no onCredentials or refreshLock.
195
+ for (const name of ["onCredentials", "refreshLock"]) {
196
+ if (!subscription && options[name] !== undefined) throw new TypeError(`${name} needs provider "codex" or "grok"`);
197
+ }
198
+ for (const name of ["credentials", "onCredentials", "refreshLock"]) {
199
+ if (options[name] !== undefined && typeof options[name] !== "function") throw new TypeError(`${name} must be a function`);
200
+ }
201
+ Object.assign(options, normalizeModelChoice(options));
202
+ // fx's default model is a Gateway id; a custom provider names its own.
203
+ if (custom && options.model === undefined) throw new TypeError("a custom provider needs a model");
133
204
  validateGatewayChatUrl(options.gatewayChatUrl);
134
205
  if (options.resizeImage !== undefined && typeof options.resizeImage !== "function") {
135
206
  throw new TypeError("resizeImage must be a function");
136
207
  }
208
+ if (options.sessionId !== undefined && options.sessionId !== null && !validSessionId(options.sessionId)) {
209
+ throw new TypeError(`sessionId must be ${sessionIdRule}`);
210
+ }
211
+ // A separate key, so normalizing these options again reads the caller's value.
212
+ if (options.modelCatalog !== undefined) options.modelCatalogBody = modelCatalogBody(options.modelCatalog);
213
+ if (options.persistence !== undefined) {
214
+ const persistence = options.persistence;
215
+ if (!persistence || typeof persistence !== "object" ||
216
+ typeof persistence.load !== "function" || typeof persistence.append !== "function" ||
217
+ (persistence.saveCheckpoint !== undefined && typeof persistence.saveCheckpoint !== "function")) {
218
+ throw new TypeError("persistence must be an object with load(), append() and, optionally, saveCheckpoint()");
219
+ }
220
+ if (options.checkpoint !== undefined) {
221
+ throw new TypeError("persistence cannot be combined with checkpoint");
222
+ }
223
+ }
224
+ if (options.checkpointAfterBytes !== undefined) {
225
+ if (options.persistence === undefined) throw new TypeError("checkpointAfterBytes needs persistence");
226
+ if (!Number.isSafeInteger(options.checkpointAfterBytes) || options.checkpointAfterBytes < 0) {
227
+ throw new RangeError("checkpointAfterBytes must be a non-negative integer");
228
+ }
229
+ }
137
230
  return options;
138
231
  }
139
232
 
233
+ // Keys for libfx's own session worker; symbols keep them off the engine's
234
+ // public surface.
235
+ export const engineInternals = Symbol.for("libfx.engineInternals");
236
+ export const journalMarksWanted = Symbol.for("libfx.journalMarks");
237
+ // Marks an error the core answered a request with, as opposed to one from a
238
+ // core that stopped answering.
239
+ export const coreAnswered = Symbol.for("libfx.coreAnswered");
240
+
241
+ /**
242
+ * Another writer took over the session after this agent loaded it. A
243
+ * persistence store rejects an append whose `expected` cursor is not its head
244
+ * with this error; the agent stops its turn at once and makes no further
245
+ * effect or write. Check `code === "FX_FENCED"` when the error may come from
246
+ * another copy of libfx.
247
+ */
248
+ export class FxFencedError extends Error {
249
+ constructor(message) {
250
+ super(message);
251
+ this.name = "FxFencedError";
252
+ this.code = "FX_FENCED";
253
+ }
254
+ }
255
+
256
+ /** The journal, or the `checkpoint` option, was written by a newer libfx. */
257
+ export class FxJournalVersionError extends Error {
258
+ constructor(message) {
259
+ super(message);
260
+ this.name = "FxJournalVersionError";
261
+ this.code = "FX_JOURNAL_VERSION";
262
+ }
263
+ }
264
+
265
+ // A journal this libfx cannot open: more than one load holds
266
+ // (FX_JOURNAL_TOO_LARGE), or events that do not fold (FX_JOURNAL_INVALID).
267
+ function journalLoadError(message, code, cause) {
268
+ const error = new Error(message, cause === undefined ? undefined : { cause });
269
+ error.code = code;
270
+ return error;
271
+ }
272
+
273
+ // The core's libfx/journal_open errors, by their exact message. The journal
274
+ // tests open a journal for each, so rewording one fails them, except the size
275
+ // limit: libfx refuses a journal over it before the core sees one, so that
276
+ // entry is a backstop.
277
+ const journalOpenErrorCodes = new Map([
278
+ ["Invalid libfx journal", "FX_JOURNAL_INVALID"],
279
+ ["libfx journal events are out of order", "FX_JOURNAL_INVALID"],
280
+ ["libfx journal is too large", "FX_JOURNAL_TOO_LARGE"],
281
+ ["libfx journal holds more than 1024 turns", "FX_JOURNAL_TOO_LARGE"],
282
+ ]);
283
+
284
+ /**
285
+ * A persistence store that keeps a session's records and its latest
286
+ * checkpoint in memory, for tests and for hosts that copy them elsewhere.
287
+ * `records` is the stored list, oldest first. An append whose `expected`
288
+ * cursor is not the last record's is refused, so a second agent cannot write
289
+ * the same session, and an append that repeats a stored write's
290
+ * `idempotencyKey` returns that write's cursor.
291
+ */
292
+ export function createMemoryPersistence() {
293
+ const records = [];
294
+ let checkpoint = null;
295
+ const head = () => records.at(-1)?.cursor ?? null;
296
+ return {
297
+ records,
298
+ get checkpoint() {
299
+ return checkpoint;
300
+ },
301
+ async load() {
302
+ const after = checkpoint ? records.findIndex((record) => record.cursor === checkpoint.through) + 1 : 0;
303
+ return {
304
+ ...(checkpoint ? { checkpoint: { data: checkpoint.data.slice(), through: checkpoint.through } } : {}),
305
+ journal: records.slice(after).map(({ cursor, data }) => ({ cursor, data: data.slice() })),
306
+ };
307
+ },
308
+ async append({ expected, idempotencyKey, data }) {
309
+ const stored = records.find((record) => record.idempotencyKey === idempotencyKey);
310
+ if (stored) return { cursor: stored.cursor };
311
+ if (expected !== head()) {
312
+ throw new FxFencedError(`another agent appended to this session: expected ${expected}, head ${head()}`);
313
+ }
314
+ const cursor = String(records.length + 1);
315
+ records.push({ cursor, idempotencyKey, data: data.slice() });
316
+ return { cursor };
317
+ },
318
+ async saveCheckpoint({ through, data }) {
319
+ checkpoint = { through, data: data.slice() };
320
+ },
321
+ };
322
+ }
323
+
324
+ // Marks the turn `agent.resume()` starts; never a caller-visible option.
325
+ const resumeTurn = Symbol("libfx.resume");
326
+ // Carries the follow-up a turn runs: `{ id, accepted }`.
327
+ const followUpInput = Symbol("libfx.followUp");
328
+ // Carries the ids of the calls a resumed turn runs again.
329
+ const rerunCallIds = Symbol("libfx.rerun");
330
+
331
+ function journalAppendError(cause) {
332
+ const error = new Error("libfx journal append failed", { cause });
333
+ error.code = "FX_JOURNAL_APPEND_FAILED";
334
+ return error;
335
+ }
336
+
337
+ // Restoring an open turn needs only its newest progress, and the ones it
338
+ // extends when it carries only what is new back to one that carries all of
339
+ // it, and the results of calls no progress has answered yet. So an older
340
+ // progress's state, and an answered call's result, are left out of a load
341
+ // (data: null). A load then grows with the session's committed turns, not
342
+ // with every step of every turn.
343
+ function withoutSupersededProgress(events) {
344
+ const kept = new Array(events.length);
345
+ let superseded = false;
346
+ let answered = false;
347
+ for (let index = events.length - 1; index >= 0; index -= 1) {
348
+ const event = events[index];
349
+ const type = event?.type;
350
+ const dropped = event?.data != null && ((type === "turn_progress" && superseded) || (type === "tool_result" && answered));
351
+ kept[index] = dropped ? { ...event, data: null } : event;
352
+ if (type === "turn_committed" || type === "turn_progress_cleared") {
353
+ superseded = true;
354
+ answered = true;
355
+ } else if (type === "turn_progress") {
356
+ answered = true;
357
+ if (event.base === undefined) superseded = true;
358
+ }
359
+ }
360
+ return kept;
361
+ }
362
+
363
+ // A session id reaches gateway headers; the core applies the same rule.
364
+ function validSessionId(id) {
365
+ return typeof id === "string" && /^[A-Za-z0-9._-]{1,255}$/.test(id) &&
366
+ id !== "." && id !== ".." && id.toLowerCase() !== "v2";
367
+ }
368
+ const sessionIdRule = "1 to 255 letters, digits, '.', '_' or '-'";
369
+
370
+ // A record is one append's events as UTF-8 JSON. Its shape is libfx's; a
371
+ // store keeps the bytes as they are.
372
+ const journalRecordFormat = "libfx-journal-v1";
373
+ // Checkpoints after the journal since the last one reaches this many bytes.
374
+ const defaultCheckpointAfterBytes = 1024 * 1024;
375
+ // A turn id reaches the journal and tool calls; the core applies the same rule.
376
+ const turnIdRule = "1 to 128 letters, digits, '.', '_' or '-'";
377
+
378
+ function validTurnId(id) {
379
+ return typeof id === "string" && /^[A-Za-z0-9._-]{1,128}$/.test(id);
380
+ }
381
+
382
+ function checkOnAmbiguous(onAmbiguous) {
383
+ if (onAmbiguous !== undefined && typeof onAmbiguous !== "function") throw new TypeError("onAmbiguous must be a function");
384
+ }
385
+
386
+ // A call's input as the model wrote it: JSON when it parses, else the text.
387
+ function parsedToolInput(text) {
388
+ try { return JSON.parse(text); } catch { return text; }
389
+ }
390
+
391
+ function validCursor(cursor) {
392
+ return typeof cursor === "string" && cursor.length > 0 && cursor.length <= 1024;
393
+ }
394
+
395
+ function encodeJournalRecord(events) {
396
+ return encoder.encode(JSON.stringify({ format: journalRecordFormat, events }));
397
+ }
398
+
399
+ function persistenceBytes(value, what) {
400
+ if (value instanceof Uint8Array) return value;
401
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
402
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
403
+ throw new TypeError(`${what} must be bytes`);
404
+ }
405
+
406
+ function decodeJournalRecord(data) {
407
+ let value;
408
+ try { value = JSON.parse(decoder.decode(persistenceBytes(data, "a journal record's data"))); } catch (error) {
409
+ if (error instanceof TypeError && /must be bytes/.test(error.message)) throw error;
410
+ throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID", error);
411
+ }
412
+ if (value?.format !== journalRecordFormat || !Array.isArray(value.events)) {
413
+ if (typeof value?.format === "string" && value.format.startsWith("libfx-journal-v")) {
414
+ throw new FxJournalVersionError("libfx journal was written by a newer fx");
415
+ }
416
+ throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID");
417
+ }
418
+ return value.events;
419
+ }
420
+
421
+ // The last `seq` a checkpoint covers, from its header: `FXSN`, a version
422
+ // byte, then the seq as a little-endian u64. The core checks the rest.
423
+ function checkpointSeq(bytes) {
424
+ if (bytes.byteLength < 13 || decoder.decode(bytes.subarray(0, 4)) !== "FXSN") {
425
+ throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID");
426
+ }
427
+ return Number(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(5, true));
428
+ }
429
+
430
+ // Reads what `persistence.load()` returns: the latest checkpoint, if any, and
431
+ // the records after it, as the events the core folds after that checkpoint.
432
+ // Modeled in `tests/tla/durable/SnapshotTail.tla`.
433
+ async function loadPersistence(persistence) {
434
+ const loaded = await persistence.load();
435
+ if (!loaded || typeof loaded !== "object") {
436
+ throw new TypeError("persistence.load() must resolve to { checkpoint?, journal? }");
437
+ }
438
+ let checkpoint = null;
439
+ let covered = 0;
440
+ let head = null;
441
+ if (loaded.checkpoint != null) {
442
+ const { data, through } = loaded.checkpoint;
443
+ if (!validCursor(through)) throw new TypeError("persistence.load() checkpoint.through must be a cursor string");
444
+ checkpoint = persistenceBytes(data, "checkpoint.data").slice();
445
+ if (checkpoint.byteLength > maxCheckpointBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
446
+ covered = checkpointSeq(checkpoint);
447
+ head = through;
448
+ }
449
+ const records = loaded.journal ?? [];
450
+ if (typeof records[Symbol.iterator] !== "function" && typeof records[Symbol.asyncIterator] !== "function") {
451
+ throw new TypeError("persistence.load() journal must be an iterable of { cursor, data }");
452
+ }
453
+ const events = [];
454
+ let bytes = 0;
455
+ for await (const record of records) {
456
+ if (!record || !validCursor(record.cursor)) throw new TypeError("each journal record needs a cursor string");
457
+ // The load limit applies once an open turn's older progress is dropped.
458
+ const data = persistenceBytes(record.data, "a journal record's data");
459
+ bytes += data.byteLength;
460
+ // A store may return records the checkpoint already covers.
461
+ for (const event of decodeJournalRecord(data)) if (!(Number.isSafeInteger(event?.seq) && event.seq <= covered)) events.push(event);
462
+ head = record.cursor;
463
+ }
464
+ // A store whose log holds entries of its own between records names the
465
+ // entry the next record follows.
466
+ if (loaded.head !== undefined) {
467
+ if (loaded.head !== null && !validCursor(loaded.head)) throw new TypeError("persistence.load() head must be a cursor string or null");
468
+ head = loaded.head;
469
+ }
470
+ return { checkpoint, events, head, tailBytes: bytes, lastSeq: events.at(-1)?.seq ?? covered };
471
+ }
472
+
473
+ // What a record's events say about turns, in order, for a store that tracks
474
+ // them without reading records: a turn started, ended, or yielded, an input
475
+ // was accepted or handed back unplaced, or the step the open turn is at, as
476
+ // its tool steps finished and the state of its calls.
477
+ function journalMarks(events) {
478
+ const marks = [];
479
+ for (const event of events) {
480
+ if (event?.type === "turn_progress") {
481
+ if (typeof event.turnId === "string") marks.push({ start: event.turnId });
482
+ // The inputs its model request carries for the first time.
483
+ for (const id of Array.isArray(event.inputs) ? event.inputs : []) if (typeof id === "string") marks.push({ placed: id });
484
+ // A progress that carries only what is new counts all the turn's steps.
485
+ const steps = Number.isSafeInteger(event.steps) ? event.steps : event.data?.execution?.tool_steps?.length;
486
+ if (Number.isSafeInteger(steps)) marks.push({ step: `${steps}:${String(event.data?.tool_state ?? "none")}` });
487
+ } else if (event?.type === "turn_committed" || event?.type === "turn_progress_cleared") marks.push({ end: true });
488
+ else if (event?.type === "turn_yielded") marks.push({ yield: true });
489
+ else if (event?.type === "input_accepted" && typeof event.data?.id === "string") marks.push({ accepted: event.data.id });
490
+ else if (event?.type === "input_withdrawn" && typeof event.data?.id === "string") marks.push({ withdrawn: event.data.id });
491
+ }
492
+ return marks;
493
+ }
494
+
140
495
  function agentEnvironment(options) {
141
496
  return {
142
- AI_GATEWAY_API_KEY: options.apiKey,
497
+ ...(options.apiKey === undefined ? {} : { AI_GATEWAY_API_KEY: options.apiKey }),
498
+ ...(options.providerName === undefined ? {} : { FX_PROVIDER: options.providerName }),
499
+ ...(options.customProvider === undefined ? {} : { FX_CONFIGURED_PROVIDERS: options.customProvider.json }),
143
500
  ...(options.model === undefined ? {} : { FX_MODEL: options.model }),
144
501
  ...(options.effort === undefined ? {} : { FX_EFFORT: options.effort }),
145
502
  ...(options.fast === undefined ? {} : { FX_FAST: options.fast ? "true" : "false" }),
@@ -150,6 +507,7 @@ function agentEnvironment(options) {
150
507
 
151
508
  function agentRpcError(response) {
152
509
  const error = new Error(response.message);
510
+ error[coreAnswered] = true;
153
511
  const data = response.data;
154
512
  if (data && ["LIBFX_MODEL_UNSUPPORTED_EFFORT", "LIBFX_MODEL_UNSUPPORTED_FAST", "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST"].includes(data.code) &&
155
513
  typeof data.model === "string" &&
@@ -253,12 +611,24 @@ function validWorkspacePath(path) {
253
611
  return path.slice(1).split("/").every((part) => part && part !== "." && part !== "..");
254
612
  }
255
613
 
614
+ function workspacePathInside(root, path) {
615
+ return root === "/" ? path.startsWith("/") : path === root || path.startsWith(`${root}/`);
616
+ }
617
+
618
+ // fx only reads AGENTS.md files below the workspace root or the host home.
619
+ function workspaceInstructionPathAllowed(info, path) {
620
+ return validWorkspacePath(path) && path.endsWith("/AGENTS.md") &&
621
+ (workspacePathInside(info.root, path) || workspacePathInside(info.home, path));
622
+ }
623
+
256
624
  function prepareWorkspaceAdapter(workspace) {
257
625
  if (workspace == null) return { present: false, valid: false };
258
626
  try {
259
627
  const info = workspace.info;
260
628
  const permission = workspace.permission;
629
+ const readFile = workspace.readFile;
261
630
  if (!info || typeof workspace.exec !== "function" || info.version !== 1 ||
631
+ (readFile !== undefined && typeof readFile !== "function") ||
262
632
  !validWorkspacePath(info.root) || !validWorkspacePath(info.cwd) ||
263
633
  !validWorkspacePath(info.home) || info.cwd !== info.root ||
264
634
  info.gitAvailable !== false || info.ephemeral !== true ||
@@ -273,6 +643,7 @@ function prepareWorkspaceAdapter(workspace) {
273
643
  git: false,
274
644
  ephemeral: true,
275
645
  permission,
646
+ instruction_files: typeof readFile === "function",
276
647
  };
277
648
  const encoded = encoder.encode(JSON.stringify(value));
278
649
  if (encoded.length > workspaceInfoLimit) return { present: true, valid: false };
@@ -488,12 +859,14 @@ function createRuntime(options) {
488
859
  const steering = [];
489
860
  let steeringBytes = 0;
490
861
  let steeringOpen = false;
862
+ // A steer that cannot join the running turn waits to run next.
863
+ let steeringHandoff = false;
491
864
  // Raw payloads beside ACP frames: the core copies inbound bytes into its
492
865
  // own memory and publishes outbound bytes for the agent to take.
493
866
  const inboundAttachments = new Map();
494
867
  const outboundAttachments = new Map();
495
868
  let nextOutboundAttachment = 1;
496
- const workspaceExecs = new Set();
869
+ const workspaceOperations = new Set();
497
870
  const workspace = prepareWorkspaceAdapter(options.workspace);
498
871
  const args = ["fx", ...(options.args || [])];
499
872
  const env = Object.entries(options.env || {}).map(([key, value]) => `${key}=${value}`);
@@ -762,9 +1135,10 @@ function createRuntime(options) {
762
1135
  function clearSteering() {
763
1136
  steering.length = 0;
764
1137
  steeringBytes = 0;
1138
+ steeringHandoff = false;
765
1139
  }
766
1140
 
767
- function queueSteering(text) {
1141
+ function queueSteering(text, id = "") {
768
1142
  if (!steeringOpen) throw new Error("no prompt is running");
769
1143
  const value = encoder.encode(text);
770
1144
  if (value.length === 0) throw new TypeError("steering text cannot be empty");
@@ -774,19 +1148,33 @@ function createRuntime(options) {
774
1148
  if (steering.length >= maxSteeringMessages || value.length > maxSteeringQueueBytes - steeringBytes) {
775
1149
  throw new Error("steering queue is full");
776
1150
  }
777
- steering.push(value);
1151
+ steering.push({ id, value });
778
1152
  steeringBytes += value.length;
779
1153
  }
780
1154
 
1155
+ // The core takes queued steering at a boundary; until then it can be withdrawn.
1156
+ function withdrawSteering(id) {
1157
+ const index = steering.findIndex((entry) => entry.id === id);
1158
+ if (index < 0) return false;
1159
+ steeringBytes -= steering[index].value.length;
1160
+ steering.splice(index, 1);
1161
+ return true;
1162
+ }
1163
+
1164
+ // Each message is its input id, a newline, then the text.
781
1165
  function steeringTake(outputPtr, outputCap) {
782
- const value = steering[0];
783
- if (!value) return 0;
1166
+ const entry = steering[0];
1167
+ if (!entry) return 0;
784
1168
  const output = checkedBytes(outputPtr, outputCap);
785
- if (!output || value.length > output.length) return -1;
786
- output.subarray(0, value.length).set(value);
1169
+ const id = encoder.encode(entry.id);
1170
+ const length = id.length + 1 + entry.value.length;
1171
+ if (!output || length > output.length) return -1;
1172
+ output.set(id, 0);
1173
+ output[id.length] = 10;
1174
+ output.set(entry.value, id.length + 1);
787
1175
  steering.shift();
788
- steeringBytes -= value.length;
789
- return value.length;
1176
+ steeringBytes -= entry.value.length;
1177
+ return length;
790
1178
  }
791
1179
 
792
1180
  function writeAttachment(id, data) {
@@ -821,14 +1209,20 @@ function createRuntime(options) {
821
1209
  return id;
822
1210
  }
823
1211
 
1212
+ // Resolves once the agent's journal holds every event the core sent.
1213
+ function journalFlush() {
1214
+ if (typeof options.journalFlush !== "function") return -1;
1215
+ return Promise.resolve().then(() => options.journalFlush()).then(() => 0, () => -1);
1216
+ }
1217
+
824
1218
  let pendingHostToolResult = null;
825
- function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr) {
1219
+ function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr, callIdPtr, callIdLen) {
826
1220
  pendingHostToolResult = null;
827
1221
  if (typeof options.hostToolExecutor !== "function") return -1;
828
1222
  if (options.traceWasi) console.error("fx host tool call start");
829
1223
  let input;
830
1224
  try { input = JSON.parse(text(argumentsPtr, argumentsLen)); } catch { return -1; }
831
- return Promise.resolve(options.hostToolExecutor(text(namePtr, nameLen), input)).then((result) => {
1225
+ return Promise.resolve(options.hostToolExecutor(text(namePtr, nameLen), input, undefined, text(callIdPtr, callIdLen))).then((result) => {
832
1226
  if (options.traceWasi) console.error("fx host tool call settled", result.cancelled, result.isError);
833
1227
  if (result.cancelled) return -2;
834
1228
  const output = encoder.encode(result.content);
@@ -1047,32 +1441,13 @@ function createRuntime(options) {
1047
1441
  }
1048
1442
  if (command.includes("\0")) return Promise.resolve(-4);
1049
1443
 
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({
1444
+ return runWorkspaceOperation(timeoutMs, "workspace command", (signal) => workspace.adapter.exec({
1069
1445
  command,
1070
1446
  cwd: workspace.info.cwd,
1071
- signal: controller.signal,
1447
+ signal,
1072
1448
  timeoutMs,
1073
1449
  outputLimitBytes: workspaceOutputLimit,
1074
- })).then((value) => {
1075
- if (state.status !== null) return state.status;
1450
+ }), (value) => {
1076
1451
  if (!value || !Number.isInteger(value.exitCode) || value.exitCode < -0x80000000 ||
1077
1452
  value.exitCode > 0x7fffffff || typeof value.stdout !== "string" ||
1078
1453
  typeof value.stderr !== "string") return -1;
@@ -1106,6 +1481,81 @@ function createRuntime(options) {
1106
1481
  view.setUint32(24, stderr.length, true);
1107
1482
  view.setUint32(28, copied < stdout.length + stderr.length ? 1 : 0, true);
1108
1483
  return 0;
1484
+ });
1485
+ }
1486
+
1487
+ function workspaceReadFile(pathPtr, pathLen, outPtr, outCap, resultPtr) {
1488
+ if (!workspace.present || (workspace.valid && typeof workspace.adapter.readFile !== "function")) {
1489
+ return Promise.resolve(-2);
1490
+ }
1491
+ if (!workspace.valid) return Promise.resolve(-4);
1492
+ const pathBytes = checkedBytes(pathPtr, pathLen);
1493
+ if (!pathBytes || !checkedBytes(outPtr, outCap) || !checkedBytes(resultPtr, 8) ||
1494
+ pathLen > workspaceInstructionPathLimit || outCap > workspaceInstructionFileLimit) {
1495
+ return Promise.resolve(-4);
1496
+ }
1497
+ let path;
1498
+ try {
1499
+ path = strictDecoder.decode(pathBytes);
1500
+ } catch {
1501
+ return Promise.resolve(-4);
1502
+ }
1503
+ if (!workspaceInstructionPathAllowed(workspace.info, path)) return Promise.resolve(-4);
1504
+
1505
+ return runWorkspaceOperation(workspaceReadTimeoutMs, "workspace read", (signal) => workspace.adapter.readFile({
1506
+ path,
1507
+ signal,
1508
+ }), (value) => {
1509
+ if (value == null) return -6;
1510
+ let content;
1511
+ if (typeof value === "string") {
1512
+ content = encoder.encode(value);
1513
+ } else if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
1514
+ content = value instanceof ArrayBuffer
1515
+ ? new Uint8Array(value)
1516
+ : new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
1517
+ try {
1518
+ strictDecoder.decode(content);
1519
+ } catch {
1520
+ return -7;
1521
+ }
1522
+ } else {
1523
+ return -1;
1524
+ }
1525
+ if (content.length > 0xffffffff) return -1;
1526
+ const copied = Math.min(content.length, outCap);
1527
+ // Reacquire views: memory may have grown while the host read was pending.
1528
+ bytes(outPtr, copied).set(content.subarray(0, copied));
1529
+ const view = new DataView(memory().buffer, resultPtr, 8);
1530
+ view.setUint32(0, content.length, true);
1531
+ view.setUint32(4, copied, true);
1532
+ return 0;
1533
+ });
1534
+ }
1535
+
1536
+ // Runs one host workspace effect under a deadline and the shared Ctrl+C
1537
+ // abort path. `finish` maps the host value to an ABI status.
1538
+ function runWorkspaceOperation(timeoutMs, label, start, finish) {
1539
+ const controller = new AbortController();
1540
+ let resolveAbort;
1541
+ const aborted = new Promise((resolve) => { resolveAbort = resolve; });
1542
+ const state = {
1543
+ status: null,
1544
+ abort(status) {
1545
+ if (this.status !== null) return;
1546
+ this.status = status;
1547
+ resolveAbort(status);
1548
+ controller.abort(new DOMException(
1549
+ status === -5 ? `${label} timed out` : `${label} aborted`,
1550
+ status === -5 ? "TimeoutError" : "AbortError",
1551
+ ));
1552
+ },
1553
+ };
1554
+ workspaceOperations.add(state);
1555
+ const timer = setTimeout(() => state.abort(-5), timeoutMs);
1556
+ const execution = Promise.resolve().then(() => start(controller.signal)).then((value) => {
1557
+ if (state.status !== null) return state.status;
1558
+ return finish(value);
1109
1559
  }).catch((error) => {
1110
1560
  if (state.status !== null) return state.status;
1111
1561
  if (error?.name === "TimeoutError") return -5;
@@ -1114,7 +1564,7 @@ function createRuntime(options) {
1114
1564
  });
1115
1565
  return Promise.race([execution, aborted]).finally(() => {
1116
1566
  clearTimeout(timer);
1117
- workspaceExecs.delete(state);
1567
+ workspaceOperations.delete(state);
1118
1568
  });
1119
1569
  }
1120
1570
 
@@ -1122,7 +1572,7 @@ function createRuntime(options) {
1122
1572
  pendingHostToolResult = null;
1123
1573
  streams.forEach((state) => state.controller.abort(abortReason));
1124
1574
  httpRequests.forEach((controller) => controller.abort(abortReason));
1125
- workspaceExecs.forEach((state) => state.abort(-3));
1575
+ workspaceOperations.forEach((state) => state.abort(-3));
1126
1576
  }
1127
1577
 
1128
1578
  const unavailable = () => 52;
@@ -1177,12 +1627,14 @@ function createRuntime(options) {
1177
1627
  fx_workspace_available() { return workspace.present ? 1 : 0; },
1178
1628
  fx_workspace_info: workspaceInfo,
1179
1629
  fx_workspace_exec: new WebAssembly.Suspending(workspaceExec),
1630
+ fx_workspace_read_file: new WebAssembly.Suspending(workspaceReadFile),
1180
1631
  fx_http_stream_open: streamOpen,
1181
1632
  fx_http_stream_status: new WebAssembly.Suspending(streamStatus),
1182
1633
  fx_http_stream_next: new WebAssembly.Suspending(streamNext),
1183
1634
  fx_http_stream_close(handle) { const state = streams.get(handle); state?.controller.abort(abortReason); streams.delete(handle); },
1184
1635
  fx_http_request: new WebAssembly.Suspending(httpRequest),
1185
1636
  fx_host_tool_call: new WebAssembly.Suspending(hostToolCall),
1637
+ fx_journal_flush: new WebAssembly.Suspending(journalFlush),
1186
1638
  fx_host_tool_result_read(offset, ptr, cap) {
1187
1639
  if (!pendingHostToolResult || offset < 0 || offset > pendingHostToolResult.length) return -1;
1188
1640
  const chunk = pendingHostToolResult.subarray(offset, offset + cap);
@@ -1192,6 +1644,9 @@ function createRuntime(options) {
1192
1644
  fx_host_tool_result_release() { pendingHostToolResult = null; },
1193
1645
  fx_steering_take: steeringTake,
1194
1646
  fx_steering_close() { steeringOpen = false; clearSteering(); },
1647
+ // -1 once the turn takes no more steering, else twice how many steers
1648
+ // wait, plus one when a handoff does.
1649
+ fx_steering_state() { return steeringOpen ? steering.length * 2 + (steeringHandoff ? 1 : 0) : -1; },
1195
1650
  fx_attachment_size: attachmentSize,
1196
1651
  fx_attachment_take: attachmentTake,
1197
1652
  fx_attachment_put: attachmentPut,
@@ -1232,6 +1687,13 @@ function createRuntime(options) {
1232
1687
  closeStdin() { steeringOpen = false; clearSteering(); stdin.close(); },
1233
1688
  openSteering() { clearSteering(); steeringOpen = true; },
1234
1689
  steer: queueSteering,
1690
+ withdrawSteering,
1691
+ // Ends the running turn at its next safe point; false when none runs.
1692
+ handOffSteering() {
1693
+ if (!steeringOpen) return false;
1694
+ steeringHandoff = true;
1695
+ return true;
1696
+ },
1235
1697
  closeSteering() { steeringOpen = false; clearSteering(); },
1236
1698
  abortHostEffects,
1237
1699
  abort(error) {
@@ -1616,16 +2078,41 @@ function normalizeSteeringInput(input) {
1616
2078
  return text;
1617
2079
  }
1618
2080
 
2081
+ // A tool set's shape, so a checkpoint can tell when the tools it continues
2082
+ // with changed since it was saved.
2083
+ function toolSchemaHash(descriptors) {
2084
+ const shape = JSON.stringify([...descriptors].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)));
2085
+ let hash = 0x811c9dc5;
2086
+ for (const byte of new TextEncoder().encode(shape)) hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
2087
+ return hash.toString(16).padStart(8, "0");
2088
+ }
2089
+
2090
+ // `tools` is an array of descriptors, or an object of descriptors keyed by
2091
+ // name. What `idempotent` and `writes` mean after a crash and for calls that
2092
+ // run together is modeled in `tests/tla/durable/ToolReplay.tla`, whose
2093
+ // `replay: "safe"` is an idempotent tool that does not write, and in
2094
+ // `tests/tla/durable/ParallelBatch.tla`.
1619
2095
  function normalizeHostTools(value) {
1620
2096
  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");
2097
+ let entries;
2098
+ if (Array.isArray(value)) {
2099
+ entries = value;
2100
+ } else if (value && typeof value === "object") {
2101
+ entries = Object.entries(value).map(([key, tool]) => {
2102
+ if (!tool || typeof tool !== "object") throw new TypeError(`tool ${key} must be an object`);
2103
+ if (tool.name !== undefined && tool.name !== key) throw new TypeError(`tool ${key} has a different name: ${tool.name}`);
2104
+ return { ...tool, name: key };
2105
+ });
2106
+ } else {
2107
+ throw new TypeError("tools must be an array or an object");
2108
+ }
2109
+ if (entries.length > 64) throw new RangeError("tools cannot contain more than 64 entries");
1623
2110
  const descriptors = [];
1624
2111
  const executors = new Map();
1625
2112
  const names = new Set();
1626
- for (const [index, tool] of value.entries()) {
2113
+ for (const [index, tool] of entries.entries()) {
1627
2114
  if (!tool || typeof tool !== "object") throw new TypeError(`tool ${index} must be an object`);
1628
- const { name, description, inputSchema, execute, providerExecuted } = tool;
2115
+ const { name, description, inputSchema, execute, providerExecuted, writes, idempotent } = tool;
1629
2116
  if (typeof name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
1630
2117
  throw new TypeError(`tool ${index} has an invalid name`);
1631
2118
  }
@@ -1641,6 +2128,8 @@ function normalizeHostTools(value) {
1641
2128
  }
1642
2129
  if (typeof description !== "string") throw new TypeError(`tool ${name} requires a description`);
1643
2130
  if (typeof execute !== "function") throw new TypeError(`tool ${name} requires execute()`);
2131
+ if (writes !== undefined && typeof writes !== "boolean") throw new TypeError(`tool ${name} writes must be a boolean`);
2132
+ if (idempotent !== undefined && typeof idempotent !== "boolean") throw new TypeError(`tool ${name} idempotent must be a boolean`);
1644
2133
  if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
1645
2134
  throw new TypeError(`tool ${name} requires an object inputSchema`);
1646
2135
  }
@@ -1648,13 +2137,46 @@ function normalizeHostTools(value) {
1648
2137
  try { schema = JSON.parse(JSON.stringify(inputSchema)); } catch {
1649
2138
  throw new TypeError(`tool ${name} inputSchema must be JSON-serializable`);
1650
2139
  }
1651
- descriptors.push({ name, description, inputSchema: schema });
2140
+ descriptors.push({
2141
+ name,
2142
+ description,
2143
+ inputSchema: schema,
2144
+ ...(writes === undefined ? {} : { writes }),
2145
+ // The engine reads `idempotent`: such a tool runs beside others unless
2146
+ // it `writes`, and one that does not write starts before its intent is
2147
+ // stored. It is also in the shape a checkpoint records, so a change to
2148
+ // it counts as a changed tool set.
2149
+ ...(idempotent === true ? { idempotent: true } : {}),
2150
+ });
1652
2151
  executors.set(name, execute);
1653
2152
  }
1654
2153
  return { descriptors, executors };
1655
2154
  }
1656
2155
 
1657
- function normalizeInstructions(value) {
2156
+ // The model catalog a host supplies, as the response body the core would
2157
+ // otherwise fetch: `{ data }` as AI Gateway returns it, or the entries alone.
2158
+ function modelCatalogBody(value) {
2159
+ const entries = Array.isArray(value) ? value : value?.data;
2160
+ if (!Array.isArray(entries)) throw new TypeError("modelCatalog must be an array of catalog entries or { data: [...] }");
2161
+ if (entries.length > maxModelCatalogEntries) {
2162
+ throw new RangeError(`modelCatalog exceeds the ${maxModelCatalogEntries} entry libfx limit`);
2163
+ }
2164
+ for (const entry of entries) {
2165
+ if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || entry.id === "") {
2166
+ throw new TypeError("each modelCatalog entry needs a string id");
2167
+ }
2168
+ }
2169
+ let body;
2170
+ try { body = JSON.stringify({ object: "list", data: entries }); } catch {
2171
+ throw new TypeError("modelCatalog must be JSON-serializable");
2172
+ }
2173
+ if (encoder.encode(body).length > maxModelCatalogBytes) {
2174
+ throw new RangeError(`modelCatalog exceeds the ${maxModelCatalogBytes} byte libfx limit`);
2175
+ }
2176
+ return body;
2177
+ }
2178
+
2179
+ export function normalizeInstructions(value) {
1658
2180
  let instructions;
1659
2181
  if (value === undefined) instructions = "";
1660
2182
  else if (typeof value === "string") instructions = value;
@@ -1722,10 +2244,22 @@ function base64ToBytes(value) {
1722
2244
  return bytes;
1723
2245
  }
1724
2246
 
1725
- export async function createFxAgent(options = {}) {
2247
+ /**
2248
+ * One session on one fx core, with no durability of its own: `createFxAgent`
2249
+ * runs each durable session's turns on one of these, and a host with its own
2250
+ * storage may pass `persistence`.
2251
+ */
2252
+ export async function createFxEngine(options = {}) {
1726
2253
  options = normalizeAgentOptions(options);
1727
2254
  const hostTools = normalizeHostTools(options.tools);
1728
2255
  const instructions = normalizeInstructions(options.instructions);
2256
+ // Recorded in every checkpoint this agent saves, so a later one can tell
2257
+ // it continues with another libfx, other tools, or another model.
2258
+ const checkpointMeta = {
2259
+ libfxVersion: String(fxSdkApiVersion),
2260
+ toolSchemaHash: toolSchemaHash(hostTools.descriptors),
2261
+ model: options.model ?? "",
2262
+ };
1729
2263
  const initialCheckpoint = checkpointBytes(options.checkpoint);
1730
2264
  // Checked before the core starts, and with the core's message, so both
1731
2265
  // backends report it the same way.
@@ -1735,21 +2269,306 @@ export async function createFxAgent(options = {}) {
1735
2269
  const pending = new Map();
1736
2270
  let nextId = 1;
1737
2271
  let sessionId = null;
2272
+ const hookScopes = new Map();
2273
+ // Observation never participates in effects, journal barriers, or retries.
2274
+ // Contain both throws and rejected promises, including an error handler's.
2275
+ const hookError = (error) => {
2276
+ try { Promise.resolve(options.hooks?.onError?.(error)).catch(() => {}); } catch {}
2277
+ };
2278
+ const hook = (event) => {
2279
+ if (!options.hooks) return;
2280
+ if (options.hooks.parent) event = {
2281
+ ...event, rootSessionId: options.hooks.parent.rootSessionId,
2282
+ ...(event.sessionId === sessionId && event.parentSessionId == null ? {
2283
+ parentSessionId: options.hooks.parent.sessionId, parentTurnId: options.hooks.parent.turnId, parentCallId: options.hooks.parent.callId,
2284
+ } : {}),
2285
+ };
2286
+ if (event.type === "tool.start") {
2287
+ const { data, type, ...scope } = event;
2288
+ hookScopes.set(`${event.sessionId}:${data.call.callId}`, scope);
2289
+ } else if (event.type === "tool.end") hookScopes.delete(`${event.sessionId}:${event.data.call.callId}`);
2290
+ else if (event.type === "turn.end") {
2291
+ for (const [key, scope] of hookScopes) if (scope.executionId === event.executionId) hookScopes.delete(key);
2292
+ } else if (event.type === "journal.error" && event.data.fenced) hookScopes.clear();
2293
+ try { Promise.resolve(options.hooks.onEvent(event)).catch(hookError); } catch (error) { hookError(error); }
2294
+ };
2295
+ const journalHook = (type, data) => hook({
2296
+ version: 1, type, sessionId, rootSessionId: sessionId, parentSessionId: null, sourceSessionId: null, workId: null, turnId: null, executionId: null, stepIndex: 0,
2297
+ timestamp: Date.now(), contentCaptured: false, contentTruncated: false, data,
2298
+ });
1738
2299
  let activeTurn = null;
1739
2300
  let closing = false;
1740
2301
  let coreExitError = null;
1741
2302
  const isCurrentTurn = (turn) => turn && activeTurn === turn && !turn.cancelled && !closing;
2303
+ // Events go to the store in the order the core sends them, one append at
2304
+ // a time: events that arrive together become one record, and so do all
2305
+ // the events that arrive while an append is in flight. Each record's append
2306
+ // names the cursor of the one before it, so a store refuses a write that
2307
+ // does not continue what it holds, and says with `barrier` whether anyone
2308
+ // waits for it: the core before an action others can see, such as a tool
2309
+ // call with effects, or a caller for its input.
2310
+ const persistence = options.persistence ?? null;
2311
+ const wantsMarks = persistence?.marks === true || persistence?.[journalMarksWanted] === true;
2312
+ const checkpointAfterBytes = options.checkpointAfterBytes ?? defaultCheckpointAfterBytes;
2313
+ // The store's last cursor, and the last seq its records hold.
2314
+ let journalHead = null;
2315
+ let appendedSeq = 0;
2316
+ // Record bytes stored since the last checkpoint.
2317
+ let bytesSinceCheckpoint = 0;
2318
+ let appendTail = Promise.resolve();
2319
+ // One per agent, so a store can tell a repeated write from another writer's.
2320
+ const writerId = crypto.randomUUID();
2321
+ // Pipelined appends and barriers are modeled in `tests/tla/durable/Journal.tla`,
2322
+ // and two processes appending to one log in `tests/tla/durable/Fencing.tla`.
2323
+ const journal = persistence && {
2324
+ append(events, barrier) {
2325
+ const data = encodeJournalRecord(events);
2326
+ const lastSeq = events.at(-1)?.seq;
2327
+ // A store that tracks turns without reading records asks for marks.
2328
+ const marks = wantsMarks ? { marks: journalMarks(events) } : {};
2329
+ const appended = appendTail.then(async () => {
2330
+ const result = await persistence.append({ expected: journalHead, idempotencyKey: `${writerId}:${events[0]?.seq}`, data, barrier, ...marks });
2331
+ if (!validCursor(result?.cursor)) throw new TypeError("persistence.append() must resolve to { cursor }");
2332
+ journalHead = result.cursor;
2333
+ if (Number.isSafeInteger(lastSeq)) appendedSeq = lastSeq;
2334
+ bytesSinceCheckpoint += data.byteLength;
2335
+ 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 });
2336
+ });
2337
+ // A failed append fails every later one: they would not continue it.
2338
+ appendTail = appended;
2339
+ return appended;
2340
+ },
2341
+ };
2342
+ let resumable = false;
2343
+ // The host's ids for the turn a crash left open and the last turn that
2344
+ // ended, so a retried prompt continues or skips its turn.
2345
+ let openTurnId = null;
2346
+ let lastTurnId = null;
2347
+ // The calls the open turn left running: `{ id, name, arguments }`, and
2348
+ // what the resume tells the model about each one it does not run again.
2349
+ let ambiguousCalls = [];
2350
+ // Asks `onAmbiguous` about each of them, once. Returns the ids of the
2351
+ // calls to run again, and the rest, which the resume answers as possibly
2352
+ // run.
2353
+ const settleAmbiguous = (onAmbiguous) => {
2354
+ const calls = ambiguousCalls;
2355
+ ambiguousCalls = [];
2356
+ const rerun = onAmbiguous === undefined
2357
+ ? []
2358
+ : calls.filter((call) => onAmbiguous({ executionId: call.id, name: call.name, input: parsedToolInput(call.arguments) }) === "rerun").map((call) => call.id);
2359
+ return { rerun, answered: calls.filter((call) => !rerun.includes(call.id)) };
2360
+ };
2361
+ // Continues the open turn: the calls it left running run again or are
2362
+ // answered as possibly run, and each answered one ends for the turn's
2363
+ // viewers as it does for the model. Modeled in
2364
+ // `tests/tla/durable/Resumption.tla`.
2365
+ const resumeOpenTurn = (promptOptions) => {
2366
+ const { rerun, answered } = settleAmbiguous(promptOptions.onAmbiguous);
2367
+ return normalizeTurn(startTurn(null, { ...promptOptions, [resumeTurn]: true, [rerunCallIds]: rerun }), answered);
2368
+ };
2369
+ // Follow-ups waiting for the turn ahead of them, oldest first.
2370
+ const followUps = [];
2371
+ // A follow-up this agent queued starts when the turn ahead of it ends, ahead
2372
+ // of any the journal held: those have no caller, so each waits for
2373
+ // `resume()` to start it, oldest first.
2374
+ const runNextFollowUp = (resuming = false) => {
2375
+ if (activeTurn || closing || journalFailure) return;
2376
+ const index = followUps.findIndex((entry) => (entry.held === true) === resuming);
2377
+ if (index < 0) return;
2378
+ const [next] = followUps.splice(index, 1);
2379
+ let turn;
2380
+ try {
2381
+ turn = normalizeTurn(startTurn(next.text, { turnId: next.id, [followUpInput]: { id: next.id, accepted: next.accepted } }));
2382
+ } catch (error) {
2383
+ next.rejectTurn?.(error);
2384
+ return;
2385
+ }
2386
+ next.resolveTurn?.(turn);
2387
+ };
2388
+ let journalQueue = [];
2389
+ let journalFlushScheduled = false;
2390
+ const journalPending = new Set();
2391
+ // The append in flight, and whether anyone waits for the events queued
2392
+ // behind it.
2393
+ let journalInFlight = null;
2394
+ let journalBarrier = false;
2395
+ let journalFailure = null;
2396
+ // Set by `turn.cancel({ reason: "handoff" })`: the journal keeps the open
2397
+ // turn for another agent, so this one stores nothing more.
2398
+ let handedOff = false;
2399
+ const handedOffError = () => new Error("this agent handed its session off; open it again to continue");
2400
+ // Input events (`accepted:<id>`, `withdrawn:<id>`) once stored, and the
2401
+ // steer and withdraw calls waiting for them, which resolve only then
2402
+ // (`tests/tla/durable/Inbox.tla`).
2403
+ const durableInputs = new Set();
2404
+ const inputWaiters = new Map();
2405
+ const waitForInput = (key) => {
2406
+ if (durableInputs.delete(key)) return Promise.resolve();
2407
+ if (journalFailure) return Promise.reject(journalFailure);
2408
+ return new Promise((resolve, reject) => {
2409
+ const waiters = inputWaiters.get(key) ?? [];
2410
+ waiters.push({ resolve, reject });
2411
+ inputWaiters.set(key, waiters);
2412
+ });
2413
+ };
2414
+ const settleInputs = (batch) => {
2415
+ for (const event of batch) {
2416
+ if (event.type !== "input_accepted" && event.type !== "input_withdrawn") continue;
2417
+ const key = `${event.type === "input_accepted" ? "accepted" : "withdrawn"}:${event.data?.id}`;
2418
+ durableInputs.add(key);
2419
+ for (const waiter of inputWaiters.get(key) ?? []) waiter.resolve();
2420
+ inputWaiters.delete(key);
2421
+ }
2422
+ };
2423
+ const rejectInputWaiters = (keys, error) => {
2424
+ for (const key of keys) {
2425
+ for (const waiter of inputWaiters.get(key) ?? []) waiter.reject(error);
2426
+ inputWaiters.delete(key);
2427
+ }
2428
+ };
2429
+ // close() reports a failure no turn or prompt call has reported yet.
2430
+ let journalFailureReported = false;
2431
+ const reportJournalFailure = () => {
2432
+ journalFailureReported = true;
2433
+ return journalFailure;
2434
+ };
2435
+ // `barrier`: the core waits for these events before it goes on.
2436
+ const queueJournalEvents = (events, barrier = false) => {
2437
+ if (journalFailure || !Array.isArray(events) || events.length === 0) return;
2438
+ if (handedOff) {
2439
+ // The agent that opens the session next decides about these inputs.
2440
+ const dropped = events
2441
+ .filter((event) => event?.type === "input_accepted" || event?.type === "input_withdrawn")
2442
+ .map((event) => `${event.type === "input_accepted" ? "accepted" : "withdrawn"}:${event.data?.id}`);
2443
+ rejectInputWaiters(dropped, handedOffError());
2444
+ return;
2445
+ }
2446
+ journalQueue.push(...events);
2447
+ // A caller waits for its steer or withdrawal to be stored.
2448
+ if (barrier || events.some((event) => event?.type === "input_accepted" || event?.type === "input_withdrawn")) journalBarrier = true;
2449
+ // A turn notes a new config right before its first progress, a barrier:
2450
+ // the two go out in one write instead of two in a row.
2451
+ if (events.every((event) => event?.type === "session_config")) return;
2452
+ if (journalFlushScheduled) return;
2453
+ journalFlushScheduled = true;
2454
+ queueMicrotask(flushJournal);
2455
+ };
2456
+ // Sends the queued events as one record, unless an append is in flight:
2457
+ // then they wait for it, with any that arrive meanwhile, and go out
2458
+ // together once it lands.
2459
+ function flushJournal() {
2460
+ journalFlushScheduled = false;
2461
+ if (journalFailure || journalInFlight || journalQueue.length === 0) return;
2462
+ const batch = journalQueue;
2463
+ journalQueue = [];
2464
+ const barrier = journalBarrier;
2465
+ journalBarrier = false;
2466
+ emit("journal.append", { events: batch.length, barrier });
2467
+ let appended;
2468
+ try {
2469
+ appended = Promise.resolve(journal.append(batch, barrier));
2470
+ } catch (error) {
2471
+ appended = Promise.reject(error);
2472
+ }
2473
+ const settled = appended.then(() => {
2474
+ settleInputs(batch);
2475
+ }, (error) => {
2476
+ // Later events would leave a gap after the failed batch.
2477
+ journalQueue = [];
2478
+ if (journalFailure) return;
2479
+ journalFailure = journalAppendError(error);
2480
+ emit("journal.error", { error: error instanceof Error ? error.name : "Error" });
2481
+ journalHook("journal.error", { error: error instanceof Error ? error.name : "Error", fenced: error?.code === "FX_FENCED" || error?.cause?.code === "FX_FENCED" });
2482
+ rejectInputWaiters([...inputWaiters.keys()], journalFailure);
2483
+ // Nothing the turn does from here can be saved, and after a fence the
2484
+ // session has another owner: stop the turn and its effects now.
2485
+ activeTurn?.cancel();
2486
+ }).finally(() => {
2487
+ journalPending.delete(settled);
2488
+ journalInFlight = null;
2489
+ flushJournal();
2490
+ });
2491
+ journalInFlight = settled;
2492
+ journalPending.add(settled);
2493
+ }
2494
+ // The journal's own work, such as a heartbeat, ends with the agent: once,
2495
+ // after the last append settles.
2496
+ let journalClosed = null;
2497
+ function closeJournal() {
2498
+ journalClosed ??= journalSettled().catch(() => {}).then(() => checkpointing);
2499
+ return journalClosed;
2500
+ }
2501
+ async function journalSettled() {
2502
+ for (;;) {
2503
+ // This caller waits for every event queued so far.
2504
+ if (journalQueue.length > 0) journalBarrier = true;
2505
+ if (journalFlushScheduled || journalQueue.length > 0) flushJournal();
2506
+ if (journalPending.size === 0) break;
2507
+ await Promise.all([...journalPending]);
2508
+ }
2509
+ if (journalFailure) throw journalFailure;
2510
+ }
1742
2511
  const emit = (type, detail = {}) => {
1743
2512
  try { options.onEvent?.({ type, timestamp: performance.now(), ...detail }); } catch {}
1744
2513
  };
2514
+ // A checkpoint saved by another libfx, with other tools, or for another
2515
+ // model still restores; the host hears what changed.
2516
+ const compareCheckpointMeta = (saved) => {
2517
+ if (!saved || typeof saved !== "object") return;
2518
+ const changed = Object.keys(checkpointMeta).filter((key) => typeof saved[key] === "string" && saved[key] !== "" && saved[key] !== checkpointMeta[key]);
2519
+ if (changed.length) emit("checkpoint.mismatch", { sessionId, changed, saved, current: checkpointMeta });
2520
+ };
2521
+ // Saves a checkpoint once the records since the last one reach
2522
+ // `checkpointAfterBytes`, or with `force` whatever their size, at a point
2523
+ // no turn is running: a turn's end, or where it yielded. A failed save
2524
+ // leaves the journal as it was; the next turn's end tries again. Returns
2525
+ // the save in flight, which never rejects.
2526
+ let checkpointing = null;
2527
+ const maybeCheckpoint = (force = false) => {
2528
+ if (checkpointing) return checkpointing;
2529
+ if (!persistence?.saveCheckpoint || closing || handedOff || journalFailure) return Promise.resolve();
2530
+ checkpointing = (async () => {
2531
+ // The turn's last records may still be in flight; count them first.
2532
+ await journalSettled();
2533
+ // A save begun before close() finishes; close() waits for it.
2534
+ if (activeTurn) return;
2535
+ if (bytesSinceCheckpoint === 0 || (!force && bytesSinceCheckpoint < checkpointAfterBytes)) return;
2536
+ const response = await request("libfx/snapshot", { sessionId, checkpointMeta });
2537
+ const id = response?.snapshotAttachment;
2538
+ if (!Number.isSafeInteger(id) || id <= 0) return;
2539
+ const bytes = runtime.takeAttachment?.(id);
2540
+ // The core took it after every event this agent stored, and no later.
2541
+ if (!(bytes instanceof Uint8Array) || response.atSeq !== appendedSeq) return;
2542
+ const through = journalHead;
2543
+ const covered = bytesSinceCheckpoint;
2544
+ await persistence.saveCheckpoint({ through, data: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) });
2545
+ bytesSinceCheckpoint -= covered;
2546
+ emit("checkpoint.save", { through, bytes: bytes.byteLength });
2547
+ })().catch((error) => {
2548
+ emit("checkpoint.error", { error: error instanceof Error ? error.name : "Error", message: error?.message ?? "" });
2549
+ }).finally(() => { checkpointing = null; });
2550
+ return checkpointing;
2551
+ };
1745
2552
  const hostFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
2553
+ // Adds the host's credentials to the provider's own requests.
2554
+ const providerAuth = subscriptionProviders.has(options.provider)
2555
+ ? createSubscriptionAuthorizer([codexSubscription, grokSubscription], { credentials: options.credentials, onCredentials: options.onCredentials, refreshLock: options.refreshLock, fetch: hostFetch, memory: options[credentialMemory] })
2556
+ : options.customProvider && options.credentials
2557
+ ? createSubscriptionAuthorizer([customProviderSpec(options.customProvider, () => sessionId)], { credentials: options.credentials, fetch: hostFetch })
2558
+ : null;
1746
2559
  const transportFetch = async (input, init = {}) => {
1747
2560
  const method = String(init.method ?? input?.method ?? "GET").toUpperCase();
1748
2561
  let endpoint = String(input?.url ?? input);
2562
+ let path = null;
1749
2563
  try {
1750
2564
  const url = new URL(endpoint);
1751
2565
  endpoint = `${url.origin}${url.pathname}`;
2566
+ path = url.pathname;
1752
2567
  } catch {}
2568
+ // A supplied catalog answers the core's catalog request without the network.
2569
+ if (options.modelCatalogBody && method === "GET" && path === "/coding-agent/v1/models") {
2570
+ return new Response(options.modelCatalogBody, { status: 200, headers: { "content-type": "application/json" } });
2571
+ }
1753
2572
  for (let attemptIndex = 0; attemptIndex < 2; attemptIndex++) {
1754
2573
  const startedAt = performance.now();
1755
2574
  const attempt = activeTurn ? ++activeTurn.transportAttempts : attemptIndex + 1;
@@ -1764,7 +2583,10 @@ export async function createFxAgent(options = {}) {
1764
2583
  throw new DOMException("Aborted", "AbortError");
1765
2584
  }
1766
2585
  if (!hostFetch) throw new TypeError("fetch is unavailable");
1767
- const response = await hostFetch(input, init);
2586
+ const spec = providerAuth?.specFor(endpoint);
2587
+ const response = spec
2588
+ ? await providerAuth.fetch(spec, endpoint, hostFetch, input, init)
2589
+ : await hostFetch(input, init);
1768
2590
  const headers = response.headers;
1769
2591
  emit("transport.response", {
1770
2592
  attempt,
@@ -1793,9 +2615,12 @@ export async function createFxAgent(options = {}) {
1793
2615
  }
1794
2616
  throw new Error("transport retry exhausted");
1795
2617
  };
1796
- const executeHostTool = async (name, input, requestedSessionId) => {
2618
+ const executeHostTool = async (name, input, requestedSessionId, toolCallId) => {
1797
2619
  const execute = hostTools.executors.get(name);
1798
- const turn = requestedSessionId === undefined || requestedSessionId === sessionId
2620
+ const issuerSessionId = requestedSessionId ?? sessionId;
2621
+ const scope = hookScopes.get(`${issuerSessionId}:${toolCallId}`);
2622
+ // Hook metadata is not authority to execute a foreign session's tool.
2623
+ const turn = issuerSessionId === sessionId
1799
2624
  ? activeTurn
1800
2625
  : null;
1801
2626
  if (!isCurrentTurn(turn)) return { content: "", isError: true, cancelled: true };
@@ -1811,7 +2636,15 @@ export async function createFxAgent(options = {}) {
1811
2636
  if (!execute) throw new Error(`unknown host tool: ${String(name)}`);
1812
2637
  const execution = Promise.resolve().then(() => {
1813
2638
  if (controller.signal.aborted || !isCurrentTurn(turn)) return;
1814
- return execute(input, { signal: controller.signal });
2639
+ // The model's call id is stable across restores of a persisted
2640
+ // session, so a tool can key its own retries on it.
2641
+ return execute(input, {
2642
+ signal: controller.signal,
2643
+ executionId: String(toolCallId ?? ""),
2644
+ sessionId: issuerSessionId,
2645
+ ...(options.hooks && scope ? { observation: toolObservation(scope, String(toolCallId), options.hooks, hook, controller.signal) } : {}),
2646
+ ...(options.toolContext === undefined ? {} : { context: options.toolContext }),
2647
+ });
1815
2648
  });
1816
2649
  const value = await Promise.race([execution, aborted]);
1817
2650
  if (!controller.signal.aborted) {
@@ -1837,6 +2670,8 @@ export async function createFxAgent(options = {}) {
1837
2670
  controller.signal.removeEventListener("abort", onAbort);
1838
2671
  turn.toolControllers.delete(controller);
1839
2672
  }
2673
+ // Providers reject an empty error result, which would end the session.
2674
+ if (isError && content === "") content = `Tool ${String(name)} failed without a message`;
1840
2675
  return { content, isError, rich, cancelled: controller.signal.aborted || !isCurrentTurn(turn) };
1841
2676
  };
1842
2677
  emit("runtime.start");
@@ -1846,6 +2681,7 @@ export async function createFxAgent(options = {}) {
1846
2681
  args: ["acp"],
1847
2682
  env: agentEnvironment(options),
1848
2683
  hostToolExecutor: executeHostTool,
2684
+ journalFlush: () => journalSettled(),
1849
2685
  onTransportChunk(byteLength) {
1850
2686
  const turn = activeTurn;
1851
2687
  if (!isCurrentTurn(turn) || !Number.isSafeInteger(byteLength) || byteLength <= 0) return;
@@ -1894,13 +2730,19 @@ export async function createFxAgent(options = {}) {
1894
2730
  async function takeCheckpoint() {
1895
2731
  if (closing) throw new Error("fx agent is closed");
1896
2732
  if (activeTurn) throw new Error("cannot checkpoint while a prompt is active");
1897
- const response = await request("libfx/checkpoint", { sessionId });
2733
+ const response = await request("libfx/checkpoint", { sessionId, checkpointMeta });
1898
2734
  const id = response?.checkpointAttachment;
1899
2735
  const bytes = Number.isSafeInteger(id) && id > 0 ? runtime.takeAttachment?.(id) : null;
1900
2736
  if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) throw new Error("fx returned an invalid checkpoint");
1901
2737
  return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1902
2738
  }
1903
- const sendPrompt = (blocks) => {
2739
+ const sendPrompt = (blocks, resuming = false, followUp = null, turnId = null, rerun = [], yieldAt = undefined) => {
2740
+ // A journaled turn given a time to stop at yields before its first model
2741
+ // request after it, keeping the turn open.
2742
+ const stopAt = journal && yieldAt !== undefined ? { yieldAt } : {};
2743
+ if (resuming) {
2744
+ return request("session/prompt", { sessionId, prompt: [], _meta: { fx: { continueRecovery: true, ...(rerun.length ? { rerun } : {}), ...stopAt } } });
2745
+ }
1904
2746
  const images = blocks.filter((block) => block.type === "image" && block.bytes !== undefined);
1905
2747
  const ids = images.length ? attachBytes(images.map((block) => block.bytes)) : [];
1906
2748
  let next = 0;
@@ -1910,10 +2752,20 @@ export async function createFxAgent(options = {}) {
1910
2752
  ...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
1911
2753
  ...(block.bytes === undefined ? {} : { _meta: { fx: { attachment: ids[next++] } } }),
1912
2754
  });
1913
- return request("session/prompt", { sessionId, prompt });
2755
+ // A follow-up's turn places it; the core records it as accepted first
2756
+ // when the host could not. A journaled turn's first progress records its id.
2757
+ const fx = {
2758
+ ...(followUp ? { inputId: followUp.id, inputAccepted: followUp.accepted } : {}),
2759
+ ...((journal || options.hooks) && turnId ? { turnId } : {}),
2760
+ ...stopAt,
2761
+ };
2762
+ return request("session/prompt", { sessionId, prompt, ...(Object.keys(fx).length ? { _meta: { fx } } : {}) });
1914
2763
  };
1915
2764
  runtime.exited.then((code) => {
2765
+ hookScopes.clear();
1916
2766
  closing = true;
2767
+ // An agent whose core exited writes nothing more.
2768
+ if (journal) void closeJournal().catch(() => {});
1917
2769
  const error = runtime.error ?? new Error(`fx-core exited with code ${code} before completing the ACP request`);
1918
2770
  coreExitError = error;
1919
2771
  activeTurn?.failImagePrep(error);
@@ -1922,7 +2774,20 @@ export async function createFxAgent(options = {}) {
1922
2774
  emit("runtime.exit", { code });
1923
2775
  });
1924
2776
  runtime.setLineHandler((message, size) => {
2777
+ if (message.method === "libfx/hook") {
2778
+ if (message.params?.rootSessionId === sessionId && message.params?.version === 1) {
2779
+ if (message.params.type === "turn.end" && handedOff) message.params.data.handoff = true;
2780
+ hook(message.params);
2781
+ }
2782
+ return;
2783
+ }
1925
2784
  emit("acp.receive", { message });
2785
+ if (message.method === "libfx/journal_append") {
2786
+ if (journal && message.params?.sessionId === sessionId) {
2787
+ queueJournalEvents(message.params.events, message.params.barrier === true);
2788
+ }
2789
+ return;
2790
+ }
1926
2791
  if (message.method === "session/update") {
1927
2792
  if (message.params.sessionId === sessionId) return activeTurn?.push(message.params.update, size);
1928
2793
  return;
@@ -1943,11 +2808,22 @@ export async function createFxAgent(options = {}) {
1943
2808
  send({ jsonrpc: "2.0", id: message.id, result: optionId ? { outcome: { outcome: "selected", optionId } } : { outcome: { outcome: "cancelled" } } });
1944
2809
  return;
1945
2810
  }
2811
+ if (message.method === "libfx/journal_flush") {
2812
+ // Every append the core sent before this request is already queued.
2813
+ let failure = null;
2814
+ try { await journalSettled(); } catch (error) { failure = error; }
2815
+ if (closing) return;
2816
+ send(failure
2817
+ ? { jsonrpc: "2.0", id: message.id, error: { code: -32000, message: failure.message } }
2818
+ : { jsonrpc: "2.0", id: message.id, result: {} });
2819
+ return;
2820
+ }
1946
2821
  if (message.method === "libfx/tool_call") {
1947
2822
  const { content, isError, rich, cancelled } = await executeHostTool(
1948
2823
  message.params?.name,
1949
2824
  message.params?.input,
1950
2825
  message.params?.sessionId,
2826
+ message.params?.toolCallId,
1951
2827
  );
1952
2828
  if (cancelled || closing) return;
1953
2829
  const response = { jsonrpc: "2.0", id: message.id, result: { content, isError, ...(rich ? { contentType: "rich" } : {}) } };
@@ -1974,11 +2850,58 @@ export async function createFxAgent(options = {}) {
1974
2850
  },
1975
2851
  });
1976
2852
 
1977
- const sessionResult = await request("libfx/new");
2853
+ const loaded = persistence ? await loadPersistence(persistence) : null;
2854
+ const requestedSessionId = options.sessionId ?? null;
2855
+ const sessionResult = await request("libfx/new", {
2856
+ ...(requestedSessionId === null ? {} : { sessionId: requestedSessionId }),
2857
+ ...(options.hooks ? { hooks: { captureContent: options.hooks.captureContent === true } } : {}),
2858
+ });
1978
2859
  sessionId = sessionResult.sessionId;
2860
+ if (options.hooks && sessionResult?._meta?.fx?.hooksVersion !== 1) throw new Error("Semantic hooks require a matching libfx core with hooks version 1");
1979
2861
  if (initialCheckpoint) {
1980
2862
  const [checkpointAttachment] = attachBytes([initialCheckpoint]);
1981
- await request("libfx/restore", { sessionId, checkpointAttachment });
2863
+ const restored = await request("libfx/restore", { sessionId, checkpointAttachment }).catch((error) => {
2864
+ if (/newer fx/.test(error?.message ?? "")) throw new FxJournalVersionError(error.message);
2865
+ throw error;
2866
+ });
2867
+ compareCheckpointMeta(restored?.checkpointMeta);
2868
+ }
2869
+ if (journal) {
2870
+ const { events, checkpoint } = loaded;
2871
+ const params = { sessionId, ...(options.inputsDurable === true ? { inputsDurable: true } : {}) };
2872
+ // One call: attaching again would drop the first attachment.
2873
+ const attachments = [];
2874
+ if (events.length > 0) {
2875
+ const bytes = encoder.encode(JSON.stringify(withoutSupersededProgress(events)));
2876
+ if (bytes.byteLength > maxJournalBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
2877
+ attachments.push(["journalAttachment", bytes]);
2878
+ }
2879
+ if (checkpoint) attachments.push(["snapshotAttachment", checkpoint]);
2880
+ const ids = attachments.length ? attachBytes(attachments.map(([, bytes]) => bytes)) : [];
2881
+ attachments.forEach(([name], index) => { params[name] = ids[index]; });
2882
+ const opened = await request("libfx/journal_open", params).catch((error) => {
2883
+ const message = error?.message ?? "";
2884
+ if (/newer fx/.test(message)) throw new FxJournalVersionError(message);
2885
+ const code = journalOpenErrorCodes.get(message);
2886
+ if (code) throw journalLoadError(message, code, error);
2887
+ throw error;
2888
+ });
2889
+ compareCheckpointMeta(opened?.checkpointMeta);
2890
+ resumable = opened?.resumable === true;
2891
+ openTurnId = resumable && validTurnId(opened?.openTurnId) ? opened.openTurnId : null;
2892
+ lastTurnId = validTurnId(opened?.lastTurnId) ? opened.lastTurnId : null;
2893
+ ambiguousCalls = resumable && Array.isArray(opened?.runningCalls)
2894
+ ? opened.runningCalls.filter((call) => typeof call?.id === "string" && typeof call.name === "string")
2895
+ : [];
2896
+ journalHead = loaded.head;
2897
+ appendedSeq = loaded.lastSeq;
2898
+ bytesSinceCheckpoint = loaded.tailBytes;
2899
+ // Follow-ups the journal holds, as accepted; each waits for `resume()`.
2900
+ for (const held of Array.isArray(opened?.followUps) ? opened.followUps : []) {
2901
+ followUps.push({ id: held.id, text: held.text, accepted: true, held: true });
2902
+ }
2903
+ emit("journal.open", { events: events.length, checkpoint: checkpoint !== null, turns: opened?.turns, resumable });
2904
+ journalHook("journal.restore", { cursor: loaded.head, lastSeq: loaded.lastSeq, resumable, openTurnId, lastTurnId });
1982
2905
  }
1983
2906
  } catch (error) {
1984
2907
  closing = true;
@@ -1989,11 +2912,131 @@ export async function createFxAgent(options = {}) {
1989
2912
  }
1990
2913
 
1991
2914
  const agent = {
2915
+ get sessionId() {
2916
+ return sessionId;
2917
+ },
2918
+ // What libfx's session worker needs beyond the public surface.
2919
+ [engineInternals]: {
2920
+ // Resolves once every record the session started has landed: a
2921
+ // turn's result does not wait for its last ones.
2922
+ settled() {
2923
+ return journal ? journalSettled() : Promise.resolve();
2924
+ },
2925
+ // Saves a checkpoint now, at a turn's end or where it yielded, whatever
2926
+ // has accumulated since the last; resolves once it is stored or skipped.
2927
+ checkpoint() {
2928
+ return maybeCheckpoint(true);
2929
+ },
2930
+ // Resolves when the core this agent runs on exits.
2931
+ exited: runtime.exited,
2932
+ // The turn the journal left open, if any, and the calls it left
2933
+ // running, as `{ executionId, name, input }`.
2934
+ get openTurn() {
2935
+ if (!resumable) return null;
2936
+ return {
2937
+ id: openTurnId,
2938
+ calls: ambiguousCalls.map((call) => ({ executionId: call.id, name: call.name, input: parsedToolInput(call.arguments) })),
2939
+ };
2940
+ },
2941
+ },
2942
+ /**
2943
+ * Starts a turn. With `turnId`, a retried call is the same turn: when the
2944
+ * journal left that turn open, this continues it as `resume()` does, and
2945
+ * when that turn already ended, this returns a turn that has ended too,
2946
+ * without running it again.
2947
+ */
1992
2948
  prompt(input, promptOptions = {}) {
1993
2949
  if (closing) throw new Error("fx agent is closed");
2950
+ if (journalFailure) throw reportJournalFailure();
2951
+ if (handedOff) throw handedOffError();
1994
2952
  if (activeTurn) throw new Error("a prompt is already in progress for this session");
1995
- return normalizeTurn(startTurn(input, promptOptions));
2953
+ const turnId = promptOptions.turnId;
2954
+ if (turnId !== undefined && !validTurnId(turnId)) throw new TypeError(`turnId must be ${turnIdRule}`);
2955
+ checkOnAmbiguous(promptOptions.onAmbiguous);
2956
+ if (turnId !== undefined && journal) {
2957
+ if (resumable && turnId === openTurnId) {
2958
+ resumable = false;
2959
+ return resumeOpenTurn(promptOptions);
2960
+ }
2961
+ if (turnId === lastTurnId) return endedTurn(turnId);
2962
+ }
2963
+ // A new prompt ends a turn the last process left open as interrupted.
2964
+ resumable = false;
2965
+ ambiguousCalls = [];
2966
+ return normalizeTurn(startTurn(input, { ...promptOptions, turnId: turnId ?? `turn_${crypto.randomUUID()}` }));
1996
2967
  },
2968
+ /**
2969
+ * Continues the turn the last process left open, with no new input, and
2970
+ * returns it; returns null when the journal holds no open turn. The
2971
+ * model is told the session was interrupted; calls that were running
2972
+ * come back answered as possibly run, for the model to decide about,
2973
+ * unless `onAmbiguous(call)` returns "rerun" for them.
2974
+ */
2975
+ resume(promptOptions = {}) {
2976
+ if (closing) throw new Error("fx agent is closed");
2977
+ if (journalFailure) throw reportJournalFailure();
2978
+ if (handedOff) throw handedOffError();
2979
+ if (activeTurn) throw new Error("a prompt is already in progress for this session");
2980
+ checkOnAmbiguous(promptOptions.onAmbiguous);
2981
+ if (resumable) {
2982
+ resumable = false;
2983
+ return resumeOpenTurn(promptOptions);
2984
+ }
2985
+ // With no open turn, the follow-ups the journal held are the work left.
2986
+ const next = followUps.find((entry) => entry.held);
2987
+ if (!next) return null;
2988
+ let started = null;
2989
+ const resolveTurn = next.resolveTurn;
2990
+ next.resolveTurn = (turn) => {
2991
+ started = turn;
2992
+ resolveTurn?.(turn);
2993
+ };
2994
+ runNextFollowUp(true);
2995
+ return started;
2996
+ },
2997
+ /**
2998
+ * Queues `input` to run as its own turn after the current one, or at once
2999
+ * when no turn is running. Returns a promise for that turn, carrying the
3000
+ * follow-up's `id` and `accepted`, which resolves `{ id }` once a
3001
+ * journal holds it.
3002
+ */
3003
+ followUp(input) {
3004
+ if (closing) return Promise.reject(new Error("fx agent is closed"));
3005
+ if (journalFailure) return Promise.reject(reportJournalFailure());
3006
+ if (handedOff) return Promise.reject(handedOffError());
3007
+ let text;
3008
+ try { text = normalizeSteeringInput(input); } catch (error) { return Promise.reject(error); }
3009
+ const id = `in_${crypto.randomUUID()}`;
3010
+ const entry = { id, text, accepted: false };
3011
+ const started = new Promise((resolve, reject) => { entry.resolveTurn = resolve; entry.rejectTurn = reject; });
3012
+ let accepted;
3013
+ if (journal && activeTurn && typeof runtime.steer !== "function") {
3014
+ // The native core records it now, ahead of the turn that will run it.
3015
+ entry.accepted = true;
3016
+ accepted = request("libfx/follow_up", { sessionId, id, text })
3017
+ .then(() => waitForInput(`accepted:${id}`))
3018
+ .then(() => ({ id }), (error) => {
3019
+ const index = followUps.indexOf(entry);
3020
+ if (index >= 0) followUps.splice(index, 1);
3021
+ entry.rejectTurn(error);
3022
+ throw error;
3023
+ });
3024
+ } else {
3025
+ // Its own turn records it with the first progress, a barrier; it is
3026
+ // accepted once that is stored.
3027
+ accepted = started.then(() => (journal ? waitForInput(`accepted:${id}`) : undefined)).then(() => ({ id }));
3028
+ }
3029
+ followUps.push(entry);
3030
+ void accepted.catch(() => {});
3031
+ queueMicrotask(runNextFollowUp);
3032
+ started.id = id;
3033
+ started.accepted = accepted;
3034
+ return started;
3035
+ },
3036
+ /**
3037
+ * The session's state as opaque bytes, when no prompt is running; pass
3038
+ * them as `checkpoint` to restore it in a new agent.
3039
+ */
1997
3040
  checkpoint() {
1998
3041
  // One checkpoint runs at a time: each holds an outbound attachment until
1999
3042
  // it is taken, and the native table holds only a few. An idle call still
@@ -2011,18 +3054,40 @@ export async function createFxAgent(options = {}) {
2011
3054
  return run;
2012
3055
  },
2013
3056
  async close() {
2014
- if (closing) { await runtime.exited; return; }
3057
+ hookScopes.clear();
3058
+ if (closing) {
3059
+ await runtime.exited;
3060
+ if (journal) await closeJournal();
3061
+ return;
3062
+ }
2015
3063
  const turn = activeTurn;
2016
3064
  turn?.cancel();
2017
3065
  if (turn) await turn.result.catch(() => {});
3066
+ // A checkpoint being taken needs the core; it finishes first.
3067
+ await checkpointing;
2018
3068
  closing = true;
3069
+ // A journal keeps these; the next `resume()` runs them.
3070
+ for (const entry of followUps.splice(0)) entry.rejectTurn?.(new Error("fx agent closed before the follow-up ran"));
2019
3071
  runtime.closeStdin();
2020
3072
  await runtime.exited;
3073
+ // Every append the session started has landed, or close reports why.
3074
+ if (journal) {
3075
+ let failure = null;
3076
+ try {
3077
+ await journalSettled();
3078
+ } catch {
3079
+ if (!journalFailureReported) failure = reportJournalFailure();
3080
+ }
3081
+ await closeJournal();
3082
+ if (failure) throw failure;
3083
+ }
2021
3084
  },
2022
3085
  };
2023
3086
  return agent;
2024
3087
 
2025
- function normalizeTurn(rawTurn) {
3088
+ // `answered`: calls a resumed turn answers as possibly run before it goes
3089
+ // on, whose `tool_end` comes first.
3090
+ function normalizeTurn(rawTurn, answered = []) {
2026
3091
  const toolNames = new Map();
2027
3092
  const started = new Set();
2028
3093
  const eventFor = (update) => {
@@ -2036,7 +3101,9 @@ export async function createFxAgent(options = {}) {
2036
3101
  return delta ? { type: "reasoning_delta", delta } : null;
2037
3102
  }
2038
3103
  if (update.sessionUpdate === "user_message_chunk" && update.content?.type === "text") {
2039
- return { type: "user_message", text: update.content.text };
3104
+ // A steer shows where it joined the turn, with the id it was sent with.
3105
+ const id = update._meta?.fx?.steering?.inputId;
3106
+ return { type: "user_message", text: update.content.text, ...(typeof id === "string" ? { id } : {}) };
2040
3107
  }
2041
3108
  if (update.sessionUpdate === "tool_call") {
2042
3109
  toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool");
@@ -2068,10 +3135,19 @@ export async function createFxAgent(options = {}) {
2068
3135
  }));
2069
3136
  void result.catch(() => {});
2070
3137
  return {
2071
- cancel() { rawTurn.cancel(); },
2072
- steer(input) { return rawTurn.steer(normalizeSteeringInput(input)); },
3138
+ id: rawTurn.id ?? null,
3139
+ cancel(cancelOptions) { rawTurn.cancel(cancelOptions); },
3140
+ steer(input, id) {
3141
+ if (id !== undefined && !validTurnId(id)) throw new TypeError(`steer id must be ${turnIdRule}`);
3142
+ return rawTurn.steer(normalizeSteeringInput(input), id);
3143
+ },
3144
+ withdraw(id) { return rawTurn.withdraw(id); },
3145
+ [engineInternals]: rawTurn[engineInternals],
2073
3146
  [Symbol.asyncIterator]() {
2074
3147
  const iterator = (async function* () {
3148
+ for (const call of answered) {
3149
+ yield { type: "tool_end", id: call.id, name: call.name, content: unfinishedToolOutput, isError: true };
3150
+ }
2075
3151
  for await (const update of rawTurn) {
2076
3152
  const event = eventFor(update);
2077
3153
  if (event) yield event;
@@ -2088,6 +3164,18 @@ export async function createFxAgent(options = {}) {
2088
3164
  };
2089
3165
  }
2090
3166
 
3167
+ // A turn the journal shows already ended, returned for a retried prompt.
3168
+ function endedTurn(id) {
3169
+ return {
3170
+ id,
3171
+ result: Promise.resolve({ stopReason: "end_turn", usage: {} }),
3172
+ cancel() {},
3173
+ steer() { return Promise.reject(new Error("no prompt is running")); },
3174
+ withdraw() { return Promise.resolve("already_placed"); },
3175
+ async *[Symbol.asyncIterator]() {},
3176
+ };
3177
+ }
3178
+
2091
3179
  function normalizeTurnUsage(usage) {
2092
3180
  const result = {};
2093
3181
  if (Number.isSafeInteger(usage?.inputTokens)) result.inputTokens = usage.inputTokens;
@@ -2111,7 +3199,9 @@ export async function createFxAgent(options = {}) {
2111
3199
 
2112
3200
  function startTurn(input, promptOptions) {
2113
3201
  const resizeImage = options.resizeImage;
2114
- const normalized = normalizePromptInput(input, { deferImageLimits: resizeImage !== undefined });
3202
+ const resuming = promptOptions[resumeTurn] === true;
3203
+ const followUp = promptOptions[followUpInput] ?? null;
3204
+ const normalized = resuming ? [] : normalizePromptInput(input, { deferImageLimits: resizeImage !== undefined });
2115
3205
  const hasPixels = normalized.some((block) => block.type === "image" && block.data !== undefined);
2116
3206
  // Blob reads and resizeImage run before the prompt frame is sent.
2117
3207
  const asyncImages = hasPixels && (resizeImage !== undefined ||
@@ -2124,6 +3214,8 @@ export async function createFxAgent(options = {}) {
2124
3214
  : preparePromptImages(normalized);
2125
3215
  const signal = promptOptions.signal;
2126
3216
  if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal");
3217
+ const yieldAt = promptOptions.yieldAt;
3218
+ if (yieldAt !== undefined && !(Number.isSafeInteger(yieldAt) && yieldAt > 0)) throw new TypeError("yieldAt must be a time in milliseconds since the epoch");
2127
3219
  const queue = [];
2128
3220
  const waiters = [];
2129
3221
  let queuedBytes = 0;
@@ -2138,10 +3230,55 @@ export async function createFxAgent(options = {}) {
2138
3230
  const promptStarted = asyncImages ? new Promise((resolve) => { resolvePromptStart = resolve; }) : null;
2139
3231
  let pendingSteeringCount = 0;
2140
3232
  let pendingSteeringBytes = 0;
3233
+ // This turn's steers: whether each reached the core, or was withdrawn first.
3234
+ const steers = new Map();
3235
+ const withdrawnSteer = (id) => Object.assign(new Error("steer was withdrawn"), { code: "FX_STEER_WITHDRAWN", id });
3236
+ const startSteer = (id, text) => {
3237
+ if (finished || cancelled || activeTurn !== turn) {
3238
+ return Promise.reject(new Error("no prompt is running"));
3239
+ }
3240
+ if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
3241
+ const local = { sent: false, withdrawn: false };
3242
+ steers.set(id, local);
3243
+ const apply = () => {
3244
+ if (local.withdrawn) return Promise.resolve({ id, withdrawn: true });
3245
+ if (finished || cancelled || activeTurn !== turn) {
3246
+ return Promise.reject(new Error("no prompt is running"));
3247
+ }
3248
+ if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
3249
+ try {
3250
+ local.sent = true;
3251
+ const accepted = () => (journal ? waitForInput(`accepted:${id}`) : Promise.resolve());
3252
+ if (typeof runtime.steer === "function") {
3253
+ runtime.steer(text, id);
3254
+ // The web core accepts it when it takes it at the next boundary,
3255
+ // and shows where it joined the turn then.
3256
+ return accepted().then(() => ({ id }), (error) => (error?.code === "FX_STEER_WITHDRAWN" ? { id, withdrawn: true } : Promise.reject(error)));
3257
+ }
3258
+ return request("libfx/steer", { sessionId, text, id }).then(accepted).then(() => ({ id }));
3259
+ } catch (error) {
3260
+ return Promise.reject(error);
3261
+ }
3262
+ };
3263
+ if (!resolvePromptStart) return apply();
3264
+ const bytes = encoder.encode(text).length;
3265
+ if (pendingSteeringCount >= maxSteeringMessages || bytes > maxSteeringQueueBytes - pendingSteeringBytes) {
3266
+ return Promise.reject(new Error("steering queue is full"));
3267
+ }
3268
+ pendingSteeringCount++;
3269
+ pendingSteeringBytes += bytes;
3270
+ return promptStarted.then((started) => {
3271
+ if (coreExitError && !cancelled) throw coreExitError;
3272
+ return started === true ? apply() : Promise.reject(started instanceof Error ? started : new Error("no prompt is running"));
3273
+ }).finally(() => { pendingSteeringCount--; pendingSteeringBytes -= bytes; });
3274
+ };
2141
3275
  const toolControllers = new Set();
2142
3276
  let finished = false;
2143
3277
  let cancelled = false;
2144
3278
  const turn = {
3279
+ // The host's id: the one it gave, or for a resumed turn, the one the
3280
+ // turn started with.
3281
+ id: resuming ? openTurnId : promptOptions.turnId ?? null,
2145
3282
  push(update, size = encoder.encode(JSON.stringify(update)).length) {
2146
3283
  if (cancelled || finished) { discardedBytes += size; return; }
2147
3284
  if (size > maxCoreMessageBytes) throw new RangeError("core output message exceeds 64 MiB");
@@ -2171,44 +3308,60 @@ export async function createFxAgent(options = {}) {
2171
3308
  resolvePromptStart?.(true);
2172
3309
  resolvePromptStart = null;
2173
3310
  },
2174
- steer(text) {
2175
- if (finished || cancelled || activeTurn !== turn) {
2176
- return Promise.reject(new Error("no prompt is running"));
3311
+ // Resolves `{ id }` once the steer is accepted: in a journaled session,
3312
+ // once its acceptance is stored. The promise carries `id` at once.
3313
+ steer(text, id = `in_${crypto.randomUUID()}`) {
3314
+ const steered = startSteer(id, text).then((value) => value ?? { id });
3315
+ steered.id = id;
3316
+ return steered;
3317
+ },
3318
+ // 'withdrawn' when the model never saw the steer, else 'already_placed'.
3319
+ withdraw(id) {
3320
+ const local = steers.get(id);
3321
+ if (!local) return Promise.resolve("already_placed");
3322
+ if (!local.sent) {
3323
+ local.withdrawn = true;
3324
+ return Promise.resolve("withdrawn");
2177
3325
  }
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"));
3326
+ if (typeof runtime.withdrawSteering === "function") {
3327
+ if (!runtime.withdrawSteering(id)) return Promise.resolve("already_placed");
3328
+ local.withdrawn = true;
3329
+ rejectInputWaiters([`accepted:${id}`], withdrawnSteer(id));
3330
+ return Promise.resolve("withdrawn");
2202
3331
  }
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; });
3332
+ return request("libfx/withdraw", { sessionId, id }).then(async (response) => {
3333
+ if (response?.result !== "withdrawn") return "already_placed";
3334
+ if (journal) await waitForInput(`withdrawn:${id}`);
3335
+ return "withdrawn";
3336
+ });
2209
3337
  },
2210
- cancel() {
3338
+ // For libfx's session worker: a steer that cannot join the turn, such
3339
+ // as one with images, ends the turn at its next safe point, at once
3340
+ // while the model streams a reply, so that steer can run next.
3341
+ [engineInternals]: {
3342
+ endForSteer() {
3343
+ const send = () => {
3344
+ if (finished || cancelled || activeTurn !== turn || closing) return undefined;
3345
+ if (typeof runtime.handOffSteering === "function") {
3346
+ runtime.handOffSteering();
3347
+ return undefined;
3348
+ }
3349
+ return request("libfx/handoff", { sessionId }).then(() => undefined);
3350
+ };
3351
+ const sent = promptStarted && resolvePromptStart ? promptStarted.then((started) => (started === true ? send() : undefined)) : Promise.resolve().then(send);
3352
+ return sent.catch(() => {});
3353
+ },
3354
+ },
3355
+ cancel(cancelOptions = {}) {
3356
+ const reason = cancelOptions?.reason;
3357
+ if (reason !== undefined && reason !== "handoff") throw new TypeError('cancel() reason must be "handoff" when given');
3358
+ if (reason === "handoff" && !journal) throw new TypeError("a handoff needs persistence to keep the turn in");
2211
3359
  if (finished || cancelled) return;
3360
+ if (reason === "handoff") {
3361
+ handedOff = true;
3362
+ // The journal keeps queued follow-ups for the next agent.
3363
+ for (const entry of followUps.splice(0)) entry.rejectTurn?.(handedOffError());
3364
+ }
2212
3365
  cancelled = true;
2213
3366
  cancelImagePrep?.();
2214
3367
  cancelImagePrep = null;
@@ -2271,21 +3424,43 @@ export async function createFxAgent(options = {}) {
2271
3424
  resolvePromptStart = null;
2272
3425
  return { stopReason: "cancelled" };
2273
3426
  }
2274
- return sendPrompt(prepared);
3427
+ return sendPrompt(prepared, false, followUp, turn.id, [], yieldAt);
2275
3428
  });
2276
3429
  } else {
2277
- try { response = sendPrompt(prompt); } catch (error) { response = Promise.reject(error); }
3430
+ try { response = sendPrompt(prompt, resuming, followUp, turn.id, promptOptions[rerunCallIds] ?? [], yieldAt); } catch (error) { response = Promise.reject(error); }
2278
3431
  }
3432
+ // Appends after a turn's first progress are lazy, so the result does not
3433
+ // wait for them; `close()` does. A failure already seen fails the turn.
2279
3434
  turn.result = response
2280
- .then((value) => ({ stopReason: cancelled ? "cancelled" : value.stopReason, usage: value.usage }))
2281
- .catch((error) => {
3435
+ .then((value) => {
3436
+ if (journalFailure) throw reportJournalFailure();
3437
+ if (value.stopReason === "yielded" && !cancelled) {
3438
+ // The core keeps the turn open: `resume()` continues it here, or
3439
+ // a later agent continues it from the journal.
3440
+ resumable = true;
3441
+ openTurnId = turn.id;
3442
+ ambiguousCalls = [];
3443
+ } else if (!handedOff) {
3444
+ // A handed-off turn stays open for the next agent.
3445
+ if (turn.id) lastTurnId = turn.id;
3446
+ openTurnId = null;
3447
+ }
3448
+ return { stopReason: cancelled ? "cancelled" : value.stopReason, usage: value.usage };
3449
+ })
3450
+ .catch(async (error) => {
2282
3451
  cancelImagePrep = null;
2283
3452
  rejectImagePrep = null;
2284
3453
  resolvePromptStart?.(error);
2285
3454
  resolvePromptStart = null;
3455
+ // A failed append is why the core stopped, including when the turn
3456
+ // was cancelled because of it; report the host's error.
3457
+ if (journalFailure) {
3458
+ terminalError = reportJournalFailure();
3459
+ throw terminalError;
3460
+ }
2286
3461
  if (cancelled && error.message === "Cancelled") return { stopReason: "cancelled" };
2287
3462
  terminalError = error;
2288
- throw error;
3463
+ throw terminalError;
2289
3464
  })
2290
3465
  .finally(() => {
2291
3466
  finished = true;
@@ -2293,6 +3468,17 @@ export async function createFxAgent(options = {}) {
2293
3468
  resumeOutput = null;
2294
3469
  signal?.removeEventListener("abort", abort);
2295
3470
  if (activeTurn === turn) activeTurn = null;
3471
+ // A steer still queued when the turn ends never reached the model.
3472
+ if (typeof runtime.withdrawSteering === "function") {
3473
+ const untaken = [...steers.keys()].filter((id) => runtime.withdrawSteering(id));
3474
+ rejectInputWaiters(untaken.map((id) => `accepted:${id}`), new Error("the turn ended before it took the steer"));
3475
+ }
3476
+ for (const id of steers.keys()) {
3477
+ durableInputs.delete(`accepted:${id}`);
3478
+ durableInputs.delete(`withdrawn:${id}`);
3479
+ }
3480
+ queueMicrotask(runNextFollowUp);
3481
+ queueMicrotask(() => { void maybeCheckpoint(); });
2296
3482
  runtime.closeSteering?.();
2297
3483
  toolControllers.clear();
2298
3484
  if (discardedBytes) emit("output.discarded", { reason: "cancelled", bytes: discardedBytes });