Files
Pranit ce6d7d998b Test each way a durable log listing can fall short of its view
- A newer listing whose lowest write has not listed yet, a whole read that ended above its checkpoint's through, and a whole read that stopped at a checkpoint each keep or drop their view as the shared listing rule says.
2026-10-19 20:49:23 -04:00

3193 lines
149 KiB
JavaScript

// Durable agent sessions: the code that runs an agent in a script runs it in
// production. Every model step and tool intent is stored in the session's
// log before anything depends on it, one worker at a time runs a session
// under a lease, and a turn that runs out of time, crashes, or lands on
// another server continues where it stopped.
//
// The agent loop itself is a harness the core drives through a small
// contract (see `createDurableAgentFactory`): fx is one, in fx-harness.js.
//
// A session is one ordered log plus one stream of what the user sees. The
// log holds three kinds of entry:
//
// - inputs (`prompt`, `steer`, `cancel`), which any server may add;
// - a chain of lease claims, lease releases and the harness's records, each
// naming the chained entry before it, so taking the lease fences the old
// holder's next write;
// - checkpoints, each covering the chain up to a record.
//
// `prompt()` only queues a message. The worker the queue delivers it to adds
// the input to the log, takes the lease, and runs every turn the log holds
// until none is left, or its time runs out: then it asks the queue to
// deliver the same message again, and the next worker continues the turn.
//
// TLA+ models: `tests/tla/sdk/DurableSession.tla` (the log, leases and
// takeovers), `tests/tla/sdk/AgentRouting.tla` (agent names and routing),
// and `tests/tla/sdk/DurableSteer.tla` (where each message a user sends is
// placed). The drivers in `tests/tla/drivers/` run this code along them.
// A write the session's chain refused because another worker continued it.
// Harnesses recognize it by its code.
class FencedError extends Error {
constructor(message) {
super(message);
this.name = "FencedError";
this.code = "FX_FENCED";
}
}
const durabilityTag = Symbol.for("libfx.durability");
const encoder = new TextEncoder();
const decoder = new TextDecoder();
// The core's rule for turn and input ids, which messages become.
const idPattern = /^[A-Za-z0-9._-]{1,128}$/;
const validId = (id) => typeof id === "string" && idPattern.test(id);
const idRule = "1 to 128 letters, digits, '.', '_' or '-'";
const newId = (prefix) => `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
// How long a worker leaves for a model step it decides not to start.
const defaultReserveMs = 30_000;
// A lease with nothing to show whether its holder lives expires this long
// after it was taken or last renewed, and its holder renews it every third of
// that while it runs. So a worker that dies frees its session this soon
// after its last renewal, not at its function's deadline.
const defaultLeaseMs = 15_000;
// Whether the queue shows whether a worker lives, delivering the message its
// delivery held again once it is gone, so the worker holds its lease to the
// deadline that bounds it and renews nothing.
const queueHeld = (backend, deadline) => !backend.livenessKnown && backend.queueLeases === true && deadline !== null;
// Whether a worker holds its lease to its deadline and renews nothing: when
// whether it lives is known, or the queue shows it.
const heldToDeadline = (backend, deadline) => backend.livenessKnown || queueHeld(backend, deadline);
// When a lease taken or renewed now runs out: never past the deadline its
// worker stops at, and when it is held to that deadline, only there.
function leaseEnd(backend, deadline) {
if (heldToDeadline(backend, deadline)) return deadline;
const renewed = Date.now() + backend.leaseMs;
return deadline === null ? renewed : Math.min(renewed, deadline);
}
// The newest consumed message ids a checkpoint keeps, so a message the queue
// delivers again after its turn ended is still recognized.
const recentIdsKept = 256;
// The longest a message waits for a live holder before it is delivered again.
const maxBackstopSeconds = 300;
// Base64 of a Uint8Array: the runtime's own encoder where it has one, and
// otherwise four letters from the table for each three bytes. Both are fast
// enough for a large image; btoa over String.fromCharCode is not.
const base64Letters = encoder.encode("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/");
const toBase64 = (bytes) => {
if (typeof bytes.toBase64 === "function") return bytes.toBase64();
const letters = new Uint8Array(Math.ceil(bytes.length / 3) * 4);
const whole = bytes.length - (bytes.length % 3);
let at = 0;
for (let index = 0; index < whole; index += 3) {
const group = (bytes[index] << 16) | (bytes[index + 1] << 8) | bytes[index + 2];
letters[at] = base64Letters[group >> 18];
letters[at + 1] = base64Letters[(group >> 12) & 63];
letters[at + 2] = base64Letters[(group >> 6) & 63];
letters[at + 3] = base64Letters[group & 63];
at += 4;
}
if (whole < bytes.length) {
// One or two bytes left: two or three letters, then "=" padding.
const two = whole + 1 < bytes.length;
const group = (bytes[whole] << 16) | (two ? bytes[whole + 1] << 8 : 0);
letters[at] = base64Letters[group >> 18];
letters[at + 1] = base64Letters[(group >> 12) & 63];
letters[at + 2] = two ? base64Letters[(group >> 6) & 63] : 61;
letters[at + 3] = 61;
}
return decoder.decode(letters);
};
const fromBase64 = (text) => {
if (typeof Uint8Array.fromBase64 === "function") return Uint8Array.fromBase64(text);
const binary = atob(text);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
};
// A session keeps each prompt image once, in its image store, named by the
// lowercase hex SHA-256 of its bytes, as libfx's core names it; the log only
// names it.
async function imageName(bytes) {
const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
let name = "";
for (const byte of digest) name += byte.toString(16).padStart(2, "0");
return name;
}
const base64url = (text) => toBase64(encoder.encode(text)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// The key a harness's store gives its image store under, `{ put, get }`:
// `persistenceImages` in fx-sdk.js, which engines read it from.
const persistenceImages = Symbol.for("libfx.persistenceImages");
// A view of the bytes in an ArrayBuffer or typed array, or null.
const byteView = (value) => (value instanceof ArrayBuffer ? new Uint8Array(value)
: ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : null);
// A prompt's input as the queue and the log hold it: JSON, so image data is
// base64 text. With `named`, the session stores each image in its image
// store and the log names it (`storePromptImages`); `images` says whether any
// image has data to store.
//
// Each image block is copied once and only the copy is read, since an
// accessor can return something else the next time it is read, and the log
// must hold what was checked. The blocks are walked by index for the same
// reason: an Array subclass can make map() return anything. Pure.
function promptInputForLog(input, { named = false } = {}) {
let images = false;
const blocks = [];
for (let index = 0; index < input.length; index += 1) {
const block = input[index];
if (block?.type !== "image") {
blocks.push(block);
continue;
}
const image = { ...block };
if (image.data !== undefined && typeof image.data !== "string") throw new TypeError("a durable prompt carries image data as a base64 string");
if (typeof image.data === "string") images = named;
blocks.push(image);
}
return { input: blocks, images };
}
// Stores each image a prompt's input holds in the session's image store,
// before the input that names it is stored, and returns the input naming
// them: `{ type: "image", mimeType, sourceRef?, data: { sha256 } }`. An older
// libfx refuses data it can't read, so a worker that predates names fails
// the turn rather than run it without its images. A store that fails rejects
// with its own error. With `images` null it only names them, for a prompt the
// log already holds.
async function storePromptImages(images, input) {
const blocks = [];
for (const block of input) {
if (block?.type !== "image" || block.data === undefined) {
blocks.push(block);
continue;
}
const { data, ...rest } = block;
const bytes = fromBase64(data);
const sha256 = await imageName(bytes);
if (images) await images.put(sha256, bytes);
blocks.push({ ...rest, data: { sha256 } });
}
return blocks;
}
// A prompt's input with each named image read back from the session's image
// store, as base64, which is how a harness has always received image data.
// `missing` names the first image the store does not hold.
async function resolvePromptImages(images, input) {
if (!Array.isArray(input)) return { input };
const blocks = [];
for (const block of input) {
const name = block?.type === "image" ? block.data?.sha256 : undefined;
if (typeof name !== "string") {
blocks.push(block);
continue;
}
const bytes = await images.get(name);
if (!bytes) return { missing: name };
// Bytes that aren't the ones named are not the image its caller sent.
if (await imageName(bytes) !== name) return { missing: name, corrupt: true };
blocks.push({ ...block, data: toBase64(bytes) });
}
return { input: blocks };
}
/**
* The durability that keeps sessions in this process only. Agents given the
* same `memory()` share its sessions, as servers share a database. With
* `maxDurationMs`, each delivery runs as if its function stopped after that
* long, stopping `reserveMs` before, as on Vercel.
*/
export function memory({ maxDurationMs, reserveMs } = {}) {
checkDurations("memory", { maxDurationMs, reserveMs });
let backend = null;
return { [durabilityTag]: true, name: "memory", create: () => (backend ??= createMemoryBackend({ maxDurationMs, reserveMs })) };
}
function checkDurations(name, { maxDurationMs, reserveMs }) {
for (const [key, value] of Object.entries({ maxDurationMs, reserveMs })) {
if (value !== undefined && !(Number.isSafeInteger(value) && value >= 0)) throw new TypeError(`${name}() ${key} must be a non-negative integer`);
}
}
// A durability is `memory()`, or a World one from `libfx/durable-world` (and
// `libfx/durable-local` and `libfx/durable-vercel`, built on it): `{ world() }`
// and how its workers hold sessions.
function isDurability(value) {
return Boolean(value && typeof value === "object" && value[durabilityTag] === true &&
(typeof value.create === "function" || typeof value.world === "function"));
}
async function createBackend(durability) {
if (typeof durability.create === "function") return durability.create();
checkDurations(durability.name, durability);
return createWorldBackend(await durability.world(), durability);
}
// The workers holding a session in this process, across every agent in it,
// so a lease this process took is judged by whether its worker still runs.
const liveHolders = (globalThis[Symbol.for("libfx.liveHolders")] ??= new Set());
// Times a turn's engine may stop under it before the turn is cancelled, or
// ends with an error when it never started; and further times it may stop
// while that cancel runs before the session can run no more turns.
const maxHarnessStops = 3;
const maxCancelStops = 2;
// Times in a row the deadline may cut off the same step of a turn, with no
// record between, before the next delivery stops running it again.
const maxCutoffs = 3;
// ---------------------------------------------------------------------------
// The session log, folded. Pure: the same entries always fold the same way.
// How many times in a row the deadline cut off an open turn's step. A
// harness that marks its records with the step a turn is at counts the
// cut-offs while that step stays the same, since continuing a cut-off step
// can write a record with no progress in it. For one that marks no steps,
// any record is progress.
const noCutoffs = { cutoffs: 0, cutStep: null, step: null, named: false };
function afterRecord(cut, marks) {
let next = cut;
for (const mark of marks ?? []) {
if (typeof mark.start === "string") next = noCutoffs;
else if (typeof mark.step === "string") next = { ...next, step: mark.step, named: true };
}
return next.named ? next : { ...next, cutoffs: 0 };
}
function afterCutoff(cut) {
const same = !cut.named || cut.step === cut.cutStep;
return { ...cut, cutoffs: same ? cut.cutoffs + 1 : 1, cutStep: cut.step };
}
/**
* Folds a session's log into what a worker acts on. `entries` are
* `{ cursor, entry }` in log order from the latest checkpoint's `through`;
* cursors are increasing integer strings. `now` and `alive(lease)` decide
* whether a lease stands; `alive` returns true, false, or null for unknown.
*/
export function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
let checkpoint = null;
for (const item of entries) if (item.entry?.k === "checkpoint") checkpoint = item;
const base = checkpoint?.entry ?? null;
let head = base ? base.through ?? null : null;
let lease = base?.lease ?? null;
// The highest epoch any lease in the chain took. A release clears the
// lease but not this: the next claim takes a higher epoch, so its UI lines
// outrank every earlier line.
let epoch = base?.lease?.epoch ?? 0;
const consumed = new Set(base?.recent ?? []);
const recent = [...(base?.recent ?? [])];
// Which of those were steers, so a prompt and a steer cannot share an id
// after a checkpoint drops their inputs. A checkpoint from before this was
// kept does not say.
const baseSteers = new Set(base?.recentSteers ?? []);
const kindsKnown = base === null || Array.isArray(base?.recentSteers);
// Inputs the open turn accepted and has not placed, which a withdrawal can
// hand back. They count as taken until then.
const held = new Set(base?.held ?? []);
for (const id of held) consumed.add(id);
const seenInputs = new Set();
const inputs = [];
const records = [];
// A checkpoint taken where a turn yielded leaves that turn open.
// `from` is where the input that opened it was stored: steers stored after
// it join it, before it started too.
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;
let lastTurnId = base?.lastTurnId ?? null;
// The agent the session belongs to: the one its first stored input, or the
// checkpoint it was restored from, names. A session stored before agents
// had names belongs to the default agent.
let agent = typeof base?.agent === "string" ? base.agent : base && base.through != null ? defaultAgentName : null;
let yielded = base?.yielded === true;
// The context each prompt's or steer's caller gave, and the settings its
// session object set, which its turn runs with: an untaken steer becomes a
// turn.
const contexts = new Map();
const settingsOf = new Map();
for (const input of base?.pending ?? []) {
if (input.context !== undefined) contexts.set(input.messageId, input.context);
if (input.settings !== undefined) settingsOf.set(input.messageId, input.settings);
}
// How many times each turn's engine stopped under it.
const stops = new Map();
const failed = new Map();
// Deadline cut-offs in a row of the open turn's step.
let cut = noCutoffs;
let maxCursor = 0;
const consume = (id) => {
if (consumed.has(id)) return;
consumed.add(id);
recent.push(id);
};
// A steer its turn accepted and ended without placing: the model never saw
// it, so it waits for a turn again.
const handBack = (id) => {
if (!consumed.delete(id)) return;
const index = recent.lastIndexOf(id);
if (index >= 0) recent.splice(index, 1);
};
for (const input of base?.pending ?? []) {
if (seenInputs.has(input.key)) continue;
seenInputs.add(input.key);
inputs.push(input);
}
for (const { cursor, entry } of entries) {
const position = Number(cursor);
if (position > maxCursor) maxCursor = position;
switch (entry?.k) {
case "input":
agent ??= typeof entry.agent === "string" ? entry.agent : defaultAgentName;
if (seenInputs.has(entry.key)) break;
seenInputs.add(entry.key);
inputs.push({ ...entry, cursor: position });
if (entry.context !== undefined) contexts.set(entry.messageId, entry.context);
if (entry.settings !== undefined) settingsOf.set(entry.messageId, entry.settings);
break;
case "lease":
case "release":
case "record": {
// A chained entry stands only when it continues the chain; the
// first writer to continue a cursor wins and the rest are fenced.
if ((entry.a ?? null) !== head) break;
head = cursor;
if (entry.k === "lease") {
lease = { holder: entry.holder, epoch: entry.epoch, expiresAt: entry.expiresAt ?? null, pid: entry.pid, host: entry.host, delivery: entry.delivery, cursor };
if (Number.isSafeInteger(entry.epoch) && entry.epoch > epoch) epoch = entry.epoch;
} else if (entry.k === "release") {
if (lease?.holder === entry.holder) lease = null;
if (typeof entry.cutoff === "string" && entry.cutoff === openTurn?.id) cut = afterCutoff(cut);
} else {
cut = afterRecord(cut, entry.marks);
records.push({ cursor, data: entry.data });
for (const mark of entry.marks ?? []) {
if (typeof mark.start === "string") {
const opener = inputs.find((input) => input.messageId === mark.start);
openTurn = { id: mark.start, at: position, from: opener?.cursor ?? position, context: contexts.get(mark.start) ?? null, settings: settingsOf.get(mark.start) ?? null };
consume(mark.start);
yielded = false;
} else if (mark.end === true) {
if (openTurn) lastTurnId = openTurn.id;
openTurn = null;
yielded = false;
// A turn's unplaced inputs end with it, handed back first.
held.clear();
} else if (mark.yield === true) {
yielded = true;
} else if (typeof mark.accepted === "string") {
consume(mark.accepted);
held.add(mark.accepted);
} else if (typeof mark.placed === "string") {
held.delete(mark.placed);
} else if (typeof mark.withdrawn === "string") {
held.delete(mark.withdrawn);
handBack(mark.withdrawn);
}
}
}
break;
}
case "failed":
// A message whose turn could not start is answered and done.
failed.set(entry.messageId, entry.error ?? { name: "Error", message: "the turn failed" });
consume(entry.messageId);
break;
case "ended":
// A turn that ended before writing a record of its own, such as one
// the harness refused or its cancel stopped first. Its outcome is on
// the UI stream.
consume(entry.messageId);
break;
case "stopped":
stops.set(entry.messageId, (stops.get(entry.messageId) ?? 0) + 1);
break;
default:
break;
}
}
// What the inputs ask for, given the turns the records started. A
// prompt's own cancel names its turn, in whichever order the two land.
const cancelTargets = new Set();
for (const input of inputs) {
if (input.type === "cancel" && typeof input.target === "string") cancelTargets.add(input.target);
}
// An open turn whose harness could not start has ended for its viewers.
const openFailed = openTurn !== null && failed.has(openTurn.id);
// The turns waiting to run, each with the steers that join it when it
// starts, whether it waited or ran when they came, as the fx CLI's steering
// does: a text steer stored after the open turn's opener joins that turn,
// and otherwise the turn waiting before it. A steer that cannot join a
// turn waits as its own, and the turn it came after ends at its next safe
// point, at once if it has not started; the steers after it join its turn.
// A steer with no turn to join starts one.
const pending = [];
const steers = [];
const openOwner = openTurn !== null && !openFailed ? { joiners: steers, ends: false } : null;
let cancelOpen = false;
let owner = null;
for (const input of inputs) {
if (openOwner !== null && owner === null && input.cursor > openTurn.from) owner = openOwner;
if (consumed.has(input.messageId)) continue;
const forOpenTurn = openTurn !== null && input.cursor > openTurn.at;
if (input.type === "prompt") {
const entry = { ...input, ...(cancelTargets.has(input.messageId) ? { cancelled: true } : {}), joiners: [], ends: false };
pending.push(entry);
// While a turn runs, steers still join it: a prompt is the next turn.
if (owner === null || owner !== openOwner) owner = entry;
} else if (input.type === "steer") {
if (owner !== null && joinable(input.input)) owner.joiners.push(input);
else {
if (owner !== null) owner.ends = true;
owner = { ...input, type: "prompt", joiners: [], ends: false };
pending.push(owner);
}
} else if (input.type === "cancel") {
if (typeof input.target === "string") {
if (openTurn?.id === input.target) cancelOpen = true;
} else if (forOpenTurn) cancelOpen = true;
}
}
// A cancel is spent once it can no longer apply: a prompt's own cancel
// once its turn ended, and a session cancel once the turn open when it
// landed ended. Its id is kept like a turn's, so a late delivery of the
// same message is not taken as a new cancel.
for (const input of inputs) {
if (consumed.has(input.messageId)) continue;
// A resume only asks that its session be woken: it is spent once
// stored, and its id is kept so its deliveries are known.
if (input.type === "resume") {
consume(input.messageId);
continue;
}
if (input.type !== "cancel") continue;
const live = typeof input.target === "string"
? !consumed.has(input.target) || openTurn?.id === input.target
: openTurn !== null && input.cursor > openTurn.at;
if (!live) consume(input.messageId);
}
// A cancel the failed open turn is due waits for the next prompt's
// engine. One whose engine stopped each time it continued it is
// cancelled; one whose engine stopped even then can never go on, and the
// session runs no more turns.
const openStops = openTurn === null ? 0 : stops.get(openTurn.id) ?? 0;
const halted = openStops >= maxHarnessStops + maxCancelStops;
const stuck = openStops >= maxHarnessStops && !halted;
const kinds = new Map(inputs.map((input) => [input.messageId, input.type]));
const kindOf = (id) => kinds.get(id) ?? (baseSteers.has(id) ? "steer" : kindsKnown ? "prompt" : null);
return {
// Inputs no turn has taken, as written: what a checkpoint keeps, with
// the ones the open turn holds unplaced, which a withdrawal hands back.
unconsumed: inputs.filter((input) => !consumed.has(input.messageId)),
held: inputs.filter((input) => held.has(input.messageId)),
heldIds: [...held],
// A steer that cannot join the open turn waits for it to end.
endOpen: openOwner?.ends === true,
head,
// The lease others must respect, and the chain's latest whether or not
// it still stands: a worker checks the latter is its own.
lease: leaseStands(lease, now, alive) ? lease : null,
lastLease: lease,
lastEpoch: epoch,
checkpoint: base ? { through: base.through ?? null, data: base.data } : null,
records,
openTurn,
cut: openTurn === null ? noCutoffs : cut,
cutoffs: openTurn === null ? 0 : cut.cutoffs,
openFailed,
lastTurnId,
agent,
yielded,
pending,
steers,
cancelOpen,
recent: recent.slice(-recentIdsKept),
recentSteers: recent.slice(-recentIdsKept).filter((id) => kindOf(id) === "steer"),
// What kind of input a taken id named, or null when no record says.
consumedKind: (id) => (consumed.has(id) ? kindOf(id) : null),
consumed,
failed,
stops,
inputKeys: seenInputs,
maxCursor,
stuck,
halted,
// A halted session's open turn is work until it ends with an error.
hasWork: (openTurn !== null && !openFailed) || pending.length > 0,
};
}
function leaseStands(lease, now, alive) {
if (!lease) return false;
const living = alive(lease);
if (living === false) return false;
if (lease.expiresAt !== null && lease.expiresAt !== undefined && now >= lease.expiresAt) return false;
return true;
}
// Which queue message a delivery carries, by the queue's own id, and which
// delivery of it this is, when the World reports both.
function deliveryOf(meta) {
const messageId = meta?.messageId;
const attempt = meta?.attempt;
return typeof messageId === "string" && messageId.length > 0 && Number.isSafeInteger(attempt) && attempt > 0
? { messageId, attempt }
: null;
}
// Whether `delivery`, in another process, carries the message whose delivery
// took `lease`. A queue hands a message on only once the delivery holding it
// let it go unacknowledged: its handler failed, or its process died or lost
// the message. On Vercel, a function whose process exits mid-delivery runs
// the same delivery again, attempt and all, in a new process. Either way the
// holder no longer runs the session, or runs it replaced without knowing,
// and its next write is fenced.
function redelivers(delivery, lease) {
const held = lease?.delivery;
return Boolean(delivery && held && held.messageId === delivery.messageId &&
Number.isSafeInteger(held.attempt) && delivery.attempt >= held.attempt);
}
// ---------------------------------------------------------------------------
// Backends. Each gives sessions a log, a stream, and a queue.
function createMemoryBackend({ maxDurationMs, reserveMs } = {}) {
const sessions = new Map();
const holders = liveHolders;
// The agents sharing the backend, by name: an agent's copies take the
// deliveries for its name in turn.
const handlers = new Map();
const timers = new Set();
const sessionFor = (id) => {
let session = sessions.get(id);
if (!session) {
session = { entries: [], head: null, ui: [], uiWaiters: new Set(), watchers: new Set(), images: new Map() };
sessions.set(id, session);
}
return session;
};
const later = (fn, ms) => {
const timer = setTimeout(() => { timers.delete(timer); fn(); }, ms);
timers.add(timer);
};
// A message keeps its id across its deliveries, as a queue's does.
const deliver = (message, attempt, messageId = newId("qmsg")) => {
if (handlers.size === 0) return later(() => deliver(message, attempt, messageId), 10);
const group = handlers.get(agentOf(message));
// No agent of that name here yet: tried again, as a failed delivery is.
if (!group) {
if (attempt < 48) later(() => deliver(message, attempt + 1, messageId), 1000);
return;
}
const handler = group.members[group.turn++ % group.members.length];
Promise.resolve()
.then(() => handler(message, { messageId, attempt }))
.then((result) => {
if (Number.isFinite(result?.timeoutSeconds)) later(() => deliver(message, attempt + 1, messageId), result.timeoutSeconds * 1000);
}, () => {
if (attempt < 48) later(() => deliver(message, attempt + 1, messageId), 1000);
});
};
return {
name: "memory",
livenessKnown: true,
runInRequest: false,
queueDurable: false,
pollMs: null,
reserveMs: reserveMs ?? defaultReserveMs,
maxDurationMs: maxDurationMs ?? null,
newSessionId: () => newId("ses"),
validSessionId: validId,
deadline: () => null,
holderInfo: () => ({}),
alive: (lease) => holders.has(lease.holder),
holding(holder, held) {
if (held) holders.add(holder);
else holders.delete(holder);
},
async session(id) {
const session = sessionFor(id);
return {
async read() {
let from = 0;
for (let index = session.entries.length - 1; index >= 0; index -= 1) {
const entry = session.entries[index].entry;
if (entry.k === "checkpoint") {
from = entry.through === null ? 0 : Number(entry.through);
break;
}
}
return session.entries.slice(from);
},
async readSince(cursor) {
return session.entries.slice(Number(cursor));
},
async append(entry) {
const chained = entry.k === "lease" || entry.k === "release" || entry.k === "record";
if (chained && (entry.a ?? null) !== session.head) {
// A retry of an entry that landed returns its cursor.
const landed = session.entries.find((item) => item.entry.key !== undefined && item.entry.key === entry.key && item.entry.k === entry.k);
if (landed) return landed.cursor;
throw new FencedError(`another worker continued session ${id}`);
}
const cursor = String(session.entries.length + 1);
session.entries.push({ cursor, entry: structuredClone(entry) });
if (chained) session.head = cursor;
for (const watcher of session.watchers) watcher();
return cursor;
},
watch(fn) {
session.watchers.add(fn);
return () => session.watchers.delete(fn);
},
images: {
async put(name, bytes) {
if (!session.images.has(name)) session.images.set(name, bytes.slice());
},
async get(name) {
return session.images.get(name)?.slice();
},
},
ui: {
async write(lines) {
session.ui.push(...lines);
for (const waiter of session.uiWaiters) waiter();
},
async length() {
return session.ui.length;
},
read(from) {
let index = from;
let wake = null;
const notify = () => wake?.();
return new ReadableStream({
start() { session.uiWaiters.add(notify); },
async pull(controller) {
while (index >= session.ui.length) await new Promise((resolve) => { wake = resolve; });
wake = null;
controller.enqueue(encoder.encode(`${session.ui[index++]}\n`));
},
cancel() { session.uiWaiters.delete(notify); notify(); },
});
},
},
};
},
queue: {
async send(message, { delaySeconds = 0 } = {}) {
later(() => deliver(message, 1), delaySeconds * 1000);
},
},
listen(fn, name = defaultAgentName) {
const group = joinGroup(handlers, name, fn);
return () => {
leaveGroup(handlers, name, group, fn);
if (handlers.size > 0) return;
// Nobody is left to deliver to.
for (const timer of timers) clearTimeout(timer);
timers.clear();
};
},
};
}
// World runs are sessions; `fx.log` steps are its entries.
const logStep = "fx.log";
// A World names streams for its whole project, so each session's stream is
// named after its run, as Workflow names a run's own streams: `strm_` and
// the run's id, then `_user_` and the base64url of the namespace
// "libfx-ui".
const uiStreamOf = (runId) => `${runId.replace(/^wrun_/, "strm_")}_user_bGliZngtdWk`;
// The stream that holds an image, named as the UI stream is, with the
// namespace "libfx-image-" and the image's name.
const imageStreamOf = (runId, name) => `${runId.replace(/^wrun_/, "strm_")}_user_${base64url(`libfx-image-${name}`)}`;
const queuePrefix = "__libfx_wkf_workflow_";
// The name of an agent created without one, and of whatever a message or a
// session from before names belonged to.
const defaultAgentName = "default";
const agentNameRule = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
// The agent a queued message is for.
const agentOf = (message) => (typeof message?.agent === "string" ? message.agent : defaultAgentName);
const queueName = `${queuePrefix}session`;
const eventIdPattern = /^[a-z]+_(\d{26})$/;
function ulid(now = Date.now()) {
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
let time = "";
for (let rest = now, index = 0; index < 10; index += 1, rest = Math.floor(rest / 32)) time = alphabet[rest % 32] + time;
let random = "";
for (const byte of crypto.getRandomValues(new Uint8Array(16))) random += alphabet[byte % 32];
return time + random;
}
// The entries that continue a session's chain of claims and records.
function chainedKind(k) {
return k === "lease" || k === "release" || k === "record";
}
// A run's in-band count right after its creation, as `@workflow/world`
// defines it (`IN_BAND_SEQ_AT_RUN_CREATION`): `run_created` counts as the
// first in-band write.
const inBandAtRunCreation = 1;
// The World's in-band writer fence refused a write: another writer moved the
// run's in-band count past the one the write expected.
function inBandSuperseded(error) {
return error?.name === "InBandSupersededError" || error?.code === "in-band-superseded" || error?.status === 412;
}
function slotOf(event) {
const match = eventIdPattern.exec(String(event?.eventId ?? ""));
if (!match) throw new Error(`the World returned event id ${JSON.stringify(event?.eventId ?? null)}, which holds no slot`);
return Number(match[1]);
}
function logEntryOf(event) {
if (event?.eventType !== "step_created" || event.eventData?.stepName !== logStep) return null;
const input = event.eventData.input;
if (!(input instanceof Uint8Array)) return null;
try { return JSON.parse(decoder.decode(input)); } catch { return null; }
}
const missingRun = (error) => error?.status === 404 || /not found|does not exist|ENOENT/i.test(String(error?.message ?? ""));
// Tries for a failed UI stream read that is not a missing stream.
const streamReadRetries = 5;
// How often a viewer looks for a session stream that does not exist yet,
// and for how long before it backs off.
const missingStreamPollMs = 50;
const missingStreamFastMs = 2000;
// Runs whose log views one backend keeps, and the characters of JSON they
// may hold together, least recently read dropped first.
const maxLogViews = 32;
const maxLogViewChars = 32 * 1024 * 1024;
// Whether a newest-first listing showed every slot from the lowest it
// listed to the newest. Pure.
const gapless = (read) => read.listed === 0 || read.newest - read.lowest + 1 === read.listed;
// The slot of a run's first event: a World numbers each run's events densely
// from 1.
const firstEventSlot = 1;
// Whether a newest-first listing reached slot `floor`, or the checkpoint it
// stopped at, which stands for every slot through its `through`. Pure.
const listedDownTo = (read, floor) => read.lowest === floor || (read.through !== null && read.lowest <= read.through);
/**
* A run's log view extended by the entries listed after it, as a full read
* returns the log: every entry after the newest checkpoint's `through`.
* Null when that checkpoint covers less than the view starts after, which
* only a full read can answer. `head` is the chain's head as a full read
* notes it: the newest chained entry, or the checkpoint's `through`. Pure.
*/
export function extendLogView(view, newer) {
const combined = [...view.entries, ...newer];
let through = view.through;
for (let index = combined.length - 1; index >= 0; index -= 1) {
const entry = combined[index].entry;
if (entry?.k === "checkpoint") {
through = entry.through === null || entry.through === undefined ? 0 : Number(entry.through);
break;
}
}
if (through < view.through) return null;
const entries = combined.filter((item) => Number(item.cursor) > through);
let head = null;
for (let index = entries.length - 1; index >= 0 && head === null; index -= 1) {
if (chainedKind(entries[index].entry?.k)) head = entries[index].cursor;
}
return { entries, through, head: head ?? (through ? String(through) : "null") };
}
// Events a session-log read lists per page: the first page, then the rest.
const firstPageEvents = 10;
const laterPageEvents = 100;
// The agents listening on each World in this process. A World holds one
// handler for libfx's queue, so the agents given one World take its
// deliveries in turn, as servers sharing a queue do.
const listenersOf = new WeakMap();
// Adds `member` to the group for `name` in `groups`, a Map of name to
// `{ members, turn }`, and returns the group; leaveGroup takes it out again.
function joinGroup(groups, name, member) {
let group = groups.get(name);
if (!group) groups.set(name, group = { members: [], turn: 0 });
group.members.push(member);
return group;
}
function leaveGroup(groups, name, group, member) {
const index = group.members.indexOf(member);
if (index >= 0) group.members.splice(index, 1);
if (group.members.length === 0 && groups.get(name) === group) groups.delete(name);
}
function noAgentNamed(name) {
return Object.assign(new Error(`no libfx agent named ${JSON.stringify(name)} runs in this process; the queue delivers this again`), { code: "FX_NO_AGENT", agent: name });
}
// The durable agents in this process, by name, from when each is created
// until it closes, each as `{ open, durability }`: a route delivers each
// message to the agent it names, and a function that only receives queue
// messages has its agents here before any of them is used. Shared by every
// copy of this module in the process.
const agentsByName = (globalThis[Symbol.for("libfx.durableAgents")] ??= new Map());
// The backend a member opens, or null when it cannot open, as an agent
// libfx refused to create cannot.
const openMember = (member) => member.open().catch(() => null);
// The World backend of an agent named `name` in this process whose
// durability `matches`, the agent's copies in turn, or null.
async function agentNamed(name, matches) {
const group = agentsByName.get(name);
if (!group) return null;
const { members } = group;
const first = group.turn++;
for (let index = 0; index < members.length; index += 1) {
const member = members[(first + index) % members.length];
if (!matches(await member.durability())) continue;
const created = await openMember(member);
if (typeof created?.deliverHere === "function") return created;
}
return null;
}
// Agents share sessions through the durability they were created with, so
// one on another durability can hold other sessions.
const agentSharing = (name, durability) => agentNamed(name, (other) => other === durability);
// The store a World keeps its sessions in, as its queue messages name it:
// its `store`, which is its name unless the app named the store.
const worldStore = (durability) => durability?.store ?? durability?.name;
// The store a durability keeps its sessions in, as libfx tells stores apart:
// a memory() is its own, and a World's is its worldStore(). Two agents of one
// name on one store are one agent.
const storeOf = (durability) => (typeof durability?.create === "function" ? durability : `world:${worldStore(durability)}`);
// A route takes its deployment's deliveries for every World in the process,
// so a delivery goes to the agent its message names on the store it names,
// and never to one of that name on another store
// (`tests/tla/sdk/TenantIsolation.tla` RunsInSenderStore).
const agentOnStore = (name, store) => agentNamed(name, (other) => typeof other.world === "function" && other.create === undefined && worldStore(other) === store);
/**
* The request handler a deployment mounts for queue deliveries, for every
* durable agent in the process: each delivery goes to the agent its message
* names, on the store it names. One route and one queue trigger serve any
* number of agents.
*/
export function wakeHandler() {
return async (request) => {
for (const group of agentsByName.values()) {
for (const member of group.members) {
const created = await openMember(member);
if (created?.viaRoute === true) return created.queueHandler(request);
}
}
return new Response("no libfx agent in this process takes queue deliveries from a route", { status: 503 });
};
}
const nameInUse = (name) => Object.assign(
new Error(`an agent named ${JSON.stringify(name)} with another configuration already runs on this store in this process; give each configuration its own name, or each store its own \`store\``),
{ code: "FX_AGENT_NAME_IN_USE", agent: name },
);
// Whether two agents' options are one configuration: the same values, and
// the same functions and objects other than plain ones. `onEvent` only
// watches the agent, so it is left out.
function definitionOf(options) {
const { onEvent: _onEvent, ...definition } = options;
return definition;
}
function sameDefinition(a, b, depth = 0) {
if (Object.is(a, b)) return true;
if (depth > 32 || a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
if (ArrayBuffer.isView(a) || ArrayBuffer.isView(b)) {
if (!ArrayBuffer.isView(a) || !ArrayBuffer.isView(b) || a.byteLength !== b.byteLength) return false;
const left = new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
const right = new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
for (let index = 0; index < left.length; index += 1) if (left[index] !== right[index]) return false;
return true;
}
const plain = (value) => Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null;
if (!plain(a) || !plain(b) || Array.isArray(a) !== Array.isArray(b)) return false;
const keys = Object.keys(a);
if (keys.length !== Object.keys(b).length) return false;
return keys.every((key) => Object.hasOwn(b, key) && sameDefinition(a[key], b[key], depth + 1));
}
// Refuses `member` when another agent of `name` on its store has another
// configuration: deliveries for a name on a store go to any of its agents,
// so they must be one agent (`tests/tla/sdk/TenantIsolation.tla`
// RunsOnSenderAgent). Agents created without a durability share their
// factory's default, so they are on one store even before one of them has
// learned which store that is.
const sameStore = (a, b) => a.store === b.store || (a.defaults !== null && a.defaults === b.defaults);
function claimName(name, member) {
for (const other of agentsByName.get(name)?.members ?? []) {
if (other !== member && sameStore(other, member) && !sameDefinition(other.definition, member.definition)) throw nameInUse(name);
}
}
// libfx starts and closes a World it `owned`, having created it; a World the
// app passed in stays the app's.
function createWorldBackend(world, durability) {
const { name, reserveMs, pollMs, leaseMs, alive, holderInfo, livenessKnown = false, queueLeases = false, runInRequest = queueLeases, waitUntil, queueDurable = false, maxDurationMs, owned = true } = durability;
const spec = world.specVersion === undefined ? {} : { specVersion: world.specVersion };
const created = new Set();
// Runs being created now: writes that go out together create a run once.
const creating = new Map();
// Workers in this process hear about each other's entries at once; others
// poll.
const watchers = new Map();
const changed = (runId) => { for (const watcher of watchers.get(runId) ?? []) watcher(); };
// Each run's log as this process last read it: the entries read() returned,
// the newest slot seen, and the checkpoint's `through` they start after.
// The log only appends, so a later read lists only slots after `known`.
// The entries are kept as the JSON the World stores, so every read hands
// out its own copy, as reading the World does, and the views' size is
// bounded in characters as well as in runs.
const logViews = new Map();
let logViewChars = 0;
const forgetView = (runId) => {
const view = logViews.get(runId);
if (!view) return;
logViewChars -= view.text.length;
logViews.delete(runId);
};
const rememberView = (runId, entries, known, through) => {
forgetView(runId);
const text = JSON.stringify(entries);
if (text.length > maxLogViewChars) return;
logViews.set(runId, { text, known, through });
logViewChars += text.length;
for (const oldest of logViews.keys()) {
if (logViews.size <= maxLogViews && logViewChars <= maxLogViewChars) break;
forgetView(oldest);
}
};
const viewOf = (runId) => {
const view = logViews.get(runId);
return view && { entries: JSON.parse(view.text), known: view.known, through: view.through };
};
let handler = null;
const route = typeof world.registerHandler !== "function";
const listEvents = (runId, cursor, limit) => world.events.list({
runId,
pagination: { sortOrder: "desc", limit, ...(cursor ? { cursor } : {}) },
resolveData: "all",
});
// `meta` says which queue message this is and which delivery of it.
const deliverTo = async (handle, message, meta) => {
const input = message?.input;
if (!input || typeof input !== "object") return undefined;
if (!handle) {
// A World libfx created closes with its agent. The app's own World
// outlives it, so its queue keeps the message for the next agent.
if (owned) return undefined;
throw new Error("no libfx agent is listening; the queue delivers this again");
}
const result = await handle(input, meta);
// The queue delivers this same message again, which takes back a lease
// its worker could not let go.
if (result?.redeliver === true) throw new Error("libfx asks the queue to deliver this message again");
return Number.isFinite(result?.timeoutSeconds) ? { timeoutSeconds: result.timeoutSeconds } : undefined;
};
const store = worldStore(durability);
// The route a deployment mounts delivers to the agent in this process the
// message names, on the store it names, whichever agent's route it is. A
// message from before stores were named is for this World's store.
const queueHandler = world.createQueueHandler(queuePrefix, async (message, meta) => {
const named = agentOf(message?.input);
const agent = await agentOnStore(named, typeof message?.input?.store === "string" ? message.input.store : store);
if (!agent) throw noAgentNamed(named);
return agent.deliverHere(message, meta);
});
const backend = {
name,
store,
livenessKnown,
queueLeases,
// A prompt runs in the request that took it, held by its backstop's
// message, which the queue keeps until after the request's deadline.
runInRequest: queueLeases && runInRequest,
// Keeps the request alive for a turn it runs after its response.
waitUntil: waitUntil ?? null,
queueDurable,
pollMs: pollMs ?? 1000,
reserveMs: reserveMs ?? defaultReserveMs,
leaseMs: leaseMs ?? defaultLeaseMs,
maxDurationMs: maxDurationMs ?? null,
world,
newSessionId: () => `wrun_${typeof world.createRunId === "function" ? world.createRunId({}) : ulid()}`,
validSessionId: (id) => typeof id === "string" && /^wrun_[0-9A-Za-z]{10,64}$/.test(id),
deadline: () => null,
holderInfo: holderInfo ?? (() => ({})),
// Whether a lease's holder still runs, as `delivery` can tell: a worker
// of this process runs while it holds it, and the message its delivery
// held, delivered to this process, means it no longer does.
alive(lease, delivery = null) {
if (liveHolders.has(lease.holder)) return true;
const mine = holderInfo?.() ?? {};
if (mine.pid !== undefined && lease.pid === mine.pid && lease.host === mine.host) return false;
if (redelivers(delivery, lease)) return false;
return alive ? alive(lease) : null;
},
holding(holder, held) {
if (held) liveHolders.add(holder);
else liveHolders.delete(holder);
},
async start() {
if (owned) await world.start?.();
if (typeof world.getRuntimeDeadline === "function") {
backend.deadline = () => backend.runtimeDeadline ?? null;
}
},
async refreshDeadline() {
if (typeof world.getRuntimeDeadline !== "function") return;
try {
const deadline = await world.getRuntimeDeadline();
backend.runtimeDeadline = deadline instanceof Date ? deadline.getTime() : null;
} catch {
backend.runtimeDeadline = null;
}
},
async session(runId) {
// The newest slot this worker has seen.
let known = 0;
// The images this worker knows the run's image store holds.
const storedImages = new Set();
// Where the World has the in-band writer fence, chained entries are
// written in-band, each expecting the World's count of in-band writes
// at the entry it continues, so the World refuses one that continues a
// superseded head and nothing of it lands. `inBandAt` maps a chained
// entry's cursor (`null` for an empty chain) to the count right after
// it: from the snapshot a read returns, or from the write itself. The
// check after a write stays, since a World may count per process.
const inBandAt = new Map();
let inBand = world.capabilities?.inBandFence === true;
const noteInBand = (cursor, count) => {
inBandAt.delete(cursor);
inBandAt.set(cursor, count);
if (inBandAt.size > 16) inBandAt.delete(inBandAt.keys().next().value);
};
// A snapshot is read before its listing, so it gives the count at the
// chain's head only when it covers the head's slot. It never replaces
// a count this worker's own write reported.
const noteSnapshot = (snapshot, cursor) => {
if (!inBand || cursor === undefined || inBandAt.has(cursor) || !Number.isSafeInteger(snapshot?.seqInBand)) return;
if (cursor !== "null" && !(Number(cursor) <= snapshot.seq)) return;
noteInBand(cursor, snapshot.seqInBand);
};
const ensureRun = () => {
if (created.has(runId)) return Promise.resolve();
let pending = creating.get(runId);
if (!pending) {
pending = createRun().finally(() => creating.delete(runId));
creating.set(runId, pending);
}
return pending;
};
const createRun = async () => {
try {
const deploymentId = typeof world.getDeploymentId === "function" ? await world.getDeploymentId() : "libfx";
await world.events.create(runId, {
eventType: "run_created",
...spec,
eventData: { deploymentId, workflowName: "libfx/session", input: encoder.encode("{}") },
});
} catch (error) {
// Another worker created it first.
if (!/exist|conflict|409|already/i.test(String(error?.message ?? "")) && error?.status !== 409) throw error;
}
try {
await world.events.create(runId, { eventType: "run_started", ...spec });
} catch (error) {
if (!/exist|conflict|409|already|started|status/i.test(String(error?.message ?? "")) && error?.status !== 409) throw error;
}
created.add(runId);
};
// Events newest first, down to and excluding slot `stopBelow`.
const newestFirst = async (stopBelow, stopAtCheckpoint) => {
const events = [];
let through = null;
let snapshot;
// The newest chained entry read: the chain's head, unless the read
// stopped before reaching any.
let newestChained;
// The newest and lowest slots this read listed above `stopBelow`,
// entries or not, and how many. Unlike `known`, which this object's
// own writes also raise, these say only what the listing showed.
let newest = stopBelow;
let lowest = Infinity;
let listed = 0;
// Ends the read. `head` is where the chain's head is when the read
// found no chained entry, or undefined when the read does not show.
const done = (head) => {
noteSnapshot(snapshot, newestChained ?? head);
return { events, through, snapshot, newest, lowest, listed };
};
// A session's newest checkpoint is usually among its last few
// entries, so a read starts with a small page and fetches more only
// while it has not reached where it stops; every page carries its
// entries' payloads, a checkpoint holding the whole conversation.
for (let cursor, limit = firstPageEvents; ; limit = laterPageEvents) {
let page;
try {
page = await listEvents(runId, cursor, limit);
} catch (error) {
if (!missingRun(error)) throw error;
// The run's creation starts its count.
if (stopBelow === 0) noteSnapshot({ seq: 0, seqInBand: inBandAtRunCreation }, "null");
return { events, through, newest, lowest, listed, missing: true };
}
snapshot ??= page.snapshot;
for (const event of page.data) {
const slot = slotOf(event);
if (slot > known) known = slot;
if (slot <= stopBelow) return done(undefined);
if (slot > newest) newest = slot;
if (slot < lowest) lowest = slot;
listed += 1;
// The checkpoint stands for the entries through its `through`,
// the last of them the chain's head.
if (through !== null && slot <= through) return done(through === 0 ? "null" : String(through));
const entry = logEntryOf(event);
if (!entry) continue;
created.add(runId);
if (newestChained === undefined && chainedKind(entry.k)) newestChained = String(slot);
events.push({ cursor: String(slot), entry });
if (stopAtCheckpoint && through === null && entry.k === "checkpoint") {
through = entry.through === null || entry.through === undefined ? 0 : Number(entry.through);
}
}
if (!page.hasMore) return done(stopBelow > 0 ? undefined : through ? String(through) : "null");
cursor = page.cursor;
}
};
// The newest slot the last read() returned every entry through, or
// null when that read could not show it had them all.
let covered = null;
const fullRead = async () => {
const read = await newestFirst(0, true);
const entries = read.events.reverse();
// A listing that skipped a slot may show a later write before an
// earlier one lands. The entries go back as they are, as every read
// returned them before, but no view keeps the gap for later reads.
// The listing must also reach the run's first event or the checkpoint
// it stopped at, so no slot below what it listed is missing.
if (!read.missing && listedDownTo(read, firstEventSlot) && gapless(read)) {
rememberView(runId, entries, read.newest, read.through ?? 0);
covered = read.newest;
} else {
forgetView(runId);
covered = null;
}
return entries;
};
return {
newest: () => covered,
async read() {
const view = viewOf(runId);
if (!view) return fullRead();
if (view.known > known) known = view.known;
const since = await newestFirst(view.known, true);
if (since.missing) {
forgetView(runId);
covered = null;
return [];
}
// The newer listing must have skipped no slot and must reach the
// view or a checkpoint that stands for what lies between; and a
// checkpoint covering less than the view starts after is not one
// the view can stand for. Otherwise read the log whole.
const reaches = since.listed === 0 || listedDownTo(since, view.known + 1);
const next = reaches && gapless(since) ? extendLogView(view, since.events.reverse()) : null;
if (!next) return fullRead();
// The in-band count at the chain's head, as a full read notes it.
noteSnapshot(since.snapshot, next.head);
rememberView(runId, next.entries, since.newest, next.through);
covered = since.newest;
return next.entries;
},
async readSince(cursor) {
const { events } = await newestFirst(Number(cursor), false);
return events.reverse();
},
async append(entry) {
await ensureRun();
const chained = chainedKind(entry.k);
// A chained entry asks the World to report every event since the
// one it continues, so a competing continuation is seen.
const after = chained ? (entry.a === null || entry.a === undefined ? 0 : Number(entry.a)) : known;
const continues = entry.a === null || entry.a === undefined ? "null" : String(entry.a);
let expected = chained && inBand ? inBandAt.get(continues) : undefined;
const event = {
eventType: "step_created",
correlationId: `step_${ulid()}`,
eventData: { stepName: logStep, input: encoder.encode(JSON.stringify(entry)) },
};
let result;
for (let recounted = false; ;) {
try {
result = await world.events.create(runId, event, expected === undefined ? { eventCount: after } : { eventCount: after, inBand: true, expectedSeqInBand: expected });
break;
} catch (error) {
if (expected === undefined) throw error;
if (inBandSuperseded(error)) {
// The next read maps the head afresh.
inBandAt.delete(continues);
// A World counts a write after it publishes it, so a read
// between the two gave this head the count before its own
// write. When nothing else continued the head, the write goes
// again once at the count the World reports.
if (!recounted && Number.isSafeInteger(error?.seqInBand)) {
const { events: since } = await newestFirst(after, false);
const rivals = since.filter(({ entry: other }) => chainedKind(other.k) && (other.a ?? null) === (entry.a ?? null));
// Our own earlier write of this entry landed first.
const ours = rivals.find(({ entry: other }) => other.key !== undefined && other.key === entry.key && other.k === entry.k);
if (ours) return ours.cursor;
if (rivals.length === 0) {
recounted = true;
expected = error.seqInBand;
continue;
}
}
throw new FencedError(`another worker continued session ${runId}`);
}
// A World may refuse in-band writes on a run it stamped before
// it had the fence; the write goes out-of-band, as before.
if (!(error?.status >= 400 && error?.status < 500) || error?.status === 409) throw error;
inBand = false;
expected = undefined;
}
}
const slot = slotOf(result.event);
if (slot > known) known = slot;
if (expected !== undefined && result.allocated !== undefined) noteInBand(String(slot), expected + result.allocated);
changed(runId);
if (!chained || slot === after + 1) return String(slot);
const reported = Array.isArray(result.events) ? result.events : (await newestFirst(after, false)).events.map((item) => ({ eventId: `evnt_${String(item.cursor).padStart(26, "0")}`, ...item }));
for (const event of reported) {
const other = event.entry ?? logEntryOf(event);
const otherSlot = event.entry ? Number(event.cursor) : slotOf(event);
if (!other || otherSlot <= after || otherSlot >= slot) continue;
const otherChained = chainedKind(other.k);
if (!otherChained || (other.a ?? null) !== (entry.a ?? null)) continue;
// Our own earlier write of this entry landed first.
if (other.key !== undefined && other.key === entry.key && other.k === entry.k) return String(otherSlot);
throw new FencedError(`another worker continued session ${runId}`);
}
return String(slot);
},
watch(fn) {
let set = watchers.get(runId);
if (!set) watchers.set(runId, set = new Set());
set.add(fn);
return () => set.delete(fn);
},
// Each image is a stream of the run, named after it, so it is read by
// name wherever checkpoints fall. Two writers of one image each add
// its bytes once; a read takes the first chunk that matches the name.
images: {
async put(name, bytes) {
if (storedImages.has(name)) return;
// Refused here, before the prompt is stored, rather than by every
// worker that reads the image back.
if (typeof world.streams.getChunks !== "function") throw new TypeError("a World needs streams.getChunks() to keep a session's images");
await ensureRun();
const stream = imageStreamOf(runId, name);
let present = false;
try {
present = Number((await world.streams.getInfo(runId, stream))?.tailIndex ?? -1) >= 0;
} catch (error) {
if (!missingRun(error)) throw error;
}
if (!present) await world.streams.write(runId, stream, bytes);
storedImages.add(name);
},
async get(name) {
let page;
try {
page = await world.streams.getChunks(runId, imageStreamOf(runId, name), { limit: 8 });
} catch (error) {
if (missingRun(error)) return undefined;
throw error;
}
for (const chunk of page?.data ?? []) {
const bytes = byteView(chunk?.data);
if (bytes && bytes.byteLength > 0 && await imageName(bytes) === name) {
storedImages.add(name);
return bytes.slice();
}
}
return undefined;
},
},
ui: {
async write(lines) {
await ensureRun();
const chunks = lines.map((line) => `${line}\n`);
if (typeof world.streams.writeMulti === "function" && chunks.length > 1) await world.streams.writeMulti(runId, uiStreamOf(runId), chunks);
else for (const chunk of chunks) await world.streams.write(runId, uiStreamOf(runId), chunk);
},
async length() {
try {
const info = await world.streams.getInfo(runId, uiStreamOf(runId));
return Math.max(0, Number(info?.tailIndex ?? -1) + 1);
} catch {
return 0;
}
},
read(from) {
let reader = null;
let cancelled = false;
const opened = Date.now();
return new ReadableStream({
async pull(controller) {
for (let delay = missingStreamPollMs, failures = 0; !reader;) {
if (cancelled) return;
try {
reader = (await world.streams.get(runId, uiStreamOf(runId), from)).getReader();
} catch (error) {
// A stream nobody wrote yet appears with the turn's first
// event; any other failure gets a few tries, then ends
// the read, so its viewer hears of it.
const missing = missingRun(error);
if (!missing && ++failures > streamReadRetries) {
controller.error(error);
return;
}
await new Promise((resolve) => setTimeout(resolve, delay));
// A new session's stream appears within moments of its
// first prompt, so it is looked for often at first.
delay = missing && Date.now() - opened < missingStreamFastMs ? missingStreamPollMs : Math.min(delay * 2, 1000);
}
}
const { value, done } = await reader.read();
if (done) controller.close();
else controller.enqueue(typeof value === "string" ? encoder.encode(value) : value);
},
// A live World read can take minutes to cancel, so nothing
// waits for it: a viewer that has its turn's end is done.
cancel() {
cancelled = true;
void reader?.cancel().catch(() => {});
},
});
},
},
};
},
queue: {
// Returns the queue's id for the message, when the World gives one.
async send(message, { delaySeconds = 0, idempotencyKey } = {}) {
const sent = await world.queue(queueName, { runId: message.sessionId, requestId: message.messageId, input: message }, {
...(idempotencyKey ? { idempotencyKey } : {}),
...(delaySeconds > 0 ? { delaySeconds } : {}),
});
return typeof sent?.messageId === "string" && sent.messageId.length > 0 ? { messageId: sent.messageId } : {};
},
},
// The route a deployment mounts for queue deliveries, until the World
// can deliver to an in-process handler itself.
queueHandler,
durability,
// Whether the queue delivers to queueHandler, mounted by the deployment.
viaRoute: route,
// Delivers a routed message to this backend's agent.
deliverHere: (message, meta) => deliverTo(handler, message, meta),
listen(fn, name = defaultAgentName) {
handler = fn;
if (route) return () => { if (handler === fn) handler = null; };
// The agents on this World, by name: an agent's copies take the
// deliveries for its name in turn. A name none of them has goes to an
// agent of that name on the same durability elsewhere in the process,
// as each agent has a World of its own, and one the process has none
// of is left for the queue to deliver again.
let listeners = listenersOf.get(world);
if (!listeners) {
listeners = new Map();
listenersOf.set(world, listeners);
world.registerHandler(queuePrefix, world.createQueueHandler(queuePrefix, async (message, meta) => {
const named = agentOf(message?.input);
const group = listeners.get(named);
if (group) return deliverTo(group.members[group.turn++ % group.members.length], message, meta);
const agent = await agentSharing(named, durability);
if (agent) return agent.deliverHere(message, meta);
if (listeners.size === 0) return deliverTo(null, message, meta);
throw noAgentNamed(named);
}));
}
const group = joinGroup(listeners, name, fn);
return () => {
if (handler === fn) handler = null;
leaveGroup(listeners, name, group, fn);
};
},
// Closing is best effort: a runtime whose HTTP agent cannot close, as
// under Bun, still lets the agent go.
async close() {
if (!owned) return;
try {
await world.close?.();
} catch {}
},
};
return backend;
}
// ---------------------------------------------------------------------------
// The worker: runs a session's turns while it holds the lease.
// Lines a worker writes to the session's UI stream, in order. A line goes
// out at once when no write is in flight; lines pushed during one go
// together in the next. Each carries the worker's lease epoch, so a reader
// can tell a replaced worker's late lines from its successor's.
function uiWriter(log, onError, epoch) {
let queued = [];
let writing = null;
const send = () => {
const lines = queued;
queued = [];
writing = log.ui.write(lines).catch(onError).then(() => {
writing = null;
if (queued.length > 0) send();
});
};
return {
push(event) {
queued.push(JSON.stringify(epoch === undefined ? event : { ...event, epoch }));
if (!writing) send();
},
// Waits until every line pushed so far is written.
async settle() {
while (writing) await writing;
},
};
}
// What a turn runs with: its caller's context and its session object's
// settings, from the input that started it or from the open turn.
const runOf = (input) => ({ context: input?.context ?? null, settings: input?.settings ?? null });
const errorSummary = (error) => ({ name: error?.name ?? "Error", message: String(error?.message ?? error), ...(error?.code ? { code: error.code } : {}) });
// The harness contract: a fenced write rejects with this code.
const isFenced = (error) => error?.code === "FX_FENCED";
// The kinds of message a session's queue carries.
const messageTypes = new Set(["prompt", "steer", "cancel", "resume"]);
// What the queue carries for a message: only which input of which session
// it names, for which agent, on which store. A delivery can arrive at a
// public route, so its body may come from anyone: a worker acts only on a
// message its session's log already holds, and runs only what that log
// says (`tests/tla/sdk/TenantIsolation.tla` OnlySentRuns).
function queuedMessage(message, store) {
return {
type: message.type,
messageId: message.messageId,
sessionId: message.sessionId,
agent: typeof message.agent === "string" ? message.agent : defaultAgentName,
...(typeof store === "string" ? { store } : {}),
};
}
// The message a delivery names, as queuedMessage() has it, or null for one
// no sender could have queued on `backend`.
function deliveredMessage(backend, delivered) {
if (!delivered || typeof delivered !== "object" || !messageTypes.has(delivered.type)) return null;
if (!validId(delivered.messageId) || !backend.validSessionId(delivered.sessionId)) return null;
if (delivered.agent !== undefined && typeof delivered.agent !== "string") return null;
return queuedMessage(delivered);
}
// Whether the session's log holds the input `message` names, or names it
// among those a turn already took.
const knownMessage = (state, message) => state.inputKeys.has(`${message.type}:${message.messageId}`) || state.consumed.has(message.messageId);
// The most a durable input may hold: its content blocks with its caller's
// context and its session object's settings, as JSON. The engine refuses a
// prompt frame over 8 MiB (`maxPromptFrameBytes` in fx-sdk.js), so a larger
// input could never run; it is refused before anything is stored.
const maxInputBytes = 8 * 1024 * 1024;
const jsonBytes = (value) => (value === undefined ? 0 : encoder.encode(JSON.stringify(value)).byteLength);
// A JSON value as it stands now, so what libfx measures is what it stores,
// however the caller changes its object later. Pure.
const jsonSnapshot = (value) => {
const text = JSON.stringify(value);
return text === undefined ? undefined : JSON.parse(text);
};
const inputTooLarge = (bytes) => Object.assign(
new RangeError(`a durable input with its context and settings is ${bytes} bytes; the most is ${maxInputBytes}`),
{ code: "FX_INPUT_TOO_LARGE", bytes, limit: maxInputBytes },
);
// What a message adds to its session's log: a restored agent's first
// checkpoint and the message's input, a prompt with its caller's context and
// its session object's settings. Only the sender calls it, before it queues
// the message, so what a turn runs and as whom comes from the app's own
// call, never from a queue delivery. Nothing the log already holds. Pure.
function messageEntries(state, message) {
const entries = [];
const named = typeof message.agent === "string" ? { agent: message.agent } : {};
if (message.seed && state.checkpoint === null && state.records.length === 0) {
entries.push({ k: "checkpoint", through: null, data: message.seed, ...named });
}
const key = `${message.type}:${message.messageId}`;
const known = state.inputKeys.has(key) || state.consumed.has(message.messageId) || idInUse(state, message);
if (messageTypes.has(message.type) && !known) {
entries.push({
k: "input",
key,
type: message.type,
messageId: message.messageId,
...named,
...(message.input === undefined ? {} : { input: message.input }),
...(typeof message.target === "string" ? { target: message.target } : {}),
...((message.type === "prompt" || message.type === "steer") && message.context !== undefined ? { context: message.context } : {}),
...((message.type === "prompt" || message.type === "steer") && message.settings !== undefined ? { settings: message.settings } : {}),
});
}
return entries;
}
// Whether a steer's input can join a turn: text. One with images or
// resources runs as its own turn, after the running one hands off to it.
const joinable = (input) => typeof input === "string" || (Array.isArray(input) && input.every((block) => block?.type === "text"));
// Whether `message` reuses the id of the session's other kind of input: one
// id names one input, prompt or steer.
function idInUse(state, message) {
const other = message.type === "prompt" ? "steer" : message.type === "steer" ? "prompt" : null;
if (other === null) return false;
return state.inputKeys.has(`${other}:${message.messageId}`) || state.consumedKind?.(message.messageId) === other;
}
const 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" },
);
// Writes what a message adds to its session, in order, and returns each
// entry it wrote with the slot it took. Only the sender calls it, before it
// queues the message.
// A message that reuses another input's id writes nothing and throws.
async function recordMessage(log, state, message) {
if (idInUse(state, message)) throw messageIdInUse(message);
const entries = messageEntries(state, message);
const appended = [];
for (const entry of entries) appended.push({ cursor: await log.append(entry), entry });
return appended;
}
/**
* The log a sender read and then extended with its own entries, as the
* worker it starts in the same request may use it instead of reading the
* log again. Only when the sender's read showed every entry through slot
* `newestSeen`, and every entry the sender then wrote took the next slot:
* then nothing landed in between, so the view holds every input that comes
* before this message's. The sender's entries go on as the JSON the World
* stored, not as the objects it wrote. Null otherwise. Pure.
*/
export function handoffEntries(read, newestSeen, appended) {
if (!Number.isSafeInteger(newestSeen) || appended.length === 0) return null;
for (const [index, item] of appended.entries()) {
if (Number(item.cursor) !== newestSeen + index + 1) return null;
}
return [...read, ...appended.map(({ cursor, entry }) => ({ cursor, entry: JSON.parse(JSON.stringify(entry)) }))];
}
/**
* The session as a worker can start it before its claim lands: the log it
* read, with this message's input in it, when that leaves no open turn and
* a prompt to run. The turn's first model request then needs nothing the
* claim writes. Null when the worker must claim first: an open turn
* continues only from the log as its claim finds it, and a restored agent's
* first checkpoint lands before anything reads it. `fold` folds entries the
* way the worker does. Pure.
*/
export function earlyStart(entries, state, message, fold) {
const writes = messageEntries(state, message);
if (writes.some((entry) => entry.k !== "input")) return null;
// The input lands after everything the worker read.
const next = writes.length === 0 ? state : fold([...entries, ...writes.map((entry) => ({ cursor: String(state.maxCursor), entry }))]);
if (next.openTurn !== null || next.halted || next.pending.length === 0) return null;
return { writes, state: next };
}
// The log as a worker writes it under a claim still in flight: each write
// waits for the claim, and none is made once it failed. `ready` resolves
// true when the claim landed and false when it failed. A UI line written
// under a failed claim is dropped: its turn never ran for anyone.
function gatedLog(log, ready, claimError) {
return {
...log,
async append(entry) {
if (!(await ready)) throw claimError();
return log.append(entry);
},
ui: {
...log.ui,
async write(lines) {
if (await ready) await log.ui.write(lines);
},
},
};
}
// How long before its deadline a worker stops itself, at most: it cancels
// the model or tool call still running, so it never runs on after its lease
// could pass to another worker.
const hardStopMs = 2000;
const durableInternals = Symbol.for("libfx.durableInternals");
// The shortest wait before a message that found its session running in this
// process is delivered again, as a backstop for the running worker.
const runningBackstopSeconds = 2;
// When a delivery that starts now must stop: its function's deadline, or
// `maxDurationMs` after now; null when nothing stops it.
const deliveryDeadline = (backend) => backend.deadline() ?? (backend.maxDurationMs === null ? null : Date.now() + backend.maxDurationMs);
// How long past the request's own deadline the queue keeps a prompt that
// request runs itself, before it delivers it as a backstop.
const inlineBackstopMarginSeconds = 5;
class SessionWorker {
constructor(agent, backend, sessionId) {
this.agent = agent;
this.backend = backend;
this.sessionId = sessionId;
// Set while this process runs the session; each run holds its own lease.
this.running = false;
// Deliveries in progress for the session in this process.
this.deliveries = 0;
this.holder = null;
}
// The log folded as `delivery` judges whether a lease's holder lives.
fold(entries, delivery = null) {
return foldSessionLog(entries, { now: Date.now(), alive: (lease) => this.backend.alive(lease, delivery) });
}
// One queue delivery, `delivery` its message's id and attempt when the
// queue reports them. Its sender stored its input before queueing it, so a
// steer or cancel reaches the running turn even before this runs. A
// delivery the session's log does not hold is not from a sender and is
// dropped before anything is written, whether or not its session exists
// (`tests/tla/sdk/TenantIsolation.tla` OnlySentRuns). Then it runs the
// session when no live worker holds it. Returns `{ timeoutSeconds }` to
// have the same message delivered again later.
// `handoff` is the sender's own session object and the log it read and
// extended, when the request that took the prompt runs it (see
// handoffEntries); the worker starts from them instead of reading again.
async consume(message, delivery = null, handoff = null) {
const log = handoff?.log ?? await this.backend.session(this.sessionId);
let entries = handoff?.entries ?? await log.read();
let state = this.fold(entries, delivery);
if (!knownMessage(state, message)) {
this.agent.emit("session.refused", { sessionId: this.sessionId, messageId: message.messageId, reason: "unknown message" });
return undefined;
}
await this.backend.refreshDeadline?.();
// When this delivery's function stops, if it does.
const deadline = this.backend.deadline() ?? (this.backend.maxDurationMs === null ? null : Date.now() + this.backend.maxDurationMs);
// A session runs only on the agent it belongs to, the one its first
// stored input names. A message that reached another agent, as one sent
// to another agent's session does, goes on to it.
const owner = state.agent ?? agentOf(message);
if (owner !== this.agent.name) {
await this.backend.queue.send(queuedMessage({ ...message, agent: owner }, this.backend.store), {
idempotencyKey: `${message.sessionId}:${message.type}:${message.messageId}:to:${owner}`,
});
this.agent.emit("session.forwarded", { sessionId: this.sessionId, agent: owner });
return undefined;
}
for (let claims = 0; claims < 8; claims += 1) {
// A session with no open turn starts its next prompt at once: the
// first model request goes out while the lease is written, and
// nothing else is written until it lands.
const early = this.running || state.lease ? null : earlyStart(entries, state, message, (items) => this.fold(items, delivery));
let outcome;
if (early) {
outcome = await this.claim(log, state, deadline, early, delivery);
} else {
if (this.running) return { timeoutSeconds: runningBackstopSeconds };
if (!state.hasWork) {
if (message.type === "resume") await this.answerIdle(log, message);
return undefined;
}
if (state.lease) {
// The live holder takes this input from the log; come back as a
// backstop once its lease would have run out.
const left = state.lease.expiresAt ? Math.ceil((state.lease.expiresAt - Date.now()) / 1000) + 1 : 5;
return { timeoutSeconds: Math.max(1, Math.min(maxBackstopSeconds, left)) };
}
outcome = await this.claim(log, state, deadline, null, delivery);
}
if (outcome === "yielded") return { timeoutSeconds: 0 };
// A write that failed for any other reason: the same message runs the
// session again shortly. A lease held to the deadline, which a release
// that failed may leave standing, is let go only by a later delivery of
// this message, and a queue may send `{ timeoutSeconds }` on as a new
// one, so the queue is asked for this one again.
if (outcome === "failed") return queueHeld(this.backend, deadline) ? { redeliver: true } : { timeoutSeconds: 1 };
if (outcome === "fenced") {
// Another worker took the session over; this one stops.
this.agent.emit("session.fenced", { sessionId: this.sessionId, epoch: state.lastEpoch + 1 });
return undefined;
}
// The turns ran, or another worker's write came before this claim:
// read the log again.
entries = await log.read();
state = this.fold(entries, delivery);
}
return { timeoutSeconds: 1 };
}
// Claims the session where `state` read it, with a lease that continues
// the chain there, and runs it. With `early`, what `earlyStart` returned,
// the message's input is written with the lease and the next turn starts
// before either lands. The lease names `delivery`, so a later delivery of
// the same message can tell it lost its holder. Returns what run()
// returns, or "refused" when another worker's write came before the claim.
async claim(log, state, deadline, early, delivery = null) {
// Claimed before the first await, so a delivery that arrives meanwhile
// sees the session running here.
this.running = true;
this.holder = newId("wkr");
const lease = {
k: "lease",
a: state.head,
holder: this.holder,
epoch: state.lastEpoch + 1,
expiresAt: leaseEnd(this.backend, deadline),
...this.backend.holderInfo(),
...(delivery ? { delivery } : {}),
};
this.backend.holding(this.holder, true);
try {
// Both writes go out at once and neither waits for the other; the
// claim settles once both have, with the lease's cursor if it landed
// and the first failure.
const writes = [log.append(lease), ...(early?.writes ?? []).map((entry) => log.append(entry))];
const claimed = Promise.allSettled(writes).then(async ([leased, ...rest]) => {
const head = leased.status === "fulfilled" ? leased.value : null;
const error = leased.status === "rejected" ? leased.reason : rest.find((item) => item.status === "rejected")?.reason ?? null;
if (error || !early) return { head, error };
// A lease the World took without reporting a competing one is
// checked against the log, as run() checks one taken first. One that
// fails is refused like a fenced write rather than stopped: this
// worker has run nothing, and its input may land after the other
// worker's last read, so the delivery reads the log again.
try {
const now = this.fold(await log.read());
return { head, error: now.lastLease?.holder === this.holder ? null : new FencedError(`another worker continued session ${this.sessionId}`) };
} catch (readError) {
return { head, error: readError };
}
});
let outcome;
try {
outcome = await this.run(log, claimed, lease, deadline, early?.state ?? null);
} catch (error) {
if ((await claimed).error === null) throw error;
}
const settled = await claimed;
if (settled.error === null) return outcome;
// A claim that failed counts for nothing, whatever its turn did: it
// wrote nothing and showed nothing. A fenced one leaves the delivery
// to read the log and try again; after any other failure the same
// message runs the session again shortly, once a lease that landed is
// let go.
if (isFenced(settled.error)) return "refused";
this.agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(settled.error) });
if (settled.head !== null) await log.append({ k: "release", a: settled.head, holder: this.holder }).catch(() => {});
return "failed";
} finally {
this.backend.holding(this.holder, false);
this.running = false;
}
}
// A resume with nothing to continue still answers its viewer.
async answerIdle(log, message) {
const ui = uiWriter(log, (error) => this.agent.emit("ui.error", { sessionId: this.sessionId, error: error?.name ?? "Error" }));
ui.push({ type: "idle", requestId: message.messageId });
await ui.settle();
}
// Runs turns under the lease `claimed` settles with, `{ head, error }`,
// until the log holds no work, the time runs out, or another worker takes
// over. With `early`, the state the session was read in, the first turn
// starts before the claim lands: every write waits for it, and once it
// failed nothing more is written and the turn stops. The caller answers
// for a claim that failed.
async run(raw, claimed, lease, deadline, early) {
const agent = this.agent;
let head = null;
let failed = null;
let claimFailed = false;
let released = false;
// Whether the claim landed.
const ready = claimed.then((claim) => {
head = claim.head;
if (claim.error === null) return true;
failed = claim.error;
claimFailed = true;
return false;
});
const log = gatedLog(raw, ready, () => failed);
let chain = ready;
// The turns a record this worker stored started, and the open turn's
// cut-offs as this worker's own records leave them.
const started = new Set();
let cut = early?.cut ?? noCutoffs;
// Every chained entry, the harness's records included, continues the
// newest one this worker wrote.
const chained = (entry) => {
if (entry.k === "record") cut = afterRecord(cut, entry.marks);
const appended = chain.then(async () => {
if (failed) throw failed;
try {
head = await log.append({ ...entry, a: head });
for (const mark of entry.marks ?? []) if (typeof mark.start === "string") started.add(mark.start);
return head;
} catch (error) {
failed = error;
throw error;
}
});
chain = appended.catch(() => {});
return appended;
};
// A release after the deadline cut off a turn's step names that turn.
const release = async (cutoff = null) => {
await chain;
if (failed || released) return;
released = true;
await chained({ k: "release", holder: this.holder, ...(cutoff ? { cutoff } : {}) }).catch(() => {});
};
// Why the chain broke: another worker's write fenced this one out, or a
// write failed for another reason, which the same message retries.
const broken = () => {
if (isFenced(failed)) return "fenced";
if (!claimFailed) agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(failed) });
return "failed";
};
let state = early;
if (!state) {
if (!(await ready)) return broken();
state = this.fold(await log.read());
if (state.lastLease?.holder !== this.holder) return "fenced";
cut = state.cut;
}
const ui = uiWriter(log, (error) => agent.emit("ui.error", { sessionId: this.sessionId, error: error?.name ?? "Error" }), lease.epoch);
// When the lease runs out, as this worker last renewed it.
let leaseExpiresAt = lease.expiresAt;
// An image read that threw while the engine opened.
let imageReadFailed = null;
const store = {
load: async () => ({
...(state.checkpoint?.data ? { checkpoint: { through: state.checkpoint.through ?? "0", data: fromBase64(state.checkpoint.data) } } : {}),
journal: state.records.map((record) => ({ cursor: record.cursor, data: fromBase64(record.data) })),
// Until the claim lands, the chain where the worker read it.
head: head ?? state.head,
}),
append: ({ idempotencyKey, data, marks }) => chained({ k: "record", key: idempotencyKey, data: toBase64(data), marks }).then((cursor) => ({ cursor })),
// The engine stores each prompt image before it sends it; a prompt
// from the log names images already here.
[persistenceImages]: {
put: (name, bytes) => log.images.put(name, bytes),
get: async (name) => {
try {
return await log.images.get(name);
} catch (error) {
// The store failed to answer, which says nothing of the image.
imageReadFailed = error;
throw error;
}
},
},
saveCheckpoint: async ({ through, data }) => {
// What a fold that starts at this checkpoint would otherwise miss:
// inputs before it that no turn took or that the open turn holds
// unplaced, the ids it ran, the turn a yield left open, and the lease.
const now = this.fold(await log.read());
// In log order, so a fold from here orders turns as one from the start.
const kept = [...now.unconsumed, ...now.held].filter((input) => input.cursor <= Number(through)).sort((a, b) => a.cursor - b.cursor);
await log.append({
k: "checkpoint",
through,
data: toBase64(data),
pending: kept,
...(now.heldIds.length > 0 ? { held: now.heldIds } : {}),
recent: now.recent,
recentSteers: now.recentSteers,
lastTurnId: now.lastTurnId,
...(now.agent === null ? {} : { agent: now.agent }),
...(now.openTurn ? { openTurn: now.openTurn, yielded: now.yielded } : {}),
lease: { holder: this.holder, epoch: lease.epoch, expiresAt: leaseExpiresAt, pid: lease.pid, host: lease.host, ...(lease.delivery ? { delivery: lease.delivery } : {}) },
});
},
};
// The turn running now, which a deadline or a lost lease cancels.
let turnNow = null;
let renew = null;
if (!heldToDeadline(this.backend, deadline)) {
// A lease nothing else shows alive is renewed while its worker runs,
// and never after its release, which would take the session back. A
// renewal fenced out by another worker's write stops the turn at once,
// as a claim that failed does.
renew = setInterval(() => {
if (released || failed || this.stopping) return;
leaseExpiresAt = leaseEnd(this.backend, deadline);
void chained({ k: "lease", holder: this.holder, epoch: lease.epoch, expiresAt: leaseExpiresAt, ...this.backend.holderInfo(), ...(lease.delivery ? { delivery: lease.delivery } : {}) })
.catch((error) => { if (isFenced(error)) turnNow?.cancel({ reason: "handoff" }); });
}, Math.max(1, Math.floor(this.backend.leaseMs / 3)));
}
// Shortly before the deadline the worker hands the open turn back to the
// log, cutting off whatever call is still running, and the same message
// continues the turn in a new delivery. Half the reserve, at most
// hardStopMs; a reserve of zero never stops a step.
const stopMargin = deadline === null ? 0 : Math.min(hardStopMs, Math.floor(this.backend.reserveMs / 2));
this.stopping = false;
this.stopNow = () => {
if (this.stopping) return;
this.stopping = true;
// Which cut-off in a row of the same step this is.
agent.emit("session.deadline", { sessionId: this.sessionId, epoch: lease.epoch, cutoffs: afterCutoff(cut).cutoffs });
turnNow?.cancel({ reason: "handoff" });
};
// A turn that started under a claim that then failed stops at once and
// stores nothing.
void ready.then((landed) => { if (!landed) turnNow?.cancel({ reason: "handoff" }); });
const hardStop = stopMargin > 0 && deadline - stopMargin > Date.now()
? setTimeout(this.stopNow, deadline - stopMargin - Date.now())
: null;
let harness = null;
let harnessRun = runOf(null);
this.settled = () => (harness ? harness.settled() : Promise.resolve());
// A harness that throws instead of returning a turn cannot run one; the
// same message runs the session again with a new harness.
const unusable = (error) => {
agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(error) });
return "failed";
};
try {
if (state.halted) {
// Even cancelling the open turn stopped its engine, so the session
// can run no more turns. The turn and every prompt waiting end with
// an error, without starting an engine.
const id = state.openTurn.id;
if (!state.failed.has(id)) {
const error = { name: "Error", message: "the engine stopped each time it continued this turn, even to cancel it" };
await log.append({ k: "failed", messageId: id, error });
ui.push({ type: "turn_end", messageId: id, stopReason: "error", usage: {}, error });
}
for (const waiting of state.pending) {
await log.append({ k: "ended", messageId: waiting.messageId });
ui.push({ type: "turn_end", messageId: waiting.messageId, stopReason: "error", usage: {}, error: { name: "Error", message: "the session's open turn cannot continue" } });
}
await ui.settle();
return "done";
}
try {
// What the turn it runs first runs with: the open turn's context and
// settings, or the next prompt's.
harnessRun = runOf(state.openTurn ?? state.pending[0]);
harness = await agent.openHarness(this.sessionId, store, harnessRun, ready);
} catch (error) {
// The image store failed to answer while the engine read the
// session's images: as when the log can't be read, the delivery
// fails and its message runs again; nothing failed the turn.
if (imageReadFailed) throw imageReadFailed;
// A harness that cannot start fails the turn waiting on it, once:
// the open turn, or after it failed, the next prompt.
const waiting = state.openTurn && !state.openFailed ? state.openTurn.id : state.pending[0]?.messageId;
if (waiting) {
const summary = errorSummary(error);
await log.append({ k: "failed", messageId: waiting, error: summary });
ui.push({ type: "turn_end", messageId: waiting, stopReason: "error", usage: {}, error: summary });
}
agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(error) });
return "done";
}
const yieldAt = deadline === null ? undefined : Math.max(1, deadline - this.backend.reserveMs);
for (;;) {
if (failed) return broken();
if (this.stopping) {
await harness.close();
harness = null;
await release();
// A worker replaced without knowing it learns so at the release.
return failed ? broken() : "yielded";
}
const open = harness.openTurn;
let turn = null;
let messageId = null;
let fresh = false;
let spent = false;
if (open?.id) {
// A call left running reruns only when running it twice is safe.
// Any other call is never run again: the model is told it may have
// partly run, and the turn goes on.
messageId = open.id;
ui.push({ type: "turn_resume", messageId, sessionId: this.sessionId });
// It lands before the turn goes on, so any line the replaced
// worker writes after it ranks below it and stays hidden.
await ui.settle();
// A step the deadline cut off maxCutoffs times in a row would be
// cut off forever. A cut-off call is not run again, so the model
// hears it may have partly run; a model request ends the turn,
// cancelled, as a turn whose engine keeps stopping does.
spent = state.openTurn?.id === messageId && state.cutoffs >= maxCutoffs;
try {
turn = harness.resume({ yieldAt, ...(spent ? { rerun: false } : {}) });
} catch (error) {
await log.append({ k: "stopped", messageId });
return unusable(error);
}
}
if (!turn) {
const next = state.pending[0];
if (!next) break;
messageId = next.messageId;
if ((state.stops.get(messageId) ?? 0) >= maxHarnessStops) {
// An engine that stops each time it starts this turn would hold
// the session forever; the turn ends with an error instead.
await log.append({ k: "ended", messageId });
ui.push({ type: "turn_end", messageId, stopReason: "error", usage: {}, error: { name: "Error", message: `the engine stopped each of the ${maxHarnessStops} times it started this turn` } });
await ui.settle();
state = this.fold(await log.read());
continue;
}
if (next.cancelled) {
// Its own cancel came before it started, so it never runs.
await log.append({ k: "ended", messageId });
ui.push({ type: "turn_end", messageId, stopReason: "cancelled", usage: {} });
await ui.settle();
state = this.fold(await log.read());
continue;
}
// Each prompt runs with the context its caller gave and its session
// object's settings.
const wanted = runOf(next);
if (JSON.stringify(wanted) !== JSON.stringify(harnessRun)) {
await harness.close();
harness = null;
harness = await agent.openHarness(this.sessionId, store, wanted, ready);
harnessRun = wanted;
}
// A prompt names the images it stored; the engine gets their bytes.
const withImages = await resolvePromptImages(log.images, next.input);
if (withImages.missing) {
// A history without the image is not the one its caller sent.
await log.append({ k: "ended", messageId });
const message = withImages.corrupt
? `the session's image store holds other bytes under image ${withImages.missing}`
: `the session's image store does not hold image ${withImages.missing}`;
ui.push({ type: "turn_end", messageId, stopReason: "error", usage: {}, error: { name: "Error", message } });
await ui.settle();
state = this.fold(await log.read());
continue;
}
try {
turn = harness.prompt(withImages.input ?? "", { turnId: messageId, yieldAt });
} catch (error) {
await log.append({ k: "stopped", messageId });
return unusable(error);
}
ui.push({ type: "turn_start", messageId, sessionId: this.sessionId, ...(typeof next.input === "string" ? { input: next.input } : {}) });
fresh = true;
}
turnNow = turn;
if (this.stopping || claimFailed) turn.cancel({ reason: "handoff" });
else if (spent && !(open.calls?.length > 0)) turn.cancel();
// A new turn takes the steers sent while it waited to start.
const joining = fresh ? { ...state, steers: state.pending[0]?.joiners ?? [], endOpen: state.pending[0]?.ends === true } : state;
const outcome = await this.drive(log, harness, turn, messageId, joining, ui);
turnNow = null;
if (outcome === "fenced") return "fenced";
if (outcome === "yielded") {
// Commit, checkpoint, release: the next delivery loads the
// checkpoint and the few records after it. A turn handed back at
// the deadline stored nothing more, so it has none to save.
await harness.saveCheckpoint();
await harness.close();
harness = null;
await release(this.stopping ? messageId : null);
return failed ? broken() : "yielded";
}
if (outcome.stopped) {
// Closing the engine waits for the records it still had in flight,
// so the log shows how far the turn got.
await harness.close().catch(() => {});
harness = null;
await chain;
if (failed) return broken();
state = this.fold(await log.read());
if (state.openTurn?.id !== messageId && state.consumed.has(messageId)) {
// Its end landed before its engine stopped, so it ended, though
// how is unknown.
ui.push({ type: "turn_end", messageId, stopReason: "unknown", usage: {} });
await ui.settle();
} else {
await log.append({ k: "stopped", messageId });
}
return unusable(outcome.stopped);
}
const { result } = outcome;
if (fresh && !failed && !started.has(messageId)) {
// A turn that ended before writing a record, such as a prompt the
// harness refused or one a cancel stopped first, is done too. The
// log says so before its viewers hear it ended, so a retry that
// reads the log finds it ended.
state = this.fold(await log.read());
if (!state.consumed.has(messageId)) await log.append({ k: "ended", messageId });
}
// The turn's records have landed, so its viewers hear it ended now;
// the log is read after, for what comes next.
ui.push({ type: "turn_end", messageId, stopReason: result.stopReason, usage: result.usage ?? {}, ...(result.error ? { error: result.error } : {}) });
await ui.settle();
state = this.fold(await log.read());
if (!fresh && !failed && harness.openTurn?.id === messageId) {
// A harness that leaves a turn open after it ended would run it
// again forever; the worker stops instead.
agent.emit("session.error", { sessionId: this.sessionId, error: { name: "Error", message: `turn ${messageId} ended but stayed open` } });
break;
}
if (state.lastLease?.holder !== this.holder && !failed) return "fenced";
}
if (failed) {
await harness.close();
harness = null;
return broken();
}
// The last turn's records and end have landed, so the worker lets go
// before its engine shuts down: a prompt that lands meanwhile starts at
// once, here or in another process, instead of waiting for the
// shutdown. The engine stores nothing more; the agent waits for it on
// close.
await release();
agent.track(harness.close().catch(() => {}));
harness = null;
return failed ? broken() : "done";
} finally {
if (hardStop) clearTimeout(hardStop);
this.stopNow = null;
this.settled = null;
if (renew) clearInterval(renew);
if (harness) await harness.close().catch(() => {});
await ui.settle().catch(() => {});
// A worker that stops on an error still lets the next one in. One
// whose write failed before its release releases outside its broken
// chain, so the retry need not wait out the lease; a fenced one holds
// nothing to release.
if (!failed && !released) await release().catch(() => {});
else if (failed && !isFenced(failed) && !released) {
released = true;
await log.append({ k: "release", a: head, holder: this.holder }).catch(() => {});
}
}
}
// Streams one turn to the session's viewers and applies the steers and
// cancels the log receives while it runs. Returns "fenced", "yielded",
// `{ stopped }` when its harness stopped under it, or `{ result }` for a
// turn that ended, whose end the caller writes.
async drive(log, harness, turn, messageId, state, ui) {
// The inputs `state` was folded from are placed already: the fold gave
// each a turn, joined it to one, or spent it. The poll applies only the
// inputs stored after them. After an early start, that includes this
// message's own input, landing past `since`.
const applied = new Set(state.inputKeys);
let since = String(state.maxCursor);
let polling = false;
let again = false;
let stopped = false;
// A text steer joins the turn. Any other ends it at its next safe point
// and, never taken, runs as the next turn, as the fx CLI hands off; the
// steers after that one join its turn.
let ending = false;
const endTurn = () => {
if (ending) return;
ending = true;
void Promise.resolve().then(() => turn.endForSteer?.()).catch(() => {});
};
const steerTurn = (steer) => {
// One id names one input, so a steer with this turn's id is its own
// opener or one that reused the id, which the log never runs.
if (steer.messageId === messageId) return;
if (!joinable(steer.input)) endTurn();
else if (!ending) void turn.steer(steer.input, steer.messageId).catch(() => {});
};
const apply = async () => {
if (stopped) return;
if (polling) { again = true; return; }
polling = true;
try {
do {
again = false;
const entries = await log.readSince(since);
for (const { cursor, entry } of entries) {
if (Number(cursor) > Number(since)) since = cursor;
if (entry?.k !== "input" || applied.has(entry.key)) continue;
applied.add(entry.key);
if (entry.type === "steer") steerTurn(entry);
else if (entry.type === "cancel" && (entry.target === undefined || entry.target === messageId)) turn.cancel();
}
} while (again && !stopped);
} catch {} finally {
polling = false;
}
};
for (const steer of state.steers) {
applied.add(steer.key);
steerTurn(steer);
}
if (state.endOpen) endTurn();
// An open turn whose engine kept stopping is cancelled the way a user
// would, so the prompts behind it can run.
if (state.cancelOpen || (state.stuck && state.openTurn?.id === messageId)) turn.cancel();
const unwatch = log.watch?.(() => { void apply(); });
const timer = this.backend.pollMs ? setInterval(() => { void apply(); }, this.backend.pollMs) : null;
void apply();
let result;
try {
for await (const event of turn) ui.push({ ...event, messageId });
result = await turn.result;
} catch (error) {
if (isFenced(error)) return "fenced";
// The harness stopped under the turn; the next delivery continues it
// with a new one.
if (error?.code === "FX_HARNESS_STOPPED") return { stopped: error };
result = { stopReason: "error", error: errorSummary(error) };
} finally {
stopped = true;
unwatch?.();
if (timer) clearInterval(timer);
}
// Viewers hear a turn ended only once the log says so too, so a retry
// that reads the log finds it ended.
try {
await harness.settled();
} catch (error) {
if (isFenced(error)) return "fenced";
throw error;
}
// A turn handed back at the deadline continues in the next delivery,
// like one that yielded.
if (result.stopReason === "yielded" || (this.stopping && result.stopReason === "cancelled")) {
ui.push({ type: "turn_yield", messageId });
await ui.settle();
return "yielded";
}
return { result };
}
}
// ---------------------------------------------------------------------------
// The public API.
const internalEvent = new Set(["turn_start", "turn_resume", "turn_yield", "turn_end", "idle"]);
// Which UI lines a reader shows: each line no earlier line outranks. A
// replaced worker's lines carry its older lease epoch, so once its successor
// has written, they stay hidden. The same stored stream always shows the
// same lines, from any server and after any refresh.
function uiRanker(high = 0) {
return (event) => {
if (!Number.isSafeInteger(event?.epoch)) return true;
if (event.epoch < high) return false;
high = event.epoch;
return true;
};
}
// The ranker as it stands after the first `count` lines of the stream.
async function uiRankerAt(log, count) {
const shows = uiRanker();
if (count === 0) return shows;
const reader = log.ui.read(0).getReader();
let buffered = "";
let seen = 0;
try {
while (seen < count) {
const { value, done } = await reader.read();
if (done) break;
buffered += decoder.decode(value, { stream: true });
for (let index = buffered.indexOf("\n"); index >= 0 && seen < count; index = buffered.indexOf("\n")) {
const line = buffered.slice(0, index);
buffered = buffered.slice(index + 1);
seen += 1;
try { shows(JSON.parse(line)); } catch {}
}
}
} finally {
void reader.cancel().catch(() => {});
}
return shows;
}
// What a retried `messageId` finds in its session: its turn still waiting or
// running (`follow`), no trace of it (`fresh`), a turn it already ran,
// settled from that turn's end on the stream before `from` or from the
// log's record of its failure, or `unsure` when the log says it ran but
// neither shows how. Once this call has `sent` the message, a recorded
// failure may be this send's own run, so it is not called a repeat.
async function retryOutcome(log, state, messageId, from, { sent }) {
const waiting = state.inputKeys.has(`prompt:${messageId}`) && !state.consumed.has(messageId);
if ((state.openTurn?.id === messageId && !state.failed.has(messageId)) || waiting) return { follow: true };
if (!state.consumed.has(messageId)) return { fresh: true };
const ended = await turnEndIn(log, messageId, from);
if (ended) return { settled: { ...ended, repeated: true } };
const error = state.failed.get(messageId);
if (error) return { settled: { messageId, stopReason: "error", error, ...(sent ? {} : { repeated: true }) } };
return { unsure: true };
}
// The end a turn's worker wrote, among the first `count` lines of the
// stream, as a reader shows them; null when none did.
async function turnEndIn(log, messageId, count) {
if (count === 0) return null;
const shows = uiRanker();
const reader = log.ui.read(0).getReader();
let buffered = "";
let seen = 0;
let found = null;
try {
while (seen < count) {
const { value, done } = await reader.read();
if (done) break;
buffered += decoder.decode(value, { stream: true });
for (let index = buffered.indexOf("\n"); index >= 0 && seen < count; index = buffered.indexOf("\n")) {
const line = buffered.slice(0, index);
buffered = buffered.slice(index + 1);
seen += 1;
let event;
try { event = JSON.parse(line); } catch { continue; }
if (shows(event) && event.type === "turn_end" && event.messageId === messageId) found = event;
}
}
} finally {
void reader.cancel().catch(() => {});
}
if (!found) return null;
const { type: _type, ...rest } = found;
return rest;
}
// How long a retried turn waits for an end the log already holds.
const retryEndWaitMs = 30_000;
// One turn as its viewer sees it: the session stream from `from` on, its
// events only, until its end. `start` queues the turn at once and returns
// the stream; a resume learns its turn from the first one the stream shows.
// The stream is read only once something reads the view, its events,
// `readable` or `result`, so a prompt nobody follows holds no read open.
function turnView({ messageId, resumeRequest = null, start }) {
// A retried turn reports the outcome of the turn its id already ran.
let repeated = false;
const events = [];
const waiters = [];
let done = false;
let failure = null;
let id = resumeRequest ? null : messageId;
let settle;
const result = new Promise((resolve, reject) => { settle = { resolve, reject }; });
void result.catch(() => {});
const wake = () => { for (const waiter of waiters.splice(0)) waiter(); };
const finish = (value, error) => {
if (done) return;
done = true;
if (error) {
failure = error;
settle.reject(error);
} else settle.resolve(repeated ? { ...value, repeated: true } : value);
wake();
};
const started = start();
void started.catch(() => {});
let following = false;
const follow = () => {
if (following) return;
following = true;
void read();
};
const read = async () => {
try {
const { log, from, settled, attaching, endWithinMs, unsure } = await started;
// A turn whose end this view finds after its own start may be the
// message's first run, so only an end found before it is a repeat.
repeated = settled?.repeated === true || (endWithinMs !== undefined && !unsure);
if (settled) {
// A turn that already ended still tells a viewer how, so a route
// returning `readable` sends its outcome.
const { messageId: _id, ...rest } = settled;
events.push({ type: "turn_end", messageId, ...rest });
return finish(settled);
}
// A view of a turn already running ranks from what came before it;
// a new turn's takeovers all come after its own start.
const shows = attaching ? await uiRankerAt(log, from) : uiRanker();
const reader = log.ui.read(from).getReader();
// A turn whose end the log holds but no line shows yet: a worker that
// stopped between the two never writes it, so the outcome is unknown.
let gaveUp = null;
if (endWithinMs !== undefined) {
gaveUp = setTimeout(() => {
void reader.cancel().catch(() => {});
finish({ messageId, stopReason: "unknown", usage: {} });
}, endWithinMs);
}
let buffered = "";
let cursor = from;
for (;;) {
const { value, done: ended } = await reader.read();
if (ended) break;
buffered += decoder.decode(value, { stream: true });
for (let index = buffered.indexOf("\n"); index >= 0; index = buffered.indexOf("\n")) {
const line = buffered.slice(0, index);
buffered = buffered.slice(index + 1);
cursor += 1;
let event;
try { event = JSON.parse(line); } catch { continue; }
if (!shows(event)) continue;
if (id === null) {
if (event.type === "idle" && event.requestId === resumeRequest) {
void reader.cancel().catch(() => {});
return finish({ stopReason: "idle", usage: {} });
}
if (event.type !== "turn_resume" && event.type !== "turn_start") continue;
id = event.messageId;
}
if (event.messageId !== id) continue;
events.push({ ...event, cursor });
wake();
if (event.type === "turn_end") {
if (gaveUp) clearTimeout(gaveUp);
void reader.cancel().catch(() => {});
const { type: _type, messageId: _id, ...rest } = event;
return finish({ messageId: id, ...rest });
}
}
}
if (gaveUp) clearTimeout(gaveUp);
finish(null, new Error("the session stream ended before the turn did"));
} catch (error) {
finish(null, error);
}
};
async function* iterate(raw) {
for (let index = 0; ; index += 1) {
while (index >= events.length) {
if (done) {
if (failure) throw failure;
return;
}
await new Promise((resolve) => waiters.push(resolve));
}
const event = events[index];
if (raw) yield event;
else if (!internalEvent.has(event.type)) {
const { messageId: _id, cursor: _cursor, ...rest } = event;
yield rest;
}
}
}
return {
get messageId() { return id; },
get result() {
follow();
return result;
},
// Dropping a turn cancels nothing; `session.cancel()` does.
[Symbol.asyncIterator]() {
follow();
return iterate(false);
},
// Each line is one event with the cursor `stream()` resumes after it.
get readable() {
follow();
const iterator = iterate(true);
return new ReadableStream({
async pull(controller) {
try {
const { value, done: ended } = await iterator.next();
if (ended) controller.close();
else controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`));
} catch (error) {
controller.error(error);
}
},
cancel() { void iterator.return?.(); },
});
},
};
}
/**
* Builds a durable agent over a harness: the agent loop that runs a turn.
* The core owns the session's log, its lease, the queue, deadlines and the
* UI stream; the harness owns turns and what its records mean.
*
* `harness(options)` receives the caller's options without `durability` and
* returns:
*
* - `open({ sessionId, store, context, settings, durability, ready })`: the
* harness session for one worker, for turns with that `context` and those
* `settings`. `store` is where it keeps the session: `load()` resolves
* `{ checkpoint?, journal, head }`, `append({ idempotencyKey, data, marks })`
* stores one opaque record durably and resolves `{ cursor }`, and
* `saveCheckpoint({ through, data })` stores what the records through
* `through` add up to. Each record's `marks` tell the core where turns
* start (`{ start: turnId }`), yield (`{ yield: true }`) and end
* (`{ end: true }`), which inputs a turn took (`{ accepted: id }`), when
* its model request first carries one (`{ placed: id }`), and which it
* ended without placing (`{ withdrawn: id }`), which wait for a turn again.
* A write another worker's write fenced out rejects with a `FX_FENCED`
* code. `ready`, when given, resolves true once the worker's claim on the
* session lands and false when it failed: the core may start a turn
* before then, and the harness runs no tool until it resolves true. The
* core holds the store's writes and the turn's UI lines meanwhile.
* - `seed` (optional): the bytes of a checkpoint a new session starts from.
* - `settings(sessionOptions)` (optional): what an `agent.session()` call
* sets over the agent's options for the turns it starts, as JSON the core
* stores with each prompt and passes to `open()`, or undefined for none.
* It throws a TypeError for options it refuses.
* - `checkPrompt(input)` (optional): throws a TypeError or RangeError for
* a prompt input the harness would refuse. A session's `prompt()` calls it
* before it stores anything, so a prompt it refuses is never stored.
*
* A harness session has `prompt(input, { turnId, yieldAt })` and
* `resume({ yieldAt, rerun })`, each returning a turn, where `rerun: false`
* runs no call left running again; `openTurn`, the turn its records left
* open as `{ id, calls? }`, with the calls it left running, or null;
* `settled()`, which resolves once
* every record it started is stored; `saveCheckpoint()`;
* `exportCheckpoint()`, the conversation as bytes; and `close()`. A turn is
* an async iterable of the events its viewers see, with `result` resolving
* `{ stopReason, usage? }`, `steer(text, id)`, and `cancel(options)`, where
* `{ reason: "handoff" }` stops it at once and stores nothing more, so the
* next worker continues it. A turn may have `endForSteer()`, which ends it
* at its next safe point, at once while its model streams a reply, for a
* steer that cannot join it; without it, such a steer waits for the turn to
* end. Either way the steer runs as the next turn. A turn stops before a
* model request once `yieldAt` has passed, with the stop reason `yielded`.
*
* An input the harness refuses only once a turn runs it is a turn that ends
* with an error.
* `prompt()` and `resume()` throw only when the harness cannot run a turn,
* and a turn whose harness stopped under it rejects with a
* `FX_HARNESS_STOPPED` code; either way the same message runs the session
* again with a new harness. After 3 stops a turn is resumed and cancelled at
* once, or ends with an error when it never started; when its harness stops
* twice more, even to cancel it, the session runs no more turns.
*
* `defaultDurability()` picks the durability when the caller names none, and
* `defaultShared` says it hands every such agent the same one, as Node's
* does, so that agents created without a durability share a store. `name` is
* the factory's name in errors, and `label` the agent's.
*/
export function createDurableAgentFactory({ harness, defaultDurability, defaultShared = false, name = "createAgent", label = "agent" }) {
return function createDurableAgent(options = {}) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError(`${name}() options must be an object`);
}
const { durability, name: agentName = defaultAgentName, ...harnessOptions } = options;
if (durability !== undefined && !isDurability(durability)) {
throw new TypeError("durability must come from memory(), local(), vercel() or world()");
}
if (typeof agentName !== "string" || !agentNameRule.test(agentName)) {
throw new TypeError(`${name}() name must be 1 to 64 letters, digits, ".", "_" or "-", starting with a letter or digit`);
}
const plugged = harness(harnessOptions);
const seed = plugged.seed === undefined ? undefined : toBase64(plugged.seed);
const emit = (type, detail = {}) => {
try { options.onEvent?.({ type, timestamp: performance.now(), ...detail }); } catch {}
};
const agent = {
name: agentName,
emit,
// Work that outlives the delivery that started it, which close() waits
// for: an engine that shuts down after its worker let go.
track(work) {
active.add(work);
void work.finally(() => active.delete(work));
},
openHarness: (sessionId, store, run, ready) => plugged.open({
sessionId,
store,
context: run?.context ?? null,
...(run?.settings == null ? {} : { settings: run.settings }),
durability: chosenDurability,
...(ready ? { ready } : {}),
}),
};
let backendPromise = null;
let durabilityPromise = null;
let chosenDurability = null;
let unlisten = null;
let closed = false;
const workers = new Map();
const active = new Set();
const backend = () => {
if (closed) return Promise.reject(new Error(`${label} is closed`));
backendPromise ??= (async () => {
const chosen = await durabilityOf();
// An agent created without a durability learns its store only now.
if (member.store !== storeOf(chosen)) {
member.store = storeOf(chosen);
try {
claimName(agentName, member);
} catch (error) {
leaveGroup(agentsByName, agentName, registered, member);
throw error;
}
}
chosenDurability = chosen;
const created = await createBackend(chosen);
await created.start?.();
unlisten = created.listen((delivered, meta) => {
// Only which input a delivery names is read from it: the rest of
// its body, which a forged delivery would choose, is ignored.
const message = deliveredMessage(created, delivered);
if (!message) {
emit("session.refused", { reason: "malformed message" });
return undefined;
}
return receive(created, message, deliveryOf(meta));
}, agentName);
return created;
})();
return backendPromise;
};
// The durability the agent's sessions live in, chosen once.
const durabilityOf = () => (durabilityPromise ??= durability ? Promise.resolve(durability) : defaultDurability());
// In the process's agents from now, so a route that receives a message
// for this agent finds it before anything uses it. One created without a
// durability claims the default's store until it knows which that is:
// the store every such agent shares where the factory hands them all one
// durability, and a store of its own where each gets its own.
const unresolved = defaultShared ? defaultDurability : Symbol("default store");
const member = { open: backend, durability: durabilityOf, definition: definitionOf(harnessOptions), store: durability ? storeOf(durability) : unresolved, defaults: durability ? null : unresolved };
claimName(agentName, member);
const registered = joinGroup(agentsByName, agentName, member);
// One delivery of `message` to the session's worker here: from the
// queue, with `delivery` naming the queue's message and attempt, or with
// `inRequest` from the request that took the prompt.
const receive = (created, message, delivery, { inRequest = false, handoff = null } = {}) => {
let worker = workers.get(message.sessionId);
if (!worker) {
worker = new SessionWorker(agent, created, message.sessionId);
workers.set(message.sessionId, worker);
}
worker.deliveries += 1;
let work = worker.consume(message, delivery, handoff);
if (inRequest) {
// No queue delivered this message, so none delivers it again: a turn
// that must go on later continues in a fresh message. One whose
// write failed is left to the backstop, which takes its lease over.
work = work.then(async (result) => {
if (Number.isFinite(result?.timeoutSeconds)) {
await created.queue.send(message, {
idempotencyKey: `${message.sessionId}:${message.type}:${message.messageId}:${newId("again")}`,
...(result.timeoutSeconds > 0 ? { delaySeconds: result.timeoutSeconds } : {}),
});
}
return result;
});
}
active.add(work);
void work.catch((error) => {
// Its queue delivers the message again; the host hears why.
emit("session.error", { sessionId: message.sessionId, error: errorSummary(error) });
}).finally(() => {
active.delete(work);
worker.deliveries -= 1;
// A worker lives while a delivery runs it.
if (worker.deliveries === 0 && !worker.running && workers.get(message.sessionId) === worker) workers.delete(message.sessionId);
});
return work;
};
const openSession = (requestedId, sessionOptions = {}) => {
if (requestedId !== undefined && !validId(requestedId)) throw new TypeError(`session id must be ${idRule}`);
let context;
try { context = jsonSnapshot(sessionOptions?.context); } catch { throw new TypeError("session context must be JSON"); }
const settings = jsonSnapshot(plugged.settings?.(sessionOptions ?? {}));
// What every input this session object sends carries besides itself.
const carried = jsonBytes(context) + jsonBytes(settings);
// Refuses an input too large to store before anything is stored
// (`tests/tla/sdk/TenantIsolation.tla` StoredWithinLimit).
const admit = (input) => {
const bytes = jsonBytes(input) + carried;
if (bytes > maxInputBytes) throw inputTooLarge(bytes);
};
let id = requestedId ?? null;
let seeded = seed === undefined || requestedId !== undefined;
const resolved = backend().then((created) => {
id ??= created.newSessionId();
if (!created.validSessionId(id)) throw new TypeError(`${created.name} durability cannot hold session id ${JSON.stringify(id)}`);
return { created, id };
});
void resolved.catch(() => {});
// With `inRequest`, a prompt runs here, in the request that took it,
// where the durability allows (see `runInRequest`).
const send = async (message, { inRequest = false } = {}) => {
const { created, id: sessionId } = await resolved;
const extra = {
...(seeded ? {} : { seed }),
...(context === undefined ? {} : { context }),
...(settings === undefined ? {} : { settings }),
};
const full = { ...message, ...extra, agent: agentName, sessionId };
// The input and its caller's context reach the log here, on the
// app's own call, before the caller hears it was accepted. The queue
// carries only which input to run, so a delivery cannot choose what
// a turn runs or as whom (`tests/tla/sdk/TenantIsolation.tla`
// OnlySentRuns), and a queue that ends with the process loses no
// input.
const log = await created.session(sessionId);
const read = await log.read();
const newestSeen = log.newest?.();
const appended = await recordMessage(log, foldSessionLog(read), full);
const queued = queuedMessage(full, created.store);
const idempotencyKey = `${sessionId}:${message.type}:${message.messageId}`;
if (inRequest && created.runInRequest) {
await created.refreshDeadline?.();
const deadline = deliveryDeadline(created);
if (deadline !== null) {
// This request runs the turn, and the queue keeps the message as
// a backstop it delivers only after the request's deadline, by
// when the request has stopped. The lease names the backstop, so
// if the request died, the backstop takes the session over and
// continues the turn.
const delaySeconds = Math.ceil(Math.max(0, deadline - Date.now()) / 1000) + inlineBackstopMarginSeconds;
const { messageId: backstop } = await created.queue.send(queued, { idempotencyKey, delaySeconds }) ?? {};
seeded = true;
const delivery = backstop ? { messageId: backstop, attempt: 0 } : null;
// receive() reports a failure to the host; the backstop retries it.
const entries = handoffEntries(read, newestSeen, appended);
const handoff = entries ? { log, entries } : null;
const work = receive(created, queued, delivery, { inRequest: true, handoff }).then(() => {}, () => {});
created.waitUntil?.(work);
return;
}
}
await created.queue.send(queued, { idempotencyKey });
seeded = true;
};
// Settles once the latest prompt from this session object with images
// to store is stored or has failed; null while none has. Each later
// prompt waits for it, so they are stored in the order made.
let storing = null;
// `ready`, when given, resolves the input once it can be stored; only a
// prompt that is stored calls it.
// `aborted()` says whether the prompt's signal has aborted. One that
// aborted before the prompt's send began never stores it; `progress.sent`
// is set as the send begins, after which an abort cancels the prompt.
const startTurn = (type, messageId, input, retried = false, aborted = () => false, ready = null, progress = null) => {
let accept;
const accepted = new Promise((resolve, reject) => { accept = { resolve, reject }; });
void accepted.catch(() => {});
const view = turnView({
messageId,
resumeRequest: type === "resume" ? messageId : null,
start: async () => {
try {
const read = input === undefined || ready === null || aborted() ? input : await ready();
const message = { type, messageId, ...(input === undefined ? {} : { input: read }) };
const queue = () => {
if (progress) progress.sent = true;
return send(message, { inRequest: type === "prompt" });
};
const { created, id: sessionId } = await resolved;
const log = await created.session(sessionId);
let attaching = type === "resume";
let from = null;
// The viewer's start is read before the message is queued, so
// every line this message's own turn writes comes after it, and
// an end for this id before it can only be from a turn this id
// already ran.
if (retried) from = await log.ui.length();
if (retried && !aborted()) {
// The log, for that answer, is read alongside the one queue
// send.
const [state] = await Promise.all([
log.read().then(foldSessionLog),
queue().then(() => accept.resolve({ messageId, sessionId })),
]);
// One the log does not know yet is this send's own. When the
// log says it ran and nothing shows how, either this send's
// own turn already ended, its end after `from`, or a turn
// this id ran before never wrote its end.
const outcome = await retryOutcome(log, state, messageId, from, { sent: true });
if (outcome.settled) return { settled: outcome.settled };
if (outcome.unsure) return { log, from, attaching: true, endWithinMs: retryEndWaitMs, unsure: true };
return { log, from, attaching: outcome.follow === true };
}
if (retried) {
// A turn this id already ran answers from the log, without
// queueing it again. The viewer's start was read first, so a
// turn that ends after it has its end in view.
const outcome = await retryOutcome(log, foldSessionLog(await log.read()), messageId, from, { sent: false });
if (outcome.follow) attaching = true;
else if (!outcome.fresh) {
accept.resolve({ messageId, sessionId });
if (outcome.settled) return { settled: outcome.settled };
return { log, from, attaching: true, endWithinMs: retryEndWaitMs };
}
}
// The viewer starts where the stream is now, before the
// message can produce anything.
from ??= await log.ui.length();
if (aborted()) {
// A prompt whose signal aborted before its send began is never
// stored; a retry of one the log holds only follows its turn.
accept.resolve({ messageId, sessionId });
if (!attaching) return { settled: { messageId, stopReason: "cancelled", usage: {} } };
return { log, from, attaching };
}
await queue();
accept.resolve({ messageId, sessionId });
return { log, from, attaching };
} catch (error) {
accept.reject(error);
throw error;
}
},
});
return Object.assign(view, { accepted });
};
const session = {
get id() { return id; },
/**
* Queues `input` as the session's next turn and returns a view of it.
* With `messageId`, a retried call is the same turn: it continues a
* turn still running and answers one that ended, without running it
* again. Each image is stored once in the session's image store and
* the log names it. A later prompt from this session object waits for
* an earlier one's images. An input the harness would refuse throws
* here and is never stored. A prompt whose signal aborts before it is
* stored is never stored either: it ends cancelled.
*/
prompt(input, promptOptions = {}) {
if (closed) throw new Error(`${label} is closed`);
const messageId = promptOptions.messageId ?? newId("msg");
if (!validId(messageId)) throw new TypeError(`messageId must be ${idRule}`);
if (typeof input !== "string" && !Array.isArray(input)) throw new TypeError("prompt input must be a string or an array of content blocks");
const signal = promptOptions.signal;
if (signal !== undefined && typeof signal?.addEventListener !== "function") throw new TypeError("prompt signal must be an AbortSignal");
const aborted = signal?.aborted === true;
const retried = promptOptions.messageId !== undefined;
// The input travels through the queue and the log as JSON. Its
// images go to the session's image store first and the log names
// them; a prompt aborted before the call stores nothing, so it
// keeps them as base64.
const named = !aborted;
const stored = Array.isArray(input) ? promptInputForLog(input, { named }) : { input, images: false };
// The copy the log will hold is what is checked: a getter can't
// show the check one value and the log another.
admit(stored.input);
plugged.checkPrompt?.(stored.input);
const before = storing;
const ready = before || stored.images ? async () => {
// The prompt waits for an earlier one and its image writes only
// until its signal aborts; what it waited for may still land, but
// the prompt is not stored.
let release = () => {};
const abortion = signal === undefined ? null : new Promise((resolve) => {
signal.addEventListener("abort", resolve, { once: true });
release = () => signal.removeEventListener("abort", resolve);
});
const unlessAborted = (promise) => {
if (abortion === null) return promise;
void promise.catch(() => {});
return Promise.race([promise, abortion]);
};
try {
await unlessAborted(before ?? Promise.resolve());
if (signal?.aborted) return null;
let read = stored.input;
if (stored.images) {
const { created, id: sessionId } = await resolved;
const log = await created.session(sessionId);
// A retry of a prompt the log already holds is answered from
// the log: its images are named, not written again.
const known = retried && knownMessage(foldSessionLog(await log.read()), { type: "prompt", messageId });
if (signal?.aborted) return null;
read = await unlessAborted(storePromptImages(known ? null : log.images, read));
if (signal?.aborted) return null;
}
// An agent closed meanwhile stores nothing more.
if (closed) throw new Error(`${label} is closed`);
return read;
} finally {
release();
}
} : null;
const progress = { sent: false };
const turn = startTurn("prompt", messageId, stored.input, retried, () => signal?.aborted === true, ready, progress);
if (stored.images && !aborted) {
// A later prompt waits for this one and every one before it: one
// withdrawn while it waited settles before them.
const settled = Promise.all([before, turn.accepted.then(() => {}, () => {})]).then(() => {});
storing = settled;
void settled.then(() => { if (storing === settled) storing = null; });
}
if (signal !== undefined && !signal.aborted) {
// The prompt's own cancel: it stops this turn whether it runs or
// still waits, and never a later one. A prompt whose send had not
// begun is never stored, so it needs none.
const stop = () => {
if (progress.sent) void send({ type: "cancel", messageId: newId("cnl"), target: messageId }).catch(() => {});
};
signal.addEventListener("abort", stop, { once: true });
void turn.result.finally(() => signal.removeEventListener("abort", stop)).catch(() => {});
}
return turn;
},
/** Continues the turn a stopped worker left open, if any. */
resume() {
if (closed) throw new Error(`${label} is closed`);
return startTurn("resume", newId("res"));
},
/**
* The session's events after `cursor`, as NDJSON, from any server.
* A cursor is one a line carried, and a line shows only when no line
* before it outranks it, so the epoch of the line a cursor names is
* the rank every line after it is held to: the read starts there, not
* at the stream's start, and lines rank the same however a reader
* reconnects. After a line with no epoch it reads from the start.
*/
stream(cursor = 0) {
if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TypeError("stream cursor must be a non-negative integer");
let reader = null;
let buffered = "";
let next = 0;
let shows = uiRanker();
// Opens the read: at the cursor's own line, which sets the rank,
// or from the start when it carries none.
const open = async (ui) => {
if (cursor === 0) return ui.read(0).getReader();
const at = ui.read(cursor - 1).getReader();
let first = "";
for (;;) {
const index = first.indexOf("\n");
if (index >= 0) {
let epoch = null;
try { epoch = JSON.parse(first.slice(0, index))?.epoch; } catch {}
if (!Number.isSafeInteger(epoch)) break;
shows = uiRanker(epoch);
buffered = first.slice(index + 1);
next = cursor;
return at;
}
const { value, done } = await at.read();
if (done) return at;
first += decoder.decode(value, { stream: true });
}
void at.cancel().catch(() => {});
return ui.read(0).getReader();
};
return new ReadableStream({
async pull(controller) {
if (!reader) {
const { created, id: sessionId } = await resolved;
reader = await open((await created.session(sessionId)).ui);
}
for (;;) {
const index = buffered.indexOf("\n");
if (index >= 0) {
const line = buffered.slice(0, index);
buffered = buffered.slice(index + 1);
next += 1;
let event;
try { event = JSON.parse(line); } catch { continue; }
if (!shows(event) || next <= cursor) continue;
controller.enqueue(encoder.encode(`${JSON.stringify({ ...event, cursor: next })}\n`));
return;
}
const { value, done } = await reader.read();
if (done) {
controller.close();
return;
}
buffered += decoder.decode(value, { stream: true });
}
},
cancel() { void reader?.cancel().catch(() => {}); },
});
},
/**
* Guidance for the running turn, or the next turn when none runs: it
* cuts a streaming reply short, waits for a running tool, and joins a
* turn waiting to start. A steer with images or resources cannot
* join a turn: the running one ends at its next safe point and the
* steer runs next. With `messageId`, a retried call adds nothing;
* one id names one input of the session, prompt or steer: reusing a
* prompt's id throws `FX_MESSAGE_ID_IN_USE`, or, where the queue
* outlives the process and the sender reads no log, the worker drops
* the steer and emits a `session.error` with that code. Resolves once
* the steer is stored, with its id, which the stream's events for it
* carry.
*/
async steer(input, steerOptions = {}) {
if (typeof input === "string" ? input.length === 0 : !Array.isArray(input) || input.length === 0) {
throw new TypeError("steer input must be a non-empty string or array of content blocks");
}
if (Array.isArray(input) && input.some((block) => block?.type === "image" && block.data !== undefined && typeof block.data !== "string")) {
throw new TypeError("a durable steer carries image data as a base64 string");
}
if (steerOptions === null || typeof steerOptions !== "object") throw new TypeError("steer options must be an object");
const messageId = steerOptions.messageId ?? newId("str");
if (!validId(messageId)) throw new TypeError(`messageId must be ${idRule}`);
admit(input);
// A steer's images go in the session's image store before the steer
// that names them, as a prompt's do; storing one again under its
// name is harmless, so a retry needs no check.
let named = input;
if (Array.isArray(input) && input.some((block) => block?.type === "image" && typeof block.data === "string")) {
const { created, id: sessionId } = await resolved;
named = await storePromptImages((await created.session(sessionId)).images, input);
if (closed) throw new Error(`${label} is closed`);
}
await send({ type: "steer", messageId, input: named });
return { messageId };
},
/** Cancels the running turn; dropping a turn's view does not. */
async cancel() {
await send({ type: "cancel", messageId: newId("cnl") });
},
/** The session's state as opaque bytes, read without changing it. */
async checkpoint() {
const { created, id: sessionId } = await resolved;
const log = await created.session(sessionId);
const state = foldSessionLog(await log.read());
const readOnly = {
load: async () => ({
...(state.checkpoint?.data ? { checkpoint: { through: state.checkpoint.through ?? "0", data: fromBase64(state.checkpoint.data) } } : {}),
journal: state.records.map((record) => ({ cursor: record.cursor, data: fromBase64(record.data) })),
head: state.head,
}),
append: async () => { throw new Error("a checkpoint reads the session without changing it"); },
// The records name the session's images; the engine reads them
// from its image store to export them.
[persistenceImages]: {
get: (name) => log.images.get(name),
put: async () => { throw new Error("a checkpoint reads the session without changing it"); },
},
};
const harness = await agent.openHarness(sessionId, readOnly, null);
try {
return await harness.exportCheckpoint();
} finally {
await harness.close().catch(() => {});
}
},
};
return session;
};
let defaultSession = null;
const durableAgent = {
// For tests and drivers: stops a session's running worker as its
// deadline would.
[durableInternals]: {
stopAtDeadline: (sessionId) => workers.get(sessionId)?.stopNow?.(),
// Waits for what a session's running turn has written so far.
settled: async (sessionId) => { await workers.get(sessionId)?.settled?.(); },
// Delivers a queue message to this agent as its queue would.
deliver: async (delivered, delivery) => {
const created = await backend();
const message = deliveredMessage(created, delivered);
return message ? receive(created, message, delivery) : undefined;
},
liveWorkers: () => workers.size,
// A session's log as one worker holds it, with its own view of the
// chain.
openLog: async (sessionId) => (await backend()).session(sessionId),
// The newest lease the session's log holds, standing or not.
lastLease: async (sessionId) => {
const log = await (await backend()).session(sessionId);
return foldSessionLog(await log.read()).lastLease;
},
// Whether a lease stands on the session now, as a worker reading it
// would judge: unexpired, and not held by a worker known dead.
leaseStands: async (sessionId) => {
const held = await backend();
const log = await held.session(sessionId);
return foldSessionLog(await log.read(), { now: Date.now(), alive: (lease) => held.alive(lease) }).lease !== null;
},
},
/** Opens session `id`, or a new one; no I/O until it is used. */
session(id, sessionOptions) {
if (closed) throw new Error(`${label} is closed`);
// A session object holds nothing a later call needs, so each call
// gets its own, with its own context. Only a missing id starts a
// new session; `null` is as invalid as any other non-id.
return openSession(id, sessionOptions);
},
/** An id for a new session, before its first prompt. */
async newSessionId() {
return (await backend()).newSessionId();
},
/** The agent's name: its sessions' turns run only on agents with this name. */
get name() {
return agentName;
},
/** The agent's own session, for code that holds one conversation. */
get sessionId() {
return defaultSession?.id ?? null;
},
prompt(input, promptOptions) {
defaultSession ??= openSession(undefined);
return defaultSession.prompt(input, promptOptions);
},
checkpoint() {
defaultSession ??= openSession(undefined);
return defaultSession.checkpoint();
},
/**
* The request handler a deployment mounts for queue deliveries, until
* its World can deliver to this process itself.
*/
wakeHandler() {
return async (request) => {
const created = await backend();
if (typeof created.queueHandler !== "function") return new Response("this durability delivers in process", { status: 404 });
return created.queueHandler(request);
};
},
/** Waits for the work this process is running, then lets go. */
async close() {
if (closed) return;
closed = true;
leaveGroup(agentsByName, agentName, registered, member);
while (active.size > 0) await Promise.allSettled([...active]);
if (backendPromise) {
const created = await backendPromise.catch(() => null);
unlisten?.();
await created?.close?.();
}
},
};
return durableAgent;
};
}