libfx 0.0.13-dev.1510.ga878d4900a6a → 0.0.13-dev.1512.ga8421a0ce13b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,8 +55,8 @@ agent.session(sessionId).prompt("Start with the schema.");
55
55
 
56
56
  - **One turn at a time.** A prompt sent while a turn runs waits behind it, even when it reaches a different server.
57
57
  - **Turns finish on their own.** A turn runs to the end whether or not anyone reads its events, and a turn nobody reads holds no connection open: its view starts reading only once you use its events, `readable` or `result`. Stop it with `session.cancel()`, or with the `signal` you passed to `prompt()`.
58
- - **Steering.** `session.steer(text)` adds guidance to the running turn at its next model request. When no turn is running, the text becomes the next turn.
59
- - **Retries are safe.** Give a prompt a `messageId`, and a repeated request with the same id runs nothing new. It follows the original turn, and its result has `repeated: true`.
58
+ - **Steering.** `session.steer(input)` steers the running turn the way the fx CLI does. While the model streams a reply, the steer cuts it short and the turn continues with it. While a tool runs, the steer waits for the tool. A steer sent while a turn waits to start joins that turn, and when no turn runs or waits, the steer becomes the next turn. A steer with images can't join a turn, so the running turn ends at its next safe point and the steer runs next.
59
+ - **Retries are safe.** Give a prompt or a steer a `messageId`, and a repeated request with the same id runs nothing new. A repeated prompt follows the original turn, and its result has `repeated: true`. One id names one input, so a steer that reuses a prompt's id is rejected with `FX_MESSAGE_ID_IN_USE`. On a queue that keeps messages past the process, such as `vercel()`'s, the sender doesn't read the session first, so the worker drops that steer and emits a `session.error` with the same code. A steer's `user_message` event carries its id as `id` where it joined a turn.
60
60
 
61
61
  Each conversation can bring its own settings, which apply to the turns that session object starts:
62
62
 
@@ -431,7 +431,7 @@ An engine differs from a session:
431
431
 
432
432
  - On AI Gateway, it needs an `apiKey`.
433
433
  - One prompt runs at a time, and breaking out of a turn's events cancels the turn.
434
- - The turn itself has `cancel()` and `steer()`.
434
+ - The turn itself has `cancel()` and `steer()`. Like a session's steer, it cuts a streaming reply short and waits for a running tool, and it takes text only.
435
435
 
436
436
  To have the engine record the conversation as it runs, pass a `persistence` store instead of a checkpoint. A new engine on the same store can then `resume()` a turn the last process left open. A store needs two methods and can add a third, and the bytes are opaque, so it never has to understand them:
437
437
 
package/durable.js CHANGED
@@ -164,11 +164,22 @@ export function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
164
164
  let epoch = base?.lease?.epoch ?? 0;
165
165
  const consumed = new Set(base?.recent ?? []);
166
166
  const recent = [...(base?.recent ?? [])];
167
+ // Which of those were steers, so a prompt and a steer cannot share an id
168
+ // after a checkpoint drops their inputs. A checkpoint from before this was
169
+ // kept does not say.
170
+ const baseSteers = new Set(base?.recentSteers ?? []);
171
+ const kindsKnown = base === null || Array.isArray(base?.recentSteers);
172
+ // Inputs the open turn accepted and has not placed, which a withdrawal can
173
+ // hand back. They count as taken until then.
174
+ const held = new Set(base?.held ?? []);
175
+ for (const id of held) consumed.add(id);
167
176
  const seenInputs = new Set();
168
177
  const inputs = [];
169
178
  const records = [];
170
179
  // A checkpoint taken where a turn yielded leaves that turn open.
171
- let openTurn = typeof base?.openTurn?.id === "string" ? { id: base.openTurn.id, at: Number(base.openTurn.at) || 0, context: base.openTurn.context ?? null, settings: base.openTurn.settings ?? null } : null;
180
+ // `from` is where the input that opened it was stored: steers stored after
181
+ // it join it, before it started too.
182
+ let openTurn = typeof base?.openTurn?.id === "string" ? { id: base.openTurn.id, at: Number(base.openTurn.at) || 0, from: Number(base.openTurn.from ?? base.openTurn.at) || 0, context: base.openTurn.context ?? null, settings: base.openTurn.settings ?? null } : null;
172
183
  let lastTurnId = base?.lastTurnId ?? null;
173
184
  // The agent the session belongs to: the one its first stored input, or the
174
185
  // checkpoint it was restored from, names. A session stored before agents
@@ -195,6 +206,13 @@ export function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
195
206
  consumed.add(id);
196
207
  recent.push(id);
197
208
  };
209
+ // A steer its turn accepted and ended without placing: the model never saw
210
+ // it, so it waits for a turn again.
211
+ const handBack = (id) => {
212
+ if (!consumed.delete(id)) return;
213
+ const index = recent.lastIndexOf(id);
214
+ if (index >= 0) recent.splice(index, 1);
215
+ };
198
216
  for (const input of base?.pending ?? []) {
199
217
  if (seenInputs.has(input.key)) continue;
200
218
  seenInputs.add(input.key);
@@ -230,17 +248,26 @@ export function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
230
248
  records.push({ cursor, data: entry.data });
231
249
  for (const mark of entry.marks ?? []) {
232
250
  if (typeof mark.start === "string") {
233
- openTurn = { id: mark.start, at: position, context: contexts.get(mark.start) ?? null, settings: settingsOf.get(mark.start) ?? null };
251
+ const opener = inputs.find((input) => input.messageId === mark.start);
252
+ openTurn = { id: mark.start, at: position, from: opener?.cursor ?? position, context: contexts.get(mark.start) ?? null, settings: settingsOf.get(mark.start) ?? null };
234
253
  consume(mark.start);
235
254
  yielded = false;
236
255
  } else if (mark.end === true) {
237
256
  if (openTurn) lastTurnId = openTurn.id;
238
257
  openTurn = null;
239
258
  yielded = false;
259
+ // A turn's unplaced inputs end with it, handed back first.
260
+ held.clear();
240
261
  } else if (mark.yield === true) {
241
262
  yielded = true;
242
263
  } else if (typeof mark.accepted === "string") {
243
264
  consume(mark.accepted);
265
+ held.add(mark.accepted);
266
+ } else if (typeof mark.placed === "string") {
267
+ held.delete(mark.placed);
268
+ } else if (typeof mark.withdrawn === "string") {
269
+ held.delete(mark.withdrawn);
270
+ handBack(mark.withdrawn);
244
271
  }
245
272
  }
246
273
  }
@@ -270,17 +297,36 @@ export function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
270
297
  for (const input of inputs) {
271
298
  if (input.type === "cancel" && typeof input.target === "string") cancelTargets.add(input.target);
272
299
  }
300
+ // An open turn whose harness could not start has ended for its viewers.
301
+ const openFailed = openTurn !== null && failed.has(openTurn.id);
302
+ // The turns waiting to run, each with the steers that join it when it
303
+ // starts, whether it waited or ran when they came, as the fx CLI's steering
304
+ // does: a text steer stored after the open turn's opener joins that turn,
305
+ // and otherwise the turn waiting before it. A steer that cannot join a
306
+ // turn waits as its own, and the turn it came after ends at its next safe
307
+ // point, at once if it has not started; the steers after it join its turn.
308
+ // A steer with no turn to join starts one.
273
309
  const pending = [];
274
310
  const steers = [];
311
+ const openOwner = openTurn !== null && !openFailed ? { joiners: steers, ends: false } : null;
275
312
  let cancelOpen = false;
313
+ let owner = null;
276
314
  for (const input of inputs) {
315
+ if (openOwner !== null && owner === null && input.cursor > openTurn.from) owner = openOwner;
277
316
  if (consumed.has(input.messageId)) continue;
278
317
  const forOpenTurn = openTurn !== null && input.cursor > openTurn.at;
279
- if (input.type === "prompt") pending.push(cancelTargets.has(input.messageId) ? { ...input, cancelled: true } : input);
280
- else if (input.type === "steer") {
281
- // A steer its turn never took becomes the next turn.
282
- if (forOpenTurn) steers.push(input);
283
- else pending.push({ ...input, type: "prompt" });
318
+ if (input.type === "prompt") {
319
+ const entry = { ...input, ...(cancelTargets.has(input.messageId) ? { cancelled: true } : {}), joiners: [], ends: false };
320
+ pending.push(entry);
321
+ // While a turn runs, steers still join it: a prompt is the next turn.
322
+ if (owner === null || owner !== openOwner) owner = entry;
323
+ } else if (input.type === "steer") {
324
+ if (owner !== null && joinable(input.input)) owner.joiners.push(input);
325
+ else {
326
+ if (owner !== null) owner.ends = true;
327
+ owner = { ...input, type: "prompt", joiners: [], ends: false };
328
+ pending.push(owner);
329
+ }
284
330
  } else if (input.type === "cancel") {
285
331
  if (typeof input.target === "string") {
286
332
  if (openTurn?.id === input.target) cancelOpen = true;
@@ -298,17 +344,23 @@ export function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
298
344
  : openTurn !== null && input.cursor > openTurn.at;
299
345
  if (!live) consume(input.messageId);
300
346
  }
301
- // An open turn whose harness could not start has ended for its viewers; a
302
- // cancel it is due waits for the next prompt's engine. One whose engine
303
- // stopped each time it continued it is cancelled; one whose engine stopped
304
- // even then can never go on, and the session runs no more turns.
305
- const openFailed = openTurn !== null && failed.has(openTurn.id);
347
+ // A cancel the failed open turn is due waits for the next prompt's
348
+ // engine. One whose engine stopped each time it continued it is
349
+ // cancelled; one whose engine stopped even then can never go on, and the
350
+ // session runs no more turns.
306
351
  const openStops = openTurn === null ? 0 : stops.get(openTurn.id) ?? 0;
307
352
  const halted = openStops >= maxHarnessStops + maxCancelStops;
308
353
  const stuck = openStops >= maxHarnessStops && !halted;
354
+ const kinds = new Map(inputs.map((input) => [input.messageId, input.type]));
355
+ const kindOf = (id) => kinds.get(id) ?? (baseSteers.has(id) ? "steer" : kindsKnown ? "prompt" : null);
309
356
  return {
310
- // Inputs no turn has taken, as written: what a checkpoint keeps.
357
+ // Inputs no turn has taken, as written: what a checkpoint keeps, with
358
+ // the ones the open turn holds unplaced, which a withdrawal hands back.
311
359
  unconsumed: inputs.filter((input) => !consumed.has(input.messageId)),
360
+ held: inputs.filter((input) => held.has(input.messageId)),
361
+ heldIds: [...held],
362
+ // A steer that cannot join the open turn waits for it to end.
363
+ endOpen: openOwner?.ends === true,
312
364
  head,
313
365
  // The lease others must respect, and the chain's latest whether or not
314
366
  // it still stands: a worker checks the latter is its own.
@@ -328,6 +380,9 @@ export function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
328
380
  steers,
329
381
  cancelOpen,
330
382
  recent: recent.slice(-recentIdsKept),
383
+ recentSteers: recent.slice(-recentIdsKept).filter((id) => kindOf(id) === "steer"),
384
+ // What kind of input a taken id named, or null when no record says.
385
+ consumedKind: (id) => (consumed.has(id) ? kindOf(id) : null),
331
386
  consumed,
332
387
  failed,
333
388
  stops,
@@ -1085,7 +1140,7 @@ function messageEntries(state, message) {
1085
1140
  entries.push({ k: "checkpoint", through: null, data: message.seed, ...named });
1086
1141
  }
1087
1142
  const key = `${message.type}:${message.messageId}`;
1088
- const known = state.inputKeys.has(key) || state.consumed.has(message.messageId);
1143
+ const known = state.inputKeys.has(key) || state.consumed.has(message.messageId) || idInUse(state, message);
1089
1144
  if ((message.type === "prompt" || message.type === "steer" || message.type === "cancel") && !known) {
1090
1145
  entries.push({
1091
1146
  k: "input",
@@ -1102,9 +1157,28 @@ function messageEntries(state, message) {
1102
1157
  return entries;
1103
1158
  }
1104
1159
 
1160
+ // Whether a steer's input can join a turn: text. One with images or
1161
+ // resources runs as its own turn, after the running one hands off to it.
1162
+ const joinable = (input) => typeof input === "string" || (Array.isArray(input) && input.every((block) => block?.type === "text"));
1163
+
1164
+ // Whether `message` reuses the id of the session's other kind of input: one
1165
+ // id names one input, prompt or steer.
1166
+ function idInUse(state, message) {
1167
+ const other = message.type === "prompt" ? "steer" : message.type === "steer" ? "prompt" : null;
1168
+ if (other === null) return false;
1169
+ return state.inputKeys.has(`${other}:${message.messageId}`) || state.consumedKind?.(message.messageId) === other;
1170
+ }
1171
+
1172
+ const messageIdInUse = (message) => Object.assign(
1173
+ new TypeError(`messageId ${JSON.stringify(message.messageId)} already names a ${message.type === "prompt" ? "steer" : "prompt"} in this session`),
1174
+ { code: "FX_MESSAGE_ID_IN_USE" },
1175
+ );
1176
+
1105
1177
  // Writes what a queue message adds to its session, in order, and returns how
1106
- // many entries it wrote. The sender and the consumer can both call it.
1178
+ // many entries it wrote. The sender and the consumer can both call it. A
1179
+ // message that reuses another input's id writes nothing and throws.
1107
1180
  async function recordMessage(log, state, message) {
1181
+ if (idInUse(state, message)) throw messageIdInUse(message);
1108
1182
  const entries = messageEntries(state, message);
1109
1183
  for (const entry of entries) await log.append(entry);
1110
1184
  return entries.length;
@@ -1216,7 +1290,16 @@ class SessionWorker {
1216
1290
  if (early) {
1217
1291
  outcome = await this.claim(log, state, deadline, early, delivery);
1218
1292
  } else {
1219
- if (await recordMessage(log, state, message) > 0) {
1293
+ let wrote;
1294
+ try {
1295
+ wrote = await recordMessage(log, state, message);
1296
+ } catch (error) {
1297
+ if (error?.code !== "FX_MESSAGE_ID_IN_USE") throw error;
1298
+ // Its sender could not hear it: a durable queue took it first.
1299
+ this.agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(error) });
1300
+ return undefined;
1301
+ }
1302
+ if (wrote > 0) {
1220
1303
  entries = await log.read();
1221
1304
  state = this.fold(entries, delivery);
1222
1305
  }
@@ -1403,15 +1486,19 @@ class SessionWorker {
1403
1486
  append: ({ idempotencyKey, data, marks }) => chained({ k: "record", key: idempotencyKey, data: toBase64(data), marks }).then((cursor) => ({ cursor })),
1404
1487
  saveCheckpoint: async ({ through, data }) => {
1405
1488
  // What a fold that starts at this checkpoint would otherwise miss:
1406
- // inputs before it that no turn took, the ids it ran, the turn a
1407
- // yield left open, and the lease.
1489
+ // inputs before it that no turn took or that the open turn holds
1490
+ // unplaced, the ids it ran, the turn a yield left open, and the lease.
1408
1491
  const now = this.fold(await log.read());
1492
+ // In log order, so a fold from here orders turns as one from the start.
1493
+ const kept = [...now.unconsumed, ...now.held].filter((input) => input.cursor <= Number(through)).sort((a, b) => a.cursor - b.cursor);
1409
1494
  await log.append({
1410
1495
  k: "checkpoint",
1411
1496
  through,
1412
1497
  data: toBase64(data),
1413
- pending: now.unconsumed.filter((input) => input.cursor <= Number(through)),
1498
+ pending: kept,
1499
+ ...(now.heldIds.length > 0 ? { held: now.heldIds } : {}),
1414
1500
  recent: now.recent,
1501
+ recentSteers: now.recentSteers,
1415
1502
  lastTurnId: now.lastTurnId,
1416
1503
  ...(now.agent === null ? {} : { agent: now.agent }),
1417
1504
  ...(now.openTurn ? { openTurn: now.openTurn, yielded: now.yielded } : {}),
@@ -1575,7 +1662,9 @@ class SessionWorker {
1575
1662
  turnNow = turn;
1576
1663
  if (this.stopping || claimFailed) turn.cancel({ reason: "handoff" });
1577
1664
  else if (spent && !(open.calls?.length > 0)) turn.cancel();
1578
- const outcome = await this.drive(log, harness, turn, messageId, state, ui);
1665
+ // A new turn takes the steers sent while it waited to start.
1666
+ const joining = fresh ? { ...state, steers: state.pending[0]?.joiners ?? [], endOpen: state.pending[0]?.ends === true } : state;
1667
+ const outcome = await this.drive(log, harness, turn, messageId, joining, ui);
1579
1668
  turnNow = null;
1580
1669
  if (outcome === "fenced") return "fenced";
1581
1670
  if (outcome === "yielded") {
@@ -1671,6 +1760,19 @@ class SessionWorker {
1671
1760
  let polling = false;
1672
1761
  let again = false;
1673
1762
  let stopped = false;
1763
+ // A text steer joins the turn. Any other ends it at its next safe point
1764
+ // and, never taken, runs as the next turn, as the fx CLI hands off; the
1765
+ // steers after that one join its turn.
1766
+ let ending = false;
1767
+ const endTurn = () => {
1768
+ if (ending) return;
1769
+ ending = true;
1770
+ void Promise.resolve().then(() => turn.endForSteer?.()).catch(() => {});
1771
+ };
1772
+ const steerTurn = (steer) => {
1773
+ if (!joinable(steer.input)) endTurn();
1774
+ else if (!ending) void turn.steer(steer.input, steer.messageId).catch(() => {});
1775
+ };
1674
1776
  const apply = async () => {
1675
1777
  if (stopped) return;
1676
1778
  if (polling) { again = true; return; }
@@ -1683,7 +1785,7 @@ class SessionWorker {
1683
1785
  if (Number(cursor) > Number(since)) since = cursor;
1684
1786
  if (entry?.k !== "input" || applied.has(entry.key)) continue;
1685
1787
  applied.add(entry.key);
1686
- if (entry.type === "steer" && typeof entry.input === "string") void turn.steer(entry.input, entry.messageId).catch(() => {});
1788
+ if (entry.type === "steer") steerTurn(entry);
1687
1789
  else if (entry.type === "cancel" && (entry.target === undefined || entry.target === messageId)) turn.cancel();
1688
1790
  }
1689
1791
  } while (again && !stopped);
@@ -1693,8 +1795,9 @@ class SessionWorker {
1693
1795
  };
1694
1796
  for (const steer of state.steers) {
1695
1797
  applied.add(steer.key);
1696
- if (typeof steer.input === "string") void turn.steer(steer.input, steer.messageId).catch(() => {});
1798
+ steerTurn(steer);
1697
1799
  }
1800
+ if (state.endOpen) endTurn();
1698
1801
  // An open turn whose engine kept stopping is cancelled the way a user
1699
1802
  // would, so the prompts behind it can run.
1700
1803
  if (state.cancelOpen || (state.stuck && state.openTurn?.id === messageId)) turn.cancel();
@@ -1991,7 +2094,9 @@ function turnView({ messageId, resumeRequest = null, start }) {
1991
2094
  * `saveCheckpoint({ through, data })` stores what the records through
1992
2095
  * `through` add up to. Each record's `marks` tell the core where turns
1993
2096
  * start (`{ start: turnId }`), yield (`{ yield: true }`) and end
1994
- * (`{ end: true }`), and which inputs a turn took (`{ accepted: id }`).
2097
+ * (`{ end: true }`), which inputs a turn took (`{ accepted: id }`), when
2098
+ * its model request first carries one (`{ placed: id }`), and which it
2099
+ * ended without placing (`{ withdrawn: id }`), which wait for a turn again.
1995
2100
  * A write another worker's write fenced out rejects with a `FX_FENCED`
1996
2101
  * code. `ready`, when given, resolves true once the worker's claim on the
1997
2102
  * session lands and false when it failed: the core may start a turn
@@ -2013,8 +2118,11 @@ function turnView({ messageId, resumeRequest = null, start }) {
2013
2118
  * an async iterable of the events its viewers see, with `result` resolving
2014
2119
  * `{ stopReason, usage? }`, `steer(text, id)`, and `cancel(options)`, where
2015
2120
  * `{ reason: "handoff" }` stops it at once and stores nothing more, so the
2016
- * next worker continues it. It stops before a model request once `yieldAt`
2017
- * has passed, with the stop reason `yielded`.
2121
+ * next worker continues it. A turn may have `endForSteer()`, which ends it
2122
+ * at its next safe point, at once while its model streams a reply, for a
2123
+ * steer that cannot join it; without it, such a steer waits for the turn to
2124
+ * end. Either way the steer runs as the next turn. A turn stops before a
2125
+ * model request once `yieldAt` has passed, with the stop reason `yielded`.
2018
2126
  *
2019
2127
  * An input the harness refuses is a turn that ends with an error.
2020
2128
  * `prompt()` and `resume()` throw only when the harness cannot run a turn,
@@ -2345,10 +2453,31 @@ export function createDurableAgentFactory({ harness, defaultDurability, name = "
2345
2453
  cancel() { void reader?.cancel().catch(() => {}); },
2346
2454
  });
2347
2455
  },
2348
- /** Guidance for the running turn, or the next turn when none runs. */
2349
- async steer(text) {
2350
- if (typeof text !== "string" || text.length === 0) throw new TypeError("steer text must be a non-empty string");
2351
- await send({ type: "steer", messageId: newId("str"), input: text });
2456
+ /**
2457
+ * Guidance for the running turn, or the next turn when none runs: it
2458
+ * cuts a streaming reply short, waits for a running tool, and joins a
2459
+ * turn waiting to start. A steer with images or resources cannot
2460
+ * join a turn: the running one ends at its next safe point and the
2461
+ * steer runs next. With `messageId`, a retried call adds nothing;
2462
+ * one id names one input of the session, prompt or steer: reusing a
2463
+ * prompt's id throws `FX_MESSAGE_ID_IN_USE`, or, where the queue
2464
+ * outlives the process and the sender reads no log, the worker drops
2465
+ * the steer and emits a `session.error` with that code. Resolves once
2466
+ * the steer is stored, with its id, which the stream's events for it
2467
+ * carry.
2468
+ */
2469
+ async steer(input, steerOptions = {}) {
2470
+ if (typeof input === "string" ? input.length === 0 : !Array.isArray(input) || input.length === 0) {
2471
+ throw new TypeError("steer input must be a non-empty string or array of content blocks");
2472
+ }
2473
+ if (Array.isArray(input) && input.some((block) => block?.type === "image" && block.data !== undefined && typeof block.data !== "string")) {
2474
+ throw new TypeError("a durable steer carries image data as a base64 string");
2475
+ }
2476
+ if (steerOptions === null || typeof steerOptions !== "object") throw new TypeError("steer options must be an object");
2477
+ const messageId = steerOptions.messageId ?? newId("str");
2478
+ if (!validId(messageId)) throw new TypeError(`messageId must be ${idRule}`);
2479
+ await send({ type: "steer", messageId, input });
2480
+ return { messageId };
2352
2481
  },
2353
2482
  /** Cancels the running turn; dropping a turn's view does not. */
2354
2483
  async cancel() {
package/fx-core.wasm CHANGED
Binary file
package/fx-harness.js CHANGED
@@ -107,6 +107,7 @@ function fxTurn(turn, exited) {
107
107
  return {
108
108
  result,
109
109
  steer: (text, id) => turn.steer(text, id),
110
+ endForSteer: () => turn[engineInternals]?.endForSteer(),
110
111
  cancel: (options) => turn.cancel(options),
111
112
  async *[Symbol.asyncIterator]() {
112
113
  try {
@@ -124,6 +125,7 @@ function refusedTurn(error) {
124
125
  return {
125
126
  result: Promise.resolve({ stopReason: "error", usage: {}, error: { name: error?.name ?? "Error", message: String(error?.message ?? error) } }),
126
127
  steer: async () => {},
128
+ endForSteer: async () => {},
127
129
  cancel() {},
128
130
  async *[Symbol.asyncIterator]() {},
129
131
  };
package/fx-sdk.js CHANGED
@@ -451,19 +451,22 @@ async function loadPersistence(persistence) {
451
451
 
452
452
  // What a record's events say about turns, in order, for a store that tracks
453
453
  // them without reading records: a turn started, ended, or yielded, an input
454
- // was accepted, or the step the open turn is at, as its tool steps finished
455
- // and the state of its calls.
454
+ // was accepted or handed back unplaced, or the step the open turn is at, as
455
+ // its tool steps finished and the state of its calls.
456
456
  function journalMarks(events) {
457
457
  const marks = [];
458
458
  for (const event of events) {
459
459
  if (event?.type === "turn_progress") {
460
460
  if (typeof event.turnId === "string") marks.push({ start: event.turnId });
461
+ // The inputs its model request carries for the first time.
462
+ for (const id of Array.isArray(event.inputs) ? event.inputs : []) if (typeof id === "string") marks.push({ placed: id });
461
463
  // A progress that carries only what is new counts all the turn's steps.
462
464
  const steps = Number.isSafeInteger(event.steps) ? event.steps : event.data?.execution?.tool_steps?.length;
463
465
  if (Number.isSafeInteger(steps)) marks.push({ step: `${steps}:${String(event.data?.tool_state ?? "none")}` });
464
466
  } else if (event?.type === "turn_committed" || event?.type === "turn_progress_cleared") marks.push({ end: true });
465
467
  else if (event?.type === "turn_yielded") marks.push({ yield: true });
466
468
  else if (event?.type === "input_accepted" && typeof event.data?.id === "string") marks.push({ accepted: event.data.id });
469
+ else if (event?.type === "input_withdrawn" && typeof event.data?.id === "string") marks.push({ withdrawn: event.data.id });
467
470
  }
468
471
  return marks;
469
472
  }
@@ -835,6 +838,8 @@ function createRuntime(options) {
835
838
  const steering = [];
836
839
  let steeringBytes = 0;
837
840
  let steeringOpen = false;
841
+ // A steer that cannot join the running turn waits to run next.
842
+ let steeringHandoff = false;
838
843
  // Raw payloads beside ACP frames: the core copies inbound bytes into its
839
844
  // own memory and publishes outbound bytes for the agent to take.
840
845
  const inboundAttachments = new Map();
@@ -1109,6 +1114,7 @@ function createRuntime(options) {
1109
1114
  function clearSteering() {
1110
1115
  steering.length = 0;
1111
1116
  steeringBytes = 0;
1117
+ steeringHandoff = false;
1112
1118
  }
1113
1119
 
1114
1120
  function queueSteering(text, id = "") {
@@ -1617,6 +1623,9 @@ function createRuntime(options) {
1617
1623
  fx_host_tool_result_release() { pendingHostToolResult = null; },
1618
1624
  fx_steering_take: steeringTake,
1619
1625
  fx_steering_close() { steeringOpen = false; clearSteering(); },
1626
+ // -1 once the turn takes no more steering, else twice how many steers
1627
+ // wait, plus one when a handoff does.
1628
+ fx_steering_state() { return steeringOpen ? steering.length * 2 + (steeringHandoff ? 1 : 0) : -1; },
1620
1629
  fx_attachment_size: attachmentSize,
1621
1630
  fx_attachment_take: attachmentTake,
1622
1631
  fx_attachment_put: attachmentPut,
@@ -1658,6 +1667,12 @@ function createRuntime(options) {
1658
1667
  openSteering() { clearSteering(); steeringOpen = true; },
1659
1668
  steer: queueSteering,
1660
1669
  withdrawSteering,
1670
+ // Ends the running turn at its next safe point; false when none runs.
1671
+ handOffSteering() {
1672
+ if (!steeringOpen) return false;
1673
+ steeringHandoff = true;
1674
+ return true;
1675
+ },
1661
1676
  closeSteering() { steeringOpen = false; clearSteering(); },
1662
1677
  abortHostEffects,
1663
1678
  abort(error) {
@@ -3011,7 +3026,9 @@ export async function createFxEngine(options = {}) {
3011
3026
  return delta ? { type: "reasoning_delta", delta } : null;
3012
3027
  }
3013
3028
  if (update.sessionUpdate === "user_message_chunk" && update.content?.type === "text") {
3014
- return { type: "user_message", text: update.content.text };
3029
+ // A steer shows where it joined the turn, with the id it was sent with.
3030
+ const id = update._meta?.fx?.steering?.inputId;
3031
+ return { type: "user_message", text: update.content.text, ...(typeof id === "string" ? { id } : {}) };
3015
3032
  }
3016
3033
  if (update.sessionUpdate === "tool_call") {
3017
3034
  toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool");
@@ -3050,6 +3067,7 @@ export async function createFxEngine(options = {}) {
3050
3067
  return rawTurn.steer(normalizeSteeringInput(input), id);
3051
3068
  },
3052
3069
  withdraw(id) { return rawTurn.withdraw(id); },
3070
+ [engineInternals]: rawTurn[engineInternals],
3053
3071
  [Symbol.asyncIterator]() {
3054
3072
  const iterator = (async function* () {
3055
3073
  for (const call of answered) {
@@ -3158,11 +3176,8 @@ export async function createFxEngine(options = {}) {
3158
3176
  const accepted = () => (journal ? waitForInput(`accepted:${id}`) : Promise.resolve());
3159
3177
  if (typeof runtime.steer === "function") {
3160
3178
  runtime.steer(text, id);
3161
- void turn.push({
3162
- sessionUpdate: "user_message_chunk",
3163
- content: { type: "text", text },
3164
- });
3165
- // The web core accepts it when it takes it at the next boundary.
3179
+ // The web core accepts it when it takes it at the next boundary,
3180
+ // and shows where it joined the turn then.
3166
3181
  return accepted().then(() => ({ id }), (error) => (error?.code === "FX_STEER_WITHDRAWN" ? { id, withdrawn: true } : Promise.reject(error)));
3167
3182
  }
3168
3183
  return request("libfx/steer", { sessionId, text, id }).then(accepted).then(() => ({ id }));
@@ -3245,6 +3260,23 @@ export async function createFxEngine(options = {}) {
3245
3260
  return "withdrawn";
3246
3261
  });
3247
3262
  },
3263
+ // For libfx's session worker: a steer that cannot join the turn, such
3264
+ // as one with images, ends the turn at its next safe point, at once
3265
+ // while the model streams a reply, so that steer can run next.
3266
+ [engineInternals]: {
3267
+ endForSteer() {
3268
+ const send = () => {
3269
+ if (finished || cancelled || activeTurn !== turn || closing) return undefined;
3270
+ if (typeof runtime.handOffSteering === "function") {
3271
+ runtime.handOffSteering();
3272
+ return undefined;
3273
+ }
3274
+ return request("libfx/handoff", { sessionId }).then(() => undefined);
3275
+ };
3276
+ const sent = promptStarted && resolvePromptStart ? promptStarted.then((started) => (started === true ? send() : undefined)) : Promise.resolve().then(send);
3277
+ return sent.catch(() => {});
3278
+ },
3279
+ },
3248
3280
  cancel(cancelOptions = {}) {
3249
3281
  const reason = cancelOptions?.reason;
3250
3282
  if (reason !== undefined && reason !== "handoff") throw new TypeError('cancel() reason must be "handoff" when given');
package/fx-term.wasm CHANGED
Binary file
Binary file
Binary file
Binary file
Binary file
package/node.cjs CHANGED
@@ -283,10 +283,15 @@ function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
283
283
  let epoch = base?.lease?.epoch ?? 0;
284
284
  const consumed = new Set(base?.recent ?? []);
285
285
  const recent = [...base?.recent ?? []];
286
+ const baseSteers = new Set(base?.recentSteers ?? []);
287
+ const kindsKnown = base === null || Array.isArray(base?.recentSteers);
288
+ const held = new Set(base?.held ?? []);
289
+ for (const id of held)
290
+ consumed.add(id);
286
291
  const seenInputs = new Set;
287
292
  const inputs = [];
288
293
  const records = [];
289
- let openTurn = typeof base?.openTurn?.id === "string" ? { id: base.openTurn.id, at: Number(base.openTurn.at) || 0, context: base.openTurn.context ?? null, settings: base.openTurn.settings ?? null } : null;
294
+ let openTurn = typeof base?.openTurn?.id === "string" ? { id: base.openTurn.id, at: Number(base.openTurn.at) || 0, from: Number(base.openTurn.from ?? base.openTurn.at) || 0, context: base.openTurn.context ?? null, settings: base.openTurn.settings ?? null } : null;
290
295
  let lastTurnId = base?.lastTurnId ?? null;
291
296
  let agent = typeof base?.agent === "string" ? base.agent : base && base.through != null ? defaultAgentName : null;
292
297
  let yielded = base?.yielded === true;
@@ -308,6 +313,13 @@ function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
308
313
  consumed.add(id);
309
314
  recent.push(id);
310
315
  };
316
+ const handBack = (id) => {
317
+ if (!consumed.delete(id))
318
+ return;
319
+ const index = recent.lastIndexOf(id);
320
+ if (index >= 0)
321
+ recent.splice(index, 1);
322
+ };
311
323
  for (const input of base?.pending ?? []) {
312
324
  if (seenInputs.has(input.key))
313
325
  continue;
@@ -350,7 +362,8 @@ function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
350
362
  records.push({ cursor, data: entry.data });
351
363
  for (const mark of entry.marks ?? []) {
352
364
  if (typeof mark.start === "string") {
353
- openTurn = { id: mark.start, at: position, context: contexts.get(mark.start) ?? null, settings: settingsOf.get(mark.start) ?? null };
365
+ const opener = inputs.find((input) => input.messageId === mark.start);
366
+ openTurn = { id: mark.start, at: position, from: opener?.cursor ?? position, context: contexts.get(mark.start) ?? null, settings: settingsOf.get(mark.start) ?? null };
354
367
  consume(mark.start);
355
368
  yielded = false;
356
369
  } else if (mark.end === true) {
@@ -358,10 +371,17 @@ function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
358
371
  lastTurnId = openTurn.id;
359
372
  openTurn = null;
360
373
  yielded = false;
374
+ held.clear();
361
375
  } else if (mark.yield === true) {
362
376
  yielded = true;
363
377
  } else if (typeof mark.accepted === "string") {
364
378
  consume(mark.accepted);
379
+ held.add(mark.accepted);
380
+ } else if (typeof mark.placed === "string") {
381
+ held.delete(mark.placed);
382
+ } else if (typeof mark.withdrawn === "string") {
383
+ held.delete(mark.withdrawn);
384
+ handBack(mark.withdrawn);
365
385
  }
366
386
  }
367
387
  }
@@ -386,20 +406,32 @@ function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
386
406
  if (input.type === "cancel" && typeof input.target === "string")
387
407
  cancelTargets.add(input.target);
388
408
  }
409
+ const openFailed = openTurn !== null && failed.has(openTurn.id);
389
410
  const pending = [];
390
411
  const steers = [];
412
+ const openOwner = openTurn !== null && !openFailed ? { joiners: steers, ends: false } : null;
391
413
  let cancelOpen = false;
414
+ let owner = null;
392
415
  for (const input of inputs) {
416
+ if (openOwner !== null && owner === null && input.cursor > openTurn.from)
417
+ owner = openOwner;
393
418
  if (consumed.has(input.messageId))
394
419
  continue;
395
420
  const forOpenTurn = openTurn !== null && input.cursor > openTurn.at;
396
- if (input.type === "prompt")
397
- pending.push(cancelTargets.has(input.messageId) ? { ...input, cancelled: true } : input);
398
- else if (input.type === "steer") {
399
- if (forOpenTurn)
400
- steers.push(input);
401
- else
402
- pending.push({ ...input, type: "prompt" });
421
+ if (input.type === "prompt") {
422
+ const entry = { ...input, ...cancelTargets.has(input.messageId) ? { cancelled: true } : {}, joiners: [], ends: false };
423
+ pending.push(entry);
424
+ if (owner === null || owner !== openOwner)
425
+ owner = entry;
426
+ } else if (input.type === "steer") {
427
+ if (owner !== null && joinable(input.input))
428
+ owner.joiners.push(input);
429
+ else {
430
+ if (owner !== null)
431
+ owner.ends = true;
432
+ owner = { ...input, type: "prompt", joiners: [], ends: false };
433
+ pending.push(owner);
434
+ }
403
435
  } else if (input.type === "cancel") {
404
436
  if (typeof input.target === "string") {
405
437
  if (openTurn?.id === input.target)
@@ -415,12 +447,16 @@ function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
415
447
  if (!live)
416
448
  consume(input.messageId);
417
449
  }
418
- const openFailed = openTurn !== null && failed.has(openTurn.id);
419
450
  const openStops = openTurn === null ? 0 : stops.get(openTurn.id) ?? 0;
420
451
  const halted = openStops >= maxHarnessStops + maxCancelStops;
421
452
  const stuck = openStops >= maxHarnessStops && !halted;
453
+ const kinds = new Map(inputs.map((input) => [input.messageId, input.type]));
454
+ const kindOf = (id) => kinds.get(id) ?? (baseSteers.has(id) ? "steer" : kindsKnown ? "prompt" : null);
422
455
  return {
423
456
  unconsumed: inputs.filter((input) => !consumed.has(input.messageId)),
457
+ held: inputs.filter((input) => held.has(input.messageId)),
458
+ heldIds: [...held],
459
+ endOpen: openOwner?.ends === true,
424
460
  head,
425
461
  lease: leaseStands(lease, now, alive) ? lease : null,
426
462
  lastLease: lease,
@@ -438,6 +474,8 @@ function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
438
474
  steers,
439
475
  cancelOpen,
440
476
  recent: recent.slice(-recentIdsKept),
477
+ recentSteers: recent.slice(-recentIdsKept).filter((id) => kindOf(id) === "steer"),
478
+ consumedKind: (id) => consumed.has(id) ? kindOf(id) : null,
441
479
  consumed,
442
480
  failed,
443
481
  stops,
@@ -1114,7 +1152,7 @@ function messageEntries(state, message) {
1114
1152
  entries.push({ k: "checkpoint", through: null, data: message.seed, ...named });
1115
1153
  }
1116
1154
  const key = `${message.type}:${message.messageId}`;
1117
- const known = state.inputKeys.has(key) || state.consumed.has(message.messageId);
1155
+ const known = state.inputKeys.has(key) || state.consumed.has(message.messageId) || idInUse(state, message);
1118
1156
  if ((message.type === "prompt" || message.type === "steer" || message.type === "cancel") && !known) {
1119
1157
  entries.push({
1120
1158
  k: "input",
@@ -1130,7 +1168,17 @@ function messageEntries(state, message) {
1130
1168
  }
1131
1169
  return entries;
1132
1170
  }
1171
+ var joinable = (input) => typeof input === "string" || Array.isArray(input) && input.every((block) => block?.type === "text");
1172
+ function idInUse(state, message) {
1173
+ const other = message.type === "prompt" ? "steer" : message.type === "steer" ? "prompt" : null;
1174
+ if (other === null)
1175
+ return false;
1176
+ return state.inputKeys.has(`${other}:${message.messageId}`) || state.consumedKind?.(message.messageId) === other;
1177
+ }
1178
+ var messageIdInUse = (message) => Object.assign(new TypeError(`messageId ${JSON.stringify(message.messageId)} already names a ${message.type === "prompt" ? "steer" : "prompt"} in this session`), { code: "FX_MESSAGE_ID_IN_USE" });
1133
1179
  async function recordMessage(log, state, message) {
1180
+ if (idInUse(state, message))
1181
+ throw messageIdInUse(message);
1134
1182
  const entries = messageEntries(state, message);
1135
1183
  for (const entry of entries)
1136
1184
  await log.append(entry);
@@ -1200,7 +1248,16 @@ class SessionWorker {
1200
1248
  if (early) {
1201
1249
  outcome = await this.claim(log, state, deadline, early, delivery);
1202
1250
  } else {
1203
- if (await recordMessage(log, state, message) > 0) {
1251
+ let wrote;
1252
+ try {
1253
+ wrote = await recordMessage(log, state, message);
1254
+ } catch (error) {
1255
+ if (error?.code !== "FX_MESSAGE_ID_IN_USE")
1256
+ throw error;
1257
+ this.agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(error) });
1258
+ return;
1259
+ }
1260
+ if (wrote > 0) {
1204
1261
  entries = await log.read();
1205
1262
  state = this.fold(entries, delivery);
1206
1263
  }
@@ -1355,12 +1412,15 @@ class SessionWorker {
1355
1412
  append: ({ idempotencyKey, data, marks }) => chained({ k: "record", key: idempotencyKey, data: toBase64(data), marks }).then((cursor) => ({ cursor })),
1356
1413
  saveCheckpoint: async ({ through, data }) => {
1357
1414
  const now = this.fold(await log.read());
1415
+ const kept = [...now.unconsumed, ...now.held].filter((input) => input.cursor <= Number(through)).sort((a, b) => a.cursor - b.cursor);
1358
1416
  await log.append({
1359
1417
  k: "checkpoint",
1360
1418
  through,
1361
1419
  data: toBase64(data),
1362
- pending: now.unconsumed.filter((input) => input.cursor <= Number(through)),
1420
+ pending: kept,
1421
+ ...now.heldIds.length > 0 ? { held: now.heldIds } : {},
1363
1422
  recent: now.recent,
1423
+ recentSteers: now.recentSteers,
1364
1424
  lastTurnId: now.lastTurnId,
1365
1425
  ...now.agent === null ? {} : { agent: now.agent },
1366
1426
  ...now.openTurn ? { openTurn: now.openTurn, yielded: now.yielded } : {},
@@ -1497,7 +1557,8 @@ class SessionWorker {
1497
1557
  turn.cancel({ reason: "handoff" });
1498
1558
  else if (spent && !(open.calls?.length > 0))
1499
1559
  turn.cancel();
1500
- const outcome = await this.drive(log, harness, turn, messageId, state, ui);
1560
+ const joining = fresh ? { ...state, steers: state.pending[0]?.joiners ?? [], endOpen: state.pending[0]?.ends === true } : state;
1561
+ const outcome = await this.drive(log, harness, turn, messageId, joining, ui);
1501
1562
  turnNow = null;
1502
1563
  if (outcome === "fenced")
1503
1564
  return "fenced";
@@ -1572,6 +1633,19 @@ class SessionWorker {
1572
1633
  let polling = false;
1573
1634
  let again = false;
1574
1635
  let stopped = false;
1636
+ let ending = false;
1637
+ const endTurn = () => {
1638
+ if (ending)
1639
+ return;
1640
+ ending = true;
1641
+ Promise.resolve().then(() => turn.endForSteer?.()).catch(() => {});
1642
+ };
1643
+ const steerTurn = (steer) => {
1644
+ if (!joinable(steer.input))
1645
+ endTurn();
1646
+ else if (!ending)
1647
+ turn.steer(steer.input, steer.messageId).catch(() => {});
1648
+ };
1575
1649
  const apply = async () => {
1576
1650
  if (stopped)
1577
1651
  return;
@@ -1590,8 +1664,8 @@ class SessionWorker {
1590
1664
  if (entry?.k !== "input" || applied.has(entry.key))
1591
1665
  continue;
1592
1666
  applied.add(entry.key);
1593
- if (entry.type === "steer" && typeof entry.input === "string")
1594
- turn.steer(entry.input, entry.messageId).catch(() => {});
1667
+ if (entry.type === "steer")
1668
+ steerTurn(entry);
1595
1669
  else if (entry.type === "cancel" && (entry.target === undefined || entry.target === messageId))
1596
1670
  turn.cancel();
1597
1671
  }
@@ -1602,9 +1676,10 @@ class SessionWorker {
1602
1676
  };
1603
1677
  for (const steer of state.steers) {
1604
1678
  applied.add(steer.key);
1605
- if (typeof steer.input === "string")
1606
- turn.steer(steer.input, steer.messageId).catch(() => {});
1679
+ steerTurn(steer);
1607
1680
  }
1681
+ if (state.endOpen)
1682
+ endTurn();
1608
1683
  if (state.cancelOpen || state.stuck && state.openTurn?.id === messageId)
1609
1684
  turn.cancel();
1610
1685
  const unwatch = log.watch?.(() => {
@@ -2197,10 +2272,20 @@ function createDurableAgentFactory({ harness, defaultDurability, name = "createA
2197
2272
  }
2198
2273
  });
2199
2274
  },
2200
- async steer(text) {
2201
- if (typeof text !== "string" || text.length === 0)
2202
- throw new TypeError("steer text must be a non-empty string");
2203
- await send({ type: "steer", messageId: newId("str"), input: text });
2275
+ async steer(input, steerOptions = {}) {
2276
+ if (typeof input === "string" ? input.length === 0 : !Array.isArray(input) || input.length === 0) {
2277
+ throw new TypeError("steer input must be a non-empty string or array of content blocks");
2278
+ }
2279
+ if (Array.isArray(input) && input.some((block) => block?.type === "image" && block.data !== undefined && typeof block.data !== "string")) {
2280
+ throw new TypeError("a durable steer carries image data as a base64 string");
2281
+ }
2282
+ if (steerOptions === null || typeof steerOptions !== "object")
2283
+ throw new TypeError("steer options must be an object");
2284
+ const messageId = steerOptions.messageId ?? newId("str");
2285
+ if (!validId(messageId))
2286
+ throw new TypeError(`messageId must be ${idRule}`);
2287
+ await send({ type: "steer", messageId, input });
2288
+ return { messageId };
2204
2289
  },
2205
2290
  async cancel() {
2206
2291
  await send({ type: "cancel", messageId: newId("cnl") });
@@ -3113,6 +3198,9 @@ function journalMarks(events) {
3113
3198
  if (event?.type === "turn_progress") {
3114
3199
  if (typeof event.turnId === "string")
3115
3200
  marks.push({ start: event.turnId });
3201
+ for (const id of Array.isArray(event.inputs) ? event.inputs : [])
3202
+ if (typeof id === "string")
3203
+ marks.push({ placed: id });
3116
3204
  const steps = Number.isSafeInteger(event.steps) ? event.steps : event.data?.execution?.tool_steps?.length;
3117
3205
  if (Number.isSafeInteger(steps))
3118
3206
  marks.push({ step: `${steps}:${String(event.data?.tool_state ?? "none")}` });
@@ -3122,6 +3210,8 @@ function journalMarks(events) {
3122
3210
  marks.push({ yield: true });
3123
3211
  else if (event?.type === "input_accepted" && typeof event.data?.id === "string")
3124
3212
  marks.push({ accepted: event.data.id });
3213
+ else if (event?.type === "input_withdrawn" && typeof event.data?.id === "string")
3214
+ marks.push({ withdrawn: event.data.id });
3125
3215
  }
3126
3216
  return marks;
3127
3217
  }
@@ -3512,6 +3602,7 @@ function createRuntime(options) {
3512
3602
  const steering = [];
3513
3603
  let steeringBytes = 0;
3514
3604
  let steeringOpen = false;
3605
+ let steeringHandoff = false;
3515
3606
  const inboundAttachments = new Map;
3516
3607
  const outboundAttachments = new Map;
3517
3608
  let nextOutboundAttachment = 1;
@@ -3810,6 +3901,7 @@ function createRuntime(options) {
3810
3901
  function clearSteering() {
3811
3902
  steering.length = 0;
3812
3903
  steeringBytes = 0;
3904
+ steeringHandoff = false;
3813
3905
  }
3814
3906
  function queueSteering(text, id = "") {
3815
3907
  if (!steeringOpen)
@@ -4402,6 +4494,9 @@ function createRuntime(options) {
4402
4494
  steeringOpen = false;
4403
4495
  clearSteering();
4404
4496
  },
4497
+ fx_steering_state() {
4498
+ return steeringOpen ? steering.length * 2 + (steeringHandoff ? 1 : 0) : -1;
4499
+ },
4405
4500
  fx_attachment_size: attachmentSize,
4406
4501
  fx_attachment_take: attachmentTake,
4407
4502
  fx_attachment_put: attachmentPut,
@@ -4458,6 +4553,12 @@ function createRuntime(options) {
4458
4553
  },
4459
4554
  steer: queueSteering,
4460
4555
  withdrawSteering,
4556
+ handOffSteering() {
4557
+ if (!steeringOpen)
4558
+ return false;
4559
+ steeringHandoff = true;
4560
+ return true;
4561
+ },
4461
4562
  closeSteering() {
4462
4563
  steeringOpen = false;
4463
4564
  clearSteering();
@@ -5807,7 +5908,8 @@ async function createFxEngine(options = {}) {
5807
5908
  return delta ? { type: "reasoning_delta", delta } : null;
5808
5909
  }
5809
5910
  if (update.sessionUpdate === "user_message_chunk" && update.content?.type === "text") {
5810
- return { type: "user_message", text: update.content.text };
5911
+ const id = update._meta?.fx?.steering?.inputId;
5912
+ return { type: "user_message", text: update.content.text, ...typeof id === "string" ? { id } : {} };
5811
5913
  }
5812
5914
  if (update.sessionUpdate === "tool_call") {
5813
5915
  toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool");
@@ -5851,6 +5953,7 @@ async function createFxEngine(options = {}) {
5851
5953
  withdraw(id) {
5852
5954
  return rawTurn.withdraw(id);
5853
5955
  },
5956
+ [engineInternals]: rawTurn[engineInternals],
5854
5957
  [Symbol.asyncIterator]() {
5855
5958
  const iterator = async function* () {
5856
5959
  for (const call of answered) {
@@ -5975,10 +6078,6 @@ async function createFxEngine(options = {}) {
5975
6078
  const accepted = () => journal ? waitForInput(`accepted:${id}`) : Promise.resolve();
5976
6079
  if (typeof runtime.steer === "function") {
5977
6080
  runtime.steer(text, id);
5978
- turn.push({
5979
- sessionUpdate: "user_message_chunk",
5980
- content: { type: "text", text }
5981
- });
5982
6081
  return accepted().then(() => ({ id }), (error) => error?.code === "FX_STEER_WITHDRAWN" ? { id, withdrawn: true } : Promise.reject(error));
5983
6082
  }
5984
6083
  return request("libfx/steer", { sessionId, text, id }).then(accepted).then(() => ({ id }));
@@ -6077,6 +6176,23 @@ async function createFxEngine(options = {}) {
6077
6176
  return "withdrawn";
6078
6177
  });
6079
6178
  },
6179
+ [engineInternals]: {
6180
+ endForSteer() {
6181
+ const send = () => {
6182
+ if (finished || cancelled || activeTurn !== turn || closing)
6183
+ return;
6184
+ if (typeof runtime.handOffSteering === "function") {
6185
+ runtime.handOffSteering();
6186
+ return;
6187
+ }
6188
+ return request("libfx/handoff", { sessionId }).then(() => {
6189
+ return;
6190
+ });
6191
+ };
6192
+ const sent = promptStarted && resolvePromptStart ? promptStarted.then((started) => started === true ? send() : undefined) : Promise.resolve().then(send);
6193
+ return sent.catch(() => {});
6194
+ }
6195
+ },
6080
6196
  cancel(cancelOptions = {}) {
6081
6197
  const reason = cancelOptions?.reason;
6082
6198
  if (reason !== undefined && reason !== "handoff")
@@ -6315,6 +6431,7 @@ function fxTurn(turn, exited) {
6315
6431
  return {
6316
6432
  result,
6317
6433
  steer: (text, id) => turn.steer(text, id),
6434
+ endForSteer: () => turn[engineInternals]?.endForSteer(),
6318
6435
  cancel: (options) => turn.cancel(options),
6319
6436
  async* [Symbol.asyncIterator]() {
6320
6437
  try {
@@ -6330,6 +6447,7 @@ function refusedTurn(error) {
6330
6447
  return {
6331
6448
  result: Promise.resolve({ stopReason: "error", usage: {}, error: { name: error?.name ?? "Error", message: String(error?.message ?? error) } }),
6332
6449
  steer: async () => {},
6450
+ endForSteer: async () => {},
6333
6451
  cancel() {},
6334
6452
  async* [Symbol.asyncIterator]() {}
6335
6453
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libfx",
3
- "version": "0.0.13-dev.1510.ga878d4900a6a",
3
+ "version": "0.0.13-dev.1512.ga8421a0ce13b",
4
4
  "description": "Embed fx agents and terminals in JavaScript hosts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -80,6 +80,8 @@ export type {
80
80
  FxSessionTurnEvent,
81
81
  FxSharedOptions,
82
82
  FxSteer,
83
+ FxSteerOptions,
84
+ FxSteered,
83
85
  FxSteeringInput,
84
86
  FxStopReason,
85
87
  FxStreamLine,
package/types/libfx.d.cts CHANGED
@@ -219,8 +219,8 @@ export type FxAgentTools = readonly FxAgentTool[] | { readonly [name: string]: F
219
219
  export type FxTurnEvent =
220
220
  | { type: "text_delta"; delta: string }
221
221
  | { type: "reasoning_delta"; delta: string }
222
- /** Accepted mid-turn steering entering the turn. */
223
- | { type: "user_message"; text: string }
222
+ /** Steering entering the turn as its model request carries it; `id` is the steer's id. */
223
+ | { type: "user_message"; text: string; id?: string }
224
224
  | { type: "tool_start"; id: string; name: string; input?: JsonValue; inputTruncated?: undefined; inputPreview?: undefined }
225
225
  /** A tool input over 64 KiB of JSON arrives as a prefix. The tool still receives complete arguments. */
226
226
  | { type: "tool_start"; id: string; name: string; input?: undefined; inputTruncated: true; inputPreview: string }
@@ -643,6 +643,22 @@ export interface FxPromptOptions {
643
643
  signal?: AbortSignal;
644
644
  }
645
645
 
646
+ /** Options for a durable session's `steer()`. */
647
+ export interface FxSteerOptions {
648
+ /**
649
+ * The steer's id, so a retried request adds nothing. Follows the session id rule. Reusing a prompt's id rejects
650
+ * with `FX_MESSAGE_ID_IN_USE`; on a queue that outlives the process, the worker drops the steer and emits a
651
+ * `session.error` with that code instead.
652
+ */
653
+ messageId?: string;
654
+ }
655
+
656
+ /** What a durable session's `steer()` resolves to once the steer is stored. */
657
+ export interface FxSteered {
658
+ /** The steer's id. Its `user_message` event carries it as `id` when it joins a turn, and its own turn's events carry it as `messageId`. */
659
+ messageId: string;
660
+ }
661
+
646
662
  /** What `turn.accepted` resolves to once the prompt is stored. */
647
663
  export interface FxAccepted {
648
664
  messageId: string;
@@ -727,8 +743,12 @@ export interface FxSession {
727
743
  resume(): FxResumeTurn;
728
744
  /** Every event the session has produced from `cursor` on, as NDJSON, staying open for the events after them. */
729
745
  stream(cursor?: number): ReadableStream<Uint8Array>;
730
- /** Adds guidance to the running turn at its next model request, or to the next turn when none is running. */
731
- steer(text: string): Promise<void>;
746
+ /**
747
+ * Steers the running turn the way the fx CLI does: it cuts a streaming reply short, waits for a running tool, and
748
+ * joins a turn waiting to start. When no turn runs or waits, it starts one. A steer with images or resources cannot
749
+ * join a turn: the running one ends at its next safe point and the steer runs next.
750
+ */
751
+ steer(input: FxSessionPromptInput, options?: FxSteerOptions): Promise<FxSteered>;
732
752
  /** Cancels the running turn. */
733
753
  cancel(): Promise<void>;
734
754
  /** The session's history as opaque bytes, read without changing the session. */
package/types/node.d.cts CHANGED
@@ -91,6 +91,8 @@ export type {
91
91
  FxSessionTurnEvent,
92
92
  FxSharedOptions,
93
93
  FxSteer,
94
+ FxSteerOptions,
95
+ FxSteered,
94
96
  FxSteeringInput,
95
97
  FxStopReason,
96
98
  FxStreamLine,