libfx 0.0.13-dev.1506.gbc968ad65622 → 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 +3 -3
- package/durable.js +158 -29
- package/fx-core.wasm +0 -0
- package/fx-harness.js +2 -0
- package/fx-sdk.js +40 -8
- package/fx-term.wasm +0 -0
- package/libfx.darwin-arm64.node +0 -0
- package/libfx.darwin-x64.node +0 -0
- package/libfx.linux-arm64.node +0 -0
- package/libfx.linux-x64.node +0 -0
- package/node.cjs +145 -27
- package/package.json +1 -1
- package/types/browser.d.ts +2 -0
- package/types/libfx.d.cts +24 -4
- package/types/node.d.cts +2 -0
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(
|
|
59
|
-
- **Retries are safe.** Give a prompt a `messageId`, and a repeated request with the same id runs nothing new.
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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 }`),
|
|
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.
|
|
2017
|
-
*
|
|
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
|
-
/**
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3162
|
-
|
|
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
|
package/libfx.darwin-arm64.node
CHANGED
|
Binary file
|
package/libfx.darwin-x64.node
CHANGED
|
Binary file
|
package/libfx.linux-arm64.node
CHANGED
|
Binary file
|
package/libfx.linux-x64.node
CHANGED
|
Binary file
|
package/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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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"
|
|
1594
|
-
|
|
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
|
-
|
|
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(
|
|
2201
|
-
if (typeof
|
|
2202
|
-
throw new TypeError("steer
|
|
2203
|
-
|
|
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
|
-
|
|
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
package/types/browser.d.ts
CHANGED
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
|
-
/**
|
|
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
|
-
/**
|
|
731
|
-
|
|
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. */
|