Make libfx sessions durable

- `createFxAgent()` returns the agent at once instead of a promise, and does no I/O until a session runs a turn.
- `agent.session(id)` opens a session, and `session.prompt()` returns a turn view with `accepted`, `result`, async iteration over its events, and an NDJSON `readable`.
- Sessions are durable by default: `vercel()` on Vercel, `local()` files in Node.js elsewhere, and `memory()` in browsers. The `durability` option picks one.
- `libfx/durable-local` and `libfx/durable-vercel` bundle their World, so an app installs no other package.
- A turn whose process crashes, redeploys, or reaches its function deadline continues from its last record in the next process that gets work for the session.
- On Vercel, a turn stops at its next model request 30 seconds before the function deadline and continues in a new invocation from the same queue message.
- A model request or tool call still running 2 seconds before the deadline is cut off, and `onEvent` receives `session.deadline`.
- One process runs a session's turns at a time, and a prompt for a busy session queues behind the running turn.
- A process that froze and was replaced stops at its next write and never starts a tool with effects, and `onEvent` receives `session.fenced`.
- After an interruption, a call to an `idempotent: true` tool runs again. Any other call never reruns, and the model gets an error saying it may have partly run.
- `executionId` is the same each time a call runs, including after a crash.
- A prompt sent again with the same `messageId` starts nothing new: its view follows the original turn, or resolves with `repeated: true`.
- `session.stream(cursor)` replays a session's events from any cursor on any server, with `turn_start`, `turn_end`, `turn_resume`, and `turn_yield` lines.
- Every reader of a session's stream shows the same lines, because lines a replaced worker lands late are hidden.
- `session.steer()` and `session.cancel()` reach the session's running turn, and `session.resume()` continues a turn a stopped process left open.
- Every turn end and yield saves a checkpoint, so continuing a session reads one checkpoint and the records after it.
- Checkpoints record the libfx version, tool set, and model. A session or checkpoint saved under different ones still loads, and `onEvent` receives `checkpoint.mismatch`.
- `session.checkpoint()` exports a session's history, and the `checkpoint` option starts a new agent's session from it.
- `agent.wakeHandler()` is the queue delivery route an app mounts on Vercel and subscribes to `__libfx_wkf_workflow_session` in `vercel.json`.
- On Vercel, libfx authenticates to the AI Gateway with the deployment's OIDC token when neither `apiKey` nor `AI_GATEWAY_API_KEY` is set.
- `createFxEngine()` accepts a `persistence` store, and `createMemoryPersistence()` provides one in memory. A store that rejects a stale append with `FxFencedError` stops a second writer.
- A turn whose store append fails stops at once with `FX_JOURNAL_APPEND_FAILED`, and the agent refuses later prompts.
This commit is contained in:
Pranit committed 2026-10-07 01:47:00 -04:00
1 parent 607b1632ff
commit b0d83eb894
116 files changed
+13255 -351

No files matched your search

+54 -1
View File
@@ -235,6 +235,9 @@ jobs:
- name: Install terminal test dependencies
run: npm ci --prefix sdk/node
- name: Install the durable Worlds
run: npm ci --prefix sdk/durable
- name: Test Node + WASM
run: npm run --prefix sdk test:node-wasm
@@ -245,6 +248,11 @@ jobs:
node --experimental-wasm-jspi sdk/tests/test-mcp-adapter.mjs http wasm
node --experimental-wasm-jspi sdk/tests/test-skills-adapter.mjs host wasm
- name: Test durable sessions on Wasm
run: |
node --experimental-wasm-jspi sdk/tests/test-durable.mjs memory wasm
node --experimental-wasm-jspi sdk/tests/test-durable.mjs local wasm
sdk-node-napi:
name: SDK (Node + N-API)
needs: sdk-napi
@@ -279,6 +287,16 @@ jobs:
node sdk/tests/test-mcp-adapter.mjs stdio native
node sdk/tests/test-skills-adapter.mjs disk native
- name: Install the durable Worlds
run: npm ci --prefix sdk/durable
- name: Test durable sessions on N-API
run: |
node sdk/tests/test-durable.mjs memory native
node sdk/tests/test-durable.mjs local native
node sdk/tests/test-durable-core.mjs memory
node sdk/tests/test-durable-core.mjs local
sdk-bun:
name: SDK (Bun + N-API/WASM)
needs: [sdk-wasm, sdk-napi]
@@ -317,6 +335,9 @@ jobs:
- name: Install terminal test dependencies
run: npm ci --prefix sdk/node
- name: Install the durable Worlds
run: npm ci --prefix sdk/durable
- name: Test Bun + N-API
run: bun sdk/tests/test-node-napi.mjs
@@ -324,13 +345,45 @@ jobs:
run: bun sdk/tests/test-node-wasm.mjs
- name: Test Bun benchmark path
run: bun sdk/tests/test-libfx-benchmark.mjs
run: |
bun sdk/tests/test-libfx-benchmark.mjs
bun sdk/tests/test-durable-harness.mjs
- name: Test durable sessions on Bun
run: |
bun sdk/tests/test-durable.mjs memory native
bun sdk/tests/test-durable.mjs local native
bun sdk/tests/test-durable-core.mjs memory
bun sdk/tests/test-durable-core.mjs local
- name: Model-check durable sessions and drive the code along the model
run: |
set -euo pipefail
jar="$RUNNER_TEMP/tla2tools.jar"
curl -fsSL -o "$jar" https://github.com/tlaplus/tlaplus/releases/download/v1.7.4/tla2tools.jar
echo "936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88 $jar" | sha256sum -c -
TLA2TOOLS_JAR="$jar" sdk/tests/model/check.sh
bun sdk/tests/model/drive.mjs send --walks 40
bun sdk/tests/model/drive.mjs lookup --walks 40 --seed 7
- name: Test libfx on a World
run: |
world_root="$(mktemp -d "$RUNNER_TEMP/libfx-world.XXXXXX")"
npm install --prefix "$world_root" --no-audit --no-fund @workflow/world@5.0.0-beta.39 @workflow/world-local@5.0.0-beta.48
node sdk/tests/test-world-persistence.mjs "$world_root" native
node --experimental-wasm-jspi sdk/tests/test-world-persistence.mjs "$world_root" wasm
bun sdk/tests/test-world-persistence.mjs "$world_root" native
bun sdk/tests/test-world-persistence.mjs "$world_root" wasm
- name: Test Bun cross-backend integrations
run: |
bun sdk/tests/test-agent-bootstrap.mjs auto
bun sdk/tests/test-checkpoint.mjs native wasm
bun sdk/tests/test-checkpoint.mjs wasm native
bun sdk/tests/test-journal.mjs native wasm
bun sdk/tests/test-journal.mjs wasm native
bun sdk/tests/test-tool-contract.mjs native
bun sdk/tests/test-tool-contract.mjs wasm
bun sdk/tests/test-host-tools.mjs native
bun sdk/tests/test-host-tools.mjs wasm
bun sdk/tests/test-host-tool-cancel.mjs native
+3
View File
@@ -246,6 +246,9 @@ jobs:
name: libfx-wasm
path: wasm-artifacts
- name: Install the durable Worlds
run: npm ci --prefix sdk/durable
- name: Assemble package
env:
LIBFX_VERSION: ${{ needs.metadata.outputs.version }}
+2 -2
View File
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../../sdk/node.js";
import { createFxEngine } from "../../sdk/node.js";
const args = process.argv.slice(2);
const value = (name, fallback) => {
@@ -48,7 +48,7 @@ const server = createServer((request, response) => {
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
let activeSample = 0;
const agent = await createFxAgent({
const agent = await createFxEngine({
backend,
nativeAddon: resolve(root, "zig-out/lib/libfx.node"),
...(backend === "wasm" ? { wasm: await readFile(resolve(root, "zig-out/bin/fx-core.wasm")) } : {}),
+3 -3
View File
@@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process";
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../../sdk/node.js";
import { createFxEngine } from "../../sdk/node.js";
const args = process.argv.slice(2);
const value = (name, fallback) => {
@@ -136,7 +136,7 @@ async function cancelAndRecover() {
}
try {
const warmup = await createFxAgent(options);
const warmup = await createFxEngine(options);
try {
await exercise(warmup, "capacity warmup");
} finally {
@@ -144,7 +144,7 @@ try {
}
await quiesce();
snapshots.push(collect("baseline"));
const created = await Promise.allSettled(Array.from({ length: count }, () => createFxAgent(options)));
const created = await Promise.allSettled(Array.from({ length: count }, () => createFxEngine(options)));
for (const [index, result] of created.entries()) {
if (result.status === "fulfilled") agents.push(result.value);
else failures.push({ index, error: String(result.reason) });
@@ -75,9 +75,9 @@ const importAt = performance.now();
let importedAt;
let initializedAt;
if (target === "libfx") {
const { createFxAgent } = await import(pathToFileURL(resolve(root, "sdk/node.js")));
const { createFxEngine } = await import(pathToFileURL(resolve(root, "sdk/node.js")));
importedAt = performance.now();
const agent = await createFxAgent({
const agent = await createFxEngine({
backend: "native",
nativeAddon: resolve(root, "zig-out/lib/libfx.node"),
fetch: globalThis.fetch,
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env node
// Durable libfx benchmark: what persistence costs per turn, per tool call and
// per restore, against the same workloads with no durability.
//
// "control" runs each scripted workload with no durability. "today-rN"
// emulates a host that makes libfx durable from outside, on a remote log with an N ms
// round trip: four sequential acknowledged writes per tool call and a
// checkpoint after each turn (durable.mjs todayAdapterWrites). "journal-rN"
// gives libfx a persistence store on the same remote log, which acks each
// append N ms after it: libfx sends records one at a time, and waits for them
// only before a model request or a tool call. Journal rows report append calls
// instead of awaited writes. The restore section measures checkpoint and
// journal size and restore time against history length. With --world-root (a
// directory holding @workflow/world-local), "world-local" runs libfx with the
// World store in sdk/tests/world-persistence.mjs.
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { createFxEngine, supportsJspi } from "../../sdk/node.js";
import { worldPersistence } from "../../sdk/tests/world-persistence.mjs";
import { durableWorkloads, promptDirectives, recordEvents, todayAdapterWrites, toolCallsIn } from "./durable.mjs";
import { agentOptions, hostTools, listOption, parseArgs, scriptedFetch, sleep } from "./durable-host.mjs";
import { sampleStats } from "./workload.mjs";
const options = parseArgs(process.argv.slice(2), {
backend: "native",
samples: "30",
warmups: "3",
rtt: "0,5,25",
"tool-ms": "10",
history: "10,100",
"restore-samples": "10",
workloads: Object.keys(durableWorkloads).join(","),
"world-root": "",
});
const backend = options.backend;
const samples = Number(options.samples);
const warmups = Number(options.warmups);
const toolMs = Number(options["tool-ms"]);
const roundTrips = listOption(options.rtt).map(Number);
const histories = listOption(options.history).map(Number);
const restoreSamples = Number(options["restore-samples"]);
const workloads = listOption(options.workloads);
if (!new Set(["native", "wasm"]).has(backend)) throw new Error("--backend must be native or wasm");
if (backend === "wasm" && !supportsJspi()) throw new Error("Wasm needs JSPI; run Node with --experimental-wasm-jspi");
for (const value of [samples, warmups, toolMs, restoreSamples, ...roundTrips, ...histories]) {
if (!Number.isInteger(value) || value < 0 || value > 10_000) throw new Error(`invalid numeric option: ${value}`);
}
for (const name of workloads) if (!durableWorkloads[name]) throw new Error(`unknown workload: ${name}`);
const createWorld = options["world-root"]
? (await import(pathToFileURL(join(options["world-root"], "node_modules/@workflow/world-local/dist/index.js")).href)).createWorld
: null;
const stepsFor = (prompt) => durableWorkloads[promptDirectives(prompt).workload] ?? durableWorkloads["no-tool"];
// A persistence store on a remote log: each append lands one round trip
// after its call. `stored` is the session's events, decoded for the report.
function remoteStore(roundTrip, records = []) {
const store = {
records: [...records],
appends: 0,
bytes: 0,
get stored() {
return store.records.flatMap((record) => recordEvents(record.data));
},
async load() { return { journal: store.records.slice() }; },
async append({ data }) {
store.appends += 1;
store.bytes += data.byteLength;
if (roundTrip) await sleep(roundTrip);
const cursor = String(store.records.length + 1);
store.records.push({ cursor, data: data.slice() });
return { cursor };
},
};
return store;
}
// A remote store is its own counter.
const persisted = (store) => Object.assign(store, { persistence: store });
const turnBytes = (events, turn) => events
.filter((event) => event.turn === turn)
.reduce((sum, event) => sum + JSON.stringify(event).length, 0);
// A World store that counts libfx's record writes as a store counts appends.
function countingWorld(inner) {
const counter = { appends: 0, bytes: 0 };
const create = (runId, request, params) => {
if (request?.eventData?.stepName === "fx.record") {
counter.appends += 1;
counter.bytes += request.eventData.input.byteLength;
}
return inner.events.create(runId, request, params);
};
counter.persistence = worldPersistence({
...inner,
events: { ...inner.events, create, list: inner.events.list.bind(inner.events) },
});
return counter;
}
// world-local's queue teardown calls undici's Agent.close(), which Bun's
// built-in undici lacks; only that known failure is skipped.
async function closeWorld(world) {
try {
await world.close?.();
} catch (error) {
if (!(process.versions.bun && error instanceof TypeError && error.message.includes("httpAgent?.close"))) throw error;
}
}
// One agent whose tools record their real start and end times on `active`.
// `store` counts appends; its `persistence` is what the agent writes to.
async function openAgent({ roundTrip = null, checkpoint, store } = {}) {
let active = null;
const run = async (name, input, { signal }) => {
const row = active;
if (roundTrip !== null) {
for (let write = 0; write < todayAdapterWrites(1).perToolBefore; write += 1) {
await sleep(roundTrip);
if (row) row.awaited_writes += 1;
}
}
if (row) row.exec_first_start ??= performance.now();
await sleep(toolMs, signal);
if (row) row.exec_last_end = performance.now();
if (roundTrip !== null) {
for (let write = 0; write < todayAdapterWrites(1).perToolAfter; write += 1) {
await sleep(roundTrip);
if (row) row.awaited_writes += 1;
}
}
return `${name}:ok`;
};
const agent = await createFxEngine(await agentOptions({
backend,
fetch: scriptedFetch({ stepsFor }),
tools: hostTools(run),
checkpoint,
persistence: store?.persistence,
}));
return { agent, journal: store, setActive: (row) => { active = row; } };
}
async function runTurn(handle, workload, index, roundTrip) {
const row = {
prompt_at: performance.now(),
first_output_at: null,
last_tool_end_at: null,
text_after_tool_at: null,
exec_first_start: null,
exec_last_end: null,
completed_at: null,
awaited_writes: 0,
checkpoint_bytes: null,
journal_bytes: null,
append_calls: null,
};
const appendsBefore = handle.journal?.appends ?? 0;
const bytesBefore = handle.journal?.bytes ?? 0;
handle.setActive(row);
const turn = handle.agent.prompt(`workload=${workload} sample=${index}`);
for await (const event of turn) {
if (event.type === "text_delta" || event.type === "tool_start") row.first_output_at ??= performance.now();
if (event.type === "tool_end") row.last_tool_end_at = performance.now();
if (event.type === "text_delta" && row.last_tool_end_at !== null) row.text_after_tool_at ??= performance.now();
}
const result = await turn.result;
if (result.stopReason !== "end_turn") throw new Error(`${workload} stopped with ${result.stopReason}`);
if (roundTrip !== null) {
const bytes = await handle.agent.checkpoint();
await sleep(roundTrip);
row.awaited_writes += todayAdapterWrites(0).perTurn;
row.checkpoint_bytes = bytes.byteLength;
}
if (handle.journal) {
row.awaited_writes = null;
row.append_calls = handle.journal.appends - appendsBefore;
row.journal_bytes = handle.journal.bytes - bytesBefore;
}
row.completed_at = performance.now();
handle.setActive(null);
return row;
}
const stats = (values) => {
const present = values.filter((value) => value !== null && Number.isFinite(value));
return present.length ? sampleStats(present) : null;
};
function summarize(rows) {
return {
prompt_to_first_output_ms: stats(rows.map((row) => row.first_output_at - row.prompt_at)),
turn_ms: stats(rows.map((row) => row.completed_at - row.prompt_at)),
tool_to_next_text_ms: stats(rows.map((row) => (row.text_after_tool_at === null ? null : row.text_after_tool_at - row.last_tool_end_at))),
batch_wall_ms: stats(rows.map((row) => (row.exec_first_start === null ? null : row.exec_last_end - row.exec_first_start))),
awaited_writes: stats(rows.map((row) => row.awaited_writes)),
checkpoint_bytes: stats(rows.map((row) => row.checkpoint_bytes)),
journal_bytes: stats(rows.map((row) => row.journal_bytes)),
append_calls: stats(rows.map((row) => row.append_calls)),
};
}
async function measure(workload, roundTrip, store) {
const durable = store !== undefined;
const handle = durable ? await openAgent({ store }) : await openAgent({ roundTrip });
try {
const rows = [];
for (let index = -warmups; index < samples; index += 1) {
const row = await runTurn(handle, workload, index, durable ? null : roundTrip);
if (index >= 0) rows.push(row);
}
return summarize(rows);
} finally {
await handle.agent.close();
}
}
const report = {
format_version: 1,
runtime: process.versions.bun ? "bun" : "node",
runtime_version: process.versions.bun ?? process.version,
backend,
options: { samples, warmups, tool_ms: toolMs, round_trips_ms: roundTrips, histories, restore_samples: restoreSamples },
workloads: {},
restore: [],
};
for (const workload of workloads) {
const modes = { control: await measure(workload, null) };
for (const roundTrip of roundTrips) {
modes[`today-r${roundTrip}`] = await measure(workload, roundTrip);
modes[`today-r${roundTrip}`].planned_awaited_writes = todayAdapterWrites(toolCallsIn(durableWorkloads[workload])).awaited;
modes[`journal-r${roundTrip}`] = await measure(workload, roundTrip, persisted(remoteStore(roundTrip)));
}
if (createWorld) {
const world = createWorld({ dataDir: mkdtempSync(join(tmpdir(), "libfx-bench-world-")), recoverActiveRuns: false });
await world.start?.();
try {
modes["world-local"] = await measure(workload, null, countingWorld(world));
} finally {
await closeWorld(world);
}
}
report.workloads[workload] = modes;
}
for (const history of histories) {
const journal = remoteStore(0);
const handle = await openAgent({ store: persisted(journal) });
let checkpoint;
let lastTurnJournalBytes = 0;
const checkpointMs = [];
try {
for (let index = 0; index < history; index += 1) {
const row = await runTurn(handle, "no-tool", index, null);
lastTurnJournalBytes = row.journal_bytes;
}
// One tool turn after `history` turns; turns count from 1.
await runTurn(handle, "one-safe", history, null);
for (let index = 0; index < restoreSamples; index += 1) {
const startedAt = performance.now();
checkpoint = await handle.agent.checkpoint();
checkpointMs.push(performance.now() - startedAt);
}
} finally {
await handle.agent.close();
}
const restoreMs = [];
const journalRestoreMs = [];
const createMs = [];
for (let index = 0; index < restoreSamples; index += 1) {
let startedAt = performance.now();
const restored = await openAgent({ checkpoint });
restoreMs.push(performance.now() - startedAt);
await restored.agent.close();
startedAt = performance.now();
const replayed = await openAgent({ store: persisted(remoteStore(0, journal.records)) });
journalRestoreMs.push(performance.now() - startedAt);
await replayed.agent.close();
startedAt = performance.now();
const fresh = await openAgent();
createMs.push(performance.now() - startedAt);
await fresh.agent.close();
}
report.restore.push({
history_turns: history,
checkpoint_bytes: checkpoint.byteLength,
checkpoint_bytes_per_turn: checkpoint.byteLength / Math.max(history, 1),
checkpoint_ms: sampleStats(checkpointMs),
journal_events: journal.stored.length,
journal_bytes: JSON.stringify(journal.stored).length,
journal_last_turn_bytes: lastTurnJournalBytes,
restore_create_ms: sampleStats(restoreMs),
journal_restore_create_ms: sampleStats(journalRestoreMs),
one_tool_turn_journal_bytes: turnBytes(journal.stored, history + 1),
fresh_create_ms: sampleStats(createMs),
});
}
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
+2 -2
View File
@@ -38,7 +38,7 @@ async function runChild() {
if (!gatewayUrl || !diagnosticsPath) throw new Error("benchmark child environment is incomplete");
const startedAt = performance.now();
const { createFxAgent } = await import(new URL("../../sdk/node.js", import.meta.url));
const { createFxEngine } = await import(new URL("../../sdk/node.js", import.meta.url));
const importedAt = performance.now();
let fetchAt = null;
let firstBodyAt = null;
@@ -80,7 +80,7 @@ async function runChild() {
});
};
const agent = await createFxAgent({
const agent = await createFxEngine({
backend,
nativeAddon: resolve(repoRoot, "zig-out/lib/libfx.node"),
wasm: resolve(repoRoot, "zig-out/bin/fx-core.wasm"),
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env node
// Parallel tool stress and fault injection. Each turn asks for one batch of
// tool calls drawn from a seeded PRNG: random writer flags, latencies and
// thrown errors, and sometimes a cancel while the batch runs. Every batch is
// checked from the real start and end order of the tools and the order the
// next model request carried their results. The WebAssembly core runs host
// tools one at a time, which satisfies every check; the report's concurrency
// and speedup show how much parallelism each backend has.
import { createFxEngine, supportsJspi } from "../../sdk/node.js";
import {
batchSteps,
checkBatch,
durableTools,
durableWorkloads,
maxConcurrency,
nextRandom,
promptDirectives,
randomBatch,
toolResultIds,
} from "./durable.mjs";
import { agentOptions, hostTools, parseArgs, scriptedFetch, sleep } from "./durable-host.mjs";
import { sampleStats } from "./workload.mjs";
const options = parseArgs(process.argv.slice(2), {
backend: "native",
batches: "200",
seed: "1",
"cancel-rate": "0.05",
"agent-turns": "50",
});
const backend = options.backend;
const batches = Number(options.batches);
const seed = Number(options.seed) >>> 0;
const cancelRate = Number(options["cancel-rate"]);
const agentTurns = Number(options["agent-turns"]);
if (!new Set(["native", "wasm"]).has(backend)) throw new Error("--backend must be native or wasm");
if (backend === "wasm" && !supportsJspi()) throw new Error("Wasm needs JSPI; run Node with --experimental-wasm-jspi");
if (!Number.isInteger(batches) || batches < 1 || !Number.isInteger(agentTurns) || agentTurns < 1) throw new Error("invalid counts");
if (!(cancelRate >= 0 && cancelRate <= 1)) throw new Error("--cancel-rate must be 0..1");
// Fixed batches first: the list-then-read order check and write-then-read.
const fixed = ["list-then-read", "write-then-read"].map((name) => ({
name,
calls: durableWorkloads[name][0].calls.map((call) => ({ name: call.name, writes: durableTools[call.name].writes, latencyMs: 2, fault: null })),
}));
function batchFor(directives) {
if (directives.fixed) return fixed.find((batch) => batch.name === directives.fixed);
return randomBatch(Number(directives.batch));
}
const stepsFor = (prompt) => {
const directives = promptDirectives(prompt);
const batch = batchFor(directives);
if (directives.fixed) {
return [{ calls: batch.calls.map((call, index) => ({ name: call.name, input: { call: index, latencyMs: call.latencyMs, fault: null } })) }, { text: ["done"] }];
}
return batchSteps(batch);
};
let current = null;
const requests = [];
async function run(_name, input, { signal }) {
const state = current;
if (!state) throw new Error("tool ran outside a batch");
if (state.cancelled) state.startsAfterCancel += 1;
state.trace.push({ call: input.call, kind: "start" });
state.onStart?.();
try {
await sleep(input.latencyMs, signal);
if (input.fault === "throw") throw new Error(`injected fault in call ${input.call}`);
return `call:${input.call}`;
} finally {
state.trace.push({ call: input.call, kind: "end" });
}
}
async function openAgent() {
return createFxEngine(await agentOptions({
backend,
fetch: scriptedFetch({ stepsFor, onRequest: (request) => requests.push(request) }),
tools: hostTools(run),
}));
}
const plan = [
...fixed.map((batch) => ({ prompt: `fixed=${batch.name}`, batch, cancel: false })),
];
let state = seed;
for (let index = 0; index < batches; index += 1) {
const drawn = nextRandom(state);
state = drawn.seed;
const cancelDraw = nextRandom(state);
state = cancelDraw.seed;
plan.push({ prompt: `batch=${drawn.seed}`, batch: randomBatch(drawn.seed), cancel: cancelDraw.value < cancelRate });
}
const results = [];
let agent = await openAgent();
let turnsOnAgent = 0;
try {
for (const entry of plan) {
if (turnsOnAgent >= agentTurns) {
await agent.close();
agent = await openAgent();
turnsOnAgent = 0;
}
turnsOnAgent += 1;
const requestsBefore = requests.length;
current = { trace: [], cancelled: false, startsAfterCancel: 0, onStart: null };
const startedAt = performance.now();
const turn = agent.prompt(entry.prompt);
if (entry.cancel) {
current.onStart = () => {
current.onStart = null;
current.cancelled = true;
turn.cancel();
};
}
for await (const _ of turn) {}
const outcome = await turn.result;
const wallMs = performance.now() - startedAt;
const turnRequests = requests.slice(requestsBefore);
const toolRequest = turnRequests.find(({ frames }) => frames.some((frame) => frame.type === "tool-call"));
const followUp = turnRequests[turnRequests.indexOf(toolRequest) + 1];
const expectedOrder = toolRequest ? toolRequest.frames.filter((frame) => frame.type === "tool-call").map((frame) => frame.toolCallId) : [];
const violations = [];
if (entry.cancel) {
if (current.startsAfterCancel > 0) violations.push(`CancelStopsBatch: ${current.startsAfterCancel} calls started after cancel`);
} else {
if (outcome.stopReason !== "end_turn") violations.push(`TurnCompletes: stopped with ${outcome.stopReason}`);
violations.push(...checkBatch({
writes: entry.batch.calls.map((call) => call.writes),
trace: current.trace,
resultOrder: followUp ? toolResultIds(followUp.body.prompt) : [],
expectedOrder,
}));
}
const latencySum = entry.batch.calls.reduce((total, call) => total + call.latencyMs, 0);
results.push({
prompt: entry.prompt,
size: entry.batch.calls.length,
writers: entry.batch.calls.filter((call) => call.writes).length,
cancelled: entry.cancel,
stop_reason: outcome.stopReason,
max_concurrency: maxConcurrency(current.trace),
latency_sum_ms: latencySum,
wall_ms: wallMs,
violations,
});
current = null;
}
} finally {
await agent.close();
}
const checked = results.filter((result) => !result.cancelled);
const failing = results.filter((result) => result.violations.length > 0);
const report = {
format_version: 1,
runtime: process.versions.bun ? "bun" : "node",
runtime_version: process.versions.bun ?? process.version,
backend,
seed,
batches: results.length,
cancelled: results.length - checked.length,
failing_batches: failing.length,
max_concurrency: sampleStats(checked.map((result) => result.max_concurrency)),
readers_overlapped: checked.some((result) => result.max_concurrency > 1),
wall_over_latency_sum: sampleStats(checked.filter((result) => result.latency_sum_ms > 0).map((result) => result.wall_ms / result.latency_sum_ms)),
fixed: results.slice(0, fixed.length),
failures: failing.slice(0, 20),
};
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
if (failing.length > 0) process.exitCode = 1;
+3 -3
View File
@@ -2,7 +2,7 @@
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../../sdk/node.js";
import { createFxEngine } from "../../sdk/node.js";
import { sampleStats as stats } from "./workload.mjs";
const args = process.argv.slice(2);
@@ -132,7 +132,7 @@ async function runWarmPrompt(agent, index) {
async function runStreamCase(chunks, bytes) {
const rows = [];
for (let sample = -1; sample < streamSamples; sample += 1) {
const agent = await createFxAgent(agentOptions(`${origin}/stream?chunks=${chunks}&bytes=${bytes}`));
const agent = await createFxEngine(agentOptions(`${origin}/stream?chunks=${chunks}&bytes=${bytes}`));
try {
const promptAt = performance.now();
const turn = agent.prompt("stream");
@@ -168,7 +168,7 @@ async function runStreamCase(chunks, bytes) {
let warmAgent;
try {
warmAgent = await createFxAgent(agentOptions(`${origin}/warm`));
warmAgent = await createFxEngine(agentOptions(`${origin}/warm`));
const firstPrompt = await runWarmPrompt(warmAgent, -4);
for (let index = -3; index < 0; index += 1) await runWarmPrompt(warmAgent, index);
const warmRows = [];
+565
View File
@@ -0,0 +1,565 @@
#!/usr/bin/env node
// Crash matrix: kill a libfx process at every step of a turn and check what
// the next process does.
//
// For each backend and workload, a worker child runs a committed setup turn,
// saves a checkpoint, then runs the crash turn one step at a time: it prints
// each step (prompt sent, each turn event, before and after each tool's side
// effect) and waits for an ack on stdin. The parent kills it with SIGKILL at
// step k instead of acking, then starts a restorer child. In "today" mode
// the restorer does what a host can do without a journal: restore the last
// checkpoint and send the stored prompt again. Side effects are counted from
// a file, and each cell is checked by durable.mjs checkCrashCell. Failures
// in "today" mode are recorded, not fatal, unless --require-clean is set.
//
// In "journal" mode the worker persists the session to a JSONL file through
// `persistence`, with a step before and after each record lands, and saves
// no checkpoint. The restorer opens the session from that file alone. It
// calls `resume()` when the journal holds the crashed turn open, does nothing
// when the turn was committed, and sends the prompt again only when no trace
// of the turn reached the journal. Then it reopens the file once more. Two
// more invariants apply: JournalFolds (every reopen succeeds) and
// StartsWithDurableIntent (checked as each tool call starts).
//
// "world" mode is journal mode with the session in a world-local World,
// through the persistence store in sdk/tests/world-persistence.mjs
// (--world-root holds @workflow/world-local).
//
// --race (journal or world mode) holds the worker at step k instead of
// killing it, runs the restorer while it waits, then releases it: a second
// process takes the session over while the first is still alive. Two more
// invariants apply: FencedNeverWrites (the released worker leaves the
// session as the restorer left it) and SendRunsOnce over both processes.
//
// --inputs (journal or world mode) steers the crash turn and queues a
// follow-up when its first tool runs, each acknowledgement a step of its
// own. AckedAreDurable: an acknowledged steer or follow-up reaches the model
// after the restore; PlacedOnce: neither reaches it twice.
import { spawn } from "node:child_process";
import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createFxEngine, FxFencedError } from "../../sdk/node.js";
import { worldPersistence } from "../../sdk/tests/world-persistence.mjs";
import { checkCrashCell, durableWorkloads, journalRecord, promptDirectives, recordEvents, toolResultIds } from "./durable.mjs";
import { agentOptions, hostTools, listOption, parseArgs, scriptedFetch } from "./durable-host.mjs";
const scriptPath = fileURLToPath(import.meta.url);
const options = parseArgs(process.argv.slice(2), {
child: "",
backend: "native",
backends: "native,wasm",
workload: "",
workloads: "no-tool,one-safe,one-send,list-then-read,write-then-read",
dir: "",
mode: "today",
// The Worlds libfx bundles, installed by `npm ci --prefix sdk/durable`.
"world-root": fileURLToPath(new URL("../../sdk/durable/", import.meta.url)),
race: false,
inputs: false,
"require-clean": false,
"timeout-ms": "20000",
});
if (!["today", "journal", "world"].includes(options.mode)) throw new Error("--mode must be today, journal or world");
const worldMode = options.mode === "world";
const journaled = options.mode !== "today";
if (options.race && !journaled) throw new Error("--race needs --mode journal or world");
if (options.inputs && !journaled) throw new Error("--inputs needs --mode journal or world");
const setupTurns = 1;
const steerText = "steer=keep";
const followUpText = "workload=no-tool follow";
// How often the steer and the follow-up reached the model in `prompt`.
function inputCounts(prompt) {
const users = prompt.filter((message) => message.role === "user").map(textOf);
return {
steers: users.filter((text) => text.includes(steerText)).length,
followUps: users.filter((text) => text.includes(followUpText)).length,
};
}
// Runs every turn `resume()` hands back: the open turn, then held follow-ups.
async function resumeAll(agent) {
let first = null;
for (let turn = agent.resume(); turn; turn = agent.resume()) {
for await (const _ of turn) {}
const result = await turn.result;
first ??= result;
}
return first;
}
const stepsFor = (prompt) => durableWorkloads[promptDirectives(prompt).workload] ?? durableWorkloads["no-tool"];
const textOf = (message) => (Array.isArray(message.content) ? message.content.filter((part) => part.type === "text").map((part) => part.text).join("") : String(message.content ?? ""));
const createWorld = worldMode && options.child
? (await import(pathToFileURL(join(options["world-root"], "node_modules/@workflow/world-local/dist/index.js")).href)).createWorld
: null;
if (options.child === "worker") await worker();
else if (options.child === "restorer") await restorer();
else if (options.child === "inspect") await inspect();
else await parent();
// Children: the effects live here (files, stdout, the agent).
function intentStored(path, callId) {
if (!existsSync(path) || !callId) return false;
return readFileSync(path, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line))
.some((event) => event.type === "tool_intent" && event.data.some((call) => call.id === callId));
}
// A persistence store in a JSONL file, one event per line, so a kill leaves
// whole lines. A record's cursor is the last seq it holds, and an append whose
// `expected` cursor is not the file's last seq is fenced, so a process whose
// session was taken over cannot interleave with the new one. `step` brackets
// the write when the worker is stepping.
// Declarations, not consts: the child roles run before this point.
function readEvents(path) {
const text = existsSync(path) ? readFileSync(path, "utf8") : "";
return text.split("\n").filter(Boolean).map((line) => JSON.parse(line));
}
function fileStore(path, step = null) {
const head = () => {
const last = readEvents(path).at(-1);
return last ? String(last.seq) : null;
};
return {
async load() {
const events = readEvents(path);
return { journal: events.length ? [{ cursor: String(events.at(-1).seq), data: journalRecord(events) }] : [] };
},
async append({ expected, data }) {
const batch = recordEvents(data);
if (step) await step(`journal_before:${batch.map((event) => event.type).join("+")}`);
if (expected !== head()) throw new FxFencedError(`expected cursor ${expected}, head ${head()}`);
appendFileSync(path, batch.map((event) => `${JSON.stringify(event)}\n`).join(""));
if (step) await step("journal_after");
return { cursor: String(batch.at(-1).seq) };
},
};
}
function openWorld() {
return createWorld({ dataDir: join(options.dir, "world"), recoverActiveRuns: false });
}
// world-local's queue teardown calls undici's Agent.close(), which Bun's
// built-in undici lacks; only that known failure is skipped.
async function closeWorld(world) {
try {
await world.close?.();
} catch (error) {
if (!(process.versions.bun && error instanceof TypeError && error.message.includes("httpAgent?.close"))) throw error;
}
}
// The events in one of the World store's record steps, or null.
function recordStepEvents(request) {
if (request?.eventData?.stepName !== "fx.record") return null;
try {
const { data } = JSON.parse(new TextDecoder().decode(request.eventData.input));
return recordEvents(new Uint8Array(Buffer.from(data, "base64")));
} catch {
return null;
}
}
// The World, with the same steps as fileStore around each record write.
// libfx writes a session's records one at a time, in order.
function steppedWorld(world, step) {
const create = async (runId, request, params) => {
const events = recordStepEvents(request);
if (!Array.isArray(events)) return world.events.create(runId, request, params);
await step(`journal_before:${events.map((event) => event.type).join("+")}`);
const result = await world.events.create(runId, request, params);
await step("journal_after");
return result;
};
return {
...world,
queue: world.queue.bind(world),
events: { ...world.events, create, list: world.events.list.bind(world.events) },
};
}
// Every journal event a World session holds: the records its store loads.
async function worldEvents(world, runId) {
const { journal = [] } = await worldPersistence(world, { runId }).load();
return journal.flatMap((record) => recordEvents(record.data));
}
async function worker() {
// Steps can wait at the same time (a tool's effect and the turn's
// tool_start event). The parent acks in the order steps were printed, so
// waiters resolve first in, first out.
const lines = createInterface({ input: process.stdin });
let pendingAcks = 0;
const waiters = [];
lines.on("line", () => {
if (waiters.length > 0) waiters.shift()();
else pendingAcks += 1;
});
let stepCount = 0;
const step = async (name) => {
stepCount += 1;
process.stdout.write(`${JSON.stringify({ step: stepCount, name })}\n`);
if (pendingAcks > 0) {
pendingAcks -= 1;
return;
}
await new Promise((resolveAck) => { waiters.push(resolveAck); });
};
let stepping = false;
let crashTurn = null;
let followed = null;
const sendInputs = () => {
if (!options.inputs || !crashTurn || followed) return;
const acks = join(options.dir, "acks.log");
crashTurn.steer(steerText).then(() => {
appendFileSync(acks, "steer\n");
return step("steer_acked");
}, () => {});
followed = agent.followUp(followUpText);
followed.accepted.then(() => {
appendFileSync(acks, "follow_up\n");
return step("follow_up_acked");
}, () => {});
followed.catch(() => {});
};
const run = async (name, _input, context) => {
sendInputs();
if (stepping) await step(`before_effect:${name}`);
// StartsWithDurableIntent: the journal already holds this call.
const stored = worldMode
? (await worldEvents(world, agent.sessionId))
.some((event) => event.type === "tool_intent" && event.data.some((call) => call.id === context?.executionId))
: intentStored(join(options.dir, "journal.jsonl"), context?.executionId);
if (journaled && !stored) {
appendFileSync(join(options.dir, "effects.log"), `intent_missing:${name}\n`);
}
appendFileSync(join(options.dir, "effects.log"), `${name}\n`);
if (stepping) await step(`after_effect:${name}`);
return `${name}:ok`;
};
const stepWhenStepping = (name) => (stepping ? step(name) : undefined);
const world = worldMode ? openWorld() : null;
await world?.start?.();
const persistence = worldMode
? worldPersistence(steppedWorld(world, stepWhenStepping))
: journaled ? fileStore(join(options.dir, "journal.jsonl"), stepWhenStepping) : undefined;
const agent = await createFxEngine(await agentOptions({
backend: options.backend,
fetch: scriptedFetch({ stepsFor }),
tools: hostTools(run),
persistence,
sessionId: persistence?.runId,
}));
if (worldMode) writeFileSync(join(options.dir, "session.txt"), persistence.runId);
for (let index = 0; index < setupTurns; index += 1) {
const setup = agent.prompt(`workload=no-tool setup ${index}`);
for await (const _ of setup) {}
await setup.result;
}
if (!journaled) writeFileSync(join(options.dir, "checkpoint.bin"), await agent.checkpoint());
process.stdout.write(`${JSON.stringify({ ready: true })}\n`);
stepping = true;
try {
const turn = agent.prompt(`workload=${options.workload} crash`);
crashTurn = turn;
await step("prompt_sent");
let sawText = false;
for await (const event of turn) {
if (event.type === "text_delta") {
if (sawText) continue;
sawText = true;
}
if (event.type === "text_delta" || event.type === "tool_start" || event.type === "tool_end") await step(`event:${event.type}`);
}
await turn.result;
await step("result");
if (followed) {
const next = await followed;
for await (const _ of next) {}
await next.result;
await step("follow_up_result");
}
await agent.close();
} catch (error) {
// A worker released after a takeover (--race) stops on the fence.
if (error?.cause?.code !== "FX_FENCED") throw error;
process.stdout.write(`${JSON.stringify({ fenced: true })}\n`);
await agent.close().catch(() => {});
} finally {
if (world) await closeWorld(world);
// The ack reader keeps stdin open; release it so the worker can exit.
lines.close();
process.stdin.destroy();
}
}
// The session's journal as its contiguous events, for comparing two points.
async function inspect() {
let events;
if (worldMode) {
const world = openWorld();
await world.start?.();
events = await worldEvents(world, readFileSync(join(options.dir, "session.txt"), "utf8"));
await closeWorld(world);
} else {
events = readEvents(join(options.dir, "journal.jsonl"));
}
const fingerprint = events.map((event) => `${event.seq}:${event.type}:${event.turn}`).join(",");
process.stdout.write(`${JSON.stringify({ summary: { fingerprint, lastSeq: events.at(-1)?.seq ?? 0 } })}\n`);
}
async function restorer() {
const requests = [];
const run = async (name) => {
appendFileSync(join(options.dir, "effects.log"), `${name}\n`);
return `${name}:ok`;
};
const checkpointPath = join(options.dir, "checkpoint.bin");
const journalPath = join(options.dir, "journal.jsonl");
const world = worldMode ? openWorld() : null;
await world?.start?.();
const runId = worldMode ? readFileSync(join(options.dir, "session.txt"), "utf8") : undefined;
const opened = [];
const open = async () => createFxEngine({
...await agentOptions({
backend: options.backend,
fetch: scriptedFetch({ stepsFor, onRequest: (request) => requests.push(request) }),
tools: hostTools(run),
checkpoint: !journaled && existsSync(checkpointPath) ? readFileSync(checkpointPath) : undefined,
persistence: worldMode ? worldPersistence(world, { runId }) : journaled ? fileStore(journalPath) : undefined,
sessionId: runId,
}),
onEvent: (event) => { if (event.type === "journal.open") opened.push(event); },
});
let journalFolds = true;
let agent;
try {
agent = await open();
} catch (error) {
process.stdout.write(`${JSON.stringify({ summary: { completed: false, rememberedSetup: false, resultCounts: {}, journalFolds: false, error: String(error?.message ?? error) } })}\n`);
if (world) await closeWorld(world);
return;
}
// The setup turn is always committed. A second committed turn means the
// crashed turn finished before the kill; an open one is resumed.
// The setup turns, then the crash turn; a follow-up may have committed one more.
const committedBeforeOpen = journaled && opened[0]?.turns >= setupTurns + 1;
let result = { stopReason: "end_turn" };
const resumed = journaled ? agent.resume() : null;
if (resumed) {
for await (const _ of resumed) {}
result = await resumed.result;
} else if (!committedBeforeOpen) {
const turn = agent.prompt(`workload=${options.workload} crash`);
for await (const _ of turn) {}
result = await turn.result;
}
if (journaled) {
// Follow-ups the journal held run before the check.
await resumeAll(agent);
// Every turn so far must reach the model; a check prompt shows them.
const check = agent.prompt("workload=no-tool check");
for await (const _ of check) {}
await check.result;
}
await agent.close();
if (journaled) {
try {
await (await open()).close();
} catch {
journalFolds = false;
}
}
if (world) await closeWorld(world);
const prompt = requests.at(-1)?.body.prompt ?? [];
const resultCounts = {};
for (const id of toolResultIds(prompt)) resultCounts[id] = (resultCounts[id] ?? 0) + 1;
process.stdout.write(`${JSON.stringify({
summary: {
completed: result.stopReason === "end_turn",
rememberedSetup: prompt.some((message) => message.role === "user" && textOf(message).includes("setup")),
resultCounts,
journalFolds,
committedBeforeOpen,
...inputCounts(prompt),
},
})}\n`);
}
// Parent: orchestration only.
function runChild(role, settings) {
return startChild(role, settings).done;
}
// `holdAt` leaves the child waiting at step k until `release()`, which acks
// every step it printed meanwhile and every later one.
function startChild(role, { backend, workload, dir, killAt = Infinity, holdAt = Infinity }) {
const execArgs = !process.versions.bun && backend === "wasm" ? ["--experimental-wasm-jspi"] : [];
const child = spawn(process.execPath, [...execArgs, scriptPath, "--child", role, "--mode", options.mode, "--world-root", options["world-root"], ...(options.inputs ? ["--inputs"] : []), "--backend", backend, "--workload", workload, "--dir", dir], {
stdio: ["pipe", "pipe", "pipe"],
});
const steps = [];
let summary = null;
let killedAt = null;
let heldAt = null;
let unacked = 0;
let released = false;
let fenced = false;
let reachHold;
const held = new Promise((resolveHeld) => { reachHold = resolveHeld; });
let stderr = "";
child.stderr.on("data", (chunk) => { stderr = (stderr + chunk).slice(-4096); });
createInterface({ input: child.stdout }).on("line", (line) => {
const message = JSON.parse(line);
if (message.summary) summary = message.summary;
if (message.fenced) fenced = true;
if (message.step === undefined) return;
steps.push(message.name);
if (message.step >= killAt && killedAt === null) {
killedAt = message.name;
child.kill("SIGKILL");
} else if (message.step >= holdAt && !released) {
unacked += 1;
if (heldAt === null) {
heldAt = message.name;
reachHold();
}
} else child.stdin.write("go\n");
});
const timer = setTimeout(() => child.kill("SIGKILL"), Number(options["timeout-ms"]));
const done = new Promise((resolveChild) => {
child.on("close", (code, signal) => {
clearTimeout(timer);
reachHold();
resolveChild({ code, signal, steps, killedAt, heldAt, fenced, summary, stderr });
});
});
return {
done,
held,
release() {
released = true;
for (; unacked > 0; unacked -= 1) child.stdin.write("go\n");
},
};
}
async function parent() {
const backends = listOption(options.backends);
const workloads = listOption(options.workloads);
// Children get the JSPI flag themselves; the parent only orchestrates.
for (const backend of backends) if (!new Set(["native", "wasm"]).has(backend)) throw new Error(`unknown backend: ${backend}`);
for (const workload of workloads) if (!durableWorkloads[workload]) throw new Error(`unknown workload: ${workload}`);
const report = {
format_version: 1,
mode: options.mode,
race: options.race,
inputs: options.inputs,
runtime: process.versions.bun ? "bun" : "node",
runtime_version: process.versions.bun ?? process.version,
groups: [],
violations_by_invariant: {},
cells: 0,
failing_cells: 0,
// Batching can make a run shorter than the clean one, so step k may not
// exist in it; such a cell tests nothing and is counted apart.
skipped_cells: 0,
};
for (const backend of backends) {
for (const workload of workloads) {
const scratch = mkdtempSync(join(tmpdir(), "libfx-crash-"));
const clean = await runChild("worker", { backend, workload, dir: scratch });
rmSync(scratch, { recursive: true, force: true });
if (clean.code !== 0) {
throw new Error(`${backend} ${workload} clean run failed (${clean.code} ${clean.signal}) after steps [${clean.steps.join(", ")}]: ${clean.stderr}`);
}
const group = { backend, workload, steps: clean.steps, cells: [] };
for (let k = 1; k <= clean.steps.length; k += 1) {
const dir = mkdtempSync(join(tmpdir(), "libfx-crash-"));
try {
let crashed;
let restored;
let race = null;
if (options.race) {
const worker = startChild("worker", { backend, workload, dir, holdAt: k });
await worker.held;
restored = await runChild("restorer", { backend, workload, dir });
const before = await runChild("inspect", { backend, workload, dir });
worker.release();
crashed = await worker.done;
const after = await runChild("inspect", { backend, workload, dir });
race = { before: before.summary ?? null, after: after.summary ?? null };
} else {
crashed = await runChild("worker", { backend, workload, dir, killAt: k });
}
const stoppedAt = options.race ? crashed.heldAt : crashed.killedAt;
if (stoppedAt === null && crashed.steps.length < k) {
report.skipped_cells += 1;
group.cells.push({ k, skipped: true, steps: crashed.steps.length });
continue;
}
if (!options.race) restored = await runChild("restorer", { backend, workload, dir });
const effects = existsSync(join(dir, "effects.log")) ? readFileSync(join(dir, "effects.log"), "utf8").split("\n").filter(Boolean) : [];
const cell = {
k,
killed_at: stoppedAt,
killed: options.race ? crashed.heldAt !== null : crashed.signal === "SIGKILL",
...(race ? { worker_exit: crashed.code, worker_fenced: crashed.fenced } : {}),
restorer_exit: restored.code,
send_effects: effects.filter((name) => name === "send_email").length,
effects: effects.filter((name) => !name.startsWith("intent_missing:")).length,
...(restored.summary ?? { completed: false, rememberedSetup: false, resultCounts: {} }),
};
cell.violations = checkCrashCell({
workload,
sendEffects: cell.send_effects,
completed: cell.completed && restored.code === 0,
rememberedSetup: cell.rememberedSetup,
resultCounts: cell.resultCounts,
});
if (!cell.killed) cell.violations.push(`Harness: worker was not ${options.race ? "held" : "killed"} at step ${k} (${crashed.code} ${crashed.signal})`);
if (options.inputs) {
const acks = existsSync(join(dir, "acks.log")) ? readFileSync(join(dir, "acks.log"), "utf8").split("\n").filter(Boolean) : [];
cell.acks = acks;
for (const [kind, count] of [["steer", cell.steers ?? 0], ["follow_up", cell.followUps ?? 0]]) {
if (acks.includes(kind) && count === 0) cell.violations.push(`AckedAreDurable: the acknowledged ${kind} never reached the model`);
if (count > 1) cell.violations.push(`PlacedOnce: the ${kind} reached the model ${count} times`);
}
}
if (race) {
const { before, after } = race;
if (crashed.code !== 0) cell.violations.push(`Harness: the released worker failed (${crashed.code} ${crashed.signal}): ${crashed.stderr.slice(-300)}`);
if (!before || before.fingerprint !== after?.fingerprint) cell.violations.push("FencedNeverWrites: the released worker changed the session");
}
if (journaled) {
const missing = effects.filter((name) => name.startsWith("intent_missing:")).length;
if (missing) cell.violations.push(`StartsWithDurableIntent: ${missing} call(s) started before their intent was stored`);
if (!cell.journalFolds) cell.violations.push(`JournalFolds: the journal did not reopen${cell.error ? ` (${cell.error})` : ""}`);
}
for (const violation of cell.violations) {
const name = violation.split(":")[0];
report.violations_by_invariant[name] = (report.violations_by_invariant[name] ?? 0) + 1;
}
report.cells += 1;
if (cell.violations.length) report.failing_cells += 1;
group.cells.push(cell);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
report.groups.push(group);
}
}
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
if (options["require-clean"] && report.failing_cells > 0) process.exitCode = 1;
}
+87
View File
@@ -0,0 +1,87 @@
// Effectful shell shared by the durable harnesses: the scripted gateway as a
// fetch function, sleeps, and agent options. Decisions live in durable.mjs.
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { durableCatalog, durableModel, durableTools, framesFor } from "./durable.mjs";
export const repoRoot = resolve(fileURLToPath(new URL("../..", import.meta.url)));
const encoder = new TextEncoder();
// A fetch that answers the catalog GET and scripts every model POST from the
// request body. `stepsFor(prompt)` picks the scripted turn; `onRequest`
// sees each parsed request and the frames sent back.
export function scriptedFetch({ stepsFor, onRequest = () => {} }) {
return async (input, init = {}) => {
const method = String(init.method ?? input?.method ?? "GET").toUpperCase();
if (method === "GET") return Response.json(durableCatalog);
const raw = typeof init.body === "string" ? init.body : new TextDecoder().decode(init.body);
const body = JSON.parse(raw);
const frames = framesFor(stepsFor(body.prompt), body.prompt);
onRequest({ body, frames });
return new Response(new ReadableStream({
start(controller) {
for (const frame of frames) controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`));
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
}), { status: 200, headers: { "content-type": "text/event-stream" } });
};
}
export function sleep(ms, signal) {
if (ms <= 0) return Promise.resolve();
return new Promise((resolveSleep, reject) => {
const timer = setTimeout(resolveSleep, ms);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(signal.reason ?? new Error("aborted"));
}, { once: true });
});
}
let wasmBytes = null;
export async function agentOptions({ backend, fetch, tools = [], checkpoint, persistence, sessionId }) {
if (backend === "wasm") wasmBytes ??= await readFile(resolve(repoRoot, "zig-out/bin/fx-core.wasm"));
return {
backend,
nativeAddon: resolve(repoRoot, "zig-out/lib/libfx.node"),
...(backend === "wasm" ? { wasm: wasmBytes } : {}),
...(checkpoint ? { checkpoint } : {}),
...(persistence ? { persistence } : {}),
...(sessionId ? { sessionId } : {}),
fetch,
apiKey: "durable-harness-key",
gatewayChatUrl: "http://127.0.0.1:9/chat",
model: durableModel,
tools,
};
}
// libfx tool descriptors for the durable tool set, all routed to `run`.
export function hostTools(run) {
return Object.entries(durableTools).map(([name, policy]) => ({
name,
description: `Durable harness tool ${name}.`,
inputSchema: { type: "object", additionalProperties: true },
...policy,
execute: (input, context) => run(name, input, context),
}));
}
export function parseArgs(argv, defaults) {
const options = { ...defaults };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2);
if (!(key in defaults)) throw new Error(`unknown option --${key}`);
if (typeof defaults[key] === "boolean") options[key] = true;
else options[key] = argv[++index];
}
return options;
}
export const listOption = (value) => String(value).split(",").map((item) => item.trim()).filter(Boolean);
+262
View File
@@ -0,0 +1,262 @@
// Pure core for the durable libfx harnesses (bench-durable.mjs,
// crash-matrix.mjs, bench-parallel.mjs). Everything here is data in, data
// out: no I/O, clocks, timers or hidden randomness. The drivers own effects.
export const durableModel = "durable/model";
export const durableCatalog = {
object: "list",
data: [{ id: durableModel, type: "language", tags: ["tool-use"], context_window: 1_000_000, max_tokens: 4096 }],
};
// Tool behavior as data: whether each tool writes. send_email is the effect a
// crash must not repeat.
export const durableTools = {
read_item: { writes: false },
list_files: { writes: false },
read_file: { writes: false },
write_file: { writes: true },
write_item: { writes: true },
send_email: { writes: true },
};
// A libfx journal record: one append's events as UTF-8 JSON. Only libfx and
// its harnesses read one; a store keeps the bytes as they are.
export function journalRecord(events) {
return new TextEncoder().encode(JSON.stringify({ format: "libfx-journal-v1", events }));
}
export function recordEvents(data) {
return JSON.parse(new TextDecoder().decode(data)).events;
}
const integers = Array.from({ length: 200 }, (_, index) => String(index + 1));
// Each workload is the model's scripted side of one turn: steps run in
// order, and a step with calls waits for every call's result.
export const durableWorkloads = {
"no-tool": [{ text: ["done"] }],
"one-safe": [{ calls: [{ name: "read_item", input: { key: "alpha" } }] }, { text: ["done"] }],
"one-send": [{ calls: [{ name: "send_email", input: { to: "team" } }] }, { text: ["sent"] }],
"list-then-read": [
{ calls: [{ name: "list_files", input: { dir: "." } }, { name: "read_file", input: { path: "a.txt" } }] },
{ text: ["done"] },
],
"write-then-read": [
{ calls: [{ name: "write_file", input: { path: "a.txt", text: "v2" } }, { name: "read_file", input: { path: "a.txt" } }] },
{ text: ["done"] },
],
"stream-200": [{ text: integers.map((value, index) => (index === 0 ? value : ` ${value}`)) }],
};
const steeringMarker = "<user_steering>";
function textOf(message) {
if (typeof message?.content === "string") return message.content;
if (!Array.isArray(message?.content)) return "";
return message.content.filter((part) => part?.type === "text").map((part) => part.text ?? "").join("");
}
// Index of the message that started the current turn: the last user
// message that is not mid-turn steering.
export function turnStartIndex(prompt) {
for (let index = prompt.length - 1; index >= 0; index -= 1) {
const message = prompt[index];
if (message?.role === "user" && !textOf(message).includes(steeringMarker)) return index;
}
return -1;
}
// Number of turns so far, counted as non-steering user messages.
export function turnNumber(prompt) {
return prompt.filter((message) => message?.role === "user" && !textOf(message).includes(steeringMarker)).length;
}
// The tool results the current turn has produced, in request order.
export function toolResultIds(prompt) {
const start = turnStartIndex(prompt);
const ids = [];
for (const message of prompt.slice(start + 1)) {
if (!Array.isArray(message?.content)) continue;
for (const part of message.content) if (part?.type === "tool-result") ids.push(part.toolCallId);
}
return ids;
}
// Directives travel in the prompt text as `key=value` words, so the
// scripted gateway is a function of the request body alone and answers the
// same way after a restore.
export function promptDirectives(prompt) {
const text = textOf(prompt[turnStartIndex(prompt)]);
const directives = {};
for (const word of text.split(/\s+/)) {
const match = /^([a-z]+)=(\S+)$/.exec(word);
if (match) directives[match[1]] = match[2];
}
return directives;
}
export function callId(turn, step, index) {
return `t${turn}_s${step}_c${index}`;
}
// Which scripted step answers this request, given the results so far.
export function stepFor(steps, resultCount) {
let remaining = resultCount;
let step = 0;
while (step < steps.length && steps[step].calls && remaining >= steps[step].calls.length) {
remaining -= steps[step].calls.length;
step += 1;
}
if (remaining !== 0) throw new Error(`partial tool results: ${remaining} beyond step ${step}`);
if (step >= steps.length) throw new Error("the scripted turn has no step left");
return step;
}
const usage = { inputTokens: { total: 1 }, outputTokens: { total: 1 } };
// The SSE frames for one model request.
export function framesFor(steps, prompt) {
const turn = turnNumber(prompt);
const step = stepFor(steps, toolResultIds(prompt).length);
const current = steps[step];
if (current.calls) {
return [
...current.calls.map((call, index) => ({
type: "tool-call",
toolCallId: callId(turn, step, index),
toolName: call.name,
input: call.input,
})),
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage },
];
}
return [
...current.text.map((delta, index) => ({ type: "text-delta", id: String(index), delta })),
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage },
];
}
export function sseBody(frames) {
return frames.map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n";
}
// Deterministic PRNG (mulberry32): the seed goes in, a value and the next
// seed come out.
export function nextRandom(seed) {
const next = (seed + 0x6d2b79f5) >>> 0;
let t = next;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return { value: ((t ^ (t >>> 14)) >>> 0) / 4294967296, seed: next };
}
// A random batch of 2 to 8 calls with writer flags, latencies and faults.
export function randomBatch(seed, { writerRate = 0.3, throwRate = 0.1, maxLatencyMs = 4 } = {}) {
let state = seed >>> 0;
const draw = () => {
const result = nextRandom(state);
state = result.seed;
return result.value;
};
const size = 2 + Math.floor(draw() * 7);
const calls = Array.from({ length: size }, () => {
const writes = draw() < writerRate;
return {
name: writes ? "write_item" : "read_item",
writes,
latencyMs: Math.floor(draw() * (maxLatencyMs + 1)),
fault: draw() < throwRate ? "throw" : null,
};
});
return { calls, seed: state };
}
// The scripted turn for a batch: one step with every call, then text.
export function batchSteps(batch) {
return [
{ calls: batch.calls.map((call, index) => ({ name: call.name, input: { call: index, latencyMs: call.latencyMs, fault: call.fault } })) },
{ text: ["done"] },
];
}
// Start and end positions of each call in an observed trace of
// { call, kind: "start" | "end" } entries in real-time order.
function intervals(trace, size) {
const spans = Array.from({ length: size }, () => ({ start: null, end: null, starts: 0, ends: 0 }));
trace.forEach((entry, position) => {
const span = spans[entry.call];
if (!span) return;
if (entry.kind === "start") {
span.starts += 1;
span.start ??= position;
} else if (entry.kind === "end") {
span.ends += 1;
span.end ??= position;
}
});
return spans;
}
// A parallel batch's rules, checked on a real run: a writer runs alone,
// and results reach the model in call order. `resultOrder`
// is the tool call ids in the order the next model request carried them.
export function checkBatch({ writes, trace, resultOrder, expectedOrder }) {
const violations = [];
if (expectedOrder.length !== writes.length) {
return [`ScriptedBatch: the model asked for ${expectedOrder.length} calls, expected ${writes.length}`];
}
const spans = intervals(trace, writes.length);
spans.forEach((span, index) => {
if (span.starts !== 1 || span.ends !== 1) violations.push(`Completeness: call ${index} started ${span.starts} and ended ${span.ends} times`);
});
if (violations.length) return violations;
const overlaps = (a, b) => a.start < b.end && b.start < a.end;
writes.forEach((isWriter, index) => {
if (!isWriter) return;
spans.forEach((other, otherIndex) => {
if (otherIndex !== index && overlaps(spans[index], other)) violations.push(`WriterRunsAlone: writer ${index} overlaps call ${otherIndex}`);
if (otherIndex > index && other.start < spans[index].end) violations.push(`NothingPassesWriter: call ${otherIndex} started before writer ${index} ended`);
if (otherIndex < index && other.end > spans[index].start) violations.push(`WriterAfterEarlier: writer ${index} started before call ${otherIndex} ended`);
});
});
if (JSON.stringify(resultOrder) !== JSON.stringify(expectedOrder)) {
violations.push(`ResultsInModelOrder: got ${JSON.stringify(resultOrder)}, expected ${JSON.stringify(expectedOrder)}`);
}
return violations;
}
// Highest number of calls running at once in a trace.
export function maxConcurrency(trace) {
let running = 0;
let peak = 0;
for (const entry of trace) {
running += entry.kind === "start" ? 1 : entry.kind === "end" ? -1 : 0;
peak = Math.max(peak, running);
}
return peak;
}
// What must hold after a crash and restore, for one crash-matrix cell.
export function checkCrashCell({ workload, sendEffects, completed, rememberedSetup, resultCounts }) {
const violations = [];
const steps = durableWorkloads[workload];
if (!steps) throw new Error(`unknown workload: ${workload}`);
if (sendEffects > 1) violations.push(`SendRunsOnce: send_email ran ${sendEffects} times`);
if (!completed) violations.push("TurnCompletes: the restored session did not finish the turn");
if (!rememberedSetup) violations.push("KeepsCommittedHistory: the restored session lost the committed setup turn");
for (const [id, count] of Object.entries(resultCounts)) {
if (count !== 1) violations.push(`OneOutcome: call ${id} has ${count} results`);
}
return violations;
}
// What a host that makes libfx durable from outside pays today: four sequential
// acknowledged writes per tool call, plus the checkpoint after each turn.
export function todayAdapterWrites(toolCalls) {
return { perToolBefore: 2, perToolAfter: 2, perTurn: 1, awaited: 4 * toolCalls + 1 };
}
export function toolCallsIn(steps) {
return steps.reduce((total, step) => total + (step.calls?.length ?? 0), 0);
}
+4
View File
@@ -21,6 +21,9 @@ The SDK has two WebAssembly surfaces and one shared JavaScript host layer:
| WASI target, optimization mode, threading, and artifact names | `build.zig` |
| Core browser fixture and its automation contract | `sdk/index.html` and `sdk/tests/test-core-browser.mjs` |
| Terminal fixture and static packaging contract | `sdk/term-demo.html` and `sdk/scripts/package-term-demo.mjs` |
| Durable sessions: log, leases, queue, deadlines, UI stream, and the harness contract | `sdk/durable.js` (no fx imports) |
| fx as a durable harness: engine options, rerun policy | `sdk/fx-harness.js` |
| Checkpoint format, including the libfx version, tool set, and model each checkpoint records | `src/core/agent/runtime/checkpoint.zig` |
| Commands required by CI | `.github/workflows/ci.yml` |
Do not treat the demos or this file as the implementation contract. When prose and behavior disagree, establish the current behavior from code and focused tests before changing either.
@@ -52,6 +55,7 @@ Do not treat the demos or this file as the implementation contract. When prose a
| `encodeXtermKeyEvent()` or `xtermAdapter()` only | `sdk/node/test-xterm-adapter.mjs` |
| Core debugger query behavior or automation state | `sdk/tests/test-core-browser.mjs` |
| Terminal demo asset references, integrity, or cache policy | Package into a fresh temporary directory and inspect the generated HTML, manifest, and headers |
| Durable sessions: leases, fencing, takeovers, tool reruns, the UI stream | `sdk/tests/test-durable-core.mjs` on memory and local (the core with a scripted harness), `sdk/tests/test-durable.mjs` on memory and local, then `sdk/tests/model/check.sh` and `bun sdk/tests/model/drive.mjs send` and `lookup`, which walk the real code along the TLC state graph of `DurableSession.tla`; change the spec with the code. With a Vercel access token, also run both against Vercel's World: `test-durable.mjs vercel native` and `drive.mjs --durability vercel`, configured as `sdk/tests/vercel-storage.mjs` describes |
| Supported public behavior or setup | Update `sdk/README.md` in the same change |
Use Node.js 24 for SDK tests. CI relies on its JSPI implementation behind `--experimental-wasm-jspi`.
+488 -43
View File
@@ -1,8 +1,9 @@
# libfx
`libfx` is the small fx agent kernel for JavaScript hosts. One agent is one
in-memory conversation with `prompt`, `checkpoint`, and `close` operations,
plus mid-turn steering on each running turn.
`libfx` is the fx agent kernel for JavaScript hosts. You create an agent once
and open sessions on it. libfx saves each session as it runs, so a crash, a
function timeout, or a redeploy continues the conversation instead of losing
it. The same code runs on your machine and on Vercel.
```sh
npm install libfx
@@ -18,40 +19,221 @@ filesystem read when imported.
```js
import { createFxAgent } from "libfx";
const agent = await createFxAgent({
apiKey: process.env.AI_GATEWAY_API_KEY,
const agent = createFxAgent({
model: "google/gemini-2.5-flash-lite",
onEvent(event) {
if (event.type === "transport.response") console.log(event.elapsedMs);
},
instructions: "Answer in one paragraph.",
});
const turn = agent.prompt("Explain this project.");
const turn = agent.session().prompt("Explain this project.");
for await (const event of turn) {
if (event.type === "text_delta") process.stdout.write(event.delta);
}
console.log(await turn.result); // { stopReason, usage }
const checkpoint = await agent.checkpoint();
console.log(await turn.result); // { messageId, stopReason, usage }
await agent.close();
```
`apiKey` is required. `model` is optional and defaults to fx's built-in model.
Agent configuration uses named options; `env` is reserved for
`createFxTerminal()`. The canonical model configuration groups the model ID
and model-specific options:
`createFxAgent()` returns at once and does no I/O until a session runs a turn.
`apiKey` is optional. Without it, libfx uses `AI_GATEWAY_API_KEY`, then on
Vercel the deployment's OIDC token. `model` defaults to fx's built-in model.
Tools, MCP clients, and skills are agent options, described in
[JavaScript tools and instructions](#javascript-tools-and-instructions) and the
sections after it.
Because nothing loads before a session runs, an option the backend rejects,
such as an effort level the model does not support, fails that session's
first turn rather than the `createFxAgent()` call. The turn's `result` has the
stop reason `error` and the reason in `error`, and `onEvent` receives
`session.error`.
An agent is the configuration its sessions run under. Every process that
creates an agent with the same options can run the same sessions, so create
one agent for your server and open a session per conversation.
`agent.close()` waits for the turns this process is running, then lets their
sessions go.
### Sessions
`agent.session(id)` opens the session `id`, and `agent.session()` starts a new
one. Opening a session reads nothing; its first prompt does. A new session's
`id` is set once that prompt is accepted, so read it from `turn.accepted`:
```js
const agent = await createFxAgent({
apiKey,
const turn = agent.session().prompt("Plan the migration.");
const { sessionId } = await turn.accepted;
// Later, in any process:
const followUp = agent.session(sessionId).prompt("Start with the schema.");
```
A session id is 1 to 128 letters, digits, `.`, `_`, or `-`. On `local()` and
`vercel()`, new ids come from the World, and an id you choose must be `wrun_`
followed by 10 to 64 letters or digits. `agent.prompt()` and
`agent.checkpoint()` use a session the agent opens for itself, for code that
holds one conversation.
One process runs a session's turns at a time, one turn after another. A
second process that receives a prompt for a busy session queues it behind the
running turn. Pass `context` to hand JSON to the session's tools:
`agent.session(id, { context: { userId } })` gives every tool call
`context` beside its `executionId`.
### Turns
`session.prompt(input)` queues `input` as the session's next turn and returns
a view of that turn. Prompt input is a string or an array of content blocks,
as described in [prompt input](#prompt-input), except that image data must be a
base64 string, because the prompt is stored as JSON before it runs. A turn
view has:
- `messageId`: the turn's id, which libfx chooses unless you pass one.
- `accepted`: resolves to `{ messageId, sessionId }` once the prompt is stored.
- `result`: resolves to `{ messageId, stopReason, usage }` when the turn ends.
- Async iteration over the turn's events, as
[model and tool events](#prompt-input) describes.
- `readable`: the same turn as an NDJSON `ReadableStream` that a route can
return as its response body. Each line is an event with a `cursor`.
Dropping a turn view cancels nothing: the turn runs to its end whether or not
anyone reads it. Call `session.cancel()` to stop it. A prompt's `signal`
cancels the turn when it aborts.
Give a prompt a `messageId` when the same request can reach your server more
than once, such as a client retry. A second `prompt()` with an id the session
already accepted runs nothing: while that turn runs, the view follows it, and
after it ends, `result` resolves to its outcome with `repeated: true`. An id
follows the session id rule.
`session.stream(cursor)` returns every event the session has produced from
`cursor` on, as NDJSON, and stays open for the events after them. Any process
can serve it, so a browser that loses its connection reconnects with the last
`cursor` it saw and continues where it stopped. Beside the turn events, the
stream marks each turn with `turn_start` and `turn_end`, a continued turn with
`turn_resume`, and a turn that stopped for a function deadline with
`turn_yield`. `turn_start` and `turn_resume` carry the `sessionId`, so a client
that started a new session learns its id from the first line of
`turn.readable`.
Each line carries the `epoch` of the lease its writer held, and epochs only
rise over a session's life. A worker that was replaced while it ran can still land lines late, because the World accepts
them. A worker that takes a turn over writes its `turn_resume` line before it
goes on, and every reader hides a line whose epoch is lower than one before
it, so the replaced worker's later lines never show and every reader, on any
server and after any refresh, shows the same lines. A replaced worker's line
that lands in the moment between its successor's claim and that `turn_resume`
line still shows; only a World that refuses stale stream writes closes that
gap.
`session.steer(text)` adds guidance to the running turn at its next model
request, or to the next turn when none is running. `session.resume()`
continues a turn a stopped process left open and returns its view, whose
`result` has the stop reason `idle` when there was none. A prompt also
continues an open turn before it runs, so most hosts never call `resume()`.
### Durability
The `durability` option chooses where sessions live. Without it, libfx picks
one from the environment:
| Environment | Durability | Sessions live in |
| --- | --- | --- |
| Vercel, where `VERCEL` is set | `vercel()` | Vercel's World, the store and queue behind Vercel Workflow |
| Node.js elsewhere | `local()` | files in `FX_SESSIONS_DIR`, or `$TMPDIR/libfx/sessions` |
| Browsers | `memory()` | this page's memory |
```js
import { createFxAgent, memory } from "libfx";
import { local } from "libfx/durable-local";
createFxAgent({ durability: local({ dir: ".fx/sessions" }) });
createFxAgent({ durability: memory() }); // nothing survives the process
```
`libfx/durable-local` and `libfx/durable-vercel` carry their World inside
them, and libfx loads one only when a session first needs it, so your app
installs no other package and an app with no sessions loads neither.
libfx records each step of a turn as it happens. A turn's prompt is stored
before the model sees it, and a tool call with effects is stored before it
runs, so a crash never loses a prompt the caller was told was accepted and
never repeats a tool call silently. Other records are written while the model
works, without holding up the turn. A session also saves a checkpoint at the
end of every turn and wherever a turn yields, so the process that continues it
reads the checkpoint and the few records after it, not the whole history.
When the process running a turn stops, the next process to receive work for
the session continues the turn from its last record:
- On `local()`, a session held by a process that died is free at once.
- On `vercel()`, a turn holds its session until its function's deadline. When
the deadline is near, libfx stops before the next model request and saves
the turn, and the next invocation continues it. A model request or tool
call still running shortly before the deadline is cut off: `onEvent`
receives `session.deadline`, and the next invocation continues the turn,
running a cut-off idempotent call again and telling the model about any
other. So a process never runs on after the deadline its hold ends at. A
process that freezes and wakes after another took its session over stops
at its next write, and `onEvent` receives `session.fenced`. Until then it
can finish a model request or a call to an idempotent tool; it never starts
a tool with effects, because that waits for its record to be stored.
- On `memory()`, sessions end with the process.
The model is told when its turn was interrupted, so it can check what
happened before going on. A turn that stopped at a model request for a
deadline continues without that notice; one whose call was cut off gets it.
A durability holds conversation history only. Every process supplies the
model, credentials, instructions, tools, MCP clients, and skills when it
creates the agent. Every checkpoint records the libfx version, tool set, and
model that saved it. When a saved session, or a checkpoint passed as
`checkpoint`, was saved under different ones than the agent now has, it still
loads, and `onEvent` receives a `checkpoint.mismatch` event naming what
changed.
`session.checkpoint()` returns the session's history as opaque bytes, read
without changing the session. Pass them as `checkpoint` to start a new agent's
own session from them, for example to move a conversation between
durabilities.
### Tools with effects
When a process stops while a tool call runs, libfx cannot know whether the
call finished. Mark a tool `idempotent: true` when running it twice is safe,
such as a lookup:
```js
const tools = [
{ name: "get_order", idempotent: true, inputSchema, async execute(input, { executionId }) { /* ... */ } },
{ name: "refund_order", inputSchema, async execute(input, { executionId }) { /* ... */ } },
];
```
An idempotent call that was running runs again when the turn continues. Any
other call never runs again on its own: the turn continues at once, and the
model receives that call's result as an error saying it may have partly run,
so it can check the call's effects before calling it again or ask the user.
`executionId` is the same for a call each time it runs, including after a
crash. Pass it to the service the tool calls, as an idempotency key, so a call
that runs again changes nothing twice. Calls to idempotent tools also start
without waiting for their record to be stored.
## Model options
Model configuration groups the model ID and model-specific options:
```js
const agent = createFxAgent({
model: { id: "anthropic/claude-opus-5.5-fast", effort: "low", fast: true },
});
```
A string `model` remains supported as shorthand. Top-level `effort` and
`fast` are deprecated but remain supported with a string model or no model;
they cannot be mixed with a model object. New code should use the model object.
Agent configuration uses named options; `env` is reserved for
`createFxTerminal()`. A string `model` remains supported as shorthand.
Top-level `effort` and `fast` are deprecated but remain supported with a
string model or no model; they cannot be mixed with a model object. New code
should use the model object.
`model.effort` sets the reasoning effort for models that advertise effort
levels. It uses the same vocabulary as the fx CLI's `--effort` flag:
@@ -98,6 +280,23 @@ included.
libfx makes at most one automatic retry after a retryable transport failure and
only before model output or tool effects escape. Cancellation prevents a retry.
## A single engine
`createFxEngine()` is the kernel under `createFxAgent()`: one conversation,
held in the memory of the process that runs it, with no queue and no session
store. Use it to run turns inside one request and keep nothing, or to keep a
conversation in storage of your own through [persistence](#persistence). It
takes the same model, tool, MCP, and skill options, `apiKey` is required, and
it resolves once its backend has loaded. In this section, `agent` is an engine:
```js
import { createFxEngine } from "libfx";
const agent = await createFxEngine({ apiKey: process.env.AI_GATEWAY_API_KEY, model });
```
### Prompt input
`prompt(input, { signal? })` accepts a string or text, image, and resource
blocks. It returns an async iterable of normalized events:
@@ -124,7 +323,7 @@ A turn has one event consumer. Breaking out of its iterator cancels the turn;
`turn.cancel()` and `agent.close()` also release blocked output. Embedded agents
have no implicit model-step cap, so hosts should cancel turns that exceed their
own budgets. The CLI's `max_agent_steps` setting does not apply to
`createFxAgent()`. Transport or message-decoding failures reject the result
`createFxEngine()`. Transport or message-decoding failures reject the result
instead of returning success with missing text.
Native transport buffers at most 8 MiB of output bytes. Unread SDK events apply
@@ -191,7 +390,7 @@ For a Node.js host that already uses `sharp`:
```js
import sharp from "sharp";
const agent = await createFxAgent({
const agent = await createFxEngine({
apiKey,
model,
async resizeImage({ bytes }) {
@@ -250,7 +449,7 @@ const prepareImage = {
};
},
};
// Supply prepareImage in createFxAgent({ tools: [prepareImage], ... }).
// Supply prepareImage in createFxEngine({ tools: [prepareImage], ... }).
```
`imageStore` is application code, not a libfx API. It must authorize references,
@@ -285,9 +484,36 @@ for await (const event of turn) {
}
```
`steer()` resolves to `{ id }`, and the returned promise carries the same `id`
at once. Until the model sees a steer, `turn.withdraw(id)` takes it back and
resolves to `withdrawn`; after that it resolves to `already_placed`. With
[persistence](#persistence), a steer resolves only once its acceptance is stored, a model request
carries it only after that, and a withdrawal resolves once it is stored, so a
crash never loses a steer the call reported as accepted: `agent.resume()`
delivers one the model had not seen yet. The web core accepts a steer when it
takes it at the next boundary, so there `steer()` resolves then, and a steer
still queued when the turn ends rejects.
`agent.followUp(input)` queues text to run as its own turn once the current
turn ends, or at once when none is running, instead of failing with
`a prompt is already in progress`. It returns a promise for that turn; the
promise carries the follow-up's `id` and `accepted`, which resolves `{ id }`
once it is stored. With persistence, a follow-up survives a crash. After a
restore, the follow-ups the session held have no caller, so each waits for
`agent.resume()`: every call continues the open turn first, then starts the
next held follow-up, and returns `null` once none is left. Follow-ups this
agent queues run on their own and do not wait behind held ones. Call `resume()`
until it returns `null` whenever a session opens, before this agent queues
follow-ups of its own: one queued during a resumed turn starts when that turn
ends, and `resume()` throws while it runs. A follow-up whose turn ends before
libfx records its first `turn_progress` is withdrawn, and one that ends after
it is recorded like any other turn, so a follow-up never runs twice. `close()`
rejects follow-ups that have not started; the session's records still hold them.
Cancelling a steered turn drops any guidance that has not reached a safe
boundary and releases its queue. Applied guidance is part of the same history
turn, so an idle `checkpoint()` includes the full steered conversation.
`checkpoint()` returns opaque, bounded, versioned bytes. Concurrent calls run
one at a time, and a call still waiting for an earlier one fails the same way a
direct call would if a prompt starts or the agent closes first. A newer libfx
@@ -295,7 +521,7 @@ restores checkpoints from older versions, but an older libfx cannot restore one
written by a newer version. Restore them only when creating a fresh agent:
```js
const restored = await createFxAgent({ apiKey, model, checkpoint });
const restored = await createFxEngine({ apiKey, model, checkpoint });
```
An already-aborted prompt signal returns `cancelled` without a model request
@@ -308,6 +534,148 @@ agent-creation options and are not stored in a checkpoint: recreate the agent
with new `model.effort`, `model.fast`, or `model.ultrafast` values to change
them, the same path as switching models.
### Persistence
Pass a store as `persistence`, and libfx records the session as it runs, so a
new agent can continue it after the process running it stops. libfx writes
opaque records and checkpoints to the store and reads them back. The store
never parses them, and libfx never sees how they are stored.
```js
import { createFxEngine, createMemoryPersistence } from "libfx";
const persistence = createMemoryPersistence();
const agent = await createFxEngine({ apiKey, model, persistence, sessionId: "support-42" });
```
A store is an object with these methods:
```ts
interface Persistence {
load(): Promise<{
checkpoint?: { data: Uint8Array; through: string };
journal?: Iterable<JournalRecord> | AsyncIterable<JournalRecord>;
}>;
append(input: { expected: string | null; idempotencyKey: string; data: Uint8Array }): Promise<{ cursor: string }>;
saveCheckpoint?(input: { through: string; data: Uint8Array }): Promise<void>;
}
interface JournalRecord {
cursor: string;
data: Uint8Array;
}
```
`append()` stores one record and resolves to its cursor, a string the store
chooses that places the record after the ones before it. The session's records
are its journal. libfx sends records one at a time, in order, and `expected`
is the cursor of the last record the agent knows of, or `null` for a new
session. Reject the append with an `FxFencedError` when `expected` is not the
cursor of the last stored record, so that a second agent writing the same
session stops the first instead of interleaving with it. `idempotencyKey` is
unique to each write, so a store that retries a write can return the stored
record's cursor instead of storing it twice.
`createFxEngine()` calls `load()` once. Resolve to the latest checkpoint, if any,
and the records stored after it, oldest first, each with its cursor. Resolve to
`{}` for a session with nothing stored. libfx skips any record the checkpoint
already covers.
libfx waits for the store only where a crash could otherwise lose work or
repeat it: the first record of each turn, which holds the prompt, is stored
before the model sees it, and a response's tool calls are stored before any of
them starts. Other appends are not waited for, so a turn's `result` can settle
before its last records land, and `agent.close()` waits for every append. If
`append` rejects, libfx stops the current turn at once: it starts no further
model request or tool call and writes nothing more. The turn fails with an
error whose `code` is `FX_JOURNAL_APPEND_FAILED` and whose `cause` is your
error, and the agent refuses later prompts. When no turn was left to report
the failure, `close()` rejects with it. A model request the turn started while
the failed append was in flight can still complete.
When the store has `saveCheckpoint()`, libfx saves a checkpoint after a turn
ends or yields once the records stored since the last checkpoint reach
`checkpointAfterBytes` (default 1 MiB). A checkpoint where a turn yielded
holds the open turn, and the next load continues it. `through` is the cursor of the last
record the checkpoint covers, so `load()` can return that checkpoint and only
the records after it, and the cost of opening a session stays bounded as its
history grows. libfx takes a checkpoint only while no turn is running. A saved
checkpoint emits a `checkpoint.save` event. A failed save emits
`checkpoint.error` and changes nothing: the records still restore the session,
and the next turn's end tries again. Deleting the records a checkpoint covers
is up to the store.
The records a load reads, after libfx drops the older progress of an open
turn, must fit in 4 MiB of JSON, a checkpoint in 4 MiB, and the history in
1,024 turns. Otherwise `createFxEngine()` rejects with an error whose `code` is
`FX_JOURNAL_TOO_LARGE`. libfx refuses records that repeat, skip, or do not
parse, and `createFxEngine()` rejects with the reason and the `code`
`FX_JOURNAL_INVALID`. For records written by a newer libfx, it rejects with an
`FxJournalVersionError`, whose `code` is `FX_JOURNAL_VERSION`. Each libfx
release resumes the sessions and checkpoints that the release before it saved,
so upgrade the processes that read a session before the ones that write it.
AI Gateway keys session affinity and prompt caching to the session's id. libfx
picks a new id for each agent unless you pass `sessionId`, so give a restored
session the id it had before. An id is 1 to 255 letters, digits, `.`, `_`, or
`-`. `agent.sessionId` is the id the agent uses.
If the last process stopped during a turn, `agent.resume()` continues that
turn with no new input and returns it, like `prompt()`. The model is told
"Resuming from unexpected session interruption.". A tool call that was
running does not run again: it comes back answered with an error saying it may
have partly run, and the model decides whether to check its effects, call it
again, or ask the user. A turn that fails or is cancelled ends in the journal
as it does in the agent, so only a stopped process leaves one to resume.
`resume()` returns `null` when the session holds no open turn and no held
follow-up, so a host can call it until it does every time it opens a session:
```js
const agent = await createFxEngine({ apiKey, model, persistence, sessionId, tools });
for (let turn = agent.resume(); turn; turn = agent.resume()) {
for await (const event of turn) console.log(event);
await turn.result;
}
```
When your host knows a call never ran, such as one it handed to another
process before the call did anything, pass `resume({ onAmbiguous })`. libfx
calls `onAmbiguous({ executionId, name, input })` once for each call the turn
left running, and each call it returns `"rerun"` for runs again under the same
`executionId` before the turn continues. Every other call keeps the answer that it may
have partly run.
Calling `prompt()` instead ends the open turn as interrupted and starts a new
one.
Give a prompt a `turnId` when the same request can reach libfx more than once,
such as from a queue that delivers it again after a crash. When the open turn
has that id, `prompt(input, { turnId })` continues it as `resume()` does, and
takes `onAmbiguous` the same way. When
the session's last turn ended with that id, it returns a turn that has already
ended, with the stop reason `end_turn`, without calling the model. Any other id
starts a new turn. A turn id is 1 to 128 letters, digits, `.`, `_`, or `-`.
Without one, libfx names the turn. `turn.id` is the turn's id.
To move a running session to another process, call
`turn.cancel({ reason: "handoff" })`. The turn stops at once, like any
cancellation, but libfx stores nothing more, so the journal keeps the turn
open and the agent that opens the session next continues it with `resume()`.
The handed-off agent then refuses `prompt()`, `resume()`, and `followUp()`;
close it. A handoff needs persistence.
Pass `persistence` instead of a `checkpoint` option; the two cannot be
combined. Like a checkpoint, persistence holds conversation history only:
resupply models, credentials, instructions, tools, MCP clients, and skill
records when you create the agent. A resumed turn continues under the tools the
new agent has. `createMemoryPersistence()` keeps the records and the latest
checkpoint in memory as `persistence.records` and `persistence.checkpoint`,
which suits tests and hosts that copy them to their own storage. It fences an
append whose `expected` cursor is not its last record's, and returns the stored
cursor for a repeated `idempotencyKey`. `FxFencedError` is exported by `libfx`;
its `code` is `FX_FENCED`, which a store of your own can use for the same case.
`FxJournalVersionError` is exported by `libfx` too.
## Models
Model discovery is explicit and does not create an Agent or load native or Wasm
@@ -325,10 +693,25 @@ const models = await listModels({
language-model IDs. It accepts the same optional `fetch` override as the Agent
API.
An agent reads the AI Gateway model catalog to learn what its model supports,
such as image input, reasoning effort, and output limits, and those details
shape every request. It fetches the catalog through your `fetch`. If that
request fails, for example behind a proxy that only forwards chat requests,
the agent cannot confirm the model's capabilities and refuses image prompts.
Pass `modelCatalog` to supply the catalog instead: the entries from
`https://ai-gateway.vercel.sh/coding-agent/v1/models`, either the response's
`{ data }` or the array, or only the entries for the models you use. The agent
then makes no catalog request, so every process that receives the same
entries builds the same requests:
```js
const agent = createFxAgent({ apiKey, model, modelCatalog });
```
## JavaScript tools and instructions
```js
const agent = await createFxAgent({
const agent = createFxAgent({
apiKey,
model,
instructions: "Keep answers concise.",
@@ -351,7 +734,7 @@ Gateway web search can run at the provider instead of in the JavaScript host.
Mark its canonical tool name with `providerExecuted: true` and omit `execute`:
```js
const agent = await createFxAgent({
const agent = createFxAgent({
apiKey,
tools: [{ name: "web_search", providerExecuted: true }],
});
@@ -365,6 +748,25 @@ call host code or require a separate provider key; their `tool_start` and
Their built-in permission policy is enforced when the request is projected, as
there is no local call-time effect to approve.
`tools` may also be an object keyed by tool name, such as
`tools: { lookup, save }`, where each value is a descriptor without `name`.
A descriptor that does include `name` must match its key.
When one model response calls several tools, the native backend runs them at
the same time and returns their results to the model in the order it called
them. Mark a tool `writes: true` when its calls must not overlap others: it
starts after every earlier call in the response finishes, and later calls
wait for it. WebAssembly runs calls one at a time. Every call still goes
through its own permission check before any of them starts.
`execute` receives `{ signal, executionId }`, plus `context` when the session
has one. `executionId` is the model's id for the call and stays the same when
the call runs again after a crash, so a tool with an external effect can pass
it on as an idempotency key. [Tools with effects](#tools-with-effects)
describes which calls run again.
When a tool rejects with an empty message, the model receives a non-empty error
so the provider does not refuse the conversation.
For ordinary tools, the JavaScript host is the authority for effects. The same
descriptors, schemas, cancellation, results, and events are used by N-API and
WebAssembly.
@@ -430,7 +832,7 @@ const mcp = await createMcpAdapter(client, {
prompts: ["review"],
});
const agent = await createFxAgent({
const agent = createFxAgent({
apiKey,
model,
tools: mcp.tools,
@@ -453,15 +855,15 @@ import { createSkillsAdapter } from "libfx/skills";
const record = await loadSkillFile("./skills/review/SKILL.md");
const skills = createSkillsAdapter([record]);
const agent = await createFxAgent({ apiKey, model, ...skills });
const agent = createFxAgent({ apiKey, model, ...skills });
```
## Backends
```js
await createFxAgent({ apiKey, backend: "auto" }); // native, then Wasm fallback
await createFxAgent({ apiKey, backend: "native" }); // require N-API
await createFxAgent({ apiKey, backend: "wasm" }); // require Wasm + JSPI
createFxAgent({ apiKey, backend: "auto" }); // native, then Wasm fallback
createFxAgent({ apiKey, backend: "native" }); // require N-API
createFxAgent({ apiKey, backend: "wasm" }); // require Wasm + JSPI
```
CommonJS applications can load the same Node API with `require("libfx")`. The
@@ -540,28 +942,71 @@ This native setup does not require JSPI. Explicit WebAssembly use still needs
JSPI and available Wasm assets; Next.js's standalone tracer excludes `.wasm`
files, so a standalone Wasm host must supply those assets separately.
Create one agent for the server and open a session per request. The route
returns the turn's stream and never runs the agent itself:
```js
// lib/agent.js
import { createFxAgent } from "libfx";
export const runtime = "nodejs";
export const agent = createFxAgent({ model: "anthropic/claude-haiku-4.5", tools });
```
```js
// app/api/chat/route.js
import { agent } from "@/lib/agent";
export async function POST(request) {
const { prompt } = await request.json();
const agent = await createFxAgent({ apiKey: process.env.AI_GATEWAY_API_KEY });
try {
let text = "";
const turn = agent.prompt(prompt, { signal: request.signal });
for await (const event of turn) {
if (event.type === "text_delta") text += event.delta;
const sessionId = new URL(request.url).searchParams.get("sessionId") ?? undefined;
// Check that the caller may use this session.
const turn = agent.session(sessionId).prompt(await request.text());
return new Response(turn.readable);
}
// Reconnects from any server.
export async function GET(request) {
const params = new URL(request.url).searchParams;
// The same check as POST.
const stream = agent.session(params.get("sessionId")).stream(Number(params.get("cursor") ?? 0));
return new Response(stream);
}
```
On your machine, the server runs each turn in its own process and keeps the
session with `local()`. On Vercel, `prompt()` sends the prompt to Vercel Queues, and a separate
invocation runs the turn while the `POST` response streams it. If the route's
response ends first, the turn keeps running, and the client reconnects with
the last `cursor` it read.
Until Vercel's World can deliver queue messages to the function itself, mount
the agent's delivery route and subscribe it to libfx's queue topic in
`vercel.json`:
```js
// app/api/libfx/route.js
import { agent } from "@/lib/agent";
export const POST = agent.wakeHandler();
```
```json
{
"functions": {
"app/api/libfx/route.js": {
"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "__libfx_wkf_workflow_session" }]
}
await turn.result;
return Response.json({ text });
} finally {
await agent.close();
}
}
```
The delivery route's maximum duration bounds each invocation. libfx stops a
turn 30 seconds before the deadline, at its next model request, and cuts off a
call still running 2 seconds before it; either way the same queue message runs
the rest of the turn in a new invocation. Pass
`durability: vercel({ reserveMs })` from `libfx/durable-vercel` to change that
margin. libfx authenticates to the AI Gateway with the deployment's OIDC token
unless you pass `apiKey` or set `AI_GATEWAY_API_KEY`.
Use the application's normal authentication and request limits around the
route. JavaScript tools and MCP clients remain host-owned and must be supplied
when creating an agent, including after checkpoint restoration. The native
+17 -3
View File
@@ -1,6 +1,11 @@
import { createDurableAgentFactory, memory } from "./durable.js";
import { fxHarness } from "./fx-harness.js";
import {
createFxAgent as createWasmAgent,
createFxEngine as createWasmAgent,
createFxTerminal as createWasmTerminal,
createMemoryPersistence,
FxFencedError,
FxJournalVersionError,
encodeXtermKeyEvent,
fxSdkApiVersion,
listModels,
@@ -8,16 +13,25 @@ import {
xtermAdapter,
} from "./fx-sdk.js";
export { encodeXtermKeyEvent, fxSdkApiVersion, listModels, supportsJspi, xtermAdapter };
export { createMemoryPersistence, encodeXtermKeyEvent, FxFencedError, FxJournalVersionError, fxSdkApiVersion, listModels, memory, supportsJspi, xtermAdapter };
export const libfxApiVersion = 2;
const defaultCoreWasm = new URL("./fx-core.wasm", import.meta.url).href;
const defaultTermWasm = new URL("./fx-term.wasm", import.meta.url).href;
export function createFxAgent(options = {}) {
/** One fx session on WebAssembly, with no durability of its own. */
export function createFxEngine(options = {}) {
return createWasmAgent({ ...options, wasm: options.wasm ?? defaultCoreWasm });
}
/** An agent whose sessions live in this page unless `durability` says otherwise. */
export const createFxAgent = createDurableAgentFactory({
name: "createFxAgent",
label: "fx agent",
defaultDurability: async () => memory(),
harness: fxHarness({ createEngine: createFxEngine }),
});
export function createFxTerminal(options = {}) {
return createWasmTerminal({ ...options, wasm: options.wasm ?? defaultTermWasm });
}
+1485
View File
File diff suppressed because it is too large. Load diff
+41
View File
@@ -0,0 +1,41 @@
// Sessions kept in files on this machine, through Workflow's local World.
// libfx bundles that World into this module when it is packaged, so an app
// installs nothing more.
import { hostname } from "node:os";
import { createWorld } from "@workflow/world-local";
const host = hostname();
/**
* `dir` is where the sessions live. Workers on this machine hold a session
* while their process runs, so a crashed worker's session frees at once.
* With `maxDurationMs`, each delivery runs as if its function stopped after
* that long, stopping `reserveMs` before, as on Vercel.
*/
export function local({ dir, maxDurationMs, reserveMs } = {}) {
if (dir !== undefined && (typeof dir !== "string" || dir.length === 0)) throw new TypeError("local() dir must be a path");
for (const [key, value] of Object.entries({ maxDurationMs, reserveMs })) {
if (value !== undefined && !(Number.isSafeInteger(value) && value >= 0)) throw new TypeError(`local() ${key} must be a non-negative integer`);
}
return {
[Symbol.for("libfx.durability")]: true,
name: "local",
world: () => createWorld({ ...(dir === undefined ? {} : { dataDir: dir }), recoverActiveRuns: false }),
livenessKnown: true,
// world-local keeps its queue in memory, so a message dies with the process.
queueDurable: false,
pollMs: 250,
...(maxDurationMs === undefined ? {} : { maxDurationMs }),
...(reserveMs === undefined ? {} : { reserveMs }),
holderInfo: () => ({ pid: process.pid, host }),
alive(lease) {
if (lease.host !== host || !Number.isSafeInteger(lease.pid)) return null;
try {
process.kill(lease.pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
},
};
}
+659
View File
@@ -0,0 +1,659 @@
{
"name": "libfx-durable-worlds",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "libfx-durable-worlds",
"dependencies": {
"@vercel/oidc": "3.8.10",
"@workflow/world-local": "5.0.2",
"@workflow/world-vercel": "5.1.0"
}
},
"node_modules/@cbor-extract/cbor-extract-darwin-arm64": {
"version": "2.2.2",
"integrity": "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@cbor-extract/cbor-extract-darwin-x64": {
"version": "2.2.2",
"integrity": "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-arm": {
"version": "2.2.2",
"integrity": "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-arm64": {
"version": "2.2.2",
"integrity": "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-x64": {
"version": "2.2.2",
"integrity": "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-win32-x64": {
"version": "2.2.2",
"integrity": "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@vercel/cli-config": {
"version": "0.3.1",
"integrity": "sha512-sISbygqlFXYb8qfPu/+2Cf/A8IjrlAuZePdag3EDQr2PRA455s7UZtl9Ro674CWsB/saBRmz3jEkw0YsQs8OoQ==",
"license": "Apache-2.0",
"dependencies": {
"xdg-app-paths": "5",
"zod": "4.1.11"
}
},
"node_modules/@vercel/cli-config/node_modules/zod": {
"version": "4.1.11",
"integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@vercel/cli-exec": {
"version": "1.0.1",
"integrity": "sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==",
"license": "Apache-2.0",
"dependencies": {
"execa": "5.1.1"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@vercel/functions": {
"version": "3.9.11",
"integrity": "sha512-DbqKqcbUHGBTYgHZieSNDuF1/L2iSPZV0OGm0Xkm/t+s61lFk4VX8yGQ22Og3WYGxypW1l4Elx1NP8BL928u8Q==",
"license": "Apache-2.0",
"dependencies": {
"@vercel/oidc": "4.0.0"
},
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"@aws-sdk/credential-provider-web-identity": "*",
"ws": ">=8"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-provider-web-identity": {
"optional": true
},
"ws": {
"optional": true
}
}
},
"node_modules/@vercel/functions/node_modules/@vercel/oidc": {
"version": "4.0.0",
"integrity": "sha512-ko6ATeRUlHZMB5hLFdC7+/Q1dI2f3NX3gZHxtpALdkxurwjD5HFHGWYF+4o+4SF0E47jg6KzcHknmRpHcKgJ5A==",
"license": "Apache-2.0",
"dependencies": {
"@vercel/cli-config": "0.3.1",
"@vercel/cli-exec": "1.0.1",
"jose": "^5.9.6"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@vercel/oidc": {
"version": "3.8.10",
"integrity": "sha512-sPw1kIfQEA6/2dB+zlAJHnhhNBRJ8t/t4HfFvKO+g0EPBA7NbCKyGovuhgAV6Mnl865ka7HqCEguIy596czdAw==",
"license": "Apache-2.0",
"dependencies": {
"@vercel/cli-config": "0.3.1",
"@vercel/cli-exec": "1.0.1",
"jose": "^5.9.6"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@vercel/queue": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@vercel/queue/-/queue-0.8.0.tgz",
"integrity": "sha512-Rwaoq8DbSN2/qVPMdHzBdBXqpoD2h2s7+gOSaE3Gff9XI1NjNh281uMesP30k1kbsnjgeaibLTSiuYDlNPlw+g==",
"license": "MIT",
"dependencies": {
"@vercel/oidc": "^3.0.5",
"minimatch": "^10.2.4",
"mixpart": "0.0.6",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@workflow/errors": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@workflow/errors/-/errors-5.0.2.tgz",
"integrity": "sha512-AcdiuwfOqHZ7Bwey4wsuhEKh8HWfUfAMcOCvEt4uIaC7v1qOJY2iVSi9ahX9YPaMB7vngcBEgvZsoasI2/FyIw==",
"license": "Apache-2.0",
"dependencies": {
"@workflow/utils": "5.0.1",
"@workflow/world": "5.0.2",
"ms": "2.1.3"
}
},
"node_modules/@workflow/utils": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@workflow/utils/-/utils-5.0.1.tgz",
"integrity": "sha512-s/32vojtP1qYDPINp9wfE5JZRkjdhXI8lyg4l+QNrKjMwiaXv6vNRpsuGQrcdj2Nfnnvz3L4DOxdNyPW3MG+Jg==",
"license": "Apache-2.0",
"dependencies": {
"ms": "2.1.3"
}
},
"node_modules/@workflow/world": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@workflow/world/-/world-5.0.2.tgz",
"integrity": "sha512-XW7sGTkyL7/NtGc52yOZYKsPdtUZymqMssUEWuSbEODDzsW8mSKx/Y3RdGAwofZm1+MUQswWftwvpIXOMkTqAw==",
"license": "Apache-2.0",
"dependencies": {
"ulid": "~3.0.1",
"zod": "~4.5.4"
}
},
"node_modules/@workflow/world-local": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@workflow/world-local/-/world-local-5.0.2.tgz",
"integrity": "sha512-D/JLwNiELaFIo0xxvOIZs+JS6j20EESG965q6EDXrvDe5YeertPmsJvbr01f84ymSyjWQEfaB6Q21KSFOlDHcA==",
"license": "Apache-2.0",
"dependencies": {
"@vercel/queue": "0.8.0",
"@workflow/errors": "5.0.2",
"@workflow/utils": "5.0.1",
"@workflow/world": "5.0.2",
"async-sema": "3.1.1",
"proper-lockfile": "4.1.2",
"ulid": "~3.0.1",
"undici": "7.30.0",
"zod": "~4.5.4"
},
"peerDependencies": {
"@opentelemetry/api": "1"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@workflow/world-vercel": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@workflow/world-vercel/-/world-vercel-5.1.0.tgz",
"integrity": "sha512-ZgaHJ1+cwfXZlRuiUNp1H2TA7gVI8KAzpvWZQ8iW8uM1EXFsO679chNeCCQlrPZLOVsICnC9/epD7xkKl3W9Bg==",
"license": "Apache-2.0",
"dependencies": {
"@vercel/functions": "^3.8.0",
"@vercel/oidc": "3.2.0",
"@vercel/queue": "0.8.0",
"@workflow/errors": "5.0.2",
"@workflow/utils": "5.0.1",
"@workflow/world": "5.0.2",
"cbor-x": "1.6.0",
"ulid": "~3.0.1",
"undici": "7.30.0",
"ws": "^8.20.0",
"zod": "~4.5.4"
},
"peerDependencies": {
"@opentelemetry/api": "1"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@workflow/world-vercel/node_modules/@vercel/oidc": {
"version": "3.2.0",
"integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
}
},
"node_modules/async-sema": {
"version": "3.1.1",
"integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/cbor-extract": {
"version": "2.2.2",
"integrity": "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.1.1"
},
"bin": {
"download-cbor-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@cbor-extract/cbor-extract-darwin-arm64": "2.2.2",
"@cbor-extract/cbor-extract-darwin-x64": "2.2.2",
"@cbor-extract/cbor-extract-linux-arm": "2.2.2",
"@cbor-extract/cbor-extract-linux-arm64": "2.2.2",
"@cbor-extract/cbor-extract-linux-x64": "2.2.2",
"@cbor-extract/cbor-extract-win32-x64": "2.2.2"
}
},
"node_modules/cbor-x": {
"version": "1.6.0",
"integrity": "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==",
"license": "MIT",
"optionalDependencies": {
"cbor-extract": "^2.2.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/execa": {
"version": "5.1.1",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/get-stream": {
"version": "6.0.1",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/human-signals": {
"version": "2.1.0",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jose": {
"version": "5.10.0",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"license": "MIT"
},
"node_modules/mimic-fn": {
"version": "2.1.0",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mixpart": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/mixpart/-/mixpart-0.0.6.tgz",
"integrity": "sha512-CRdXtgfQH2jARmtNmPR0Q7jL20fiESbaYk1b0KvLD0jCdUuemepREtsbd8nbiY6BHV9OGGddAZITNXklupUPUQ==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.1.1",
"integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/npm-run-path": {
"version": "4.0.1",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/onetime": {
"version": "5.1.2",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"license": "MIT",
"dependencies": {
"mimic-fn": "^2.1.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/os-paths": {
"version": "4.4.0",
"integrity": "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==",
"license": "MIT",
"engines": {
"node": ">= 6.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/proper-lockfile": {
"version": "4.1.2",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"retry": "^0.12.0",
"signal-exit": "^3.0.2"
}
},
"node_modules/retry": {
"version": "0.12.0",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "3.0.7",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/strip-final-newline": {
"version": "2.0.0",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/ulid": {
"version": "3.0.2",
"integrity": "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==",
"license": "MIT",
"bin": {
"ulid": "dist/cli.js"
}
},
"node_modules/undici": {
"version": "7.30.0",
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/which": {
"version": "2.0.2",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/ws": {
"version": "8.22.0",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xdg-app-paths": {
"version": "5.5.1",
"integrity": "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==",
"license": "MIT",
"dependencies": {
"os-paths": "^4.0.1",
"xdg-portable": "^7.2.0"
},
"engines": {
"node": ">= 6.0"
}
},
"node_modules/xdg-portable": {
"version": "7.3.0",
"integrity": "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==",
"license": "MIT",
"dependencies": {
"os-paths": "^4.0.1"
},
"engines": {
"node": ">= 6.0"
}
},
"node_modules/zod": {
"version": "4.5.4",
"integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "libfx-durable-worlds",
"private": true,
"description": "The Workflow Worlds libfx bundles into libfx/durable-local and libfx/durable-vercel",
"type": "module",
"dependencies": {
"@vercel/oidc": "3.8.10",
"@workflow/world-local": "5.0.2",
"@workflow/world-vercel": "5.1.0"
}
}
+25
View File
@@ -0,0 +1,25 @@
// Sessions kept in Vercel's World, the store and queue behind Vercel
// Workflow. libfx bundles that World into this module when it is packaged,
// so an app installs nothing more and ignores any copy it has.
import { getVercelOidcToken } from "@vercel/oidc";
import { createWorld } from "@workflow/world-vercel";
/**
* Each worker holds a session until its function's deadline, and stops
* `reserveMs` before it so the next invocation continues the turn.
*/
export function vercel({ reserveMs } = {}) {
if (reserveMs !== undefined && !(Number.isSafeInteger(reserveMs) && reserveMs >= 0)) throw new TypeError("vercel() reserveMs must be a non-negative integer");
return {
[Symbol.for("libfx.durability")]: true,
name: "vercel",
world: () => createWorld(),
// The deployment's own AI Gateway credential, fresh for each request.
gatewayKey: () => getVercelOidcToken(),
livenessKnown: false,
// Vercel Queues keeps a message until a delivery acknowledges it.
queueDurable: true,
pollMs: 1000,
...(reserveMs === undefined ? {} : { reserveMs }),
};
}
+93
View File
@@ -0,0 +1,93 @@
// fx as a harness of the durable core in durable.js: `createFxAgent` is that
// core running fx engines. Everything the core needs to know about fx is
// here; the core itself knows only the harness contract.
import { engineInternals, journalMarksWanted } from "./fx-sdk.js";
const encoder = new TextEncoder();
/**
* The fx harness for `createDurableAgentFactory`. `createEngine(options)`
* returns a `createFxEngine` session, and `defaultApiKey(durability)` the
* credential when the caller passes none.
*/
export function fxHarness({ createEngine, defaultApiKey = async () => undefined }) {
return (options) => {
const { checkpoint, ...engineOptions } = options;
for (const name of ["persistence", "checkpointAfterBytes", "sessionId", "inputsDurable", "toolContext"]) {
if (Object.hasOwn(engineOptions, name)) throw new TypeError(`createFxAgent() does not accept ${name}`);
}
const idempotent = idempotentTools(engineOptions.tools);
return {
...(checkpoint === undefined ? {} : { seed: snapshotOf(checkpointBytesOf(checkpoint)) }),
async open({ sessionId, store, context, durability }) {
const apiKey = engineOptions.apiKey ?? await defaultApiKey(durability);
const engine = await createEngine({
...engineOptions,
...(apiKey === undefined ? {} : { apiKey }),
sessionId,
// The core folds the session from the marks on each record.
persistence: { ...store, [journalMarksWanted]: true },
inputsDurable: true,
// A checkpoint at every turn end; where a turn yields the core
// saves one itself before it lets the session go.
checkpointAfterBytes: 0,
...(context === undefined || context === null ? {} : { toolContext: context }),
});
return fxSession(engine, idempotent);
},
};
};
}
function fxSession(engine, idempotent) {
const internals = engine[engineInternals];
return {
prompt: (input, options) => engine.prompt(input, options),
// 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.
resume: (options) => engine.resume({ ...options, onAmbiguous: (call) => (idempotent.has(call.name) ? "rerun" : undefined) }),
get openTurn() {
return internals.openTurn;
},
settled: () => internals.settled(),
saveCheckpoint: () => internals.checkpoint(),
exportCheckpoint: () => engine.checkpoint(),
close: () => engine.close(),
};
}
function toolEntries(tools) {
if (Array.isArray(tools)) return tools;
if (tools && typeof tools === "object") return Object.entries(tools).map(([name, tool]) => ({ ...tool, name: tool?.name ?? name }));
return [];
}
function idempotentTools(tools) {
const names = new Set();
for (const tool of toolEntries(tools)) if (tool?.idempotent === true && typeof tool.name === "string") names.add(tool.name);
return names;
}
function checkpointBytesOf(value) {
if (value instanceof Uint8Array) return value;
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
throw new TypeError("checkpoint must be bytes");
}
// A kernel checkpoint as the snapshot a session's log starts from: `FXSN`,
// version 1, then the event it covers through, the turn after it, and no
// turn id. The seed counts as the session's first event, so the engine's
// own events start at seq 2.
function snapshotOf(checkpoint) {
const bytes = new Uint8Array(4 + 1 + 8 + 8 + 1 + checkpoint.byteLength);
bytes.set(encoder.encode("FXSN"), 0);
bytes[4] = 1;
const view = new DataView(bytes.buffer);
view.setBigUint64(5, 1n, true);
view.setBigUint64(13, 1n, true);
bytes[21] = 0;
bytes.set(checkpoint, 22);
return bytes;
}
+954 -69
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -56,7 +56,7 @@
<button type="button" id="cancel" disabled>Cancel</button>
</form>
<script type="module">
import { createFxAgent, supportsJspi } from "./browser.js";
import { createFxEngine, supportsJspi } from "./browser.js";
import { createSkillsAdapter } from "./skills.js";
const configPrefix = "fx.core.config.";
@@ -332,7 +332,7 @@
name: "browser-skill",
instructions: "BROWSER_SKILL_SENTINEL",
}]) : { instructions: "", tools: [] };
agent = await createFxAgent({
agent = await createFxEngine({
wasm: location.hostname === "localhost" || location.hostname === "127.0.0.1"
? "../zig-out/bin/fx-core.wasm"
: "./fx-core.wasm",
+46 -4
View File
@@ -2,15 +2,20 @@ import { access, readFile } from "node:fs/promises";
import { closeSync } from "node:fs";
import { createRequire } from "node:module";
import { Socket } from "node:net";
import { homedir } from "node:os";
import { homedir, tmpdir } from "node:os";
import { isAbsolute, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { CoreOutput } from "./core-output.js";
import { loadModule, withModuleFailure } from "./wasm-module.js";
import { fetchCleanupAction, cleanupTimeoutMs, cleanupByteLimit } from "./fetch-cleanup.js";
import { createDurableAgentFactory, memory } from "./durable.js";
import { fxHarness } from "./fx-harness.js";
import {
createFxAgent as createWasmAgent,
createFxEngine as createWasmAgent,
createFxTerminal as createWasmTerminal,
createMemoryPersistence,
FxFencedError,
FxJournalVersionError,
encodeXtermKeyEvent,
fxSdkApiVersion,
listModels,
@@ -19,7 +24,7 @@ import {
xtermAdapter,
} from "./fx-sdk.js";
export { encodeXtermKeyEvent, fxSdkApiVersion, listModels, supportsJspi, xtermAdapter };
export { createMemoryPersistence, encodeXtermKeyEvent, FxFencedError, FxJournalVersionError, fxSdkApiVersion, listModels, memory, supportsJspi, xtermAdapter };
export const libfxApiVersion = 2;
const nativeCoreApiVersion = 4;
@@ -644,7 +649,12 @@ async function createWithFallback(surface, nativeMethod, wasmFactory, defaultWas
return wasmFactory({ ...runtimeOptions, wasm: await wasmInput(wasmSource) });
}
export async function createFxAgent(options = {}) {
/**
* One fx session on the native core, or on WebAssembly when the native
* addon is unavailable, with no durability of its own. `createFxAgent`
* runs durable sessions on these.
*/
export async function createFxEngine(options = {}) {
return createWithFallback(
"agent",
"createCore",
@@ -654,6 +664,38 @@ export async function createFxAgent(options = {}) {
);
}
// The durability a session gets when the caller names none: Vercel's World
// on Vercel, otherwise files in the temporary directory. Each is its own
// module, loaded only here: a bundler copies the file the URL names and
// leaves the import itself to Node.
async function environmentDurability() {
const load = (asset) => import(/* webpackIgnore: true */ /* turbopackIgnore: true */ (asset.protocol === "" ? bundledAssetUrl(asset) : asset).href);
if (process.env.VERCEL) {
const { vercel } = await load(new URL("./durable/vercel.mjs", import.meta.url));
return vercel();
}
const { local } = await load(new URL("./durable/local.mjs", import.meta.url));
return local({ dir: process.env.FX_SESSIONS_DIR || resolve(tmpdir(), "libfx", "sessions") });
}
/**
* An agent whose sessions survive crashes, timeouts and redeployments. It
* does no I/O until a session is used.
*/
export const createFxAgent = createDurableAgentFactory({
name: "createFxAgent",
label: "fx agent",
defaultDurability: environmentDurability,
harness: fxHarness({
createEngine: createFxEngine,
// An explicit key, then the durability's own credential, such as a
// Vercel deployment's OIDC token, then the one in the environment.
defaultApiKey: async (durability) => process.env.AI_GATEWAY_API_KEY ||
(await Promise.resolve(durability?.gatewayKey?.()).catch(() => undefined)) ||
process.env.VERCEL_OIDC_TOKEN || undefined,
}),
});
export function createFxTerminal(options = {}) {
return createWithFallback("terminal", "createFxTerminal", createWasmTerminal, defaultTermWasm, options);
}
+6
View File
@@ -30,6 +30,8 @@
},
"./browser": "./browser.js",
"./wasm": "./fx-sdk.js",
"./durable-local": "./durable/local.mjs",
"./durable-vercel": "./durable/vercel.mjs",
"./mcp": "./mcp.js",
"./skills": "./skills.js",
"./skills/node": "./skills-node.js"
@@ -39,6 +41,10 @@
"node.js",
"node.cjs",
"fx-sdk.js",
"durable.js",
"fx-harness.js",
"durable/local.mjs",
"durable/vercel.mjs",
"wasm-module.js",
"core-output.js",
"fetch-cleanup.js",
+43
View File
@@ -0,0 +1,43 @@
// Bundles each durability module with the World it wraps into one
// self-contained ES module, so an app installs nothing more and never loads a
// World of its own. Run with Bun: `bun sdk/scripts/bundle-durable.mjs <out>`.
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const [outputDir] = process.argv.slice(2);
if (!outputDir) throw new Error("usage: bun sdk/scripts/bundle-durable.mjs <output-dir>");
const worlds = new URL("../durable/node_modules/@workflow/", import.meta.url);
const localWorld = await Bun.file(new URL("world-local/package.json", worlds)).json();
// world-local versions its data directory from the package.json beside its
// own module, which a bundle no longer has, so the version is pinned here.
const marker = "export async function getPackageInfo() {";
const pinWorldVersion = {
name: "pin-world-local-version",
setup(build) {
build.onLoad({ filter: /[\\/]world-local[\\/]dist[\\/]init\.js$/ }, async ({ path }) => {
const source = await Bun.file(path).text();
if (source.split(marker).length !== 2) throw new Error("world-local's getPackageInfo() changed; update the version pin");
const pinned = JSON.stringify({ name: localWorld.name, version: localWorld.version });
return { contents: source.replace(marker, `${marker}\n return ${pinned};`), loader: "js" };
});
},
};
for (const name of ["local", "vercel"]) {
const result = await Bun.build({
entrypoints: [fileURLToPath(new URL(`../durable/${name}.mjs`, import.meta.url))],
outdir: resolve(outputDir, "durable"),
naming: `${name}.mjs`,
target: "node",
format: "esm",
minify: true,
// cbor-x prefers an optional native addon and falls back without it.
external: ["cbor-extract"],
plugins: [pinWorldVersion],
});
if (!result.success) {
for (const log of result.logs) console.error(log);
process.exit(1);
}
}
+13
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -29,6 +30,8 @@ const files = [
["sdk/browser.js", "browser.js"],
["sdk/node.js", "node.js"],
["sdk/fx-sdk.js", "fx-sdk.js"],
["sdk/durable.js", "durable.js"],
["sdk/fx-harness.js", "fx-harness.js"],
["sdk/wasm-module.js", "wasm-module.js"],
["sdk/core-output.js", "core-output.js"],
["sdk/fetch-cleanup.js", "fetch-cleanup.js"],
@@ -74,6 +77,14 @@ for (const addon of nativeAddons) {
await cp(addon, resolve(outputDir, destination));
}
// Each durability module carries the World it wraps; see bundle-durable.mjs.
if (!existsSync(resolve(repoRoot, "sdk/durable/node_modules/@workflow"))) {
throw new Error("bundling the durable Worlds needs `npm ci --prefix sdk/durable` first");
}
const bundle = spawnSync(process.env.BUN_BIN || "bun", [resolve(repoRoot, "sdk/scripts/bundle-durable.mjs"), outputDir], { cwd: repoRoot, stdio: "inherit" });
if (bundle.error) throw bundle.error;
if (bundle.status !== 0) process.exit(bundle.status ?? 1);
const manifest = JSON.parse(await readFile(resolve(outputDir, "package.json"), "utf8"));
manifest.files = undefined;
await writeFile(resolve(outputDir, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -81,6 +92,8 @@ await writeFile(resolve(outputDir, "package.json"), `${JSON.stringify(manifest,
console.log(`packaged ${manifest.name} in ${outputDir}`);
console.log(" node.cjs");
for (const [, destination] of files) console.log(` ${destination}`);
console.log(" durable/local.mjs");
console.log(" durable/vercel.mjs");
for (const addon of nativeAddons) {
console.log(` ${requestedNativeAddons.length ? basename(addon) : localNativeName}`);
}
+4
View File
@@ -0,0 +1,4 @@
{
"libfx": "0.0.11",
"checkpoint": "RlhDUAEAAAAhBgAAAItE1RgiDQqoGoFp9lFgk70UKxT2XUFui5ul8zr/jnl7Imhpc3RvcnkiOlt7ImtpbmQiOiJhc3Npc3RhbnQiLCJ1c2VyIjp7InRleHQiOiJyZW1lbWJlciBwbHVtcyIsImltYWdlcyI6W119LCJhc3Npc3RhbnQiOiJhbnN3ZXIgdG8gcmVtZW1iZXIgcGx1bXMiLCJwcm92aWRlcl9yZXBsYXkiOm51bGwsImV4ZWN1dGlvbiI6eyJzY2hlbWFfdmVyc2lvbiI6MTAsInRvb2xfc3RlcHMiOltdLCJmaWxlcyI6W10sInN0ZWVyaW5nIjpbXSwidHVybl9zdW1tYXJ5Ijp7InN0YXJ0ZWRfYXRfbXMiOjE3OTA5MTM4NjAwNDAsImNvbXBsZXRlZF9hdF9tcyI6MTc5MDkxMzg2MDA1NywidGhpbmtpbmdfZHVyYXRpb25fbXMiOjMsInR1cm5fZHVyYXRpb25fbXMiOjE3LCJ0b2tlbl9wcm9ncmVzcyI6eyJpbnB1dF90b2tlbnMiOjQsIm91dHB1dF90b2tlbnMiOjEsImlucHV0X2V4YWN0IjpmYWxzZSwib3V0cHV0X2V4YWN0Ijp0cnVlfX19fSx7ImtpbmQiOiJhc3Npc3RhbnQiLCJ1c2VyIjp7InRleHQiOiJ1c2UgdGhlIHRvb2wiLCJpbWFnZXMiOltdfSwiYXNzaXN0YW50IjoidG9vbCBzYWlkIGJldGEiLCJwcm92aWRlcl9yZXBsYXkiOm51bGwsImV4ZWN1dGlvbiI6eyJzY2hlbWFfdmVyc2lvbiI6MTAsInRvb2xfc3RlcHMiOlt7ImFzc2lzdGFudCI6bnVsbCwicHJvdmlkZXJfcmVwbGF5IjpudWxsLCJ0b29sX2NhbGxzIjpbeyJpZCI6ImNhbGwtMSIsIm5hbWUiOiJsb29rdXAiLCJhcmd1bWVudHNfanNvbiI6IntcImtleVwiOlwiYWxwaGFcIn0iLCJwcm92aWRlcl9yZXN1bHQiOm51bGx9XSwidG9vbF9yZXN1bHRzIjpbeyJ0b29sX2NhbGxfaWQiOiJjYWxsLTEiLCJ0b29sX25hbWUiOiJsb29rdXAiLCJzdGF0dXMiOiJzdWNjZXNzIiwib3V0cHV0IjoidmFsdWUgb2YgYWxwaGEgaXMgYmV0YSIsIm91dHB1dF9oYW5kbGUiOm51bGwsInByZXZpZXciOm51bGwsIm91dHB1dF9ieXRlcyI6MjIsInN0b3JlZF9vdXRwdXRfYnl0ZXMiOjIyLCJ0cnVuY2F0ZWQiOmZhbHNlLCJwcm92aWRlcl9uYXRpdmUiOmZhbHNlLCJyZXZpZXdfZmVlZGJhY2siOmZhbHNlLCJjcmVhdGVkX2F0X21zIjoxNzkwOTEzODYwMDYxLCJwZXJtaXNzaW9uX2ZlZWRiYWNrIjpbXSwiY29tbWl0dGVkX2ZpbGVfcHJlc2VudGF0aW9uIjpudWxsLCJjb21tYW5kX291dHB1dF9yZXBsYXkiOm51bGwsImNvbW1hbmRfcHJvY2Vzc19wcmVzZW50YXRpb24iOm51bGwsInRlcm1pbmFsX2FjdGlvbl9wcmVzZW50YXRpb24iOm51bGx9XX1dLCJmaWxlcyI6W10sInN0ZWVyaW5nIjpbXSwidHVybl9zdW1tYXJ5Ijp7InN0YXJ0ZWRfYXRfbXMiOjE3OTA5MTM4NjAwNTcsImNvbXBsZXRlZF9hdF9tcyI6MTc5MDkxMzg2MDA2MSwidGhpbmtpbmdfZHVyYXRpb25fbXMiOjQsInR1cm5fZHVyYXRpb25fbXMiOjQsInRva2VuX3Byb2dyZXNzIjp7ImlucHV0X3Rva2VucyI6Mywib3V0cHV0X3Rva2VucyI6MiwiaW5wdXRfZXhhY3QiOmZhbHNlLCJvdXRwdXRfZXhhY3QiOnRydWV9fX19XSwidXNhZ2UiOnsiaW5wdXRfdG9rZW5zIjoyLCJvdXRwdXRfdG9rZW5zIjoyLCJjYWNoZV9yZWFkX3Rva2VucyI6bnVsbCwiY2FjaGVfd3JpdGVfdG9rZW5zIjpudWxsLCJyZWFzb25pbmdfdG9rZW5zIjpudWxsfX0="
}
+224
View File
@@ -0,0 +1,224 @@
{
"format": "libfx-journal-v1",
"plain": {
"events": [
{
"v": 1,
"seq": 1,
"turn": 1,
"type": "turn_progress",
"data": {
"version": 2,
"turn_id": 1,
"user": {
"text": "remember plums",
"images": []
},
"assistant_source": "",
"execution": {
"schema_version": 10,
"tool_steps": [],
"files": [],
"steering": [],
"turn_summary": null
},
"cause": "network_interrupted",
"action": "retrying_request",
"tool_state": "none",
"authority": {
"provider": "gateway",
"model": "journal/model",
"credential_source": "ai_gateway_api_key",
"credential_identity": "3844282c2145ae9856d2b1011be6ac72c4eb3b46a13bb2c2bfdfc81747ce4e94"
},
"requested_fast_mode": false,
"fast_mode": false,
"max_provider_attempts": 10,
"consumed_provider_attempts": 0,
"outstanding_reservation": true
},
"model": "journal/model"
},
{
"v": 1,
"seq": 2,
"turn": 1,
"type": "turn_progress",
"data": {
"version": 2,
"turn_id": 1,
"user": {
"text": "remember plums",
"images": []
},
"assistant_source": "answer to remember plums",
"execution": {
"schema_version": 10,
"tool_steps": [],
"files": [],
"steering": [],
"turn_summary": null
},
"cause": "network_interrupted",
"action": "retrying_request",
"tool_state": "none",
"authority": {
"provider": "gateway",
"model": "journal/model",
"credential_source": "ai_gateway_api_key",
"credential_identity": "3844282c2145ae9856d2b1011be6ac72c4eb3b46a13bb2c2bfdfc81747ce4e94"
},
"requested_fast_mode": false,
"fast_mode": false,
"max_provider_attempts": 10,
"consumed_provider_attempts": 1,
"outstanding_reservation": false
},
"model": "journal/model"
},
{
"v": 1,
"seq": 3,
"turn": 1,
"type": "turn_committed",
"data": {
"kind": "assistant",
"user": {
"text": "remember plums",
"images": []
},
"assistant": "answer to remember plums",
"provider_replay": null,
"execution": {
"schema_version": 10,
"tool_steps": [],
"files": [],
"steering": [],
"turn_summary": {
"started_at_ms": 1790929977083,
"completed_at_ms": 1790929977095,
"thinking_duration_ms": 2,
"turn_duration_ms": 12,
"token_progress": {
"input_tokens": 4,
"output_tokens": 1,
"input_exact": false,
"output_exact": true
}
}
}
}
},
{
"v": 1,
"seq": 4,
"turn": 2,
"type": "turn_progress",
"data": {
"version": 2,
"turn_id": 2,
"user": {
"text": "use the tool",
"images": []
},
"assistant_source": "",
"execution": {
"schema_version": 10,
"tool_steps": [],
"files": [],
"steering": [],
"turn_summary": null
},
"cause": "network_interrupted",
"action": "retrying_request",
"tool_state": "none",
"authority": {
"provider": "gateway",
"model": "journal/model",
"credential_source": "ai_gateway_api_key",
"credential_identity": "3844282c2145ae9856d2b1011be6ac72c4eb3b46a13bb2c2bfdfc81747ce4e94"
},
"requested_fast_mode": false,
"fast_mode": false,
"max_provider_attempts": 10,
"consumed_provider_attempts": 0,
"outstanding_reservation": true
},
"model": "journal/model"
},
{
"v": 1,
"seq": 5,
"turn": 2,
"type": "turn_progress",
"data": {
"version": 2,
"turn_id": 2,
"user": {
"text": "use the tool",
"images": []
},
"assistant_source": "",
"execution": {
"schema_version": 10,
"tool_steps": [],
"files": [],
"steering": [],
"turn_summary": null
},
"cause": "network_interrupted",
"action": "retrying_request",
"tool_state": "uncertain",
"authority": {
"provider": "gateway",
"model": "journal/model",
"credential_source": "ai_gateway_api_key",
"credential_identity": "3844282c2145ae9856d2b1011be6ac72c4eb3b46a13bb2c2bfdfc81747ce4e94"
},
"requested_fast_mode": false,
"fast_mode": false,
"max_provider_attempts": 10,
"consumed_provider_attempts": 1,
"outstanding_reservation": false
},
"model": "journal/model"
},
{
"v": 1,
"seq": 6,
"turn": 2,
"type": "tool_intent",
"data": [
{
"id": "call-1",
"name": "lookup",
"arguments_json": "{\"key\":\"alpha\"}",
"provider_result": null
}
]
},
{
"v": 1,
"seq": 7,
"turn": 2,
"type": "input_accepted",
"data": {
"id": "in_87844db5-4d6d-4991-bd80-4b4aadd3ce9e",
"kind": "steer",
"text": "answer in one word"
}
},
{
"v": 1,
"seq": 8,
"turn": 2,
"type": "input_accepted",
"data": {
"id": "in_c58a967f-c612-4d55-a754-989505f3ac22",
"kind": "follow_up",
"text": "then summarize"
}
}
]
}
}
+1
View File
@@ -0,0 +1 @@
graphs/
+14
View File
@@ -0,0 +1,14 @@
CONSTANTS
Workers = {"A", "B"}
Tool = "lookup"
MaxEpoch = 3
MaxFreezes = 2
SelfStop = TRUE
SPECIFICATION Spec
INVARIANTS
TypeOK
EffectsAtMostOnce
EffectsAfterIntent
DoneByHead
LegitShown
TwoActiveNeedsFreeze
+14
View File
@@ -0,0 +1,14 @@
CONSTANTS
Workers = {"A", "B"}
Tool = "send"
MaxEpoch = 3
MaxFreezes = 2
SelfStop = TRUE
SPECIFICATION Spec
INVARIANTS
TypeOK
EffectsAtMostOnce
EffectsAfterIntent
DoneByHead
LegitShown
TwoActiveNeedsFreeze
+292
View File
@@ -0,0 +1,292 @@
---------------------------- MODULE DurableSession ----------------------------
(***************************************************************************)
(* One libfx session, one turn, two workers sharing one World. *)
(* *)
(* The turn is: a model step that calls one tool, the tool, then a model *)
(* step that answers. Workers take the session with a lease whose entries *)
(* form a chain in the session log. The World accepts every write (the *)
(* "bump and report" behavior of world-vercel 5.0.1 and world-local), so a *)
(* write counts only when its writer still heads the chain, and a writer *)
(* that does not learns it at that write and stops. *)
(* *)
(* The UI stream is a separate World stream with no fencing, so a replaced *)
(* worker's late lines land in it. A worker that takes the session over *)
(* writes its first line, turn_resume or turn_start, before it continues, *)
(* and readers hide any line whose epoch is lower than one before it. *)
(* *)
(* A worker can freeze while it waits on the model or a tool; others then *)
(* treat its lease as dead and may take the session over. A frozen worker *)
(* that never wakes is a crash. One that wakes carries on until its next *)
(* write, so two workers can be active at once (the witness below). *)
(* *)
(* Shortly before its deadline a worker's own timer stops it: it cuts off *)
(* the call still running, hands the open turn back to the log, and the *)
(* same message comes back for a new delivery. So a lease that runs out at *)
(* the deadline never leaves its worker running, and two workers are *)
(* active at once only when one of them froze. With SelfStop = FALSE the *)
(* platform ends the lease while the worker runs on (Witness-NoSelfStop). *)
(* *)
(* `act` names each step and its worker, so a driver can replay any path *)
(* of the state graph against the real code and compare every state: *)
(* sdk/tests/model/drive.mjs. *)
(***************************************************************************)
EXTENDS Naturals, Sequences
CONSTANTS
Workers, \* the processes that can run the session
Tool, \* "lookup" (safe to run twice) or "send" (has effects)
MaxEpoch, \* bound on lease claims
MaxFreezes, \* bound on freezes
SelfStop \* whether a worker stops itself before its deadline
ASSUME Tool \in {"lookup", "send"}
ASSUME SelfStop \in BOOLEAN
None == "none"
Waiting == {"announce", "model1", "tool", "model2"}
Busy == {"model1", "tool", "model2"}
VARIABLES
pc, \* where each worker is in the turn
epoch, \* the lease epoch each worker last claimed
frozen, \* whether a worker is frozen
lease, \* the lease at the head of the chain
progress, \* how far the chain has recorded the turn
runs, \* how many times the tool has started
ui, \* each line in the UI stream: its writer's epoch, and whether
\* that writer still headed the chain when it wrote
freezes, \* freezes so far
froze, \* whether a worker froze since it last claimed the session
act \* the step that led here
vars == <<pc, epoch, frozen, lease, progress, runs, ui, freezes, froze, act>>
TypeOK ==
/\ pc \in [Workers -> {"none", "announce", "model1", "tool", "model2", "done", "stopped"}]
/\ epoch \in [Workers -> 0..MaxEpoch]
/\ frozen \in [Workers -> BOOLEAN]
/\ lease \in [holder: Workers \cup {None}, epoch: 0..MaxEpoch, live: BOOLEAN]
/\ progress \in {"start", "intent", "result", "done"}
/\ runs \in 0..(MaxEpoch + 1)
/\ ui \in Seq([e: 0..MaxEpoch, legit: BOOLEAN])
/\ freezes \in 0..MaxFreezes
/\ froze \in [Workers -> BOOLEAN]
/\ act \in [name: {"Init", "Claim", "Announce", "Model1", "ToolEnd", "Model2",
"Freeze", "Wake", "Deadline", "Expire"},
w: Workers \cup {None}]
Init ==
/\ pc = [w \in Workers |-> "none"]
/\ epoch = [w \in Workers |-> 0]
/\ frozen = [w \in Workers |-> FALSE]
/\ lease = [holder |-> None, epoch |-> 0, live |-> FALSE]
/\ progress = "start"
/\ runs = 0
/\ ui = <<>>
/\ freezes = 0
/\ froze = [w \in Workers |-> FALSE]
/\ act = [name |-> "Init", w |-> None]
\* A worker's writes count while its claim heads the lease chain.
Heads(w) == lease.holder = w /\ lease.epoch = epoch[w]
\* A line w writes now.
Line(w) == [e |-> epoch[w], legit |-> Heads(w)]
\* A write that does not count may still have put a line in the UI stream.
MaybeLine(w) == ui' \in {ui, Append(ui, Line(w))}
\* The lines every reader shows: each line no earlier line outranks. The
\* same stored stream always shows the same lines.
ShownAt(lines) == {i \in 1..Len(lines) : \A j \in 1..(i - 1) : lines[j].e <= lines[i].e}
\* w receives work for the session while no live worker holds it and claims
\* the lease. Its first line follows; until then a replaced worker's late
\* lines still show.
Claim(w) ==
/\ pc[w] \in {"none", "stopped"}
/\ ~frozen[w]
/\ progress # "done"
/\ lease.holder = None \/ ~lease.live
/\ lease.epoch < MaxEpoch
/\ lease' = [holder |-> w, epoch |-> lease.epoch + 1, live |-> TRUE]
/\ epoch' = [epoch EXCEPT ![w] = lease.epoch + 1]
/\ pc' = [pc EXCEPT ![w] = "announce"]
/\ froze' = [froze EXCEPT ![w] = FALSE]
/\ UNCHANGED <<frozen, progress, runs, ui, freezes>>
/\ act' = [name |-> "Claim", w |-> w]
\* w writes turn_start or turn_resume and waits for it to land, then
\* continues the turn from what the chain recorded. A call left running is
\* rerun only when it is safe to run twice; otherwise the model is told it
\* may have partly run, and the turn goes on to the answer.
Announce(w) ==
/\ pc[w] = "announce"
/\ ~frozen[w]
/\ LET rerun == progress = "intent" /\ Tool = "lookup"
IN /\ pc' = [pc EXCEPT ![w] =
CASE progress = "start" -> "model1"
[] progress = "intent" -> IF rerun THEN "tool" ELSE "model2"
[] progress \in {"result", "done"} -> "model2"]
/\ runs' = IF rerun THEN runs + 1 ELSE runs
\* then tool_start for a rerun
/\ ui' = IF rerun THEN ui \o <<Line(w), Line(w)>> ELSE Append(ui, Line(w))
/\ UNCHANGED <<epoch, frozen, lease, progress, freezes, froze>>
/\ act' = [name |-> "Announce", w |-> w]
\* The model asks for the tool. A call with effects starts only once its
\* intent is in the chain; a safe one starts at once, its intent behind it.
Model1(w) ==
/\ pc[w] = "model1"
/\ ~frozen[w]
/\ IF Tool = "send"
THEN IF Heads(w)
THEN /\ progress' = "intent"
/\ runs' = runs + 1
/\ pc' = [pc EXCEPT ![w] = "tool"]
/\ ui' = Append(ui, Line(w))
ELSE /\ pc' = [pc EXCEPT ![w] = "stopped"]
/\ MaybeLine(w)
/\ UNCHANGED <<progress, runs>>
ELSE /\ runs' = runs + 1
/\ ui' = Append(ui, Line(w))
/\ IF Heads(w)
THEN /\ progress' = "intent"
/\ pc' = [pc EXCEPT ![w] = "tool"]
ELSE /\ pc' = [pc EXCEPT ![w] = "stopped"]
/\ UNCHANGED progress
/\ UNCHANGED <<epoch, frozen, lease, freezes, froze>>
/\ act' = [name |-> "Model1", w |-> w]
\* The tool returns, and its result goes to the chain.
ToolEnd(w) ==
/\ pc[w] = "tool"
/\ ~frozen[w]
/\ IF Heads(w)
THEN /\ progress' = "result"
/\ pc' = [pc EXCEPT ![w] = "model2"]
/\ ui' = Append(ui, Line(w))
ELSE /\ pc' = [pc EXCEPT ![w] = "stopped"]
/\ MaybeLine(w)
/\ UNCHANGED progress
/\ UNCHANGED <<epoch, frozen, lease, runs, freezes, froze>>
/\ act' = [name |-> "ToolEnd", w |-> w]
\* The model answers; the turn ends and the worker lets the session go.
Model2(w) ==
/\ pc[w] = "model2"
/\ ~frozen[w]
/\ IF Heads(w)
THEN /\ progress' = "done"
/\ pc' = [pc EXCEPT ![w] = "done"]
/\ lease' = [lease EXCEPT !.holder = None, !.live = FALSE]
\* the answer's text, then turn_end
/\ ui' = ui \o <<Line(w), Line(w)>>
ELSE /\ pc' = [pc EXCEPT ![w] = "stopped"]
/\ MaybeLine(w)
/\ UNCHANGED <<progress, lease>>
/\ UNCHANGED <<epoch, frozen, runs, freezes, froze>>
/\ act' = [name |-> "Model2", w |-> w]
\* w freezes while it waits; its lease no longer counts as alive.
Freeze(w) ==
/\ pc[w] \in Waiting
/\ ~frozen[w]
/\ freezes < MaxFreezes
/\ frozen' = [frozen EXCEPT ![w] = TRUE]
/\ freezes' = freezes + 1
/\ froze' = [froze EXCEPT ![w] = TRUE]
/\ lease' = IF lease.holder = w THEN [lease EXCEPT !.live = FALSE] ELSE lease
/\ UNCHANGED <<pc, epoch, progress, runs, ui>>
/\ act' = [name |-> "Freeze", w |-> w]
\* w wakes and carries on from where it waited.
Wake(w) ==
/\ frozen[w]
/\ frozen' = [frozen EXCEPT ![w] = FALSE]
/\ UNCHANGED <<pc, epoch, lease, progress, runs, ui, freezes, froze>>
/\ act' = [name |-> "Wake", w |-> w]
\* Shortly before its deadline, w's own timer stops it and cuts off the call
\* still running; a frozen worker's timer does not run. w writes turn_yield,
\* hands the open turn back to the log and releases the session, and the
\* same message comes back at once. w takes it again, or, where the platform
\* routes it elsewhere, any worker may. A worker that was replaced without
\* knowing it learns so at the release and stops.
Deadline(w) ==
/\ SelfStop
/\ pc[w] \in Busy
/\ ~frozen[w]
/\ IF Heads(w)
THEN /\ ui' = Append(ui, Line(w))
/\ \/ /\ lease.epoch < MaxEpoch
/\ lease' = [holder |-> w, epoch |-> lease.epoch + 1, live |-> TRUE]
/\ epoch' = [epoch EXCEPT ![w] = lease.epoch + 1]
/\ pc' = [pc EXCEPT ![w] = "announce"]
/\ froze' = [froze EXCEPT ![w] = FALSE]
\/ /\ lease' = [lease EXCEPT !.holder = None, !.live = FALSE]
/\ pc' = [pc EXCEPT ![w] = "none"]
/\ UNCHANGED <<epoch, froze>>
ELSE /\ pc' = [pc EXCEPT ![w] = "stopped"]
/\ MaybeLine(w)
/\ UNCHANGED <<epoch, lease, froze>>
/\ UNCHANGED <<frozen, progress, runs, freezes>>
/\ act' = [name |-> "Deadline", w |-> w]
\* Without the self-stop: the deadline passes while w is still busy, and its
\* lease runs out while it runs on.
Expire(w) ==
/\ ~SelfStop
/\ pc[w] \in Busy
/\ ~frozen[w]
/\ Heads(w)
/\ lease.live
/\ lease' = [lease EXCEPT !.live = FALSE]
/\ UNCHANGED <<pc, epoch, frozen, progress, runs, ui, freezes, froze>>
/\ act' = [name |-> "Expire", w |-> w]
Next ==
\E w \in Workers :
\/ Claim(w) \/ Announce(w) \/ Model1(w) \/ ToolEnd(w) \/ Model2(w)
\/ Freeze(w) \/ Wake(w) \/ Deadline(w) \/ Expire(w)
Spec == Init /\ [][Next]_vars
-----------------------------------------------------------------------------
(* Properties *)
\* A call with effects never starts twice.
EffectsAtMostOnce == Tool = "send" => runs <= 1
\* A call with effects starts only after its intent counts in the chain.
EffectsAfterIntent == Tool = "send" /\ runs > 0 => progress # "start"
\* Only the chain's head ends the turn.
DoneByHead == progress = "done" => \E w \in Workers : pc[w] = "done"
\* No reader hides a line its writer wrote while it headed the chain.
LegitShown == \A i \in 1..Len(ui) : ui[i].legit => i \in ShownAt(ui)
Active(w) == pc[w] \in Waiting /\ ~frozen[w]
\* Two workers are active at once only when one of them froze since it
\* claimed the session. A clock that stops is the one case left, and only a
\* World that refuses stale writes closes it.
TwoActiveNeedsFreeze ==
\A v, w \in Workers : v # w /\ Active(v) /\ Active(w) => froze[v] \/ froze[w]
\* Witness, expected to FAIL: a replaced worker's line can still show when
\* it lands between its successor's claim and first line. Only a World
\* that refuses stale stream writes closes that window.
NoStaleShown == \A i \in ShownAt(ui) : ui[i].legit
\* The stream as stored, expected to FAIL: late lines land out of order.
UiInOrder == \A i \in 1..(Len(ui) - 1) : ui[i].e <= ui[i + 1].e
\* Witness, expected to FAIL: two workers active at once.
NotTwoActive ==
~(\E v, w \in Workers :
/\ v # w
/\ Active(v)
/\ Active(w))
=============================================================================
+8
View File
@@ -0,0 +1,8 @@
CONSTANTS
Workers = {"A", "B"}
Tool = "send"
MaxEpoch = 3
MaxFreezes = 0
SelfStop = FALSE
SPECIFICATION Spec
INVARIANT TwoActiveNeedsFreeze
+8
View File
@@ -0,0 +1,8 @@
CONSTANTS
Workers = {"A", "B"}
Tool = "send"
MaxEpoch = 3
MaxFreezes = 2
SelfStop = TRUE
SPECIFICATION Spec
INVARIANT NoStaleShown
+8
View File
@@ -0,0 +1,8 @@
CONSTANTS
Workers = {"A", "B"}
Tool = "send"
MaxEpoch = 3
MaxFreezes = 2
SelfStop = TRUE
SPECIFICATION Spec
INVARIANT NotTwoActive
+8
View File
@@ -0,0 +1,8 @@
CONSTANTS
Workers = {"A", "B"}
Tool = "send"
MaxEpoch = 3
MaxFreezes = 2
SelfStop = TRUE
SPECIFICATION Spec
INVARIANT UiInOrder
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
# Model-checks DurableSession.tla with TLC, confirms each witness is
# violated, and writes the state graphs drive.mjs walks against the code.
#
# Needs Java 11+ and tla2tools.jar:
# brew install openjdk
# mkdir -p ~/.local/share/tla
# curl -fL -o ~/.local/share/tla/tla2tools.jar \
# https://github.com/tlaplus/tlaplus/releases/latest/download/tla2tools.jar
#
# Then drive the code along the graphs:
# bun drive.mjs send && bun drive.mjs lookup
set -eu
cd "$(dirname "$0")"
jar="${TLA2TOOLS_JAR:-$HOME/.local/share/tla/tla2tools.jar}"
[ -f "$jar" ] || { echo "tla2tools.jar not found at $jar (set TLA2TOOLS_JAR)" >&2; exit 2; }
java=""
for candidate in "${JAVA_HOME:+$JAVA_HOME/bin/java}" /opt/homebrew/opt/openjdk/bin/java /usr/local/opt/openjdk/bin/java "$(command -v java 2>/dev/null || true)"; do
if [ -n "$candidate" ] && [ -x "$candidate" ] && "$candidate" -version >/dev/null 2>&1; then java="$candidate"; break; fi
done
[ -n "$java" ] || { echo "no working Java runtime (set JAVA_HOME or brew install openjdk)" >&2; exit 2; }
work="${TMPDIR:-/tmp}/libfx-model-tlc"
mkdir -p "$work" graphs
status=0
for tool in send lookup; do
echo "== DurableSession ($tool)"
if ! "$java" -XX:+UseParallelGC -cp "$jar" tlc2.TLC -deadlock -workers 1 -cleanup \
-metadir "$work/$tool" -dump dot,actionlabels "graphs/$tool" \
-config "DurableSession-$tool.cfg" DurableSession.tla > "$work/$tool.log" 2>&1; then
cat "$work/$tool.log"; status=1; continue
fi
grep -E 'distinct states|depth of' "$work/$tool.log"
done
# Each witness must be violated: it shows the model reaches that state.
# NoSelfStop breaks TwoActiveNeedsFreeze: without the self-stop, two workers
# are active at once with no freeze at all.
for witness in UiInOrder NoStaleShown NotTwoActive NoSelfStop; do
if "$java" -cp "$jar" tlc2.TLC -deadlock -workers auto -cleanup -metadir "$work/w-$witness" \
-config "Witness-$witness.cfg" DurableSession.tla 2>&1 | grep -q -E "Invariant ($witness|TwoActiveNeedsFreeze) is violated"; then
echo "== witness $witness reached"
else
echo "== witness $witness NOT reached" >&2; status=1
fi
done
exit "$status"
+440
View File
@@ -0,0 +1,440 @@
#!/usr/bin/env bun
// Drives real libfx, with its Zig kernel loaded through the native addon or
// WebAssembly, along paths of the TLC state graph of DurableSession.tla, and
// checks after every step that the code is in a state the model allows.
//
// bun sdk/tests/model/drive.mjs [send|lookup] [--walks N] [--seed S] [--backend native|wasm]
// [--durability local|vercel]
//
// With --durability vercel the agents keep the session in Vercel's World
// (see sdk/tests/vercel-storage.mjs for what it needs) instead of files.
//
// `./check.sh` model-checks the spec and writes graphs/<tool>.dot. Each walk
// starts two agents, A and B, on one local() World directory. Every model
// request and tool call waits until the driver releases it, so the driver
// decides the interleaving: it performs the step the walk chose, waits for
// the code to settle, reads back each worker's position, the tool runs, and
// the epoch of every UI line, and moves to the successor state that matches.
// A step the model has no matching successor for is a conformance failure.
import { strict as assert } from "node:assert";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../../node.js";
import { local } from "../../durable/local.mjs";
const args = process.argv.slice(2);
const option = (name, fallback) => {
const index = args.indexOf(`--${name}`);
return index >= 0 ? args[index + 1] : fallback;
};
const tool = args.find((arg) => arg === "send" || arg === "lookup") ?? "send";
const walks = Number(option("walks", 40));
const backend = option("backend", "native");
const durabilityKind = option("durability", "local");
if (durabilityKind !== "local" && durabilityKind !== "vercel") throw new Error("--durability is local or vercel");
// Vercel's World is a round trip away: settle, wait for streams, and give up
// on a step later.
const remote = durabilityKind === "vercel";
const settleMs = remote ? 1500 : 60;
const streamQuietMs = remote ? 2500 : 120;
const stepTimeoutMs = remote ? 30_000 : 5000;
const { vercelStorage } = remote ? await import("../vercel-storage.mjs") : {};
const verbose = args.includes("--verbose");
let seed = Number(option("seed", 1));
const here = fileURLToPath(new URL(".", import.meta.url));
const addon = resolve(here, "../../../zig-out/lib/libfx.node");
const wasm = backend === "wasm" ? await readFile(resolve(here, "../../../zig-out/bin/fx-core.wasm")) : undefined;
const Workers = ["A", "B"];
// ---------------------------------------------------------------------------
// The state graph TLC wrote.
// A TLA+ value as TLC prints it: numbers, strings, booleans, records (and
// functions over names, which print as records), and sequences.
function parseValue(text) {
let at = 0;
const space = () => { while (/\s/.test(text[at] ?? "")) at += 1; };
const expect = (token) => {
space();
if (!text.startsWith(token, at)) throw new Error(`expected ${token} at ${at} in ${text}`);
at += token.length;
};
const value = () => {
space();
if (text.startsWith("<<", at)) {
at += 2;
const items = [];
space();
if (text.startsWith(">>", at)) { at += 2; return items; }
for (;;) {
items.push(value());
space();
if (text[at] === ",") { at += 1; continue; }
expect(">>");
return items;
}
}
if (text[at] === "[") {
at += 1;
const record = {};
for (;;) {
space();
const key = /^[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(at))?.[0];
if (!key) throw new Error(`expected a field at ${at} in ${text}`);
at += key.length;
expect("|->");
record[key] = value();
space();
if (text[at] === ",") { at += 1; continue; }
expect("]");
return record;
}
}
if (text[at] === "\"") {
const end = text.indexOf("\"", at + 1);
const string = text.slice(at + 1, end);
at = end + 1;
return string;
}
for (const [token, result] of [["TRUE", true], ["FALSE", false]]) {
if (text.startsWith(token, at)) { at += token.length; return result; }
}
const number = /^-?\d+/.exec(text.slice(at))?.[0];
if (number) { at += number.length; return Number(number); }
throw new Error(`cannot read a value at ${at} in ${text}`);
};
const result = value();
space();
if (at !== text.length) throw new Error(`trailing text at ${at} in ${text}`);
return result;
}
async function loadGraph(path) {
const dot = await readFile(path, "utf8").catch(() => {
throw new Error(`${path} is missing; run sdk/tests/model/check.sh first`);
});
const states = new Map();
const next = new Map();
let init = null;
const unescape = (label) => label.replace(/\\(.)/g, (_, char) => (char === "n" ? "\n" : char));
for (const line of dot.split("\n")) {
const edge = /^(-?\d+) -> (-?\d+) \[label="(\w+)"/.exec(line);
if (edge) {
const [, from, to] = edge;
if (!next.has(from)) next.set(from, new Set());
next.get(from).add(to);
continue;
}
const node = /^(-?\d+) \[label="((?:[^"\\]|\\.)*)"/.exec(line);
if (node) {
const [, id, label] = node;
const state = {};
// One entry per variable; TLC wraps a long value over several lines.
for (const entry of unescape(label).split(/\n(?=\/\\ )/)) {
const match = /^\/\\ (\w+) = ([\s\S]*)$/.exec(entry.trim());
if (match) state[match[1]] = parseValue(match[2].trim());
}
states.set(id, state);
if (state.act?.name === "Init") init = id;
}
}
assert.ok(init, "the graph has an initial state");
return { states, next, init };
}
// The epochs of the lines every reader shows, by the spec's ShownAt: each
// line no earlier line outranks.
const shownEpochs = (lines) => {
let high = 0;
const shown = [];
for (const line of lines) {
if (line.e < high) continue;
high = line.e;
shown.push(line.e);
}
return shown;
};
// What the driver compares: everything the code shows from outside.
const view = (state) => JSON.stringify({ pc: state.pc, frozen: state.frozen, runs: state.runs, ui: shownEpochs(state.ui) });
const sameAct = (a, b) => a.name === b.name && a.w === b.w;
// ---------------------------------------------------------------------------
// The code under test: two agents on one World, with every model request and
// tool call held until the driver lets it go.
const usage = { inputTokens: { total: 1 }, outputTokens: { total: 1 } };
const finish = (reason) => ({ type: "finish", finishReason: { unified: reason, raw: reason }, usage });
const sse = (frames) => new Response(
[...frames, "[DONE]"].map((frame) => `data: ${typeof frame === "string" ? frame : JSON.stringify(frame)}\n\n`).join(""),
{ headers: { "content-type": "text/event-stream" } },
);
const toolResults = (prompt) => prompt
.flatMap((message) => Array.isArray(message.content) ? message.content : [])
.filter((part) => part.type === "tool-result");
const modeledLines = new Set(["turn_start", "turn_resume", "tool_start", "tool_end", "text_delta", "turn_yield", "turn_end"]);
const liveHolders = globalThis[Symbol.for("libfx.liveHolders")];
const durableInternals = Symbol.for("libfx.durableInternals");
const until = async (check, ms = stepTimeoutMs) => {
const started = Date.now();
while (!(await check())) {
if (Date.now() - started > ms) return false;
await new Promise((resolveWait) => setTimeout(resolveWait, 5));
}
return true;
};
const sleep = (ms) => new Promise((resolveWait) => setTimeout(resolveWait, ms));
async function harness() {
const dir = await mkdtemp(join(tmpdir(), "libfx-model-"));
const requests = [];
const calls = [];
const fenced = { A: 0, B: 0 };
const holders = { A: [], B: [] };
const claimed = { A: null, B: null };
const frozen = { A: false, B: false };
let runs = 0;
let sessionId = null;
const remove = (list, entry) => {
const index = list.indexOf(entry);
if (index >= 0) list.splice(index, 1);
};
const agents = {};
for (const w of Workers) {
agents[w] = createFxAgent({
backend,
nativeAddon: addon,
...(wasm ? { wasm } : {}),
apiKey: "model-key",
model: "model/test",
async fetch(input, init) {
// The WebAssembly host passes a Request, the native one a URL and init.
const request = input instanceof Request ? input : new Request(input, init);
if (request.method === "GET") return Response.json({ object: "list", data: [{ id: "model/test", type: "language" }] });
const prompt = JSON.parse(await request.text()).prompt;
const signal = init?.signal ?? request.signal;
return new Promise((resolveResponse, rejectResponse) => {
const entry = { w, kind: toolResults(prompt).length > 0 ? "model2" : "model1", answer: (frames) => { remove(requests, entry); resolveResponse(sse(frames)); } };
requests.push(entry);
signal?.addEventListener("abort", () => { remove(requests, entry); rejectResponse(signal.reason ?? new Error("aborted")); }, { once: true });
});
},
tools: [{
name: tool,
description: tool === "send" ? "Sends an invoice" : "Looks up a key",
...(tool === "lookup" ? { idempotent: true } : {}),
inputSchema: { type: "object", properties: { key: { type: "string" } } },
execute: (input, { signal }) => {
runs += 1;
return new Promise((resolveCall, rejectCall) => {
const entry = { w, end: () => { remove(calls, entry); resolveCall(`value of ${input.key}`); } };
calls.push(entry);
signal?.addEventListener("abort", () => { remove(calls, entry); rejectCall(signal.reason ?? new Error("aborted")); }, { once: true });
});
},
}],
durability: remote ? vercelStorage({ dir }) : local({ dir }),
onEvent: (event) => { if (event.type === "session.fenced") fenced[w] += 1; },
});
}
async function streamLines(cursor = 0) {
if (!sessionId) return [];
const reader = agents.A.session(sessionId).stream(cursor).getReader();
const decoder = new TextDecoder();
const lines = [];
let buffered = "";
for (;;) {
let timer;
const quiet = new Promise((resolveQuiet) => { timer = setTimeout(() => resolveQuiet({ done: true }), streamQuietMs); });
const { value, done } = await Promise.race([reader.read(), quiet]);
clearTimeout(timer);
if (done) break;
buffered += decoder.decode(value, { stream: true });
for (let index = buffered.indexOf("\n"); index >= 0; index = buffered.indexOf("\n")) {
lines.push(JSON.parse(buffered.slice(0, index)));
buffered = buffered.slice(index + 1);
}
}
await reader.cancel().catch(() => {});
return lines;
}
// Where each worker is, by what it waits on.
const position = (w, lines) => {
if (claimed[w] === null) return "none";
if (fenced[w] > claimed[w].fenced) return "stopped";
if (lines.some((line) => line.type === "turn_end" && line.epoch === claimed[w].epoch)) return "done";
const request = requests.find((entry) => entry.w === w);
if (request) return request.kind;
if (calls.some((entry) => entry.w === w)) return "tool";
return "busy";
};
const waiting = (w) => requests.some((entry) => entry.w === w) || calls.some((entry) => entry.w === w);
async function observe() {
const lines = await streamLines();
// A reader that reconnects from any cursor sees the same lines after it.
if (lines.length > 0) {
const cursor = lines[Math.floor(random() * lines.length)].cursor;
const again = await streamLines(cursor);
assert.deepEqual(again, lines.filter((line) => line.cursor > cursor), `reconnecting from cursor ${cursor} shows the same lines`);
}
return {
pc: Object.fromEntries(Workers.map((w) => [w, position(w, lines)])),
frozen: { ...frozen },
runs,
ui: lines.filter((line) => modeledLines.has(line.type)).map((line) => line.epoch ?? 0),
};
}
// One step of the model, done to the code.
async function perform({ name, w }, expectedEpoch) {
const stopped = () => claimed[w] !== null && fenced[w] > claimed[w].fenced;
if (name === "Claim") {
const before = new Set(liveHolders);
claimed[w] = { fenced: fenced[w], epoch: expectedEpoch };
if (!sessionId) {
const turn = agents[w].session().prompt(`use ${tool}`, { messageId: "turn-1" });
void turn.result.catch(() => {});
sessionId = (await turn.accepted).sessionId;
} else {
void agents[w].session(sessionId).resume().result.catch(() => {});
}
await until(() => waiting(w) || stopped());
holders[w] = [...liveHolders].filter((holder) => !before.has(holder));
} else if (name === "Model1" || name === "Model2") {
const request = requests.find((entry) => entry.w === w);
assert.ok(request, `${w} has a model request to answer`);
request.answer(name === "Model1"
? [{ type: "tool-call", toolCallId: "call-1", toolName: tool, input: { key: "alpha" } }, finish("tool-calls")]
: [{ type: "text-delta", id: "answer", delta: "done" }, finish("stop")]);
if (name === "Model1") await until(() => calls.some((entry) => entry.w === w) || stopped());
else await until(async () => stopped() || (await streamLines()).some((line) => line.type === "turn_end" && line.epoch === claimed[w].epoch));
} else if (name === "ToolEnd") {
const call = calls.find((entry) => entry.w === w);
assert.ok(call, `${w} has a tool call to end`);
call.end();
await until(() => requests.some((entry) => entry.w === w && entry.kind === "model2") || stopped());
} else if (name === "Freeze") {
// The platform gives up on w: its lease no longer counts as alive.
for (const holder of holders[w]) liveHolders.delete(holder);
frozen[w] = true;
} else if (name === "Wake") {
frozen[w] = false;
} else if (name === "Deadline") {
// w's timer fires: it stops, and the same message comes back to it.
const before = new Set(liveHolders);
const fencedBefore = fenced[w];
agents[w][durableInternals].stopAtDeadline(sessionId);
const reclaimed = () => [...liveHolders].some((holder) => !before.has(holder));
await until(() => (reclaimed() && waiting(w)) || fenced[w] > fencedBefore);
if (reclaimed()) {
claimed[w] = { fenced: fenced[w], epoch: expectedEpoch };
holders[w] = [...liveHolders].filter((holder) => !before.has(holder));
}
}
// Let background writes land before the code is read back.
await sleep(settleMs);
}
async function close() {
for (const entry of [...requests]) entry.answer([{ type: "error", errorText: "walk over" }]);
for (const entry of [...calls]) entry.end();
await Promise.race([Promise.all(Workers.map((w) => agents[w].close().catch(() => {}))), sleep(3000)]);
await rm(dir, { recursive: true, force: true });
}
return { perform, observe, close };
}
// ---------------------------------------------------------------------------
// Walks.
const random = () => {
seed = (seed * 1103515245 + 12345) % 2147483648;
return seed / 2147483648;
};
const graph = await loadGraph(resolve(here, "graphs", `${tool}.dot`));
const covered = new Set();
const totalEdges = [...graph.next.values()].reduce((sum, targets) => sum + targets.size, 0);
let steps = 0;
const successors = (id) => [...(graph.next.get(id) ?? [])].filter((to) => to !== id);
const actOf = (id) => graph.states.get(id).act;
for (let walk = 1; walk <= walks; walk += 1) {
const code = await harness();
const path = [];
// Every model state the code could be in, given all it has shown so far.
let current = new Set([graph.init]);
try {
for (;;) {
// The steps the driver can take. Announce follows at once in the code
// whenever a worker claims, so the two are one step here.
const options = [];
for (const from of current) {
for (const to of successors(from)) {
const act = actOf(to);
if (act.name === "Announce") continue;
// local() hands a message that comes back at the deadline to the
// same worker, so a Deadline that only releases the session is a
// path the code cannot take.
if (act.name === "Deadline" && graph.states.get(to).pc[act.w] === "none") continue;
let option = options.find((item) => sameAct(item.act, act));
if (!option) options.push(option = { act, fresh: false });
if (!covered.has(`${from}>${to}`)) option.fresh = true;
}
}
if (options.length === 0) break;
const fresh = options.filter((option) => option.fresh);
const pool = fresh.length > 0 ? fresh : options;
const { act } = pool[Math.floor(random() * pool.length)];
const expectedEpoch = act.name === "Claim" || act.name === "Deadline"
? Math.max(...[...current].flatMap((from) => successors(from).filter((to) => sameAct(actOf(to), act)).map((to) => graph.states.get(to).epoch[act.w])))
: undefined;
if (verbose) console.log(`walk ${walk}: ${act.name}(${act.w})`);
await code.perform(act, expectedEpoch);
const seen = JSON.stringify(await code.observe());
if (verbose) console.log(` shows ${seen}`);
path.push(`${act.name}(${act.w})`);
steps += 1;
const next = new Set();
const allowed = new Set();
for (const from of current) {
for (const to of successors(from).filter((id) => sameAct(actOf(id), act))) {
const ends = graph.states.get(to).pc[act.w] === "announce"
? successors(to).filter((id) => sameAct(actOf(id), { name: "Announce", w: act.w })).map((id) => [to, id])
: [[null, to]];
for (const [middle, end] of ends) {
allowed.add(view(graph.states.get(end)));
if (view(graph.states.get(end)) !== seen) continue;
next.add(end);
covered.add(`${from}>${middle ?? end}`);
if (middle) covered.add(`${middle}>${end}`);
}
}
}
if (next.size === 0) {
console.error(`FAIL walk ${walk} after ${path.join(" ")}`);
console.error(` the code shows ${seen}`);
for (const view of allowed) console.error(` the model allows ${view}`);
process.exitCode = 1;
break;
}
current = next;
}
} catch (error) {
console.error(`FAIL walk ${walk} after ${path.join(" ")}: ${error.message}`);
process.exitCode = 1;
} finally {
await code.close();
}
if (process.exitCode) break;
if (walk % 25 === 0 || walk === walks) console.log(`${walk} walks, ${steps} steps, ${covered.size} of ${totalEdges} edges`);
}
if (!process.exitCode) console.log(`ok ${tool}: ${walks} walks, ${steps} steps matched the model; ${covered.size} of ${totalEdges} edges covered (${backend})`);
// An agent a failed walk left mid-turn keeps timers alive; nothing more runs.
process.exit(process.exitCode ?? 0);
+47
View File
@@ -0,0 +1,47 @@
import { createFxAgent } from "libfx";
export const runtime = "nodejs";
function stream(events) {
const data = [...events, "[DONE]"].map((event) => `data: ${JSON.stringify(event)}\n\n`).join("");
return new Response(data, { headers: { "content-type": "text/event-stream" } });
}
// One agent for the server, as an app holds it; its sessions live in the
// durability the environment picks.
const agent = createFxAgent({
apiKey: "fixture-unused-key",
model: "fixture/model",
async fetch(_url, init) {
if (init?.method === "GET") return Response.json({ data: [{ id: "fixture/model", type: "language" }] });
const body = JSON.parse(init.body);
const users = body.prompt.filter((message) => message.role === "user").length;
return stream([
{ type: "text-delta", delta: `durable answer ${users}` },
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: { inputTokens: { total: 1 }, outputTokens: { total: 1 } } },
]);
},
});
function authorized(request) {
const token = process.env.LIBFX_SMOKE_TOKEN;
return !token || request.headers.get("authorization") === `Bearer ${token}`;
}
export async function POST(request) {
if (!authorized(request)) return new Response(null, { status: 401 });
const sessionId = new URL(request.url).searchParams.get("sessionId") ?? undefined;
const session = agent.session(sessionId);
const turn = session.prompt(await request.text());
await turn.accepted;
return new Response(turn.readable, { headers: { "content-type": "application/x-ndjson", "x-libfx-session": session.id } });
}
// Reconnects from any server.
export async function GET(request) {
if (!authorized(request)) return new Response(null, { status: 401 });
const params = new URL(request.url).searchParams;
return new Response(agent.session(params.get("sessionId")).stream(Number(params.get("cursor") ?? 0)), {
headers: { "content-type": "application/x-ndjson" },
});
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { createFxAgent, getBackendInfo } from "libfx";
import { createFxEngine, getBackendInfo } from "libfx";
import { createMcpAdapter } from "libfx/mcp";
export const runtime = "nodejs";
@@ -102,7 +102,7 @@ export async function GET(request) {
]);
} } : {}),
};
agent = await createFxAgent(options);
agent = await createFxEngine(options);
if (scenario === "startup") {
return Response.json({ ok: true, probe, checkpointBytes: (await agent.checkpoint()).length });
}
@@ -125,7 +125,7 @@ export async function GET(request) {
await agent.close();
agent = null;
if (scenario === "resume") {
agent = await createFxAgent({ ...options, checkpoint });
agent = await createFxEngine({ ...options, checkpoint });
const resumed = agent.prompt("Repeat the value you looked up without calling another tool.", { signal: controller.signal });
let resumedText = "";
for await (const event of resumed) if (event.type === "text_delta") resumedText += event.delta;
+3 -3
View File
@@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const scriptPath = fileURLToPath(import.meta.url);
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
@@ -41,7 +41,7 @@ if (!child) {
for (let index = 0; index < attempts; index++) {
await assert.rejects(
createFxAgent({ ...options, checkpoint: new Uint8Array([1, 2, 3]) }),
createFxEngine({ ...options, checkpoint: new Uint8Array([1, 2, 3]) }),
(error) => {
assert.match(error.message, /Invalid or non-fresh libfx checkpoint/);
return true;
@@ -49,7 +49,7 @@ if (!child) {
);
}
const agent = await createFxAgent(options);
const agent = await createFxEngine(options);
await agent.close();
console.log(`${backend} failed bootstrap cleanup passed`);
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const backend = process.argv[2] || "native";
@@ -58,7 +58,7 @@ const baseOptions = {
: { wasm: await readFile(resolve(scriptDir, "../../zig-out/bin/fx-core.wasm")) }),
};
const createAgent = (gateway, overrides) =>
createFxAgent({ ...baseOptions, fetch: gateway.fetch, ...overrides });
createFxEngine({ ...baseOptions, fetch: gateway.fetch, ...overrides });
async function runPrompt(agent) {
const turn = agent.prompt("say ok");
+2 -2
View File
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const backend = process.argv[2] || "native";
@@ -54,7 +54,7 @@ const baseOptions = {
: { wasm: await readFile(resolve(scriptDir, "../../zig-out/bin/fx-core.wasm")) }),
};
const createAgent = (gateway, overrides) =>
createFxAgent({ ...baseOptions, fetch: gateway.fetch, ...overrides });
createFxEngine({ ...baseOptions, fetch: gateway.fetch, ...overrides });
async function runPrompt(agent) {
const turn = agent.prompt("say ok");
+33 -3
View File
@@ -3,8 +3,8 @@ import { strict as assert } from "node:assert";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxAgent as createSharedAgent } from "../fx-sdk.js";
import { createFxEngine, supportsJspi } from "../node.js";
import { createFxEngine as createSharedAgent } from "../fx-sdk.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const backend = process.argv[2] || "native";
@@ -108,7 +108,7 @@ const baseOptions = {
: { wasm: await readFile(resolve(scriptDir, "../../zig-out/bin/fx-core.wasm")) }),
};
const createAgent = (gateway, overrides) =>
createFxAgent({ ...baseOptions, fetch: gateway.fetch, ...overrides });
createFxEngine({ ...baseOptions, fetch: gateway.fetch, ...overrides });
async function runPrompt(agent, input) {
const turn = agent.prompt(input);
@@ -336,6 +336,36 @@ for (const resizeImage of [undefined, (image) => image]) {
await agent.close();
}
// A proxy that refuses the catalog request leaves image support unknown, so
// an image-capable model refuses images. With `modelCatalog`, the same model
// takes the image and the catalog is never requested.
{
const model = "sdk/proxied-vision-model";
const refusing = mockGateway();
const fetch405 = async (url, init = {}) => (String(init.method ?? "GET").toUpperCase() === "GET"
? new Response("", { status: 405 })
: refusing.fetch(url, init));
const blocked = await createFxEngine({ ...baseOptions, fetch: fetch405, model });
const refused = blocked.prompt([{ type: "image", data: pngData, mimeType: "image/png" }]);
await assert.rejects(refused.result, /Image prompts are unavailable for the selected model/);
await blocked.close();
const gateway = mockGateway();
const modelCatalog = [{ id: model, type: "language", tags: ["tool-use", "vision", "file-input"] }];
const agent = await createAgent(gateway, { model, modelCatalog });
const result = await runPrompt(agent, [
{ type: "text", text: "what is in this image?" },
{ type: "image", data: pngData, mimeType: "image/png" },
]);
assert.equal(result.stopReason, "end_turn");
assert.equal(gateway.state.catalogFetches, 0, "the supplied catalog replaces the request");
assert.equal(fileParts(gateway.state.chatBodies[0]).length, 1);
await agent.close();
await assert.rejects(createAgent(mockGateway(), { modelCatalog: [{ name: "no id" }] }), /needs a string id/);
await assert.rejects(createAgent(mockGateway(), { modelCatalog: "catalog" }), /modelCatalog must be/);
}
// Checkpoint/restore round-trips prompt images inside the checkpoint bound:
// the restored agent re-sends the same bytes on the next turn.
{
+3 -3
View File
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const backend = process.argv[2] || "native";
if (!new Set(["native", "wasm"]).has(backend)) throw new Error("usage: test-agent-provider-tools.mjs [native|wasm]");
@@ -79,7 +79,7 @@ const options = (checkpoint) => ({
let agent;
try {
agent = await createFxAgent(options());
agent = await createFxEngine(options());
const turn = agent.prompt("search the web");
const events = [];
for await (const event of turn) events.push(event);
@@ -93,7 +93,7 @@ try {
const checkpoint = await agent.checkpoint();
await agent.close();
agent = await createFxAgent(options(checkpoint));
agent = await createFxEngine(options(checkpoint));
const restored = agent.prompt("use the saved search");
let restoredText = "";
for await (const event of restored) if (event.type === "text_delta") restoredText += event.delta;
+2 -2
View File
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const backend = process.argv[2] || "native";
if (!new Set(["native", "wasm"]).has(backend)) {
@@ -79,7 +79,7 @@ const backendOptions = {
};
async function capture(instructions, prompt) {
const agent = await createFxAgent({
const agent = await createFxEngine({
...backendOptions,
fetch: gatewayFetch,
onEvent(event) { sdkEvents.push(event); },
+2 -2
View File
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const backend = process.argv[2] || "native";
@@ -68,7 +68,7 @@ const gateway = {
},
};
const agent = await createFxAgent({
const agent = await createFxEngine({
backend,
apiKey: "sdk-reserved-names-key",
model: "sdk/tool-model",
+3 -3
View File
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const backend = process.argv[2] || "native";
if (!new Set(["native", "wasm"]).has(backend)) throw new Error("usage: test-agent-steering.mjs [native|wasm]");
@@ -91,7 +91,7 @@ const options = (checkpoint) => ({
let agent;
try {
agent = await createFxAgent(options());
agent = await createFxEngine(options());
const turn = agent.prompt("start work");
const events = [];
let steered = false;
@@ -118,7 +118,7 @@ try {
const checkpoint = await agent.checkpoint();
await agent.close();
agent = await createFxAgent(options(checkpoint));
agent = await createFxEngine(options(checkpoint));
const restored = agent.prompt("continue saved work");
let restoredText = "";
for await (const event of restored) if (event.type === "text_delta") restoredText += event.delta;
+2 -2
View File
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const backend = process.argv[2] || "native";
@@ -13,7 +13,7 @@ if (backend === "wasm" && !supportsJspi()) throw new Error("WebAssembly backend
const toolSteps = 65;
let requests = 0;
let executions = 0;
const agent = await createFxAgent({
const agent = await createFxEngine({
backend,
apiKey: "step-limit-test-key",
model: "sdk/step-model",
+5 -5
View File
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const backend = process.argv[2] || "native";
@@ -70,7 +70,7 @@ async function collectTurn(agent, input) {
toolCallFinish("call-1", "run_command", { command: "ls -la" }),
textFinish("done"),
]);
const agent = await createFxAgent({
const agent = await createFxEngine({
...baseOptions,
fetch: gateway.fetch,
tools: [{
@@ -113,7 +113,7 @@ async function collectTurn(agent, input) {
],
textFinish("done"),
]);
const agent = await createFxAgent({
const agent = await createFxEngine({
...baseOptions,
fetch: gateway.fetch,
tools: [
@@ -140,7 +140,7 @@ async function collectTurn(agent, input) {
toolCallFinish("call-big", "upload_blob", { path: "/tmp/big.txt", content: huge }),
textFinish("done"),
]);
const agent = await createFxAgent({
const agent = await createFxEngine({
...baseOptions,
fetch: gateway.fetch,
tools: [{
@@ -183,7 +183,7 @@ async function collectTurn(agent, input) {
toolCallFinish("call-utf8", "probe", nonAscii),
textFinish("three"),
]);
const agent = await createFxAgent({
const agent = await createFxEngine({
...baseOptions,
fetch: gateway.fetch,
tools: [{ name: "probe", description: "Probe inputs", inputSchema: { type: "object" }, execute: () => "ok" }],
+4 -4
View File
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const backend = process.argv[2] || "native";
if (!new Set(["native", "wasm"]).has(backend)) {
@@ -39,7 +39,7 @@ const fetchOnceThenSucceed = async (_url, init) => {
}), { status: 200, headers: { "content-type": "text/event-stream" } });
};
const agent = await createFxAgent({
const agent = await createFxEngine({
backend,
nativeAddon: resolve(scriptDir, "../../zig-out/lib/libfx.node"),
...(backend === "wasm"
@@ -87,7 +87,7 @@ const cancelEvents = [];
let cancelFetchCalls = 0;
let fetchStartedResolve;
const fetchStarted = new Promise((resolveStarted) => { fetchStartedResolve = resolveStarted; });
const cancelledAgent = await createFxAgent({
const cancelledAgent = await createFxEngine({
backend,
nativeAddon: resolve(scriptDir, "../../zig-out/lib/libfx.node"),
...(backend === "wasm"
@@ -127,7 +127,7 @@ console.log(`${process.versions.bun ? "Bun" : "Node"} ${backend} Agent cancellat
const retryBoundaryEvents = [];
const retryBoundaryController = new AbortController();
let retryBoundaryFetchCalls = 0;
const retryBoundaryAgent = await createFxAgent({
const retryBoundaryAgent = await createFxEngine({
backend,
nativeAddon: resolve(scriptDir, "../../zig-out/lib/libfx.node"),
...(backend === "wasm"
+2 -2
View File
@@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const backend = process.argv[2] || "native";
@@ -131,7 +131,7 @@ try {
: { wasm: await readFile(resolve(scriptDir, "../../zig-out/bin/fx-core.wasm")) }),
};
const createAgent = (gateway, overrides, events = []) =>
createFxAgent({
createFxEngine({
...baseOptions,
fetch: gateway.fetch,
onEvent(event) {
+12 -12
View File
@@ -5,7 +5,7 @@ import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, createFxTerminal, getBackendInfo } from "../node.js";
import { createFxEngine, createFxTerminal, getBackendInfo } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const coreWasm = resolve(scriptDir, "../../zig-out/bin/fx-core.wasm");
@@ -48,7 +48,7 @@ try {
assert.deepEqual(await getBackendInfo({ surface: undefined, backend: undefined, nativeAddon: matchingCore }), defaults);
assert.deepEqual(await getBackendInfo({ surface: "agent", backend: "auto", nativeAddon: matchingCore }), defaults);
const backendError = { name: "TypeError", message: 'backend must be "auto", "native", or "wasm"' };
await assert.rejects(createFxAgent({ backend: null }), backendError);
await assert.rejects(createFxEngine({ backend: null }), backendError);
await assert.rejects(createFxTerminal({ backend: null }), backendError);
assert.deepEqual(await getBackendInfo({ backend: "native", nativeAddon: matchingCore }), {
@@ -93,7 +93,7 @@ try {
assert.equal(missing.attempts[0].reason.code, "LIBFX_NATIVE_ARTIFACT_MISSING");
assert.equal(missing.attempts[0].reason.causeCode, "MODULE_NOT_FOUND");
await assert.rejects(
createFxAgent({ backend: "native", nativeAddon: missingAddon, apiKey: "missing-override-key" }),
createFxEngine({ backend: "native", nativeAddon: missingAddon, apiKey: "missing-override-key" }),
(error) => error?.code === "MODULE_NOT_FOUND",
);
@@ -102,7 +102,7 @@ try {
assert.equal(dependencyFailure.attempts[0].reason.code, "LIBFX_NATIVE_LOAD_FAILED");
assert.equal(dependencyFailure.attempts[0].reason.causeCode, "ERR_MODULE_NOT_FOUND");
await assert.rejects(
createFxAgent({ backend: "native", nativeAddon: dependencyAddon, apiKey: "dependency-override-key" }),
createFxEngine({ backend: "native", nativeAddon: dependencyAddon, apiKey: "dependency-override-key" }),
(error) => error?.code === "ERR_MODULE_NOT_FOUND",
);
@@ -111,7 +111,7 @@ try {
assert.equal(defaultMissing.attempts[0].reason.code, "LIBFX_NATIVE_ARTIFACT_MISSING");
assert.equal(defaultMissing.attempts[0].reason.causeCode, "ENOENT");
await assert.rejects(
createFxAgent({ backend: "native", apiKey: "missing-default-key" }),
createFxEngine({ backend: "native", apiKey: "missing-default-key" }),
(error) => error?.code === "LIBFX_NATIVE_UNAVAILABLE",
);
await assert.rejects(
@@ -131,7 +131,7 @@ try {
assert.equal(nonlocal.attempts[0].reason.code, "LIBFX_NATIVE_LOAD_FAILED");
assert.equal(nonlocal.attempts[0].reason.causeCode, "ERR_INVALID_FILE_URL_HOST");
await assert.rejects(
createFxAgent({ backend: "native", nativeAddon: nonlocalAddon, apiKey: "nonlocal-key" }),
createFxEngine({ backend: "native", nativeAddon: nonlocalAddon, apiKey: "nonlocal-key" }),
(error) => error?.code === "ERR_INVALID_FILE_URL_HOST",
);
@@ -153,7 +153,7 @@ try {
}],
});
await assert.rejects(
createFxAgent({ backend: "auto", nativeAddon: nonlocalAddon, apiKey: "nonlocal-key" }),
createFxEngine({ backend: "auto", nativeAddon: nonlocalAddon, apiKey: "nonlocal-key" }),
(error) => error?.code === "LIBFX_JSPI_REQUIRED" && error.cause?.code === "ERR_INVALID_FILE_URL_HOST",
);
} finally {
@@ -161,7 +161,7 @@ try {
Object.defineProperty(WebAssembly, "promising", { configurable: true, value: savedPromising });
}
const nonlocalFallback = await createFxAgent({
const nonlocalFallback = await createFxEngine({
backend: "auto",
nativeAddon: nonlocalAddon,
wasm: coreWasm,
@@ -182,7 +182,7 @@ try {
["wasm-jspi", "LIBFX_WASM_LOAD_FAILED"],
]);
await assert.rejects(
createFxAgent({ backend: "wasm", wasm: nonlocalWasm, apiKey: "nonlocal-wasm-key" }),
createFxEngine({ backend: "wasm", wasm: nonlocalWasm, apiKey: "nonlocal-wasm-key" }),
(error) => error?.code === "ERR_INVALID_FILE_URL_HOST",
);
assert.equal((await getBackendInfo({ backend: "auto", nativeAddon: false, wasm: coreWasm })).backend, "wasm-jspi");
@@ -201,7 +201,7 @@ try {
await writeFile(replacedWasm, await readFile(coreWasm));
const secondFileProbe = await getBackendInfo({ backend: "wasm", wasm: replacedWasm });
assert.equal(secondFileProbe.backend, "wasm-jspi", "replaced Wasm file must be read again after compile failure");
const replacedAgent = await createFxAgent({ backend: "wasm", wasm: replacedWasm, apiKey: "probe-retry-key" });
const replacedAgent = await createFxEngine({ backend: "wasm", wasm: replacedWasm, apiKey: "probe-retry-key" });
const replacedCheckpoint = await replacedAgent.checkpoint();
await replacedAgent.close();
assert.ok(replacedCheckpoint.length > 0, "factory must reuse the valid replacement after the failed probe");
@@ -220,7 +220,7 @@ try {
try {
assert.equal((await getBackendInfo({ backend: "wasm", wasm: stableWasm })).backend, "wasm-jspi");
await assert.rejects(
createFxAgent({
createFxEngine({
backend: "wasm",
wasm: stableWasm,
apiKey: "stable-cache-key",
@@ -229,7 +229,7 @@ try {
/Invalid or non-fresh libfx checkpoint/,
);
assert.equal((await getBackendInfo({ backend: "wasm", wasm: stableWasm })).backend, "wasm-jspi");
const stableAgent = await createFxAgent({ backend: "wasm", wasm: stableWasm, apiKey: "stable-cache-key" });
const stableAgent = await createFxEngine({ backend: "wasm", wasm: stableWasm, apiKey: "stable-cache-key" });
await stableAgent.close();
assert.equal(stableCompileCalls, 1, "downstream factory errors must retain a successful compiled module");
} finally {
+3 -3
View File
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const sourceBackend = process.argv[2] || "native";
const targetBackend = process.argv[3] || "wasm";
@@ -109,7 +109,7 @@ for (const shape of ["plain", "reasoning-text", "reasoning-only", "provider-term
let source;
let target;
try {
source = await createFxAgent(options(sourceBackend));
source = await createFxEngine(options(sourceBackend));
const first = source.prompt(shape === "image"
? [{ type: "text", text: "store this context" }, { type: "image", data: pngData, mimeType: "image/png" }]
: "store this context");
@@ -120,7 +120,7 @@ for (const shape of ["plain", "reasoning-text", "reasoning-only", "provider-term
assert.equal(await source.close(), undefined);
source = null;
target = await createFxAgent(options(targetBackend, checkpoint));
target = await createFxEngine(options(targetBackend, checkpoint));
const second = target.prompt("continue");
let text = "";
for await (const update of second) {
+4 -4
View File
@@ -181,7 +181,7 @@ try {
expect(modelChunks.filter((chunk) => chunk.trim()).length >= 2, "browser stream was buffered");
expect(result.fetchCalls === 1, `expected one prompt fetch, got ${result.fetchCalls}`);
expect(result.model === "sdk/chrome-model", `unexpected model ${result.model}`);
expect(JSON.stringify(result.api) === JSON.stringify(["checkpoint", "close", "prompt"]), `unexpected public API ${JSON.stringify(result.api)}`);
expect(JSON.stringify(result.api) === JSON.stringify(["checkpoint", "close", "followUp", "prompt", "resume", "sessionId"]), `unexpected public API ${JSON.stringify(result.api)}`);
});
await runCase("Blob prompt", "transport=mock&autorun=describe&model=sdk%2Fchrome-model&blob-prompt=1", (result) => {
expect(result.stopReason === "end_turn", `unexpected stop reason ${result.stopReason}`);
@@ -196,7 +196,7 @@ try {
await runCase("stalled cancellation", "transport=stall&autorun=wait&cancel-after=50", (result) => {
expect(result.stopReason === "cancelled", `unexpected stop reason ${result.stopReason}`);
expect(result.fetchAborted, "browser fetch did not receive abort");
expect(JSON.stringify(result.api) === JSON.stringify(["checkpoint", "close", "prompt"]), `unexpected public API ${JSON.stringify(result.api)}`);
expect(JSON.stringify(result.api) === JSON.stringify(["checkpoint", "close", "followUp", "prompt", "resume", "sessionId"]), `unexpected public API ${JSON.stringify(result.api)}`);
});
await runCase("host tool and skill", "transport=mock&autorun=use%20the%20tool&host-tool=1&host-skill=1&model=sdk%2Fchrome-model", (result) => {
expect(result.stopReason === "end_turn", `unexpected stop reason ${result.stopReason}`);
@@ -225,7 +225,7 @@ try {
returnByValue: true,
expression: `(async () => {
const expect = ${expect.toString()};
const { createFxAgent } = await import('/sdk/browser.js');
const { createFxEngine } = await import('/sdk/browser.js');
const response = await fetch('/zig-out/bin/fx-core.wasm', { cache: 'no-store' });
expect(response.ok, 'local core WASM fetch failed: ' + response.status);
const wasm = await response.arrayBuffer();
@@ -267,7 +267,7 @@ try {
};
try {
try {
agent = await createFxAgent({ wasm, apiKey: 'browser-ultrafast-test-key',
agent = await createFxEngine({ wasm, apiKey: 'browser-ultrafast-test-key',
model: { id: scenario.model, ultrafast: scenario.ultrafast }, fetch: fakeFetch });
} catch (error) {
if (!scenario.reject) throw error;
+3 -3
View File
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import { strict as assert } from "node:assert";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const defaultWasm = resolve(scriptDir, "../../zig-out/bin/fx-core.wasm");
@@ -36,7 +36,7 @@ const timeout = (label, ms = 5000) => new Promise((_, reject) => {
});
const agent = await Promise.race([
createFxAgent({
createFxEngine({
backend: "wasm",
wasm: await readFile(wasmPath),
fetch: stalledFetch,
@@ -79,7 +79,7 @@ for (const stage of ["headers", "body", "late-body", "close"]) {
let followingUp = false;
const model = `sdk/catalog-cancel-${stage}`;
const catalog = () => Response.json({ object: "list", data: [{ id: model, type: "language", tags: ["tool-use"] }] });
const catalogAgent = await createFxAgent({
const catalogAgent = await createFxEngine({
backend: "wasm",
wasm: await readFile(wasmPath),
apiKey: "sdk-test-key",
+2 -2
View File
@@ -2,7 +2,7 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const defaultWasm = resolve(scriptDir, "../../zig-out/bin/fx-core.wasm");
@@ -50,7 +50,7 @@ const timeout = (label, ms = 8000) => {
const initializeTimeout = timeout("fx-core initialize");
const agent = await Promise.race([
createFxAgent({
createFxEngine({
backend: "wasm",
wasm: await readFile(wasmPath),
fetch: mockFetch,
+2 -2
View File
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const defaultWasm = resolve(scriptDir, "../../zig-out/bin/fx-core.wasm");
@@ -72,7 +72,7 @@ const tracedFetch = async (url, init) => {
};
const agent = await Promise.race([
createFxAgent({ backend, nativeAddon, wasm: await readFile(wasmPath), fetch: tracedFetch, apiKey, model }),
createFxEngine({ backend, nativeAddon, wasm: await readFile(wasmPath), fetch: tracedFetch, apiKey, model }),
new Promise((_, reject) => setTimeout(() => reject(new Error("timed out waiting for fx-core initialize")), 5000)),
]);
+3 -3
View File
@@ -5,7 +5,7 @@ import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const backend = process.argv[2] ?? "native";
const root = fileURLToPath(new URL("../..", import.meta.url));
@@ -20,7 +20,7 @@ const deadline = setTimeout(() => { throw new Error("bounded output did not sett
let agent;
async function start(fetch, onEvent, options = {}) {
agent = await createFxAgent({
agent = await createFxEngine({
backend, nativeAddon, wasm, apiKey: "output-pressure-fixture", model: "fixture/output",
fetch: (url, init) => init.method === "GET" ? catalogResponse() : fetch(url, init),
onEvent, ...options,
@@ -116,7 +116,7 @@ try {
});
const closing = agent.prompt("close while unread");
while (!pressure) await pause(5);
const independent = await createFxAgent({
const independent = await createFxEngine({
backend, nativeAddon, wasm, apiKey: "independent-fixture", model: "fixture/output",
fetch: async (_url, init) => init.method === "GET" ? catalogResponse() : new Response(delta("independent") + finish),
});
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { CoreOutput } from "../core-output.js";
import { createFxAgent } from "../fx-sdk.js";
import { createFxEngine } from "../fx-sdk.js";
const encoder = new TextEncoder();
const message = { text: "¢€\u{1f600}界\nsecond line", escaped: '"\\' };
@@ -89,7 +89,7 @@ for (const cancelAt of ["permission.request", "permission.resolve"]) {
abortHostEffects() {},
closeStdin() { exit(0); },
};
const agent = await createFxAgent({
const agent = await createFxEngine({
apiKey: "callback-fixture", runtimeFactory: () => runtime,
onEvent(event) { if (event.type === cancelAt) turn.cancel(); },
onPermission() { permissionCalls++; return "allow-once"; },
+3 -3
View File
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const wasmPath = resolve(process.argv[2] || resolve(scriptDir, "../../zig-out/bin/fx-core.wasm"));
@@ -39,14 +39,14 @@ const mockFetch = async (url, init) => {
}), { status: 200, headers: { "content-type": "text/event-stream" } });
};
const agent = await createFxAgent({
const agent = await createFxEngine({
backend: "wasm",
wasm: await readFile(wasmPath),
fetch: mockFetch,
apiKey: "sdk-test-key",
model: "sdk/core-model",
});
assert.deepEqual(Object.keys(agent).sort(), ["checkpoint", "close", "prompt"]);
assert.deepEqual(Object.keys(agent).sort(), ["checkpoint", "close", "followUp", "prompt", "resume", "sessionId"]);
const turn = agent.prompt([
{ type: "text", text: "say hello" },
+1 -1
View File
@@ -11,7 +11,7 @@ globalThis.fetch = (...args) => {
try {
const entry = await import(`../node.js?default-import=${Date.now()}`);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(typeof entry.createFxAgent, "function");
assert.equal(typeof entry.createFxEngine, "function");
assert.equal(typeof entry.listModels, "function");
assert.equal("createMcpAdapter" in entry, false);
assert.equal("createSkillsAdapter" in entry, false);
+269
View File
@@ -0,0 +1,269 @@
// The durable core with a harness that is not fx: a scripted agent whose
// turns are a few steps, each stored as one record. It shows the contract
// `createDurableAgentFactory` documents is all a harness needs, and is the
// smallest example of one.
//
// node sdk/tests/test-durable-core.mjs [memory|local]
import { strict as assert } from "node:assert";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDurableAgentFactory, memory } from "../durable.js";
const durabilityKind = process.argv[2] || "memory";
const { local } = durabilityKind === "local" ? await import("../durable/local.mjs") : {};
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const dirs = [];
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Every step any harness session ran, as `turnId:step`.
const ran = [];
// Steps that wait until a test lets them go, by text.
const holds = new Map();
/**
* A turn of `input` is `steps` steps, each producing one word. A session
* keeps them as records `{ turnId, step, word }`, with the turn's start and
* end marked, and a yield as its own record.
*/
function scriptedHarness({ steps = 3 } = {}) {
return () => ({
async open({ store }) {
const loaded = await store.load();
const records = loaded.journal.map((record) => JSON.parse(decoder.decode(record.data)));
let handedOff = false;
let key = 0;
const append = async (record, marks) => {
if (handedOff) throw new Error("handed off");
await store.append({ idempotencyKey: `scripted:${Date.now()}:${key++}`, data: encoder.encode(JSON.stringify(record)), marks });
records.push(record);
};
const turnOf = (turnId) => records.filter((record) => record.turnId === turnId && record.word !== undefined);
const openTurn = () => {
for (let index = records.length - 1; index >= 0; index -= 1) {
if (records[index].end) return null;
if (records[index].start) return { id: records[index].turnId, input: records[index].input };
}
return null;
};
const run = (turnId, input, yieldAt) => scriptedTurn(async (push, signal) => {
let first = true;
for (let step = turnOf(turnId).length; step < steps; step += 1) {
if (!first && yieldAt !== undefined && Date.now() >= yieldAt) {
await append({ turnId, yield: true }, [{ yield: true }]);
return { stopReason: "yielded" };
}
first = false;
const word = `${input}-${step}`;
const hold = holds.get(word);
if (hold) {
hold.started();
await Promise.race([hold.released, new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }))]);
}
// A step takes time, as a model call or a tool does.
await sleep(2);
if (signal.aborted) throw new Error("aborted");
ran.push(`${turnId}:${step}`);
await append({ turnId, step, word, ...(step === steps - 1 ? { end: true } : {}) }, step === steps - 1 ? [{ end: true }] : []);
push({ type: "text_delta", delta: `${word} ` });
}
return { stopReason: "end_turn" };
}, () => { handedOff = true; });
return {
prompt(input, { turnId, yieldAt } = {}) {
const started = append({ turnId, start: true, input }, [{ start: turnId }]);
return chain(started, () => run(turnId, input, yieldAt));
},
resume({ yieldAt } = {}) {
const open = openTurn();
return run(open.id, open.input, yieldAt);
},
get openTurn() {
const open = openTurn();
return open && { id: open.id };
},
settled: async () => {},
saveCheckpoint: async () => {},
exportCheckpoint: async () => encoder.encode(JSON.stringify(records.filter((record) => record.word !== undefined).map((record) => record.word))),
close: async () => {},
};
},
});
}
// A turn: its events as an async iterable, its `result`, and `cancel()`; a
// handoff stops it and stores nothing more.
function scriptedTurn(body, onHandoff) {
const queue = [];
let wake = null;
let finished = false;
const controller = new AbortController();
let cancelled = false;
const result = body((event) => { queue.push(event); wake?.(); }, controller.signal)
.catch((error) => (cancelled ? { stopReason: "cancelled" } : { stopReason: "error", error: { name: "Error", message: String(error?.message ?? error) } }))
.finally(() => { finished = true; wake?.(); });
return {
result,
steer: async () => {},
cancel(options = {}) {
if (options.reason === "handoff") onHandoff();
cancelled = true;
controller.abort();
},
async *[Symbol.asyncIterator]() {
for (;;) {
while (queue.length) yield queue.shift();
if (finished) return;
await new Promise((resolve) => { wake = resolve; });
wake = null;
}
},
};
}
// A turn that starts once `before` resolves.
function chain(before, next) {
let turn = null;
const ready = before.then(() => { turn = next(); return turn; });
return {
result: ready.then((value) => value.result),
steer: async (...args) => (await ready).steer(...args),
cancel: (options) => { void ready.then((value) => value.cancel(options)); },
async *[Symbol.asyncIterator]() { yield* await ready; },
};
}
const createAgent = createDurableAgentFactory({ harness: scriptedHarness(), defaultDurability: async () => memory(), name: "createScriptedAgent" });
async function durabilityFor(options = {}) {
if (durabilityKind === "memory") return memory(options);
const dir = await mkdtemp(join(tmpdir(), "libfx-core-"));
dirs.push(dir);
return local({ dir, ...options });
}
async function textOf(turn) {
let text = "";
for await (const event of turn) if (event.type === "text_delta") text += event.delta;
return { text: text.trim(), result: await turn.result };
}
async function readLines(stream, count, quietMs = 300) {
const reader = stream.getReader();
let buffered = "";
const lines = [];
while (lines.length < count) {
let timer;
const quiet = new Promise((resolve) => { timer = setTimeout(() => resolve({ done: true }), quietMs); });
const { value, done } = await Promise.race([reader.read(), quiet]);
clearTimeout(timer);
if (done) break;
buffered += decoder.decode(value, { stream: true });
for (let index = buffered.indexOf("\n"); index >= 0; index = buffered.indexOf("\n")) {
lines.push(JSON.parse(buffered.slice(0, index)));
buffered = buffered.slice(index + 1);
}
}
await reader.cancel();
return lines;
}
const tests = [];
const test = (name, fn) => tests.push([name, fn]);
test("a prompt runs its steps, and the session replays from any cursor", async () => {
const agent = createAgent({ durability: await durabilityFor() });
const session = agent.session();
const { text, result } = await textOf(session.prompt("alpha", { messageId: "turn-a" }));
assert.equal(result.stopReason, "end_turn");
assert.equal(text, "alpha-0 alpha-1 alpha-2");
const lines = await readLines(session.stream(0), 8);
assert.deepEqual(lines.map((line) => line.type), ["turn_start", "text_delta", "text_delta", "text_delta", "turn_end"]);
const later = await readLines(session.stream(lines[2].cursor), 8);
assert.deepEqual(later, lines.slice(3), "a reconnect from a cursor shows the lines after it");
assert.deepEqual(JSON.parse(decoder.decode(await session.checkpoint())), ["alpha-0", "alpha-1", "alpha-2"]);
await agent.close();
});
test("a turn longer than the time limit continues in the next delivery, each step once", async () => {
const agent = createAgent({ durability: await durabilityFor({ maxDurationMs: 1, reserveMs: 0 }) });
const session = agent.session();
const before = ran.length;
const { text, result } = await textOf(session.prompt("beta", { messageId: "turn-b" }));
assert.equal(result.stopReason, "end_turn");
assert.equal(text, "beta-0 beta-1 beta-2");
assert.deepEqual(ran.slice(before), ["turn-b:0", "turn-b:1", "turn-b:2"]);
const lines = await readLines(session.stream(0), 16);
assert.equal(lines.filter((line) => line.type === "turn_yield").length, 2);
assert.equal(lines.filter((line) => line.type === "turn_resume").length, 2);
await agent.close();
});
test("a step cut off at the deadline continues in the next delivery", async () => {
const agent = createAgent({ durability: await durabilityFor() });
const session = agent.session();
let started;
const hold = { started: () => started(), released: new Promise(() => {}) };
const holding = new Promise((resolve) => { started = resolve; });
holds.set("gamma-1", hold);
const before = ran.length;
const turn = session.prompt("gamma", { messageId: "turn-g" });
await holding;
holds.delete("gamma-1");
agent[Symbol.for("libfx.durableInternals")].stopAtDeadline(session.id);
const { text, result } = await textOf(turn);
assert.equal(result.stopReason, "end_turn");
assert.equal(text, "gamma-0 gamma-1 gamma-2");
assert.deepEqual(ran.slice(before), ["turn-g:0", "turn-g:1", "turn-g:2"], "the cut-off step ran once, in the next delivery");
await agent.close();
});
test("two agents prompting one session run its turns one at a time, in one history", async () => {
const durability = await durabilityFor();
const first = createAgent({ durability });
const second = createAgent({ durability });
const session = first.session();
await textOf(session.prompt("delta", { messageId: "turn-d" }));
const [a, b] = await Promise.all([
textOf(first.session(session.id).prompt("one", { messageId: "turn-1" })),
textOf(second.session(session.id).prompt("two", { messageId: "turn-2" })),
]);
assert.equal(a.text, "one-0 one-1 one-2");
assert.equal(b.text, "two-0 two-1 two-2");
const words = JSON.parse(decoder.decode(await first.session(session.id).checkpoint()));
assert.equal(words.length, 9);
for (const name of ["one", "two"]) {
const at = words.indexOf(`${name}-0`);
assert.deepEqual(words.slice(at, at + 3), [`${name}-0`, `${name}-1`, `${name}-2`], "turns never interleave");
}
await first.close();
await second.close();
});
test("a retried message runs its turn once", async () => {
const agent = createAgent({ durability: await durabilityFor() });
const session = agent.session();
const before = ran.length;
await textOf(session.prompt("epsilon", { messageId: "turn-e" }));
const again = await session.prompt("epsilon", { messageId: "turn-e" }).result;
assert.equal(again.stopReason, "end_turn");
assert.equal(ran.length - before, 3, "the retry ran nothing");
await agent.close();
});
let failed = 0;
for (const [name, fn] of tests) {
const started = performance.now();
try {
let timer;
await Promise.race([fn(), new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error("timed out after 30s")), 30_000); })]).finally(() => clearTimeout(timer));
console.log(`ok ${name} (${(performance.now() - started).toFixed(0)}ms)`);
} catch (error) {
failed += 1;
console.log(`FAIL ${name}\n ${error?.stack ?? error}`);
}
}
for (const dir of dirs) await rm(dir, { recursive: true, force: true }).catch(() => {});
console.log(`${tests.length - failed}/${tests.length} core tests passed (${durabilityKind})`);
process.exit(failed ? 1 : 0);
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env node
// The durable libfx harnesses: their pure core on plain data, then a small
// run of each driver (benchmarks/libfx/bench-durable.mjs, bench-parallel.mjs,
// crash-matrix.mjs) on both backends.
import { strict as assert } from "node:assert";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
batchSteps,
callId,
checkBatch,
checkCrashCell,
durableWorkloads,
framesFor,
maxConcurrency,
nextRandom,
promptDirectives,
randomBatch,
sseBody,
stepFor,
toolCallsIn,
todayAdapterWrites,
toolResultIds,
turnNumber,
turnStartIndex,
} from "../../benchmarks/libfx/durable.mjs";
// The pure core, on plain data.
const user = (text) => ({ role: "user", content: [{ type: "text", text }] });
const steer = (text) => user(`<user_steering>${text}</user_steering>`);
const results = (...ids) => ({ role: "tool", content: ids.map((toolCallId) => ({ type: "tool-result", toolCallId })) });
{
const prompt = [user("workload=one-safe setup"), { role: "assistant", content: [] }, user("workload=list-then-read go"), steer("faster")];
assert.equal(turnStartIndex(prompt), 2, "steering never starts a turn");
assert.equal(turnNumber(prompt), 2);
assert.deepEqual(promptDirectives(prompt), { workload: "list-then-read" });
assert.deepEqual(toolResultIds([...prompt, results("a", "b")]), ["a", "b"]);
assert.deepEqual(toolResultIds([user("x"), results("old"), user("y")]), [], "results before the turn do not count");
}
{
const steps = durableWorkloads["list-then-read"];
assert.equal(stepFor(steps, 0), 0);
assert.equal(stepFor(steps, 2), 1);
assert.throws(() => stepFor(steps, 1), /partial tool results/);
assert.throws(() => stepFor(durableWorkloads["no-tool"], 1), /partial tool results/);
const first = framesFor(steps, [user("workload=list-then-read")]);
assert.deepEqual(first.filter((frame) => frame.type === "tool-call").map((frame) => frame.toolCallId), [callId(1, 0, 0), callId(1, 0, 1)]);
assert.equal(first.at(-1).finishReason.unified, "tool-calls");
const second = framesFor(steps, [user("workload=list-then-read"), results(callId(1, 0, 0), callId(1, 0, 1))]);
assert.deepEqual(second.map((frame) => frame.type), ["text-delta", "finish"]);
assert.equal(second.at(-1).finishReason.unified, "stop");
assert.ok(sseBody(second).endsWith("data: [DONE]\n\n"));
assert.equal(framesFor(durableWorkloads["stream-200"], [user("x")]).filter((frame) => frame.type === "text-delta").length, 200);
}
{
assert.deepEqual(nextRandom(7), nextRandom(7), "the PRNG is a pure function of its seed");
assert.notEqual(nextRandom(7).value, nextRandom(8).value);
const batch = randomBatch(42);
assert.deepEqual(batch, randomBatch(42));
for (let seed = 0; seed < 200; seed += 1) {
const { calls } = randomBatch(seed);
assert.ok(calls.length >= 2 && calls.length <= 8);
for (const call of calls) assert.equal(call.name, call.writes ? "write_item" : "read_item");
}
assert.equal(batchSteps(batch)[0].calls.length, batch.calls.length);
}
{
const ids = ["a", "b", "c"];
const sequential = [0, 1, 2].flatMap((call) => [{ call, kind: "start" }, { call, kind: "end" }]);
assert.deepEqual(checkBatch({ writes: [false, true, false], trace: sequential, resultOrder: ids, expectedOrder: ids }), []);
assert.equal(maxConcurrency(sequential), 1);
const readersOverlap = [{ call: 0, kind: "start" }, { call: 1, kind: "start" }, { call: 0, kind: "end" }, { call: 1, kind: "end" }];
assert.deepEqual(checkBatch({ writes: [false, false], trace: readersOverlap, resultOrder: ["a", "b"], expectedOrder: ["a", "b"] }), []);
assert.equal(maxConcurrency(readersOverlap), 2);
const writerOverlaps = [{ call: 0, kind: "start" }, { call: 1, kind: "start" }, { call: 1, kind: "end" }, { call: 0, kind: "end" }];
const writerViolations = checkBatch({ writes: [true, false], trace: writerOverlaps, resultOrder: ["a", "b"], expectedOrder: ["a", "b"] });
assert.ok(writerViolations.some((violation) => violation.startsWith("WriterRunsAlone")));
assert.ok(writerViolations.some((violation) => violation.startsWith("NothingPassesWriter")));
assert.ok(checkBatch({ writes: [false, true], trace: writerOverlaps, resultOrder: ["a", "b"], expectedOrder: ["a", "b"] })
.some((violation) => violation.startsWith("WriterAfterEarlier")));
assert.ok(checkBatch({ writes: [false, false], trace: readersOverlap, resultOrder: ["b", "a"], expectedOrder: ["a", "b"] })
.some((violation) => violation.startsWith("ResultsInModelOrder")), "completion order is not model order");
assert.ok(checkBatch({ writes: [false, false], trace: readersOverlap, resultOrder: [], expectedOrder: [] })[0].startsWith("ScriptedBatch"));
assert.ok(checkBatch({ writes: [false], trace: [{ call: 0, kind: "start" }], resultOrder: ["a"], expectedOrder: ["a"] })[0].startsWith("Completeness"));
}
{
const clean = { workload: "one-send", sendEffects: 1, completed: true, rememberedSetup: true, resultCounts: { a: 1 } };
assert.deepEqual(checkCrashCell(clean), []);
assert.match(checkCrashCell({ ...clean, sendEffects: 2 })[0], /^SendRunsOnce/);
assert.match(checkCrashCell({ ...clean, completed: false })[0], /^TurnCompletes/);
assert.match(checkCrashCell({ ...clean, rememberedSetup: false })[0], /^KeepsCommittedHistory/);
assert.match(checkCrashCell({ ...clean, resultCounts: { a: 2 } })[0], /^OneOutcome/);
assert.throws(() => checkCrashCell({ ...clean, workload: "nope" }), /unknown workload/);
}
assert.equal(todayAdapterWrites(0).awaited, 1);
assert.equal(todayAdapterWrites(2).awaited, 9);
assert.equal(toolCallsIn(durableWorkloads["write-then-read"]), 2);
// The drivers, small runs on both backends.
const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
const runtimeArgs = (backend) => (!process.versions.bun && backend === "wasm" ? ["--experimental-wasm-jspi"] : []);
const run = (script, args, backend) => {
const result = spawnSync(process.execPath, [...runtimeArgs(backend), fileURLToPath(new URL(`../../benchmarks/libfx/${script}`, import.meta.url)), ...args], {
cwd: repoRoot,
encoding: "utf8",
timeout: 120_000,
});
assert.equal(result.status, 0, `${script} ${backend} failed:\n${result.stderr}`);
return JSON.parse(result.stdout);
};
for (const backend of ["native", "wasm"]) {
const durable = run("bench-durable.mjs", ["--backend", backend, "--samples", "2", "--warmups", "0", "--rtt", "0", "--history", "2", "--restore-samples", "2", "--tool-ms", "1"], backend);
assert.equal(durable.backend, backend);
for (const [name, steps] of Object.entries(durableWorkloads)) {
const today = durable.workloads[name]["today-r0"];
assert.equal(today.awaited_writes.p50, today.planned_awaited_writes, `${name}: counted writes match the adapter plan`);
assert.equal(durable.workloads[name].control.awaited_writes.max, 0);
assert.equal(durable.workloads[name].control.batch_wall_ms === null, toolCallsIn(steps) === 0);
}
assert.equal(durable.restore[0].history_turns, 2);
assert.ok(durable.restore[0].checkpoint_bytes > 0);
const parallel = run("bench-parallel.mjs", ["--backend", backend, "--batches", "12", "--seed", "3"], backend);
assert.equal(parallel.failing_batches, 0, JSON.stringify(parallel.failures));
assert.deepEqual(parallel.fixed.map((batch) => batch.violations), [[], []], "list-then-read and write-then-read keep their order");
const crash = run("crash-matrix.mjs", ["--backends", backend, "--workloads", "one-send"], backend);
assert.ok(crash.cells >= 5, "one cell per crash step");
for (const cell of crash.groups[0].cells) {
assert.ok(cell.killed, `step ${cell.k} was a real SIGKILL`);
assert.ok(cell.completed && cell.rememberedSetup, `step ${cell.k}: restore finished the turn and kept history`);
}
// With persistence, no crash step may run send_email twice or lose the
// setup turn.
const journaled = run("crash-matrix.mjs", ["--backends", backend, "--workloads", "one-send", "--mode", "journal", "--require-clean"], backend);
assert.ok(journaled.cells >= 5, "one cell per crash step");
assert.equal(journaled.failing_cells, 0, `journal crash matrix on ${backend}`);
console.log(`${backend}: durable harnesses passed; today's crash matrix: ${crash.failing_cells}/${crash.cells} one-send cells ran send_email twice; journaled: none`);
}
console.log("durable harness checks passed");
+645
View File
@@ -0,0 +1,645 @@
#!/usr/bin/env node
// Durable sessions through createFxAgent: the production problems Rauch's
// durability doc lists, on memory() and local(), against a fake gateway.
//
// node --experimental-wasm-jspi sdk/tests/test-durable.mjs [memory|local] [native|wasm]
import { strict as assert } from "node:assert";
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, memory } from "../node.js";
import { foldSessionLog } from "../durable.js";
const durabilityKind = process.argv[2] || "memory";
const engineBackend = process.argv[3] || "native";
const childMode = process.argv[4] === "--child";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const addon = resolve(scriptDir, "../../zig-out/lib/libfx.node");
const wasm = engineBackend === "wasm" ? await readFile(resolve(scriptDir, "../../zig-out/bin/fx-core.wasm")) : undefined;
const { local } = durabilityKind === "memory" ? {} : await import("../durable/local.mjs");
// "vercel": Vercel's World holds the sessions; see vercel-storage.mjs.
const { vercelStorage } = durabilityKind === "vercel" ? await import("./vercel-storage.mjs") : {};
// Vercel's World is a round trip away, so its tests wait longer for a
// stream's next chunk, give a function more time, and run longer.
const remote = durabilityKind === "vercel";
const streamQuietMs = remote ? 3000 : 300;
// A remote stream's first chunk waits for its connection as well.
const streamFirstMs = remote ? 15_000 : streamQuietMs;
const timeScale = remote ? 6 : 1;
const testTimeoutMs = remote ? 120_000 : 30_000;
const durabilityAt = (dir, options = {}) => (durabilityKind === "vercel" ? vercelStorage({ dir, ...options }) : local({ dir, ...options }));
const usage = { inputTokens: { total: 1 }, outputTokens: { total: 1 } };
const resumeNotice = "Resuming from unexpected session interruption.";
const textOf = (message) => typeof message.content === "string"
? message.content
: message.content.filter((part) => part.type === "text").map((part) => part.text).join("");
const userTexts = (prompt) => prompt.filter((message) => message.role === "user").map(textOf);
const toolResults = (prompt) => prompt
.flatMap((message) => Array.isArray(message.content) ? message.content : [])
.filter((part) => part.type === "tool-result");
const finish = (reason) => ({ type: "finish", finishReason: { unified: reason, raw: reason }, usage });
const toolCall = (id, name, input) => [{ type: "tool-call", toolCallId: id, toolName: name, input }, finish("tool-calls")];
const answer = (text) => [{ type: "text-delta", id: "answer", delta: text }, finish("stop")];
// The model, by the newest user message of the request, past any resume
// notice: "use <tool>" calls the tool once, then answers with its result;
// "loop" calls lookup three times; anything else is echoed.
function framesFor(prompt) {
const users = userTexts(prompt).filter((text) => text !== resumeNotice);
const text = users.at(-1) ?? "";
const results = toolResults(prompt);
const turnResults = results.filter((part) => String(part.toolCallId).startsWith(`call-${users.length}-`));
if (text.startsWith("use ")) {
const tool = text.slice(4).split(" ")[0];
if (turnResults.length === 0) return toolCall(`call-${users.length}-0`, tool, { key: "alpha" });
return answer(`done: ${JSON.stringify(turnResults.at(-1).output)}`);
}
if (text === "loop") {
if (turnResults.length < 3) return toolCall(`call-${users.length}-${turnResults.length}`, "lookup", { key: `k${turnResults.length}` });
return answer(`looped ${turnResults.length}`);
}
return answer(`echo: ${text}`);
}
const requests = [];
let inFlight = 0;
let maxInFlight = 0;
const server = createServer((request, response) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk) => { body += chunk; });
request.on("end", () => {
if (request.method === "GET") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ object: "list", data: [{ id: "durable/model", type: "language" }] }));
return;
}
const prompt = JSON.parse(body).prompt;
requests.push(prompt);
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
// Each answer takes a moment, so overlapping workers would overlap here.
setTimeout(() => {
inFlight -= 1;
response.writeHead(200, { "content-type": "text/event-stream" });
response.end(framesFor(prompt).map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n");
}, 15);
});
});
await new Promise((ready) => server.listen(0, "127.0.0.1", ready));
const { port } = server.address();
const loopbackFetch = (input, init) => {
const url = new URL(String(input?.url ?? input));
if (url.hostname === "ai-gateway.vercel.sh") return fetch(`http://127.0.0.1:${port}${url.pathname}${url.search}`, init);
return fetch(input, init);
};
// Tools. A gate holds a call until the test opens it.
const runs = [];
const gates = new Map();
const gate = (name) => {
let open;
const promise = new Promise((resolveGate) => { open = resolveGate; });
const entry = { promise, open, started: null };
entry.started = new Promise((resolveStarted) => { entry.markStarted = resolveStarted; });
gates.set(name, entry);
return entry;
};
const lookup = {
name: "lookup",
description: "Looks up a key",
idempotent: true,
inputSchema: { type: "object", properties: { key: { type: "string" } } },
execute: async ({ key }, { executionId }) => {
runs.push(["lookup", executionId]);
const held = gates.get("lookup");
if (held) {
held.markStarted();
await held.promise;
}
return `value of ${key}`;
},
};
const send = {
name: "send",
description: "Sends an invoice",
inputSchema: { type: "object", properties: { key: { type: "string" } } },
execute: async (_input, { executionId }) => {
runs.push(["send", executionId]);
const held = gates.get("send");
if (held) {
held.markStarted();
await held.promise;
}
return "sent";
},
};
const dirs = [];
async function durabilityFor(options = {}) {
if (durabilityKind === "memory") return memory(options);
const dir = options.dir ?? await mkdtemp(join(tmpdir(), "libfx-durable-"));
dirs.push(dir);
return durabilityAt(dir, options);
}
const agentOptions = (durability, extra = {}) => ({
backend: engineBackend,
nativeAddon: addon,
...(wasm ? { wasm } : {}),
fetch: loopbackFetch,
apiKey: "durable-key",
gatewayChatUrl: `http://127.0.0.1:${port}/chat`,
model: "durable/model",
tools: [lookup, send],
durability,
...extra,
});
async function collect(turn) {
let text = "";
const types = [];
for await (const event of turn) {
types.push(event.type);
if (event.type === "text_delta") text += event.delta;
}
return { text, types, result: await turn.result };
}
// Up to `count` lines, or what arrives before the stream is quiet for
// `quietMs`: a session's stream follows it forever.
async function readLines(stream, count, quietMs = streamQuietMs) {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffered = "";
const lines = [];
while (lines.length < count) {
let timer;
const wait = lines.length === 0 && buffered === "" ? Math.max(quietMs, streamFirstMs) : quietMs;
const quiet = new Promise((resolveQuiet) => { timer = setTimeout(() => resolveQuiet({ done: true }), wait); });
const { value, done } = await Promise.race([reader.read(), quiet]);
clearTimeout(timer);
if (done) break;
buffered += decoder.decode(value, { stream: true });
for (let index = buffered.indexOf("\n"); index >= 0; index = buffered.indexOf("\n")) {
lines.push(JSON.parse(buffered.slice(0, index)));
buffered = buffered.slice(index + 1);
}
}
await reader.cancel();
return lines.slice(0, count);
}
const until = async (check, what, ms = 10_000) => {
const started = Date.now();
while (!(await check())) {
if (Date.now() - started > ms) throw new Error(`timed out waiting for ${what}`);
await new Promise((wait) => setTimeout(wait, 10));
}
};
// A child process for the crash tests. With a tool, it starts a turn whose
// tool never finishes and prints the session id once the tool runs. With
// `accepted`, it dies the moment its prompt is accepted.
if (childMode && process.argv[6] === "accepted") {
const agent = createFxAgent(agentOptions(durabilityAt(process.argv[5])));
const session = agent.session();
const { sessionId } = await session.prompt("hello").accepted;
process.stdout.write(`${JSON.stringify({ sessionId })}\n`, () => process.kill(process.pid, "SIGKILL"));
await new Promise(() => {});
}
if (childMode) {
const dir = process.argv[5];
const tool = process.argv[6];
gate(tool);
const agent = createFxAgent(agentOptions(durabilityAt(dir)));
const session = agent.session();
void session.prompt(`use ${tool}`, { messageId: "crash-turn" }).result.catch(() => {});
await gates.get(tool).started;
// A safe call starts before its record lands: wait for it, so the crash
// comes after the log holds the call.
await agent[Symbol.for("libfx.durableInternals")].settled(session.id);
console.log(JSON.stringify({ sessionId: session.id }));
await new Promise(() => {});
}
const tests = [];
const test = (name, fn) => tests.push([name, fn]);
test("a prompt streams its turn, and the session replays from any cursor", async () => {
const agent = createFxAgent(agentOptions(await durabilityFor()));
const turn = agent.prompt("hello");
const { text, result } = await collect(turn);
assert.equal(text, "echo: hello");
assert.equal(result.stopReason, "end_turn");
const accepted = await turn.accepted;
assert.match(accepted.messageId, /^msg_/);
assert.equal(accepted.sessionId, agent.sessionId);
const lines = await readLines(agent.session(agent.sessionId).stream(0), 3);
assert.deepEqual(lines.map((line) => line.type), ["turn_start", "text_delta", "turn_end"]);
assert.equal(lines[0].sessionId, agent.sessionId, "a client learns the session from the first line");
assert.deepEqual(lines.map((line) => line.cursor), [1, 2, 3]);
const later = await readLines(agent.session(agent.sessionId).stream(2), 1);
assert.equal(later[0].type, "turn_end");
await agent.close();
});
test("each session's stream holds only its own turns", async () => {
const agent = createFxAgent(agentOptions(await durabilityFor()));
const first = agent.session();
const second = agent.session();
await collect(first.prompt("first session"));
await collect(second.prompt("second session"));
for (const [session, input] of [[first, "first session"], [second, "second session"]]) {
const lines = await readLines(session.stream(0), 16);
const starts = lines.filter((line) => line.type === "turn_start");
assert.deepEqual(starts.map((line) => line.input), [input], "a stream never shows another session's turn");
assert.ok(starts.every((line) => line.sessionId === session.id));
}
await agent.close();
});
test("a retried prompt with the same messageId runs its turn once", async () => {
const agent = createFxAgent(agentOptions(await durabilityFor()));
const session = agent.session();
const first = await collect(session.prompt("only once", { messageId: "same-1" }));
const before = requests.length;
const repeat = session.prompt("only once", { messageId: "same-1" });
const again = await repeat.result;
const repeatedLines = await readLines(repeat.readable, 1);
assert.equal(repeatedLines[0].type, "turn_end", "a route returning readable still sends the outcome");
assert.equal(repeatedLines[0].repeated, true);
assert.equal(first.result.stopReason, "end_turn");
assert.equal(again.stopReason, "end_turn");
assert.equal(again.repeated, true);
assert.equal(requests.length, before, "the retry sent nothing to the model");
await agent.close();
});
test("two servers prompting one session run its turns one at a time, in one history", async () => {
const durability = await durabilityFor();
const one = createFxAgent(agentOptions(durability));
const two = createFxAgent(agentOptions(durability));
const session = one.session();
await session.prompt("first").result;
maxInFlight = 0;
const id = session.id;
const [a, b] = await Promise.all([
collect(one.session(id).prompt("from one")),
collect(two.session(id).prompt("from two")),
]);
assert.equal(a.result.stopReason, "end_turn");
assert.equal(b.result.stopReason, "end_turn");
assert.equal(maxInFlight, 1, "only one worker ran the session at a time");
const last = requests.at(-1);
const users = userTexts(last);
assert.equal(users[0], "first");
assert.ok(users.includes("from one") && users.includes("from two"), "the later turn saw the earlier one");
await one.close();
await two.close();
});
test("a steer reaches the running turn at its next model request", async () => {
const agent = createFxAgent(agentOptions(await durabilityFor()));
const held = gate("lookup");
const session = agent.session();
const turn = session.prompt("use lookup please");
await held.started;
await session.steer("also mention the color blue");
// The worker takes it from the log while the tool runs.
await new Promise((wait) => setTimeout(wait, durabilityKind === "memory" ? 50 : 600));
held.open();
gates.delete("lookup");
const { result } = await collect(turn);
assert.equal(result.stopReason, "end_turn");
assert.ok(JSON.stringify(requests.at(-1)).includes("also mention the color blue"), "the next request carried the steer");
await agent.close();
});
test("cancel stops the running turn; dropping its view does not", async () => {
const agent = createFxAgent(agentOptions(await durabilityFor()));
const held = gate("lookup");
const session = agent.session();
const dropped = session.prompt("use lookup for dropping");
for await (const _event of dropped) break;
await held.started;
const turn = session.prompt("after the dropped one");
await new Promise((wait) => setTimeout(wait, 30));
assert.equal(gates.get("lookup"), held, "the dropped turn still runs");
await session.cancel();
await new Promise((wait) => setTimeout(wait, durabilityKind === "memory" ? 50 : 600));
held.open();
gates.delete("lookup");
assert.equal((await dropped.result).stopReason, "cancelled");
assert.equal((await turn.result).stopReason, "end_turn");
await agent.close();
});
test("every turn's end saves a checkpoint, and the next turn starts from it", async () => {
const saves = [];
const opens = [];
const agent = createFxAgent(agentOptions(await durabilityFor(), {
onEvent: (event) => {
if (event.type === "checkpoint.save") saves.push(event);
else if (event.type === "journal.open") opens.push(event);
},
}));
const session = agent.session();
for (const text of ["use lookup", "hello again"]) assert.equal((await collect(session.prompt(text))).result.stopReason, "end_turn");
await agent.close();
assert.equal(saves.length, 2, JSON.stringify(saves));
const later = opens.filter((open) => open.turns > 0);
assert.ok(later.length >= 1 && later.every((open) => open.checkpoint && open.events === 0), JSON.stringify(opens));
});
test("a checkpoint where a turn yielded keeps the turn open, and its steers stay its own", () => {
const log = [
{ cursor: "1", entry: { k: "lease", a: null, holder: "w1", epoch: 1, expiresAt: null } },
{ cursor: "2", entry: { k: "input", key: "prompt:p1", type: "prompt", messageId: "p1", input: "loop" } },
{ cursor: "3", entry: { k: "record", a: "1", key: "r1", data: "AA==", marks: [{ start: "p1" }] } },
{ cursor: "4", entry: { k: "input", key: "steer:s1", type: "steer", messageId: "s1", input: "also this" } },
{ cursor: "5", entry: { k: "record", a: "3", key: "r2", data: "AA==", marks: [{ yield: true }] } },
];
const before = foldSessionLog(log);
assert.equal(before.openTurn.id, "p1");
assert.equal(before.yielded, true);
assert.deepEqual(before.steers.map((input) => input.messageId), ["s1"]);
// The checkpoint a worker saves at that yield.
const checkpoint = {
k: "checkpoint",
through: "5",
data: "AA==",
pending: before.unconsumed.filter((input) => input.cursor <= 5),
recent: before.recent,
lastTurnId: before.lastTurnId,
openTurn: before.openTurn,
yielded: before.yielded,
lease: before.lastLease,
};
const after = foldSessionLog([{ cursor: "6", entry: checkpoint }]);
assert.deepEqual(after.openTurn, before.openTurn);
assert.equal(after.yielded, true);
assert.deepEqual(after.steers.map((input) => input.messageId), ["s1"], "the open turn's steer stays its steer");
assert.deepEqual(after.pending, [], "no input becomes a new turn");
assert.equal(after.lastEpoch, 1);
});
test("a turn longer than a function's time limit continues in the next delivery", async () => {
const saves = [];
const opens = [];
const errors = [];
const agent = createFxAgent(agentOptions(await durabilityFor({ maxDurationMs: 1, reserveMs: 0 }), {
onEvent: (event) => {
if (event.type === "checkpoint.save") saves.push(event);
else if (event.type === "checkpoint.error") errors.push(event);
else if (event.type === "journal.open") opens.push(event);
},
}));
const before = requests.length;
const runsBefore = runs.length;
const session = agent.session();
const turn = session.prompt("loop");
const { text, result } = await collect(turn);
assert.equal(result.stopReason, "end_turn");
assert.equal(text, "looped 3");
const sent = requests.slice(before);
assert.equal(sent.length, 4, "each model step ran once");
assert.ok(sent.every((prompt) => !JSON.stringify(prompt).includes(resumeNotice)), "a yield is not an interruption");
assert.equal(runs.length - runsBefore, 3, "each tool call ran once");
// Every delivery after the first model step stopped and handed on.
const lines = await readLines(session.stream(0), 32);
assert.equal(lines.filter((line) => line.type === "turn_yield").length, 3);
assert.equal(lines.filter((line) => line.type === "turn_resume").length, 3);
// A checkpoint at each yield and at the turn's end, and each delivery
// after a yield loaded its checkpoint with no record after it.
assert.deepEqual(errors, []);
assert.equal(saves.length, 4, JSON.stringify(saves));
const resumed = opens.filter((open) => open.resumable);
assert.equal(resumed.length, 3);
assert.ok(resumed.every((open) => open.checkpoint && open.events === 0), JSON.stringify(resumed));
await agent.close();
});
for (const tool of ["lookup", "send"]) {
test(`a ${tool} call still running near the deadline is cut off, and the next delivery continues the turn`, async () => {
const deadlines = [];
const agent = createFxAgent(agentOptions(await durabilityFor({ maxDurationMs: 1500 * timeScale, reserveMs: 1000 * timeScale }), {
onEvent: (event) => { if (event.type === "session.deadline") deadlines.push(event); },
}));
const held = gate(tool);
const before = runs.filter(([name]) => name === tool).length;
const turn = agent.prompt(`use ${tool}`);
await held.started;
// The call never returns on its own; a rerun goes through at once.
gates.delete(tool);
const { result } = await collect(turn);
assert.equal(result.stopReason, "end_turn");
assert.equal(deadlines.length, 1, "the worker stopped itself once, before its deadline");
const ran = runs.filter(([name]) => name === tool).length - before;
if (tool === "lookup") assert.equal(ran, 2, "the cut-off call ran again in the next delivery");
else {
assert.equal(ran, 1, "a call with effects never runs twice on its own");
assert.ok(JSON.stringify(requests.at(-1)).includes("may have partly run"), "the model hears the call may have run");
}
const lines = await readLines(agent.session(agent.sessionId).stream(0), 32);
assert.equal(lines.filter((line) => line.type === "turn_yield").length, 1);
assert.equal(lines.filter((line) => line.type === "turn_resume").length, 1);
assert.equal(lines.filter((line) => line.type === "turn_end").length, 1);
await agent.close();
});
}
test("a worker that froze is replaced, and its next write is refused", async () => {
const durability = await durabilityFor();
const slow = createFxAgent(agentOptions(durability));
const held = gate("lookup");
const session = slow.session();
const turn = session.prompt("use lookup while frozen", { messageId: "frozen-turn" });
await held.started;
// The platform gives up on the frozen worker: its lease no longer stands.
const holders = globalThis[Symbol.for("libfx.liveHolders")];
const frozen = [...holders];
for (const holder of frozen) holders.delete(holder);
gates.delete("lookup");
const fresh = createFxAgent(agentOptions(durability));
const next = fresh.session(session.id).prompt("after the takeover");
assert.equal((await next.result).stopReason, "end_turn");
// The open turn was continued by the new worker: lookup is idempotent, so
// it ran again there.
const result = await turn.result;
assert.equal(result.stopReason, "end_turn");
// Waking, the frozen worker's next write is refused. A model request may
// overlap that write, but no tool runs and the session hears nothing.
const runsBefore = runs.length;
held.open();
await new Promise((wait) => setTimeout(wait, 300));
assert.equal(runs.length, runsBefore, "the frozen worker ran no tool after waking");
const lines = await readLines(fresh.session(session.id).stream(0), 64).catch(() => []);
const ends = lines.filter((line) => line.type === "turn_end" && line.messageId === "frozen-turn");
assert.equal(ends.length, 1, "the turn ended once");
// Its late lines rank below its successor's and stay hidden, and a reader
// that reconnects mid-stream sees the same lines.
const epochs = lines.map((line) => line.epoch).filter(Number.isSafeInteger);
assert.deepEqual(epochs, [...epochs].sort((a, b) => a - b), "no shown line comes from a replaced worker after its successor's");
const middle = lines[Math.floor(lines.length / 2)].cursor;
assert.deepEqual(await readLines(fresh.session(session.id).stream(middle), 64), lines.filter((line) => line.cursor > middle), "a reconnect shows the same lines");
// A later turn, after the session was released, claims a higher epoch, so
// its lines still show after the takeover's.
const later = fresh.session(session.id).prompt("a later turn", { messageId: "later-turn" });
assert.equal((await later.result).stopReason, "end_turn");
const shown = await readLines(fresh.session(session.id).stream(0), 96);
const end = shown.find((line) => line.type === "turn_end" && line.messageId === "later-turn");
assert.ok(end, "the later turn's lines show");
assert.ok(end.epoch > Math.max(...epochs), "the later turn's epoch outranks every earlier line");
await slow.close();
await fresh.close();
});
if (durabilityKind !== "memory") {
for (const tool of ["lookup", "send"]) {
test(`a crash during ${tool} ${tool === "send" ? "never sends twice, and the turn goes on" : "reruns the idempotent call"}`, async () => {
const dir = await mkdtemp(join(tmpdir(), "libfx-durable-crash-"));
dirs.push(dir);
const child = spawn(process.execPath, [
...process.execArgv,
fileURLToPath(import.meta.url),
durabilityKind,
engineBackend,
"--child",
dir,
tool,
], { stdio: ["ignore", "pipe", "inherit"], env: { ...process.env } });
let output = "";
child.stdout.on("data", (chunk) => { output += chunk; });
await until(() => output.includes("\n"), "the child to start its tool", 30_000);
const { sessionId } = JSON.parse(output.trim().split("\n")[0]);
child.kill("SIGKILL");
await new Promise((exited) => child.once("exit", exited));
const runsBefore = runs.filter(([name]) => name === tool).length;
const agent = createFxAgent(agentOptions(durabilityAt(dir)));
const session = agent.session(sessionId);
const resumed = await session.resume().result;
assert.equal(resumed.stopReason, "end_turn");
const reran = runs.filter(([name]) => name === tool).length - runsBefore;
if (tool === "send") {
assert.equal(reran, 0, "a call with effects never runs twice on its own");
// The turn went on at once, and the model was told the call may have
// partly run.
assert.ok(JSON.stringify(requests.at(-1)).includes("may have partly run"), "the model hears the call may have run");
} else {
// The last request shows how the resumed turn saw the call, if not.
assert.equal(reran, 1, `the idempotent call ran again; the model last saw ${JSON.stringify(requests.at(-1)).slice(-600)}`);
}
await agent.close();
});
}
}
if (durabilityKind !== "memory") {
test("a prompt accepted just before a crash still runs", async () => {
const dir = await mkdtemp(join(tmpdir(), "libfx-durable-accepted-"));
dirs.push(dir);
const child = spawn(process.execPath, [...process.execArgv, fileURLToPath(import.meta.url), durabilityKind, engineBackend, "--child", dir, "accepted"], {
stdio: ["ignore", "pipe", "inherit"], env: { ...process.env },
});
let output = "";
child.stdout.on("data", (chunk) => { output += chunk; });
await new Promise((exited) => child.once("exit", exited));
const { sessionId } = JSON.parse(output.trim().split("\n")[0]);
const agent = createFxAgent(agentOptions(durabilityAt(dir)));
const { text, result } = await collect(agent.session(sessionId).resume());
assert.equal(result.stopReason, "end_turn", "the accepted prompt was in the log");
assert.equal(text, "echo: hello");
await agent.close();
});
}
test("an option the backend rejects fails the first turn once", async () => {
const events = [];
const agent = createFxAgent(agentOptions(await durabilityFor(), {
model: { id: "durable/model", effort: "high" },
onEvent: (event) => { if (event.type === "session.error") events.push(event); },
}));
const sent = requests.length;
const result = await agent.prompt("hello").result;
assert.equal(result.stopReason, "error");
assert.equal(result.error?.code, "LIBFX_MODEL_UNSUPPORTED_EFFORT");
assert.equal(events.length, 1);
assert.equal(requests.length, sent, "no model request was made");
await agent.close();
});
test("a checkpoint names the libfx, tools and model that saved it, and a resume with others hears so", async () => {
const durability = await durabilityFor();
const mismatches = (list) => (event) => { if (event.type === "checkpoint.mismatch") list.push(event); };
const first = createFxAgent(agentOptions(durability));
const session = first.session();
assert.equal((await session.prompt("hello").result).stopReason, "end_turn");
await first.close();
const same = [];
const again = createFxAgent(agentOptions(durability, { onEvent: mismatches(same) }));
assert.equal((await again.session(session.id).prompt("same tools").result).stopReason, "end_turn");
await again.close();
assert.deepEqual(same, []);
const changed = [];
const other = createFxAgent(agentOptions(durability, { tools: [lookup], model: "durable/other", onEvent: mismatches(changed) }));
assert.equal((await other.session(session.id).prompt("fewer tools").result).stopReason, "end_turn");
await other.close();
assert.equal(changed.length, 1, JSON.stringify(changed));
assert.equal(changed[0].sessionId, session.id);
assert.deepEqual(changed[0].changed, ["toolSchemaHash", "model"]);
assert.equal(changed[0].saved.model, "durable/model");
assert.equal(changed[0].current.model, "durable/other");
assert.equal(changed[0].saved.libfxVersion, changed[0].current.libfxVersion);
// A checkpoint carried to a new agent reports the same way.
const source = createFxAgent(agentOptions(await durabilityFor()));
await source.prompt("carry this").result;
const checkpoint = await source.checkpoint();
await source.close();
const carried = [];
const restored = createFxAgent(agentOptions(await durabilityFor(), { checkpoint, model: "durable/other", onEvent: mismatches(carried) }));
assert.equal((await restored.prompt("still here").result).stopReason, "end_turn");
await restored.close();
assert.deepEqual(carried.map((event) => event.changed), [["model"]]);
});
test("a checkpoint restores the conversation in a new agent", async () => {
const agent = createFxAgent(agentOptions(await durabilityFor()));
await agent.prompt("remember the word kiwi").result;
const checkpoint = await agent.checkpoint();
assert.ok(checkpoint instanceof Uint8Array && checkpoint.byteLength > 0);
await agent.close();
const restored = createFxAgent(agentOptions(await durabilityFor(), { checkpoint }));
assert.equal((await restored.prompt("what was the word").result).stopReason, "end_turn");
assert.deepEqual(userTexts(requests.at(-1)), ["remember the word kiwi", "what was the word"]);
await restored.close();
});
// LIBFX_TEST_ONLY runs only the tests whose names contain it.
const only = process.env.LIBFX_TEST_ONLY;
const selected = only ? tests.filter(([name]) => name.includes(only)) : tests;
let failed = 0;
for (const [name, fn] of selected) {
const started = performance.now();
try {
let timer;
await Promise.race([
fn(),
new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error(`timed out after ${testTimeoutMs / 1000}s`)), testTimeoutMs); }),
]).finally(() => clearTimeout(timer));
console.log(`ok ${name} (${(performance.now() - started).toFixed(0)}ms)`);
} catch (error) {
failed += 1;
console.log(`FAIL ${name}\n ${error?.stack ?? error}`);
}
}
server.close();
for (const dir of dirs) await rm(dir, { recursive: true, force: true }).catch(() => {});
console.log(`${selected.length - failed}/${selected.length} durable tests passed (${durabilityKind}, ${engineBackend})`);
process.exit(failed ? 1 : 0);
+2 -2
View File
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const backend = process.argv[2] || "native";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
@@ -60,7 +60,7 @@ async function exerciseCancellation(settlement, closeBeforeSettle) {
const controller = new AbortController();
let agent;
try {
agent = await createFxAgent({
agent = await createFxEngine({
backend,
nativeAddon: resolve(scriptDir, "../../zig-out/lib/libfx.node"),
...(backend === "wasm" ? { wasm: await readFile(resolve(scriptDir, "../../zig-out/bin/fx-core.wasm")) } : {}),
+2 -2
View File
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const backend = process.argv[2] || "native";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
@@ -55,7 +55,7 @@ const { port } = server.address();
let agent;
try {
agent = await createFxAgent({
agent = await createFxEngine({
backend,
nativeAddon: addon,
...(backend === "wasm" ? { wasm: await readFile(wasmPath) } : {}),
+3 -3
View File
@@ -3,8 +3,8 @@ import { strict as assert } from "node:assert";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxAgent as createSharedAgent } from "../fx-sdk.js";
import { createFxEngine } from "../node.js";
import { createFxEngine as createSharedAgent } from "../fx-sdk.js";
import { createMcpAdapter } from "../mcp.js";
import { createSkillsAdapter } from "../skills.js";
@@ -27,7 +27,7 @@ const options = {
apiKey: "instruction-limit-test-key",
};
const agent = await createFxAgent({ ...options, instructions: exactInstructions });
const agent = await createFxEngine({ ...options, instructions: exactInstructions });
await agent.close();
let runtimeCreations = 0;
File diff suppressed because it is too large. Load diff
+19 -19
View File
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
createFxAgent,
createFxEngine,
createFxTerminal,
fxSdkApiVersion,
libfxApiVersion,
@@ -18,7 +18,7 @@ import * as browser from "../browser.js";
assert.equal(libfxApiVersion, 2);
assert.equal(fxSdkApiVersion, 2);
assert.equal(browser.libfxApiVersion, 2);
assert.equal(typeof browser.createFxAgent, "function");
assert.equal(typeof browser.createFxEngine, "function");
assert.equal(typeof browser.createFxTerminal, "function");
assert.equal(typeof browser.listModels, "function");
assert.equal(typeof listModels, "function");
@@ -36,7 +36,7 @@ const nativeUrl = pathToFileURL(nativePath);
const highLevelAgentPath = resolve(dir, "high-level-agent.mjs");
await writeFile(highLevelAgentPath, `
export const libfxApiVersion = 2;
export function createFxAgent() { throw new Error("high-level createFxAgent invoked"); }
export function createFxEngine() { throw new Error("high-level createFxEngine invoked"); }
`);
// Complete the fixture directory before a runtime caches its module-resolution entries.
@@ -48,7 +48,7 @@ await writeFile(coreOnlyPath, `
const incompatiblePath = resolve(dir, "incompatible.mjs");
await writeFile(incompatiblePath, `
export const libfxApiVersion = 4;
export async function createFxAgent() {}
export async function createFxEngine() {}
`);
const versionFixtures = [
["missing-version", `
@@ -70,18 +70,18 @@ await writeFile(matchingVersionPath, `
}
`);
await assert.rejects(
createFxAgent({
createFxEngine({
backend: "native",
nativeAddon: pathToFileURL(highLevelAgentPath),
apiKey: "loader-key",
}),
(error) => error?.code === "LIBFX_NATIVE_UNAVAILABLE" &&
error.message.includes("createCore") &&
!String(error.cause).includes("high-level createFxAgent invoked"),
!String(error.cause).includes("high-level createFxEngine invoked"),
);
await assert.rejects(
createFxAgent({
createFxEngine({
backend: "native",
nativeAddon: {
libfxApiVersion: 4,
@@ -101,13 +101,13 @@ for (const gatewayChatUrl of [
"file:///tmp/socket",
]) {
await assert.rejects(
createFxAgent({ backend: "native", nativeAddon: realNativeAddon, apiKey: "loader-key", gatewayChatUrl }),
createFxEngine({ backend: "native", nativeAddon: realNativeAddon, apiKey: "loader-key", gatewayChatUrl }),
TypeError,
);
}
await assert.rejects(
createFxAgent({
createFxEngine({
nativeAddon: nativeUrl,
apiKey: "loader-key",
env: { FX_MODEL: "legacy/model" },
@@ -115,11 +115,11 @@ await assert.rejects(
(error) => error instanceof TypeError && error.message.includes("apiKey") && error.message.includes("model"),
);
await assert.rejects(
browser.createFxAgent({ apiKey: "loader-key", env: { FX_MODEL: "legacy/model" } }),
browser.createFxEngine({ apiKey: "loader-key", env: { FX_MODEL: "legacy/model" } }),
(error) => error instanceof TypeError && error.message.includes("apiKey") && error.message.includes("model"),
);
await assert.rejects(
createFxAgent({ nativeAddon: nativeUrl, apiKey: "loader-key", env: undefined }),
createFxEngine({ nativeAddon: nativeUrl, apiKey: "loader-key", env: undefined }),
(error) => error instanceof TypeError && error.message.includes("apiKey") && error.message.includes("model"),
);
@@ -133,7 +133,7 @@ for (const [options, errorType, message] of [
[{ apiKey: "loader-key", model: "x".repeat(1_025) }, RangeError, "1024"],
]) {
await assert.rejects(
createFxAgent({ backend: "native", nativeAddon: realNativeAddon, ...options }),
createFxEngine({ backend: "native", nativeAddon: realNativeAddon, ...options }),
(error) => error instanceof errorType && error.message.includes(message),
);
}
@@ -149,16 +149,16 @@ try {
Object.defineProperty(WebAssembly, "Suspending", { configurable: true, value: undefined });
Object.defineProperty(WebAssembly, "promising", { configurable: true, value: undefined });
await assert.rejects(
createFxAgent({ nativeAddon: nativeUrl, backend: "wasm", apiKey: "loader-key" }),
createFxEngine({ nativeAddon: nativeUrl, backend: "wasm", apiKey: "loader-key" }),
(error) => error?.code === "LIBFX_JSPI_REQUIRED" &&
error.message.includes("--experimental-wasm-jspi"),
);
await assert.rejects(
createFxAgent({ nativeAddon: realNativeAddon, apiKey: "loader-key", instructions: "x".repeat(65_537) }),
createFxEngine({ nativeAddon: realNativeAddon, apiKey: "loader-key", instructions: "x".repeat(65_537) }),
(error) => error instanceof RangeError && error.message.includes("65536"),
);
await assert.rejects(
createFxAgent({
createFxEngine({
nativeAddon: realNativeAddon,
checkpoint: new Uint8Array([1, 2, 3]),
apiKey: "loader-checkpoint-key",
@@ -177,7 +177,7 @@ await assert.rejects(
);
await assert.rejects(
createFxAgent({ nativeAddon: pathToFileURL(incompatiblePath), backend: "native", apiKey: "loader-key" }),
createFxEngine({ nativeAddon: pathToFileURL(incompatiblePath), backend: "native", apiKey: "loader-key" }),
(error) => error?.code === "LIBFX_NATIVE_UNAVAILABLE" &&
error.message.includes("incompatible"),
);
@@ -185,7 +185,7 @@ await assert.rejects(
for (const [name] of versionFixtures) {
const modulePath = resolve(dir, `${name}.mjs`);
await assert.rejects(
createFxAgent({ nativeAddon: pathToFileURL(modulePath), backend: "native", apiKey: "loader-key" }),
createFxEngine({ nativeAddon: pathToFileURL(modulePath), backend: "native", apiKey: "loader-key" }),
(error) => error?.code === "LIBFX_NATIVE_UNAVAILABLE" &&
error.message.includes("incompatible") &&
!String(error.cause).includes("createCore invoked"),
@@ -194,7 +194,7 @@ for (const [name] of versionFixtures) {
}
await assert.rejects(
createFxAgent({ nativeAddon: pathToFileURL(matchingVersionPath), backend: "native", apiKey: "loader-key" }),
createFxEngine({ nativeAddon: pathToFileURL(matchingVersionPath), backend: "native", apiKey: "loader-key" }),
(error) => error?.code === "MATCHING_VERSION_INVOKED",
"matching v3 low-level addon must reach createCore",
);
@@ -216,7 +216,7 @@ const brokenReaderAddon = {
},
};
try {
await assert.rejects(createFxAgent({ backend: "native", nativeAddon: brokenReaderAddon, apiKey: "loader-key" }));
await assert.rejects(createFxEngine({ backend: "native", nativeAddon: brokenReaderAddon, apiKey: "loader-key" }));
assert.ok(failedCoreDestroyed, "failed readiness adoption must still destroy the native runtime");
} finally {
if (failedCore) addon.destroyCore(failedCore);
+3 -3
View File
@@ -6,7 +6,7 @@ import { createServer } from "node:http";
import { resolve } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
import { createMcpAdapter } from "../mcp.js";
const imageError = process.argv[4] === "images-error";
@@ -154,7 +154,7 @@ try {
gatewayChatUrl: `http://127.0.0.1:${gateway.address().port}/chat`,
model: "mcp/model",
};
agent = await createFxAgent(options);
agent = await createFxEngine(options);
const turn = agent.prompt("use MCP");
let text = "";
for await (const event of turn) if (event.type === "text_delta") text += event.delta;
@@ -163,7 +163,7 @@ try {
if (imageMode) {
const checkpoint = await agent.checkpoint();
await agent.close();
agent = await createFxAgent({ ...options, checkpoint });
agent = await createFxEngine({ ...options, checkpoint });
const resumed = agent.prompt("Describe that screenshot again");
for await (const event of resumed) {}
assert.equal((await resumed.result).stopReason, "end_turn");
+3 -3
View File
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
let requestedAuthorization;
@@ -33,7 +33,7 @@ const { port } = server.address();
let agent;
try {
agent = await createFxAgent({
agent = await createFxEngine({
backend: "native",
nativeAddon: resolve(scriptDir, "../../zig-out/lib/libfx.node"),
fetch,
@@ -41,7 +41,7 @@ try {
gatewayChatUrl: `http://127.0.0.1:${port}/chat`,
model: "minimal/model",
});
assert.deepEqual(Object.keys(agent).sort(), ["checkpoint", "close", "prompt"]);
assert.deepEqual(Object.keys(agent).sort(), ["checkpoint", "close", "followUp", "prompt", "resume", "sessionId"]);
const turn = agent.prompt("hello");
let text = "";
let reasoning = "";
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const require = createRequire(import.meta.url);
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
@@ -100,7 +100,7 @@ const delayedAddon = {
return request;
},
};
const agent = await createFxAgent({
const agent = await createFxEngine({
backend: "native",
nativeAddon: delayedAddon,
apiKey: "late-cancel-key",
+2 -2
View File
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
let requestStartedResolve;
const requestStarted = new Promise((resolveStarted) => { requestStartedResolve = resolveStarted; });
@@ -22,7 +22,7 @@ const timeout = (label, ms = 5000) => new Promise((_, reject) => {
});
try {
let aborted = false;
const agent = await createFxAgent({
const agent = await createFxEngine({
nativeAddon: addon,
backend: "native",
fetch(input, init) {
@@ -5,7 +5,7 @@ import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const marker = "LIBFX_EXPLICIT_HOST_INSTRUCTIONS";
const workspaceMarker = "LIBFX_WORKSPACE_CONTEXT_MUST_NOT_LOAD";
@@ -54,7 +54,7 @@ const addon = resolve(process.argv[2] || resolve(scriptDir, "../../zig-out/lib/l
let agent;
try {
process.chdir(processWorkspace);
agent = await createFxAgent({
agent = await createFxEngine({
nativeAddon: addon,
backend: "native",
fetch(input, init) {
+2 -2
View File
@@ -2,14 +2,14 @@
import { strict as assert } from "node:assert";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const addon = resolve(process.argv[2] || resolve(scriptDir, "../../zig-out/lib/libfx.node"));
let timeoutId;
let fetchCalls = 0;
let catalogCalls = 0;
const agent = await createFxAgent({
const agent = await createFxEngine({
nativeAddon: addon,
backend: "native",
fetch(_url, init) {
+2 -2
View File
@@ -5,7 +5,7 @@ import { createRequire } from "node:module";
import { resolve } from "node:path";
import { setImmediate as nextLoop } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const apiKey = "http-cleanup-fixture-key";
const modelId = "native/http-cleanup-model";
@@ -80,7 +80,7 @@ await new Promise((done, fail) => { server.once("error", fail); server.listen(0,
const gatewayChatUrl = `http://127.0.0.1:${server.address().port}/chat`;
async function newTurn(record, extra = {}) {
const agentIndex = catalogCounts.push(0) - 1;
const agent = await bounded(createFxAgent({
const agent = await bounded(createFxEngine({
backend: "native", nativeAddon: addon, apiKey, model: modelId, gatewayChatUrl,
fetch(input, init) {
if (init.method === "GET") {
+2 -2
View File
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const addon = resolve(process.argv[2] || resolve(scriptDir, "../../zig-out/lib/libfx.node"));
@@ -26,7 +26,7 @@ const sse = (...events) => new Response(
);
try {
agent = await createFxAgent({
agent = await createFxEngine({
backend: "native",
nativeAddon: addon,
home,
+2 -2
View File
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const events = [];
const unicodeText = "\u{1f600}界".repeat(200_000);
@@ -66,7 +66,7 @@ try {
let catalogCalls = 0;
let firstAbortResolve;
const firstAbort = new Promise((resolveAbort) => { firstAbortResolve = resolveAbort; });
agent = await createFxAgent({
agent = await createFxEngine({
nativeAddon: addon,
backend: "native",
async fetch(input, init) {
@@ -23,8 +23,8 @@ try {
const worker = new Worker(`
const { parentPort, workerData } = require("node:worker_threads");
(async () => {
const { createFxAgent } = await import(workerData.nodeModuleUrl);
const agent = await createFxAgent({
const { createFxEngine } = await import(workerData.nodeModuleUrl);
const agent = await createFxEngine({
nativeAddon: workerData.addonPath,
backend: "native",
fetch(input, init) {
+3 -3
View File
@@ -2,19 +2,19 @@
import { strict as assert } from "node:assert";
import { relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const defaultAddon = resolve(scriptDir, "../../zig-out/lib/libfx.node");
const addon = process.argv[2] || `./${relative(process.cwd(), defaultAddon)}`;
const events = [];
const agent = await createFxAgent({
const agent = await createFxEngine({
nativeAddon: addon,
backend: "native",
apiKey: "native-core-test-key",
onEvent(event) { events.push(event); },
});
assert.deepEqual(Object.keys(agent).sort(), ["checkpoint", "close", "prompt"]);
assert.deepEqual(Object.keys(agent).sort(), ["checkpoint", "close", "followUp", "prompt", "resume", "sessionId"]);
assert.ok((await agent.checkpoint()).length > 0);
assert.equal(await agent.close(), undefined);
assert.ok(events.some((event) => event.type === "runtime.ready"));
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const addon = resolve(process.argv[2] || resolve(scriptDir, "../../zig-out/lib/libfx.node"));
@@ -27,7 +27,7 @@ const sse = (...events) => new Response(
);
try {
agent = await createFxAgent({
agent = await createFxEngine({
backend: "native",
nativeAddon: addon,
home,
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
let modelRequests = 0;
@@ -54,7 +54,7 @@ async function exerciseLateSettlement(closeBeforeSettle) {
let agent;
try {
agent = await createFxAgent({
agent = await createFxEngine({
backend: "native",
nativeAddon: resolve(scriptDir, "../../zig-out/lib/libfx.node"),
fetch(input, init) {
+54
View File
@@ -23,6 +23,7 @@ const app = resolve(root, "app");
const fixture = fileURLToPath(new URL("./next/", import.meta.url));
const token = randomUUID();
const env = { ...process.env, NODE_OPTIONS: "", NODE_PATH: "", NEXT_TELEMETRY_DISABLED: "1", AI_GATEWAY_API_KEY: "",
FX_SESSIONS_DIR: resolve(root, "sessions"),
LIBFX_LIVE: "0", LIBFX_SMOKE_TOKEN: token, LIBFX_TEST_MODEL: "" };
const servers = new Set();
const results = [];
@@ -117,10 +118,63 @@ async function exercise(server, stage) {
}));
assert.ok(concurrent.every((result) => result.toolCalls === 1 && result.ok));
console.log(`${stage}/eight concurrent tool turns passed`);
// A durable session through the package's default durability: two turns
// in one session, then the whole session replayed from its start.
const authorization = { authorization: `Bearer ${token}` };
const first = await fetch(`${server.url}/api/durable`, { method: "POST", headers: authorization, body: "hello", signal: AbortSignal.timeout(60_000) });
assert.equal(first.status, 200, await first.clone().text());
const sessionId = first.headers.get("x-libfx-session");
assert.match(sessionId ?? "", /^wrun_/);
const firstLines = await ndjsonUntil(first, (line) => line.type === "turn_end");
assert.equal(firstLines.at(-1).stopReason, "end_turn");
assert.equal(firstLines.filter((line) => line.type === "text_delta").map((line) => line.delta).join(""), "durable answer 1");
const second = await fetch(`${server.url}/api/durable?sessionId=${sessionId}`, { method: "POST", headers: authorization, body: "again", signal: AbortSignal.timeout(60_000) });
const secondLines = await ndjsonUntil(second, (line) => line.type === "turn_end");
assert.equal(secondLines.filter((line) => line.type === "text_delta").map((line) => line.delta).join(""), "durable answer 2");
const replay = await fetch(`${server.url}/api/durable?sessionId=${sessionId}&cursor=0`, { headers: authorization, signal: AbortSignal.timeout(60_000) });
let ends = 0;
const replayed = await ndjsonUntil(replay, (line) => line.type === "turn_end" && ++ends === 2);
assert.equal(replayed[0].cursor, 1);
assert.equal(replayed.filter((line) => line.type === "turn_start").length, 2);
results.push({ stage, scenario: "durable", sessionId, events: replayed.length });
console.log(`${stage}/durable session passed`);
}
// NDJSON lines from `response` until `done(line)`; a session stream stays
// open, so the reader stops there.
async function ndjsonUntil(response, done) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
const lines = [];
let buffered = "";
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 = JSON.parse(buffered.slice(0, index));
buffered = buffered.slice(index + 1);
lines.push(line);
if (done(line)) {
await reader.cancel();
return lines;
}
}
}
throw new Error(`the stream ended after ${lines.length} lines without its last event`);
}
async function assertBundledNativeAssets(buildDir) {
if (!webpack) return;
// A deployment carries the durability modules libfx loads on first use.
const durableDir = resolve(buildDir, "server/app/api/durable");
const durableTrace = JSON.parse(await readFile(resolve(durableDir, "route.js.nft.json"), "utf8"));
for (const name of ["local", "vercel"]) {
const file = durableTrace.files.find((path) => new RegExp(`/static/media/${name}\\.[0-9a-f]+\\.mjs$`).test(path));
assert.ok(file, `the durable route must trace the emitted durable/${name}.mjs`);
await access(resolve(durableDir, file));
}
const routeDir = resolve(buildDir, "server/app/api/fx");
const trace = JSON.parse(await readFile(resolve(routeDir, "route.js.nft.json"), "utf8"));
for (const platform of ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"]) {
+1 -1
View File
@@ -41,7 +41,7 @@ watchdog.unref();
async function exercise(sdk, surface, options) {
if (surface === "agent") {
const agent = await sdk.createFxAgent({ ...options, apiKey: "async-asset-key", model: "async/model", fetch: modelFetch });
const agent = await sdk.createFxEngine({ ...options, apiKey: "async-asset-key", model: "async/model", fetch: modelFetch });
try {
const turn = agent.prompt("hello");
let text = "";
+2 -2
View File
@@ -4,7 +4,7 @@ import { cp, mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createFxAgent, getBackendInfo } from "../node.js";
import { createFxEngine, getBackendInfo } from "../node.js";
const root = await mkdtemp(join(tmpdir(), "libfx bundled assets "));
const media = join(root, "static", "media");
@@ -35,7 +35,7 @@ try {
assert.ok(nativeAddon instanceof URL);
assert.throws(() => fileURLToPath(nativeAddon), { code: "ERR_INVALID_ARG_TYPE" });
assert.equal((await getBackendInfo({ backend: "native", nativeAddon })).backend, "native");
const agent = await createFxAgent({ backend: "native", nativeAddon, apiKey: "fixture-key" });
const agent = await createFxEngine({ backend: "native", nativeAddon, apiKey: "fixture-key" });
try { assert.ok((await agent.checkpoint()).length > 48); }
finally { await agent.close(); }
for (const [surface, artifact] of [["agent", "core"], ["terminal", "term"]]) {
+6 -6
View File
@@ -80,15 +80,15 @@ try {
await writeFile(join(consumerDir, "esm.mjs"), `
import * as libfx from "libfx";
import * as nodeEntry from "libfx/node";
const { createFxAgent, createFxTerminal, getBackendInfo } = libfx;
const { createFxEngine, createFxTerminal, getBackendInfo } = libfx;
if (JSON.stringify(Object.keys(libfx).sort()) !== JSON.stringify(Object.keys(nodeEntry).sort())) {
throw new Error("root and Node subpath ESM exports differ");
}
const info = await getBackendInfo({ backend: "native" });
if (typeof createFxAgent !== "function" || typeof createFxTerminal !== "function" || info.backend !== "native") {
if (typeof createFxEngine !== "function" || typeof createFxTerminal !== "function" || info.backend !== "native") {
throw new Error(JSON.stringify(info));
}
const agent = await createFxAgent({ backend: "native", apiKey: "package-test-key" });
const agent = await createFxEngine({ backend: "native", apiKey: "package-test-key" });
const checkpoint = await agent.checkpoint();
await agent.close();
if (!(checkpoint instanceof Uint8Array) || checkpoint.length === 0) throw new Error("empty checkpoint");
@@ -97,16 +97,16 @@ try {
await writeFile(join(consumerDir, "cjs.cjs"), `
const libfx = require("libfx");
const nodeEntry = require("libfx/node");
const { createFxAgent, createFxTerminal, getBackendInfo } = libfx;
const { createFxEngine, createFxTerminal, getBackendInfo } = libfx;
(async () => {
if (JSON.stringify(Object.keys(libfx).sort()) !== JSON.stringify(Object.keys(nodeEntry).sort())) {
throw new Error("root and Node subpath CommonJS exports differ");
}
const info = await getBackendInfo({ backend: "native" });
if (typeof createFxAgent !== "function" || typeof createFxTerminal !== "function" || info.backend !== "native") {
if (typeof createFxEngine !== "function" || typeof createFxTerminal !== "function" || info.backend !== "native") {
throw new Error(JSON.stringify(info));
}
const agent = await createFxAgent({ backend: "native", apiKey: "package-test-key" });
const agent = await createFxEngine({ backend: "native", apiKey: "package-test-key" });
const checkpoint = await agent.checkpoint();
await agent.close();
if (!(checkpoint instanceof Uint8Array) || checkpoint.length === 0) throw new Error("empty checkpoint");
+1 -1
View File
@@ -43,7 +43,7 @@ async function exercise(sdk) {
const info = await sdk.getBackendInfo({ backend: "native" });
assert.equal(info.backend, "native", JSON.stringify(info));
for (const backend of ["native", "auto"]) {
const agent = await sdk.createFxAgent({
const agent = await sdk.createFxEngine({
backend, home, workspaceRoot: process.cwd(), apiKey: "trace-key", model: "trace/model",
gatewayChatUrl: `${origin}/chat`,
fetch(input, init) {
+14 -14
View File
@@ -262,7 +262,7 @@ async function installedWorkerMain() {
assert.deepEqual(Object.keys(cjs).sort(), exportNames, "ESM and CJS exports differ");
if (config.mode === "cjs-happy") {
assert.equal(typeof cjs.createFxAgent, "function");
assert.equal(typeof cjs.createFxEngine, "function");
const info = await cjs.getBackendInfo({ backend: "native" });
assert.equal(info.backend, "native");
console.log(JSON.stringify({ mode: config.mode, exportNames, info }));
@@ -278,7 +278,7 @@ async function installedWorkerMain() {
: "LIBFX_NATIVE_LOAD_FAILED");
if (config.mode === "fault-missing-addon") assert.equal(reason.causeCode, "ENOENT");
else assert.equal(reason.causeCode, "ERR_DLOPEN_FAILED");
const factoryError = await expectedError(() => esm.createFxAgent({ backend: "native", apiKey: "test-placeholder" }));
const factoryError = await expectedError(() => esm.createFxEngine({ backend: "native", apiKey: "test-placeholder" }));
assert.ok(factoryError.code, "factory error must preserve a code");
console.log(JSON.stringify({ mode: config.mode, info, factoryError: errorRecord(factoryError) }));
return;
@@ -290,7 +290,7 @@ async function installedWorkerMain() {
const forcedWasm = await esm.getBackendInfo({ backend: "wasm" });
assert.equal(forcedWasm.backend, "unavailable");
assert.equal(forcedWasm.attempts[0].reason.code, "LIBFX_JSPI_UNAVAILABLE");
const factoryError = await expectedError(() => esm.createFxAgent({ backend: "wasm", apiKey: "test-placeholder" }));
const factoryError = await expectedError(() => esm.createFxEngine({ backend: "wasm", apiKey: "test-placeholder" }));
assert.equal(factoryError.code, "LIBFX_JSPI_REQUIRED");
const automatic = await esm.getBackendInfo({ backend: "auto" });
assert.equal(automatic.backend, "native");
@@ -303,7 +303,7 @@ async function installedWorkerMain() {
const info = await esm.getBackendInfo({ backend: "native", nativeAddon: badAddon });
assert.equal(info.backend, "unavailable");
assert.equal(info.attempts[0].reason.code, "LIBFX_NATIVE_API_MISMATCH");
const factoryError = await expectedError(() => esm.createFxAgent({
const factoryError = await expectedError(() => esm.createFxEngine({
backend: "native",
nativeAddon: badAddon,
apiKey: "test-placeholder",
@@ -318,16 +318,16 @@ async function installedWorkerMain() {
const invalidProbe = await expectedError(() => esm.getBackendInfo(null));
assert.ok(invalidProbe instanceof TypeError);
assert.equal(invalidProbe.message, "getBackendInfo() options must be an object");
const invalidBackend = await expectedError(() => esm.createFxAgent({ backend: "other", apiKey: "test-placeholder" }));
const invalidBackend = await expectedError(() => esm.createFxEngine({ backend: "other", apiKey: "test-placeholder" }));
assert.ok(invalidBackend instanceof TypeError);
assert.equal(invalidBackend.message, 'backend must be "auto", "native", or "wasm"');
const invalidCheckpoint = await expectedError(() => esm.createFxAgent({
const invalidCheckpoint = await expectedError(() => esm.createFxEngine({
backend: "native",
apiKey: "test-placeholder",
checkpoint: new Uint8Array([1, 2, 3]),
}));
assert.match(invalidCheckpoint.message, /Invalid or non-fresh libfx checkpoint/);
const recovery = await esm.createFxAgent({ backend: "native", apiKey: "test-placeholder" });
const recovery = await esm.createFxEngine({ backend: "native", apiKey: "test-placeholder" });
const checkpoint = await recovery.checkpoint();
await recovery.close();
assert.ok(checkpoint.length > 48);
@@ -346,11 +346,11 @@ async function installedWorkerMain() {
assert.equal(first.backend, "unavailable");
assert.equal(first.attempts[0].reason.code, "LIBFX_WASM_LOAD_FAILED");
if (config.mode === "fault-missing-wasm") assert.equal(first.attempts[0].reason.causeCode, "ENOENT");
const factoryError = await expectedError(() => esm.createFxAgent({ backend: "wasm", apiKey: "test-placeholder" }));
const factoryError = await expectedError(() => esm.createFxEngine({ backend: "wasm", apiKey: "test-placeholder" }));
await writeFile(config.wasmPath, await readFile(config.restoreWasmPath));
const second = await esm.getBackendInfo({ backend: "wasm" });
assert.equal(second.backend, "wasm-jspi");
const recovery = await esm.createFxAgent({ backend: "wasm", apiKey: "test-placeholder" });
const recovery = await esm.createFxEngine({ backend: "wasm", apiKey: "test-placeholder" });
const checkpoint = await recovery.checkpoint();
await recovery.close();
assert.ok(checkpoint.length > 48);
@@ -530,7 +530,7 @@ async function installedWorkerMain() {
const started = performance.now();
let agent;
try {
agent = await esm.createFxAgent(baseOptions(backend, [lookupTool(marker, mode, state)]));
agent = await esm.createFxEngine(baseOptions(backend, [lookupTool(marker, mode, state)]));
const observed = await withTimeout(consumeTurn(agent.prompt(`CALL ${marker}`)), 10_000, `tool workflow ${marker}`);
assert.equal(observed.result.stopReason, "end_turn");
assert.equal(observed.text, `done:${marker}`);
@@ -559,14 +559,14 @@ async function installedWorkerMain() {
let source;
let restored;
try {
source = await esm.createFxAgent(baseOptions("native", tools));
source = await esm.createFxEngine(baseOptions("native", tools));
const first = await consumeTurn(source.prompt(`CALL ${marker}`));
assert.equal(first.text, `done:${marker}`);
const checkpoint = await source.checkpoint();
assert.ok(checkpoint.length > 48);
await source.close();
source = null;
restored = await esm.createFxAgent(baseOptions("native", tools, checkpoint));
restored = await esm.createFxEngine(baseOptions("native", tools, checkpoint));
const second = await consumeTurn(restored.prompt(`RESTORE ${marker}`));
assert.equal(second.text, `restored:${marker}`);
assert.equal(state.callbacks, 1);
@@ -606,7 +606,7 @@ async function installedWorkerMain() {
const runtimeEvents = [];
let agent;
try {
agent = await esm.createFxAgent({
agent = await esm.createFxEngine({
...baseOptions("native", [waitTool]),
onEvent(event) { runtimeEvents.push(event); },
});
@@ -651,7 +651,7 @@ async function installedWorkerMain() {
const started = performance.now();
const info = await esm.getBackendInfo({ backend });
assert.equal(info.backend, "native");
const agent = await esm.createFxAgent({
const agent = await esm.createFxEngine({
backend,
apiKey: "test-placeholder",
home: process.cwd(),
+33 -6
View File
@@ -2,7 +2,7 @@
import { strict as assert } from "node:assert";
import { createServer } from "node:http";
import { spawnSync } from "node:child_process";
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { cp, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { createRequire } from "node:module";
import { resolve } from "node:path";
@@ -32,7 +32,7 @@ if (process.argv[2] !== "--installed") {
await rm(temp, { recursive: true, force: true });
}
} else {
const { createFxAgent, createFxTerminal, getBackendInfo } = format === "cjs" ? createRequire(import.meta.url)("libfx") : await import("libfx");
const { createFxAgent, createFxEngine, createFxTerminal, getBackendInfo, memory } = format === "cjs" ? createRequire(import.meta.url)("libfx") : await import("libfx");
const { createMcpAdapter } = await import("libfx/mcp");
const { createSkillsAdapter } = await import("libfx/skills");
assert.equal(typeof createMcpAdapter, "function");
@@ -47,7 +47,7 @@ if (process.argv[2] !== "--installed") {
await assert.rejects(getBackendInfo(makeOptions("backend", invalid)), TypeError);
await assert.rejects(getBackendInfo(makeOptions("surface", invalid)), TypeError);
const backendError = { name: "TypeError", message: 'backend must be "auto", "native", or "wasm"' };
await assert.rejects(createFxAgent(makeOptions("backend", invalid)), backendError);
await assert.rejects(createFxEngine(makeOptions("backend", invalid)), backendError);
await assert.rejects(createFxTerminal(makeOptions("backend", invalid)), backendError);
}
for (const value of [undefined, "auto", "native", "wasm"]) {
@@ -58,7 +58,7 @@ if (process.argv[2] !== "--installed") {
}
assert.deepEqual(await getBackendInfo({ backend: undefined, surface: undefined }), await getBackendInfo());
assert.deepEqual(await getBackendInfo({ backend: "auto", surface: "agent" }), await getBackendInfo());
for (const select of [getBackendInfo, createFxAgent, createFxTerminal]) {
for (const select of [getBackendInfo, createFxEngine, createFxTerminal]) {
let reads = 0;
const options = { backend: "native", get nativeAddon() { reads++; this.backend = null; } };
await assert.rejects(select(options), { name: "TypeError", message: 'backend must be "auto", "native", or "wasm"' });
@@ -83,7 +83,7 @@ if (process.argv[2] !== "--installed") {
});
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
const agent = await createFxAgent({
const agent = await createFxEngine({
backend,
fetch(input, init) {
const origin = `http://127.0.0.1:${server.address().port}`;
@@ -96,7 +96,7 @@ if (process.argv[2] !== "--installed") {
model: "packed/model",
});
try {
assert.deepEqual(Object.keys(agent).sort(), ["checkpoint", "close", "prompt"]);
assert.deepEqual(Object.keys(agent).sort(), ["checkpoint", "close", "followUp", "prompt", "resume", "sessionId"]);
const turn = agent.prompt("hello");
let text = "";
for await (const event of turn) if (event.type === "text_delta") text += event.delta;
@@ -106,6 +106,33 @@ if (process.argv[2] !== "--installed") {
assert.equal(requestedModel, "packed/model");
assert.ok((await agent.checkpoint()).length > 48);
await agent.close();
// A durable agent from the package: the local World it bundles keeps the
// session in files, then memory() opts out.
const sessionsDir = await mkdtemp(resolve(tmpdir(), "libfx-packed-sessions-"));
process.env.FX_SESSIONS_DIR = sessionsDir;
try {
for (const durability of [undefined, memory()]) {
const durable = createFxAgent({
backend,
fetch: (input, init) => fetch(init?.method === "GET" ? `http://127.0.0.1:${server.address().port}/models` : String(input), init),
apiKey: "packed-key",
gatewayChatUrl: `http://127.0.0.1:${server.address().port}/chat`,
model: "packed/model",
...(durability ? { durability } : {}),
});
const durableTurn = durable.session().prompt("hello");
let durableText = "";
for await (const event of durableTurn) if (event.type === "text_delta") durableText += event.delta;
assert.equal(durableText, "packed");
assert.equal((await durableTurn.result).stopReason, "end_turn");
await durable.close();
}
assert.ok((await readdir(sessionsDir)).includes("runs"), "the default durability kept the session on disk");
} finally {
delete process.env.FX_SESSIONS_DIR;
await rm(sessionsDir, { recursive: true, force: true });
}
console.log(`${format} ${backend} packed libfx example passed`);
} finally {
await agent.close().catch(() => {});
+2 -2
View File
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { mkdtemp } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { createFxAgent } from "../node.js";
import { createFxEngine } from "../node.js";
import { createSkillsAdapter } from "../skills.js";
import { loadSkillFile } from "../skills-node.js";
@@ -52,7 +52,7 @@ await new Promise((resolveListen) => gateway.listen(0, "127.0.0.1", resolveListe
let agent;
try {
agent = await createFxAgent({
agent = await createFxEngine({
backend,
nativeAddon: resolve(scriptDir, "../../zig-out/lib/libfx.node"),
...(backend === "wasm" ? { wasm: await readFile(resolve(scriptDir, "../../zig-out/bin/fx-core.wasm")) } : {}),
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env node
// The host tool contract: tools as an object, the writes declaration, the
// turn and call ids a tool receives, non-empty error results, and calls that
// run together until a writer, with results in the model's order.
import { strict as assert } from "node:assert";
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxEngine, createMemoryPersistence } from "../node.js";
const backend = process.argv[2] || "native";
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const addon = resolve(scriptDir, "../../zig-out/lib/libfx.node");
const wasm = await readFile(resolve(scriptDir, "../../zig-out/bin/fx-core.wasm"));
const usage = { inputTokens: { total: 1 }, outputTokens: { total: 1 } };
// The prompt names the calls of the first response: "call a b c" calls the
// tools a, b and c in that order. The next request is answered with text.
const textOf = (message) => typeof message.content === "string"
? message.content
: message.content.filter((part) => part.type === "text").map((part) => part.text).join("");
const toolResults = (prompt) => prompt
.flatMap((message) => Array.isArray(message.content) ? message.content : [])
.filter((part) => part.type === "tool-result");
const requests = [];
const server = createServer((request, response) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk) => { body += chunk; });
request.on("end", () => {
if (request.method === "GET") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ object: "list", data: [{ id: "contract/model", type: "language" }] }));
return;
}
const prompt = JSON.parse(body).prompt;
requests.push(prompt);
const words = textOf(prompt.filter((message) => message.role === "user").at(-1)).split(/\s+/);
const frames = words[0] === "call" && toolResults(prompt).length === 0
? [
...words.slice(1).map((name, index) => ({ type: "tool-call", toolCallId: `call-${index}`, toolName: name, input: {} })),
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage },
]
: [
{ type: "text-delta", id: "answer", delta: "done" },
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage },
];
response.writeHead(200, { "content-type": "text/event-stream" });
response.end(frames.map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n");
});
});
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
const { port } = server.address();
const loopbackFetch = (input, init) => {
const url = new URL(String(input?.url ?? input));
const method = String(init?.method ?? input?.method ?? "GET").toUpperCase();
if (url.hostname === "ai-gateway.vercel.sh" && method === "GET") {
return fetch(`http://127.0.0.1:${port}${url.pathname}${url.search}`, init);
}
return fetch(input, init);
};
const options = (tools, extra = {}) => ({
backend,
nativeAddon: addon,
...(backend === "wasm" ? { wasm } : {}),
fetch: loopbackFetch,
apiKey: "contract-key",
gatewayChatUrl: `http://127.0.0.1:${port}/chat`,
model: "contract/model",
tools,
...extra,
});
async function run(agent, input) {
const turn = agent.prompt(input);
for await (const _ of turn) {}
return turn.result;
}
// A tool that records when each of its calls ran.
const spans = [];
const timed = (name, extra = {}) => ({
description: `Records when ${name} runs.`,
inputSchema: { type: "object" },
...extra,
execute: async () => {
const span = { name, start: performance.now(), end: null };
spans.push(span);
await new Promise((resolveDelay) => setTimeout(resolveDelay, 40));
span.end = performance.now();
return `${name} ok`;
},
});
const cases = [];
const test = (name, body) => cases.push({ name, body });
test("tools may be an object keyed by name", async () => {
const agent = await createFxEngine(options({ list: timed("list"), read: timed("read") }));
requests.length = 0;
spans.length = 0;
assert.equal((await run(agent, "call list read")).stopReason, "end_turn");
await agent.close();
assert.deepEqual(spans.map((span) => span.name).sort(), ["list", "read"]);
await assert.rejects(createFxEngine(options({ list: { ...timed("list"), name: "other" } })), /tool list has a different name: other/);
});
test("calls run together until a writer, and results keep the model's order", async () => {
const agent = await createFxEngine(options({
first: timed("first"),
second: timed("second"),
save: timed("save", { writes: true }),
after: timed("after"),
}));
requests.length = 0;
spans.length = 0;
await run(agent, "call first second save after");
await agent.close();
const span = Object.fromEntries(spans.map((value) => [value.name, value]));
const overlap = (a, b) => a.start < b.end && b.start < a.end;
if (backend === "native") {
assert.ok(overlap(span.first, span.second), "calls before a writer run together");
} else {
assert.ok(!overlap(span.first, span.second), "a build without threads runs calls one at a time");
}
// The writer starts after every earlier call ends, and nothing passes it.
assert.ok(span.save.start >= Math.max(span.first.end, span.second.end));
assert.ok(span.after.start >= span.save.end);
const results = toolResults(requests.at(-1)).map((part) => part.toolName);
assert.deepEqual(results, ["first", "second", "save", "after"]);
});
test("execute receives the model's call id as its execution id", async () => {
const seen = [];
const probe = {
description: "Records its context.",
inputSchema: { type: "object" },
execute: async (_input, context) => {
seen.push({ executionId: context.executionId, aborted: context.signal.aborted });
return "probed";
},
};
const agent = await createFxEngine(options({ probe }));
const turn = agent.prompt("call probe probe", { turnId: "turn-probe" });
for await (const _ of turn) {}
await turn.result;
await agent.close();
assert.deepEqual(seen.map((entry) => entry.executionId).sort(), ["call-0", "call-1"]);
assert.ok(seen.every((entry) => entry.aborted === false));
});
test("an error without a message still reaches the model as text", async () => {
const agent = await createFxEngine(options({
broken: { description: "Fails.", inputSchema: { type: "object" }, execute: async () => { throw new Error(""); } },
}));
requests.length = 0;
await run(agent, "call broken");
await agent.close();
const [result] = toolResults(requests.at(-1));
const text = JSON.stringify(result.output ?? result);
assert.match(text, /Tool broken failed without a message/);
});
test("writes is checked, and a persisted session needs no other tool declaration", async () => {
await assert.rejects(createFxEngine(options({ read: timed("read", { writes: "yes" }) })), /tool read writes must be a boolean/);
const agent = await createFxEngine(options({ read: timed("read") }, { persistence: createMemoryPersistence() }));
spans.length = 0;
assert.equal((await run(agent, "call read")).stopReason, "end_turn");
await agent.close();
assert.deepEqual(spans.map((span) => span.name), ["read"]);
});
try {
for (const { name, body } of cases) {
await body();
console.log(`ok - ${name}`);
}
console.log(`tool contract passed: ${backend}`);
} finally {
server.closeAllConnections();
await new Promise((resolveClose) => server.close(resolveClose));
}
+4 -4
View File
@@ -5,7 +5,7 @@ import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createFxAgent, supportsJspi } from "../node.js";
import { createFxEngine, supportsJspi } from "../node.js";
if (!supportsJspi()) {
console.error("Node JSPI is disabled. Run with --experimental-wasm-jspi");
@@ -83,7 +83,7 @@ const options = (wasm = wasmPath) => ({
});
async function create(wasm = wasmPath) {
const agent = await createFxAgent(options(wasm));
const agent = await createFxEngine(options(wasm));
agents.push(agent);
return agent;
}
@@ -118,12 +118,12 @@ try {
assert.equal(compileCalls, 2, "a distinct canonical source must compile independently");
failNextCompile = true;
await assert.rejects(createFxAgent(options(retryPath)), /injected Wasm compilation failure/);
await assert.rejects(createFxEngine(options(retryPath)), /injected Wasm compilation failure/);
const retried = await create(retryPath);
await retried.close();
assert.equal(compileCalls, 4, "a rejected compilation must be removed so the next attempt retries");
await assert.rejects(createFxAgent(options(missingPath)), /ENOENT|no such file/i);
await assert.rejects(createFxEngine(options(missingPath)), /ENOENT|no such file/i);
await writeFile(missingPath, wasmBytes);
const recoveredRead = await create(missingPath);
await recoveredRead.close();
+343
View File
@@ -0,0 +1,343 @@
#!/usr/bin/env node
// libfx on a real World (@workflow/world-local) through a persistence store
// written outside libfx (world-persistence.mjs): a session stored in a run,
// fencing between two processes, a killed process whose session a new one
// resumes, and checkpoints a load starts from.
//
// node sdk/tests/test-world-persistence.mjs <world-root> [native|wasm]
//
// <world-root> holds node_modules/@workflow/world-local, installed at test
// time; libfx itself depends on no @workflow package.
import { strict as assert } from "node:assert";
import { spawn } from "node:child_process";
import { appendFileSync, existsSync, mkdtempSync, readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createFxEngine, FxFencedError } from "../node.js";
import { worldPersistence } from "./world-persistence.mjs";
const args = process.argv.slice(2);
const child = args[0] === "--child" ? Object.fromEntries(args.slice(1).map((value, index, all) => index % 2 === 0 ? [value.replace(/^--/, ""), all[index + 1]] : null).filter(Boolean)) : null;
const worldRoot = child ? child.world : args[0];
const backend = child ? child.backend : (args[1] || "native");
if (!worldRoot || !existsSync(join(worldRoot, "node_modules/@workflow/world-local"))) {
throw new Error("usage: test-world-persistence.mjs <world-root> [native|wasm]; <world-root> must contain @workflow/world-local");
}
const { createWorld } = await import(pathToFileURL(join(worldRoot, "node_modules/@workflow/world-local/dist/index.js")).href);
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const addon = resolve(scriptDir, "../../zig-out/lib/libfx.node");
const wasm = backend === "wasm" ? await readFile(resolve(scriptDir, "../../zig-out/bin/fx-core.wasm")) : null;
const usage = { inputTokens: { total: 1 }, outputTokens: { total: 1 } };
const textOf = (message) => typeof message.content === "string"
? message.content
: message.content.filter((part) => part.type === "text").map((part) => part.text).join("");
const toolResults = (prompt) => prompt
.flatMap((message) => Array.isArray(message.content) ? message.content : [])
.filter((part) => part.type === "tool-result");
const userTexts = (prompt) => prompt.filter((message) => message.role === "user").map(textOf);
// "send it" asks for one send_email call, then answers. Any other prompt is
// answered with its own text.
function framesFor(prompt) {
const asked = prompt.some((message) => message.role === "user" && textOf(message) === "send it");
if (asked && toolResults(prompt).length === 0) {
return [
{ type: "tool-call", toolCallId: "call-send", toolName: "send_email", input: { to: "ops@example.com" } },
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage },
];
}
const text = textOf(prompt.filter((message) => message.role === "user").at(-1));
return [
{ type: "text-delta", id: "answer", delta: asked ? "sent" : `answer to ${text}` },
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage },
];
}
async function startGateway() {
const requests = [];
const sessionIds = [];
const server = createServer((request, response) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk) => { body += chunk; });
request.on("end", () => {
if (request.method === "GET") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ object: "list", data: [{ id: "workflow/model", type: "language" }] }));
return;
}
const prompt = JSON.parse(body).prompt;
requests.push(prompt);
sessionIds.push(request.headers["x-session-id"]);
response.writeHead(200, { "content-type": "text/event-stream" });
response.end(framesFor(prompt).map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n");
});
});
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
return { server, requests, sessionIds, port: server.address().port };
}
// The app's agent, the same in every process: a store for the session, whose
// run id is also the session id.
function defineAgent(port, onSend) {
const send = {
description: "Sends an email.",
inputSchema: { type: "object" },
writes: true,
execute: (input) => onSend(input),
};
return (persistence, settings = {}) => createFxEngine({
backend,
nativeAddon: addon,
...(wasm ? { wasm } : {}),
fetch: (input, init) => {
const url = new URL(String(input?.url ?? input));
return url.hostname === "ai-gateway.vercel.sh" ? fetch(`http://127.0.0.1:${port}${url.pathname}`, init) : fetch(input, init);
},
apiKey: "workflow-key",
gatewayChatUrl: `http://127.0.0.1:${port}/chat`,
model: "workflow/model",
tools: { send_email: send },
persistence,
sessionId: persistence.runId,
...settings,
});
}
const freshWorld = async () => {
const world = createWorld({ dataDir: mkdtempSync(join(tmpdir(), "libfx-world-")), recoverActiveRuns: false });
await world.start?.();
return world;
};
// The session's events, decoded from the records a load returns. Records are
// libfx's bytes; only libfx's own tests read them.
async function storedEvents(world, runId) {
const { journal } = await worldPersistence(world, { runId }).load();
return journal.flatMap((record) => JSON.parse(new TextDecoder().decode(record.data)).events);
}
async function until(check, message, ms = 5000) {
for (const deadline = Date.now() + ms; !(await check());) {
if (Date.now() > deadline) throw new Error(message);
await new Promise((resolveWait) => setTimeout(resolveWait, 20));
}
}
// world-local's queue teardown calls undici's Agent.close(), which Bun's
// built-in undici lacks. Only that known failure is skipped, and only on Bun.
async function closeWorld(world) {
try {
await world.close?.();
} catch (error) {
if (!(process.versions.bun && error instanceof TypeError && error.message.includes("httpAgent?.close"))) throw error;
}
}
async function run(agent, input, options) {
const turn = agent.prompt(input, options);
for await (const _ of turn) {}
return turn.result;
}
// Child: start a session, report its run id, run "send it", and stop inside
// send_email after its effect, the way a process dies mid-turn. The parent
// kills it there.
if (child) {
const world = createWorld({ dataDir: child.data, recoverActiveRuns: false });
await world.start?.();
const createAgent = defineAgent(Number(child.port), async () => {
appendFileSync(child.effects, "send_email\n");
process.stdout.write("effect\n");
await new Promise(() => {});
});
const store = worldPersistence(world);
process.stdout.write(`session ${store.runId}\n`);
const agent = await createAgent(store);
await run(agent, "send it");
throw new Error("the child should have been killed inside send_email");
}
const gateway = await startGateway();
const cases = [];
const test = (name, body) => cases.push({ name, body });
test("a session stored in a World restores in a new agent", async () => {
const world = await freshWorld();
const createAgent = defineAgent(gateway.port, () => "sent");
gateway.sessionIds.length = 0;
const store = worldPersistence(world);
const agent = await createAgent(store);
assert.equal((await run(agent, "remember plums")).stopReason, "end_turn");
await agent.close();
assert.match(store.runId, /^wrun_/);
const again = await createAgent(worldPersistence(world, { runId: store.runId }));
gateway.requests.length = 0;
await run(again, "what did I say");
await again.close();
assert.ok(userTexts(gateway.requests[0]).includes("remember plums"), "the restored session holds the earlier turn");
assert.ok(gateway.sessionIds.length >= 2 && gateway.sessionIds.every((id) => id === store.runId), JSON.stringify(gateway.sessionIds));
await closeWorld(world);
});
test("a new session reaches its first model request after three World writes and no reads", async () => {
const world = await freshWorld();
const calls = [];
const events = new Proxy(world.events, {
get(target, prop) {
const value = Reflect.get(target, prop);
if (prop === "create") {
return (runId, request, params) => {
calls.push(request.eventType === "step_created" ? request.eventData?.stepName : request.eventType);
return value.call(target, runId, request, params);
};
}
if (prop === "list") return (...list) => { calls.push("list"); return value.apply(target, list); };
return typeof value === "function" ? value.bind(target) : value;
},
});
const recorded = new Proxy(world, { get: (target, prop) => prop === "events" ? events : Reflect.get(target, prop) });
const store = worldPersistence(recorded);
const agent = await defineAgent(gateway.port, () => "sent")(store, {
fetch: (input, init) => {
const url = new URL(String(input?.url ?? input));
if ((init?.method ?? "GET") === "POST") calls.push("model");
return fetch(url.hostname === "ai-gateway.vercel.sh" ? `http://127.0.0.1:${gateway.port}${url.pathname}` : input, init);
},
});
assert.equal((await run(agent, "first words")).stopReason, "end_turn");
await agent.close();
assert.deepEqual(calls.slice(0, calls.indexOf("model")), ["run_created", "run_started", "fx.record"]);
const again = await defineAgent(gateway.port, () => "sent")(worldPersistence(world, { runId: store.runId }));
gateway.requests.length = 0;
await run(again, "what came first");
await again.close();
assert.ok(userTexts(gateway.requests[0]).includes("first words"), "the new run restores");
await closeWorld(world);
});
test("a second process on the session fences the first, and its late write is not part of the session", async () => {
const world = await freshWorld();
const createAgent = defineAgent(gateway.port, () => "sent");
const store = worldPersistence(world);
const first = await createAgent(store);
await run(first, "one");
// turn.result does not wait for the turn's last appends, so the takeover
// waits for the commit to reach the World.
await until(async () => (await storedEvents(world, store.runId)).some((event) => event.type === "turn_committed"), "the first turn never committed");
const second = await createAgent(worldPersistence(world, { runId: store.runId }));
await run(second, "two");
await second.close();
const fenced = (error) => error.code === "FX_JOURNAL_APPEND_FAILED" && error.cause instanceof FxFencedError;
await assert.rejects(run(first, "three"), fenced);
await first.close();
const events = await storedEvents(world, store.runId);
assert.deepEqual(events.filter((event) => event.type === "turn_committed").map((event) => event.data.user.text), ["one", "two"]);
const reopened = await createAgent(worldPersistence(world, { runId: store.runId }));
gateway.requests.length = 0;
await run(reopened, "and now");
await reopened.close();
assert.ok(!userTexts(gateway.requests[0]).includes("three"), "the fenced write left nothing behind");
await closeWorld(world);
});
test("a killed process's session resumes in a new process, which answers the call it left running", async () => {
const data = mkdtempSync(join(tmpdir(), "libfx-world-"));
const effects = join(data, "effects.log");
const execArgs = backend === "wasm" && !process.versions.bun ? ["--experimental-wasm-jspi"] : [];
const worker = spawn(process.execPath, [...execArgs, fileURLToPath(import.meta.url), "--child",
"--world", worldRoot, "--backend", backend, "--data", data,
"--port", String(gateway.port), "--effects", effects], { stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
let stdout = "";
worker.stderr.on("data", (chunk) => { stderr += chunk; });
await new Promise((resolveEffect, rejectEffect) => {
worker.stdout.on("data", (chunk) => {
stdout += chunk;
if (stdout.includes("effect\n")) resolveEffect();
});
worker.on("exit", (code) => rejectEffect(new Error(`worker exited (${code}) before the effect: ${stderr}`)));
});
worker.kill("SIGKILL");
await new Promise((resolveExit) => worker.once("close", resolveExit));
assert.equal(readFileSync(effects, "utf8"), "send_email\n");
const runId = /session (\S+)\n/.exec(stdout)?.[1];
assert.ok(runId, stdout);
let sends = 0;
const world = createWorld({ dataDir: data, recoverActiveRuns: false });
await world.start?.();
const agent = await defineAgent(gateway.port, () => { sends += 1; return "sent"; })(worldPersistence(world, { runId }));
gateway.requests.length = 0;
const resumed = agent.resume();
assert.ok(resumed, "the killed turn is open");
for await (const _ of resumed) {}
assert.equal((await resumed.result).stopReason, "end_turn");
await agent.close();
assert.equal(sends, 0, "send_email did not run again");
assert.equal(readFileSync(effects, "utf8"), "send_email\n");
assert.ok(JSON.stringify(gateway.requests[0]).includes("Resuming from unexpected session interruption."), "the model is told");
assert.match(JSON.stringify(toolResults(gateway.requests[0])), /may have partly run/);
const last = (await storedEvents(world, runId)).at(-1);
assert.equal(last.type, "turn_committed");
assert.equal(last.data.kind, "assistant");
assert.equal(last.data.user.text, "send it");
await closeWorld(world);
});
test("a checkpoint in the run lets a load read only from it on", async () => {
const world = await freshWorld();
const createAgent = defineAgent(gateway.port, () => "sent");
const store = worldPersistence(world);
const agent = await createAgent(store, { checkpointAfterBytes: 0 });
await run(agent, "remember plums");
await run(agent, "send it");
await run(agent, "and pears", { turnId: "turn-pears" });
await agent.close();
let read = 0;
const events = new Proxy(world.events, {
get(target, prop) {
const value = Reflect.get(target, prop);
if (prop === "list") return async (...list) => { const page = await value.apply(target, list); read += page.data.length; return page; };
return typeof value === "function" ? value.bind(target) : value;
},
});
const counted = new Proxy(world, { get: (target, prop) => prop === "events" ? events : Reflect.get(target, prop) });
const loaded = await worldPersistence(counted, { runId: store.runId, pageSize: 2 }).load();
assert.ok(loaded.checkpoint, "the run holds a checkpoint");
assert.equal(loaded.journal.length, 0, "the last checkpoint covers every record");
const total = (await world.events.list({ runId: store.runId, pagination: { limit: 1000 } })).data.length;
assert.ok(read < total, `a load read ${read} of ${total} events`);
const again = await createAgent(worldPersistence(world, { runId: store.runId }));
gateway.requests.length = 0;
const retried = again.prompt("and pears", { turnId: "turn-pears" });
assert.equal((await retried.result).stopReason, "end_turn");
assert.equal(gateway.requests.length, 0, "the ended turn did not run again");
await run(again, "what did I say");
await again.close();
const users = userTexts(gateway.requests[0]);
for (const text of ["remember plums", "send it", "and pears", "what did I say"]) assert.ok(users.includes(text), `${text} is in the history`);
await closeWorld(world);
});
try {
for (const { name, body } of cases) {
await body();
console.log(`ok - ${name}`);
}
console.log(`world persistence passed: ${backend}`);
} finally {
gateway.server.closeAllConnections();
await new Promise((resolveClose) => gateway.server.close(resolveClose));
}
+34
View File
@@ -0,0 +1,34 @@
// Vercel's World for runs, events and streams, with world-local's queue in
// this process, for tests that drive libfx from one machine: they and the
// processes they crash receive their own messages, while every record, lease
// and stream line goes through Vercel. Delivery through Vercel Queues is
// covered by the deployed demo.
//
// Needs a Vercel access token in WORKFLOW_VERCEL_AUTH_TOKEN (or, inside a
// deployment, its OIDC token), and VERCEL_PROJECT_ID, VERCEL_PROJECT_NAME,
// VERCEL_TEAM_ID and a VERCEL_DEPLOYMENT_ID of that project.
import { local } from "../durable/local.mjs";
const { createWorld } = await import(new URL("../durable/node_modules/@workflow/world-vercel/dist/index.js", import.meta.url).href);
/** `local()` with Vercel's World holding what `local()` keeps in files. */
export function vercelStorage(options) {
const base = local(options);
return {
...base,
name: "vercel",
world() {
const queued = base.world();
// The deadline the test sets, not a function's.
const { getRuntimeDeadline, ...stored } = createWorld({
...(process.env.WORKFLOW_VERCEL_AUTH_TOKEN ? { token: process.env.WORKFLOW_VERCEL_AUTH_TOKEN } : {}),
projectConfig: {
projectId: process.env.VERCEL_PROJECT_ID,
projectName: process.env.VERCEL_PROJECT_NAME,
teamId: process.env.VERCEL_TEAM_ID,
},
});
return { ...stored, queue: queued.queue, createQueueHandler: queued.createQueueHandler, registerHandler: queued.registerHandler };
},
};
}
+157
View File
@@ -0,0 +1,157 @@
// A libfx persistence store over a Workflow World, written the way an app or
// the Workflow package would write it: libfx never sees the World, and this
// store never reads libfx's records.
//
// A session is one World run. Each record and each checkpoint is a
// `step_created` event in it, and a record's cursor is its event slot. A
// World commits a write at the next free slot rather than refusing it, so a
// record also stores the cursor it continued: a load keeps only the unbroken
// chain of records, and a write that lands after another writer's record for
// the same cursor is fenced.
import { FxFencedError } from "../node.js";
const recordStep = "fx.record";
const checkpointStep = "fx.checkpoint";
// Event ids are a prefix and the slot, zero-padded to 26 digits.
const eventId = /^[a-z]+_(\d{26})$/;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
// A ULID: 48 bits of time, then 80 random bits, in Crockford base32.
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;
}
function slotOf(event) {
const match = eventId.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]);
}
const toBase64 = (bytes) => Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
const fromBase64 = (text) => new Uint8Array(Buffer.from(text, "base64"));
// The payload of one of this store's steps, or null for any other event.
function stepOf(event, stepName) {
if (event?.eventType !== "step_created" || event.eventData?.stepName !== stepName) return null;
const input = event.eventData.input;
if (!(input instanceof Uint8Array)) return null;
try { return JSON.parse(decoder.decode(input)); } catch { return null; }
}
/**
* The persistence store for one session. Without `runId`, the store names a
* new run at once and creates it with the session's first record, so a new
* session reads nothing before its first model request.
*/
export function worldPersistence(world, { runId = null, pageSize = 100 } = {}) {
let created = runId !== null;
const id = runId ?? `wrun_${typeof world.createRunId === "function" ? world.createRunId({}) : ulid()}`;
// The newest slot this store has seen; each write names it.
let eventCount = 0;
const spec = world.specVersion === undefined ? {} : { specVersion: world.specVersion };
async function createRun() {
await world.events.create(id, {
eventType: "run_created",
...spec,
eventData: { deploymentId: "fx", workflowName: "fx", input: encoder.encode("{}") },
});
const started = await world.events.create(id, { eventType: "run_started", ...spec }, { eventCount: 1 });
eventCount = slotOf(started.event);
created = true;
}
// Writes one step after the newest slot seen; returns its slot and the
// events that landed between.
async function write(stepName, payload) {
const known = eventCount;
const result = await world.events.create(id, {
eventType: "step_created",
correlationId: `step_${ulid()}`,
eventData: { stepName, input: encoder.encode(JSON.stringify(payload)) },
}, { eventCount: known });
const slot = slotOf(result.event);
eventCount = Math.max(eventCount, slot);
if (slot === known + 1) return { slot, between: [] };
const reported = Array.isArray(result.events) ? result.events : await readNewestFirst(known);
return { slot, between: reported.filter((event) => slotOf(event) > known && slotOf(event) < slot) };
}
// The run's events, newest first, down to and excluding `stopBelow`.
async function readNewestFirst(stopBelow) {
const events = [];
for (let cursor; ;) {
const page = await world.events.list({ runId: id, pagination: { sortOrder: "desc", limit: pageSize, ...(cursor ? { cursor } : {}) }, resolveData: "all" });
for (const event of page.data) {
if (slotOf(event) <= stopBelow) return events;
events.push(event);
}
if (!page.hasMore) return events;
cursor = page.cursor;
}
}
return {
get runId() {
return id;
},
// The latest checkpoint and the records after it: the run is read newest
// first, as far back as that checkpoint's cursor.
async load() {
if (!created) return {};
const newestFirst = [];
let checkpoint = null;
let stopAt = 0;
for (let cursor, done = false; !done;) {
const page = await world.events.list({ runId: id, pagination: { sortOrder: "desc", limit: pageSize, ...(cursor ? { cursor } : {}) }, resolveData: "all" });
for (const event of page.data) {
const slot = slotOf(event);
eventCount = Math.max(eventCount, slot);
if (checkpoint && slot <= stopAt) { done = true; break; }
const saved = checkpoint ? null : stepOf(event, checkpointStep);
if (saved) {
// Records stored after the snapshot but before this event follow it.
checkpoint = saved;
stopAt = Number(saved.through);
continue;
}
newestFirst.push(event);
}
if (!page.hasMore) done = true;
cursor = page.cursor;
}
let head = checkpoint?.through ?? null;
const journal = [];
for (const event of newestFirst.reverse()) {
const record = stepOf(event, recordStep);
if (!record || record.expected !== head) continue;
head = String(slotOf(event));
journal.push({ cursor: head, data: fromBase64(record.data) });
}
return {
...(checkpoint ? { checkpoint: { through: checkpoint.through, data: fromBase64(checkpoint.data) } } : {}),
journal,
};
},
async append({ expected, idempotencyKey, data }) {
if (!created) {
if (expected !== null) throw new FxFencedError(`run ${id} does not exist yet`);
await createRun();
}
const { slot, between } = await write(recordStep, { expected, key: idempotencyKey, data: toBase64(data) });
if (between.some((event) => stepOf(event, recordStep)?.expected === expected)) {
throw new FxFencedError(`another writer continued run ${id} at cursor ${expected}`);
}
return { cursor: String(slot) };
},
async saveCheckpoint({ through, data }) {
await write(checkpointStep, { through, data: toBase64(data) });
},
};
}
+717
View File
@@ -0,0 +1,717 @@
//! The ACP side of the libfx journal. A journaled session sends each event
//! to the host as a `libfx/journal_append` notification, in `seq` order, and
//! opens from the events the host stored through `libfx/journal_open`.
//! The events themselves are `core/agent/runtime/journal.zig`'s.
const std = @import("std");
const jsonrpc = @import("jsonrpc.zig");
const journal = @import("../core/agent/runtime/journal.zig");
const session_runtime = @import("../core/session/session.zig");
const session_codec = @import("../core/session/session_codec.zig");
const types = @import("../core/shared/types.zig");
const debug_trace = @import("../core/shared/debug_trace.zig");
const session_adapter = @import("../core/session/session_adapter.zig");
const checkpoint_codec = @import("../core/agent/runtime/checkpoint.zig");
const execution_memory = @import("../core/agent/execution_memory.zig");
const tool_dispatch = @import("../core/tooling/tool_dispatch.zig");
const Allocator = std.mem.Allocator;
pub const Event = journal.Event;
pub const max_load_bytes = journal.max_load_bytes;
/// What the model is told when libfx continues a turn a crash left open.
const resume_notice = "Resuming from unexpected session interruption.";
/// A host's id for a turn, kept in place so copying a journal copies it.
pub const TurnId = struct {
bytes: [journal.max_turn_id_bytes]u8 = undefined,
len: u8 = 0,
/// `id` is at most `journal.max_turn_id_bytes` long.
pub fn of(id: ?[]const u8) TurnId {
const value = id orelse return .{};
std.debug.assert(value.len <= journal.max_turn_id_bytes);
var turn_id: TurnId = .{ .len = @intCast(value.len) };
@memcpy(turn_id.bytes[0..value.len], value);
return turn_id;
}
pub fn slice(self: *const TurnId) ?[]const u8 {
return if (self.len == 0) null else self.bytes[0..self.len];
}
};
/// One session's journal. Its owner serializes every call with the
/// session's write mutex, so events reach the host in `seq` order.
pub const Journal = struct {
/// The connection's writer, which outlives every session on it.
writer: *jsonrpc.Writer,
cursor: journal.Cursor = .{},
/// Whether the last progress event still stands, as the fold sees it.
progress_open: bool = false,
/// The turn a crash left open, its running calls answered, until the
/// host resumes it or starts another turn. Owned by the session's
/// allocator.
pending_resume: ?session_codec.RecoveryCheckpoint = null,
/// Which step of the pending resume answers the calls the crash left
/// running, when it left any. The host may have the resume run them again.
pending_resume_answered: ?usize = null,
/// Set when a turn starts: its first progress carries the prompt, or the
/// resolution of a resumed turn, and must be stored before the model
/// sees it. Also set when the next progress places inputs.
barrier_next_progress: bool = false,
/// Inputs taken for the next model request; its progress places them.
/// Owned by the session's allocator.
placed_next: std.ArrayListUnmanaged([]const u8) = .empty,
/// Inputs the pending resume accepted and never placed, in order. Owned
/// by the session's allocator.
pending_inputs: []journal.PendingInput = &.{},
/// Follow-ups the journal holds that no turn ran, until the host takes
/// them. Owned by the session's allocator.
pending_follow_ups: []journal.PendingInput = &.{},
/// Accepted follow-ups no turn has placed yet. A snapshot waits for none.
follow_ups_waiting: usize = 0,
/// Steers the open turn accepted and has not placed. A snapshot where a
/// turn yielded waits for none, since it carries no inputs. A withdrawn
/// input leaves the count as it is, which only skips such a snapshot.
steers_waiting: usize = 0,
/// The follow-up the running turn runs, until a progress places it.
/// Owned by the session's allocator.
placing_follow_up: ?[]u8 = null,
/// The progress whose barrier a cancel interrupted: the turn commits
/// from it as interrupted. Owned by the session's allocator.
cancelled_progress: ?session_codec.RecoveryCheckpoint = null,
/// The host's id for the starting turn, until its first progress names
/// it; then the open turn's, until it ends; then the last turn's.
next_turn_id: TurnId = .{},
open_turn_id: TurnId = .{},
last_turn_id: TurnId = .{},
/// When the running turn stops, in milliseconds since the epoch, if the
/// host gave a time: its first progress at or after it yields.
yield_at_ms: ?i64 = null,
/// Set once the running turn has sent a model request in this prompt. A
/// turn yields only after one, so every prompt makes progress.
yield_armed: bool = false,
/// The host stores every input before it hands it in, so a progress that
/// places one, or a turn's first, need not wait for the host.
inputs_durable: bool = false,
/// The pending resume is a turn that yielded: it continues without being
/// told it was interrupted.
pending_resume_yielded: bool = false,
pub fn deinit(self: *Journal, alloc: Allocator) void {
self.dropPendingResume(alloc);
self.clearPlaced(alloc);
self.placed_next.deinit(alloc);
if (self.placing_follow_up) |id| alloc.free(id);
if (self.cancelled_progress) |*checkpoint| checkpoint.deinit(alloc);
self.cancelled_progress = null;
journal.freePendingInputs(alloc, self.takeFollowUps());
}
/// Names the turn that is starting; its first progress records the name.
pub fn nameNextTurn(self: *Journal, id: ?[]const u8) void {
self.next_turn_id = TurnId.of(id);
}
/// Hands the follow-ups the journal held to the caller, who owns them.
pub fn takeFollowUps(self: *Journal) []journal.PendingInput {
const follow_ups = self.pending_follow_ups;
self.pending_follow_ups = &.{};
return follow_ups;
}
pub fn dropPendingResume(self: *Journal, alloc: Allocator) void {
if (self.pending_resume) |*checkpoint| checkpoint.deinit(alloc);
self.pending_resume = null;
self.pending_resume_answered = null;
self.pending_resume_yielded = false;
if (self.pending_inputs.len > 0) {
debug_trace.logf("session", "event=libfx_journal_inputs_dropped count={d} reason=resume_replaced", .{self.pending_inputs.len});
}
self.dropPendingInputs(alloc);
}
/// The calls the crash left running in the pending resume, answered as
/// possibly run unless the host has the resume run them again. Borrowed
/// from the pending resume.
pub fn ambiguousCalls(self: *const Journal) []const types.ToolCall {
const pending = self.pending_resume orelse return &.{};
const step = self.pending_resume_answered orelse return &.{};
if (step >= pending.execution.tool_steps.len) return &.{};
return pending.execution.tool_steps[step].tool_calls;
}
/// Hands the pending resume's inputs to the caller, who owns them.
pub fn takePendingInputs(self: *Journal) []journal.PendingInput {
const inputs = self.pending_inputs;
self.pending_inputs = &.{};
return inputs;
}
fn dropPendingInputs(self: *Journal, alloc: Allocator) void {
journal.freePendingInputs(alloc, self.pending_inputs);
self.pending_inputs = &.{};
}
/// Records inputs taken for the next model request. Its progress places
/// them and is a barrier, so the request never carries an input the
/// journal could lose.
pub fn notePlaced(self: *Journal, alloc: Allocator, ids: []const []const u8) Allocator.Error!void {
if (ids.len == 0) return;
try self.placed_next.ensureUnusedCapacity(alloc, ids.len);
for (ids) |id| self.placed_next.appendAssumeCapacity(try alloc.dupe(u8, id));
if (!self.inputs_durable) self.barrier_next_progress = true;
}
/// Starts a turn's time limit: `yield_at_ms` is when it stops, or null.
pub fn startYieldClock(self: *Journal, yield_at_ms: ?i64) void {
self.yield_at_ms = yield_at_ms;
self.yield_armed = false;
}
/// Whether the progress just sent, before a model request at `now_ms`,
/// is where the turn yields. The first request of a prompt always goes
/// out, so each prompt makes progress however late it starts.
pub fn yieldsAt(self: *Journal, now_ms: i64) bool {
const deadline = self.yield_at_ms orelse return false;
if (!self.yield_armed) {
self.yield_armed = true;
return false;
}
return now_ms >= deadline;
}
/// Keeps `checkpoint`, the progress a yield stopped at, as the pending
/// resume, so this session can continue it as a later process would.
/// `session_alloc` owns the copy.
pub fn keepYielded(self: *Journal, session_alloc: Allocator, checkpoint: session_codec.RecoveryCheckpoint) Allocator.Error!void {
const owned = try checkpoint.dupe(session_alloc);
self.dropPendingResume(session_alloc);
self.pending_resume = owned;
self.pending_resume_yielded = true;
self.yield_at_ms = null;
}
fn clearPlaced(self: *Journal, alloc: Allocator) void {
for (self.placed_next.items) |id| alloc.free(id);
self.placed_next.clearRetainingCapacity();
}
/// Settles inputs taken for a model request that never went out because
/// their turn ended first. The model never saw them: the journal dropped
/// the steers with their turn, and the follow-up the turn ran is
/// withdrawn, since that turn ran and ended. `session_alloc` owns the
/// placed ids.
pub fn endUnplaced(self: *Journal, alloc: Allocator, session_alloc: Allocator, session_id: []const u8) !void {
if (self.placing_follow_up) |id| {
try self.append(alloc, session_id, .{ .input_withdrawn = id });
debug_trace.logf("session", "event=libfx_journal_follow_up_withdrawn reason=turn_ended_before_request", .{});
self.follow_ups_waiting -|= 1;
session_alloc.free(id);
self.placing_follow_up = null;
}
if (self.placed_next.items.len == 0) return;
debug_trace.logf("session", "event=libfx_journal_inputs_dropped count={d} reason=turn_ended_before_request", .{self.placed_next.items.len});
self.clearPlaced(session_alloc);
}
/// Hands the progress a cancelled barrier interrupted to the caller, who
/// owns it.
pub fn takeCancelledProgress(self: *Journal) ?session_codec.RecoveryCheckpoint {
const checkpoint = self.cancelled_progress;
self.cancelled_progress = null;
return checkpoint;
}
/// Sends the open turn so far and the model its next request goes to,
/// placing the inputs taken since the last progress. `session_alloc`
/// owns the placed ids.
pub fn appendProgress(
self: *Journal,
alloc: Allocator,
session_alloc: Allocator,
session_id: []const u8,
checkpoint: session_codec.RecoveryCheckpoint,
model: []const u8,
) !void {
try self.append(alloc, session_id, .{ .turn_progress = .{
.checkpoint = checkpoint,
.placed = self.placed_next.items,
.model = model,
.turn_id = self.next_turn_id.slice(),
} });
const follow_up_placed: usize = @intFromBool(self.placing_follow_up != null);
self.steers_waiting -|= self.placed_next.items.len -| follow_up_placed;
if (self.next_turn_id.len > 0) {
self.open_turn_id = self.next_turn_id;
self.next_turn_id = .{};
}
if (self.placing_follow_up) |id| {
self.follow_ups_waiting -|= 1;
session_alloc.free(id);
self.placing_follow_up = null;
}
self.clearPlaced(session_alloc);
}
/// Sends `event` as the session's next entry. On failure the cursor
/// stays put, so the next event takes the same `seq`.
pub fn append(self: *Journal, alloc: Allocator, session_id: []const u8, event: Event) !void {
var params: std.Io.Writer.Allocating = .init(alloc);
defer params.deinit();
try params.writer.writeAll("{\"sessionId\":");
try jsonrpc.writeJsonStr(session_id, &params.writer);
try params.writer.writeAll(",\"events\":[");
const next = try self.cursor.write(&params.writer, event);
try params.writer.writeAll("]}");
try self.writer.writeNotification(alloc, "libfx/journal_append", params.written());
self.cursor = next;
// Matches the fold: intents and compaction leave the open turn
// standing.
switch (event) {
.turn_progress => self.progress_open = true,
.turn_committed, .turn_progress_cleared => {
self.progress_open = false;
self.last_turn_id = self.open_turn_id;
self.open_turn_id = .{};
// A turn's steers end with it.
self.steers_waiting = 0;
},
.input_accepted => |input| switch (input.kind) {
.follow_up => self.follow_ups_waiting += 1,
.steer => self.steers_waiting += 1,
},
.tool_intent, .history_replaced, .input_withdrawn, .turn_yielded => {},
}
}
/// The follow-up a starting turn runs: its first progress places it.
pub fn placeFollowUp(self: *Journal, alloc: Allocator, id: []const u8) Allocator.Error!void {
const owned = try alloc.dupe(u8, id);
errdefer alloc.free(owned);
try self.notePlaced(alloc, &.{id});
if (self.placing_follow_up) |old| alloc.free(old);
self.placing_follow_up = owned;
}
/// Whether the session is at a point a snapshot can stand for: no turn
/// open or waiting to resume, and no follow-up waiting for its turn.
pub fn quiet(self: *const Journal) bool {
return !self.progress_open and self.pending_resume == null and
self.follow_ups_waiting == 0 and self.cursor.next_seq > 1;
}
/// Whether the session is at a turn that yielded and nothing else waits:
/// no call a crash left running, no input for the turn, no follow-up. A
/// snapshot can stand for it with the open turn in it.
pub fn yieldedQuiet(self: *const Journal) bool {
return self.progress_open and self.pending_resume != null and self.pending_resume_yielded and
self.pending_resume_answered == null and self.pending_inputs.len == 0 and
self.steers_waiting == 0 and self.follow_ups_waiting == 0;
}
/// Records that the open turn ended without a history entry. A turn
/// with no progress on record needs no event.
pub fn clearProgress(self: *Journal, alloc: Allocator, session_id: []const u8) !void {
if (!self.progress_open) return;
try self.append(alloc, session_id, .turn_progress_cleared);
}
};
pub const Opened = struct {
turns: usize,
/// A crash left a turn open; `resume` continues it.
resumable: bool,
/// The host's ids for the open turn and the last turn that ended.
open_turn_id: TurnId = .{},
last_turn_id: TurnId = .{},
/// The open turn yielded; the resume continues it without the notice.
yielded: bool = false,
/// What the snapshot recorded about the agent that saved it, owned by the
/// `alloc` passed to `open` (`Meta.free`); empty without one.
checkpoint_meta: checkpoint_codec.Meta = .{},
};
fn dupePendingInputs(alloc: Allocator, inputs: []const journal.PendingInput) Allocator.Error![]journal.PendingInput {
const owned = try alloc.alloc(journal.PendingInput, inputs.len);
var filled: usize = 0;
errdefer {
for (owned[0..filled]) |input| {
alloc.free(input.id);
alloc.free(input.text);
}
alloc.free(owned);
}
for (inputs) |input| {
const id = try alloc.dupe(u8, input.id);
errdefer alloc.free(id);
owned[filled] = .{ .id = id, .text = try alloc.dupe(u8, input.text) };
filled += 1;
}
return owned;
}
/// Rebuilds a fresh session from the host's events, after `snapshot` when
/// the host stored one, and starts its journal after them. A turn a crash
/// left open becomes the pending resume, with every call it left running
/// answered as possibly run, for the model to decide about unless the host
/// has the resume run it again (`rerunCalls`); `session_alloc` owns it.
/// With `inputs_durable`, the host stores every input before handing it in.
pub fn open(
alloc: Allocator,
session_alloc: Allocator,
writer: *jsonrpc.Writer,
runtime: *session_runtime.SessionRuntime,
events_json: []const u8,
snapshot: ?[]const u8,
inputs_durable: bool,
) !struct { Journal, Opened } {
if (!runtime.agent.fresh or runtime.agent.history.items.len != 0) return error.AgentNotFresh;
var base: journal.Base = .{};
var decoded: ?checkpoint_codec.Decoded = null;
defer if (decoded) |*value| value.deinit(alloc);
var saved_meta: checkpoint_codec.Meta = .{};
errdefer saved_meta.free(alloc);
if (snapshot) |bytes| {
const parsed = try journal.parseSnapshot(bytes);
decoded = checkpoint_codec.decode(alloc, parsed.checkpoint) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.UnsupportedCheckpointVersion => return error.UnsupportedJournalVersion,
else => return error.InvalidJournal,
};
saved_meta = try decoded.?.meta.dupe(alloc);
base = .{
.history = decoded.?.history,
.cursor = .{ .next_seq = parsed.seq + 1, .turn = parsed.turn },
.last_turn_id = parsed.last_turn_id,
.open_turn = parsed.open_turn,
};
}
var folded = try journal.foldFrom(alloc, events_json, base);
defer folded.deinit(alloc);
if (folded.history.len > 0) try runtime.agent.restoreHistory(alloc, folded.history);
if (decoded) |value| runtime.agent.turn_usage = value.usage;
var state: Journal = .{
.writer = writer,
.cursor = folded.cursor,
.progress_open = folded.open_turn != null,
.pending_follow_ups = try dupePendingInputs(session_alloc, folded.pending_follow_ups),
.follow_ups_waiting = folded.pending_follow_ups.len,
.open_turn_id = TurnId.of(folded.open_turn_id),
.last_turn_id = TurnId.of(folded.last_turn_id),
.inputs_durable = inputs_durable,
.steers_waiting = folded.pending_inputs.len,
};
errdefer state.deinit(session_alloc);
const checkpoint = folded.open_turn orelse
return .{ state, .{ .turns = folded.history.len, .resumable = false, .last_turn_id = state.last_turn_id, .checkpoint_meta = saved_meta } };
debug_trace.logf("session", "event=libfx_journal_open_turn turn={d} running_calls={d} resolved=pending_resume", .{ folded.cursor.turn, folded.running_calls.len });
var scratch = std.heap.ArenaAllocator.init(alloc);
defer scratch.deinit();
var answered = checkpoint;
const answered_step = answered.execution.tool_steps.len;
try answerRunning(scratch.allocator(), &answered, folded.running_calls);
state.pending_resume = try answered.dupe(session_alloc);
if (folded.running_calls.len > 0) state.pending_resume_answered = answered_step;
state.pending_resume_yielded = folded.yielded;
state.pending_inputs = try dupePendingInputs(session_alloc, folded.pending_inputs);
return .{ state, .{
.turns = folded.history.len,
.resumable = true,
.open_turn_id = state.open_turn_id,
.last_turn_id = state.last_turn_id,
.yielded = folded.yielded,
.checkpoint_meta = saved_meta,
} };
}
/// An owned copy of `pending` ready to continue: unless the turn yielded,
/// the model is told the session was interrupted; then it sees the inputs
/// the turn accepted and never placed, and the attempt budget starts over,
/// so repeated crashes never turn into a refusal to resume.
pub fn resumeCheckpoint(
alloc: Allocator,
pending: session_codec.RecoveryCheckpoint,
inputs: []const journal.PendingInput,
yielded: bool,
) !session_codec.RecoveryCheckpoint {
var scratch = std.heap.ArenaAllocator.init(alloc);
defer scratch.deinit();
var view = pending;
const old = view.execution.steering;
const notices: usize = if (yielded) 0 else 1;
const steering = try scratch.allocator().alloc(types.PersistedSteering, old.len + notices + inputs.len);
@memcpy(steering[0..old.len], old);
const after = view.execution.tool_steps.len;
if (!yielded) steering[old.len] = .{ .text = @constCast(resume_notice), .after_tool_step_count = after };
for (inputs, steering[old.len + notices ..]) |input, *entry| {
entry.* = .{ .text = input.text, .after_tool_step_count = after };
}
view.execution.steering = steering;
view.consumed_provider_attempts = 0;
view.outstanding_reservation = false;
return view.dupe(alloc);
}
/// Gives each call the crash left running a result saying it may have
/// partly run, as one more step of the open turn, so the model sees every
/// call it made answered and decides whether to run it again. The response
/// that made the calls, with any text the model wrote before them, is now
/// that step, so the turn continues after its tools. Borrows `calls` and the
/// turn's text; allocates in `scratch`.
fn answerRunning(scratch: Allocator, turn: *session_codec.RecoveryCheckpoint, calls: []types.ToolCall) Allocator.Error!void {
if (calls.len == 0) return;
const output = session_adapter.unfinished_tool_output;
const results = try scratch.alloc(types.PersistedToolResult, calls.len);
for (calls, results) |call, *result| result.* = .{
.tool_call_id = @constCast(call.id),
.tool_name = @constCast(call.name),
.status = .failure,
.output = @constCast(output),
.output_bytes = output.len,
.stored_output_bytes = output.len,
};
const old = turn.execution.tool_steps;
const steps = try scratch.alloc(types.ToolExecutionStep, old.len + 1);
@memcpy(steps[0..old.len], old);
steps[old.len] = .{
.assistant = if (turn.assistant_source.len > 0) turn.assistant_source else null,
.tool_calls = calls,
.tool_results = results,
};
turn.execution.tool_steps = steps;
turn.assistant_source = @constCast("");
turn.tool_state = .confirmed;
}
/// Runs again each call of `checkpoint`'s step `step_index`, the step that
/// answers the calls a crash left running, that `runner.rerun` says the host
/// asked to run again, under the id the model gave it, and puts what
/// `runner.run` returns in place of the answer that it may have partly run.
/// Every other call keeps that answer, for the model to decide about. Stops
/// at the first call `runner.run` returns null for (the turn is being
/// cancelled or handed off), so that call and every later one keep it too.
/// `alloc` owns `checkpoint` and the results put in it. Returns how many
/// calls ran again.
pub fn rerunCalls(
alloc: Allocator,
checkpoint: *session_codec.RecoveryCheckpoint,
step_index: usize,
runner: anytype,
) !usize {
const steps = checkpoint.execution.tool_steps;
if (step_index >= steps.len) return 0;
const step = &steps[step_index];
var reran: usize = 0;
for (step.tool_calls, step.tool_results) |call, *result| {
if (!runner.rerun(call)) continue;
const outcome = (try runner.run(alloc, call)) orelse {
debug_trace.logf("session", "event=libfx_resume_rerun_stopped call={s} reran={d} kept=may_have_partly_run", .{ call.id, reran });
break;
};
defer outcome.deinit(alloc);
const replacement = try persistedResult(alloc, call, outcome);
types.freePersistedToolResult(alloc, result.*);
result.* = replacement;
reran += 1;
debug_trace.logf("session", "event=libfx_resume_rerun call={s} tool={s} status={s}", .{ call.id, call.name, @tagName(replacement.status) });
}
return reran;
}
fn persistedResult(alloc: Allocator, call: types.ToolCall, outcome: tool_dispatch.ToolResult) !types.PersistedToolResult {
return switch (outcome) {
.success => |text| execution_memory.makePersistedToolResult(alloc, call.id, call.name, .success, text, null),
.failure => |text| execution_memory.makePersistedToolResult(alloc, call.id, call.name, .failure, text, null),
.rich => |content| execution_memory.makePersistedToolResult(
alloc,
call.id,
call.name,
if (content.is_error) .failure else .success,
content.text,
.{ .tool_images = content.images, .output_bytes = content.text.len, .stored_output_bytes = content.text.len },
),
};
}
const TestCapture = struct {
frames: std.ArrayList(u8) = .empty,
fn write(raw: ?*anyopaque, frame: []const u8) !void {
const self: *TestCapture = @ptrCast(@alignCast(raw.?));
try self.frames.appendSlice(std.testing.allocator, frame);
}
};
fn testProgress(alloc: Allocator) !session_codec.RecoveryCheckpoint {
return .{
.turn_id = 1,
.user = .{ .text = try alloc.dupe(u8, "prompt") },
.assistant_source = try alloc.dupe(u8, ""),
.cause = .network_interrupted,
.action = .retrying_request,
.authority = .{ .provider = .gateway, .model = try alloc.dupe(u8, "fake/model") },
.requested_fast_mode = false,
.fast_mode = false,
.max_provider_attempts = 3,
.consumed_provider_attempts = 0,
};
}
test "inputs taken for a request that never went out are not placed by the next turn" {
const alloc = std.testing.allocator;
var capture: TestCapture = .{};
defer capture.frames.deinit(alloc);
var writer = jsonrpc.Writer.initCallback(&capture, TestCapture.write);
var session: Journal = .{ .writer = &writer };
defer session.deinit(alloc);
var progress = try testProgress(alloc);
defer progress.deinit(alloc);
try session.notePlaced(alloc, &.{"steer-1"});
// Its turn ended before the next progress; the next turn starts.
try session.endUnplaced(alloc, alloc, "session");
try session.appendProgress(alloc, alloc, "session", progress, "fake/model");
try std.testing.expect(std.mem.find(u8, capture.frames.items, "steer-1") == null);
}
test "a progress places the follow-up its turn runs" {
const alloc = std.testing.allocator;
var capture: TestCapture = .{};
defer capture.frames.deinit(alloc);
var writer = jsonrpc.Writer.initCallback(&capture, TestCapture.write);
var session: Journal = .{ .writer = &writer, .follow_ups_waiting = 1, .cursor = .{ .next_seq = 3, .turn = 2 } };
defer session.deinit(alloc);
var progress = try testProgress(alloc);
defer progress.deinit(alloc);
try session.placeFollowUp(alloc, "follow-1");
try std.testing.expectEqual(@as(usize, 1), session.follow_ups_waiting);
try session.appendProgress(alloc, alloc, "session", progress, "fake/model");
try std.testing.expectEqual(@as(usize, 0), session.follow_ups_waiting);
try std.testing.expect(std.mem.find(u8, capture.frames.items, "\"inputs\":[\"follow-1\"]") != null);
// Nothing is left to settle when the turn ends.
try session.endUnplaced(alloc, alloc, "session");
try std.testing.expect(std.mem.find(u8, capture.frames.items, "input_withdrawn") == null);
}
test "a follow-up whose turn ended before its request is withdrawn, so it never runs again" {
const alloc = std.testing.allocator;
var capture: TestCapture = .{};
defer capture.frames.deinit(alloc);
var writer = jsonrpc.Writer.initCallback(&capture, TestCapture.write);
var session: Journal = .{ .writer = &writer, .follow_ups_waiting = 1, .cursor = .{ .next_seq = 3, .turn = 2 } };
defer session.deinit(alloc);
try session.placeFollowUp(alloc, "follow-1");
try std.testing.expect(!session.quiet());
try session.endUnplaced(alloc, alloc, "session");
try std.testing.expectEqual(@as(usize, 0), session.follow_ups_waiting);
try std.testing.expect(session.quiet());
try std.testing.expect(std.mem.find(u8, capture.frames.items, "\"type\":\"input_withdrawn\",\"data\":{\"id\":\"follow-1\"}") != null);
}
test "a turn's id reaches its first progress only, and is the last turn's once the turn ends" {
const alloc = std.testing.allocator;
var capture: TestCapture = .{};
defer capture.frames.deinit(alloc);
var writer = jsonrpc.Writer.initCallback(&capture, TestCapture.write);
var session: Journal = .{ .writer = &writer };
defer session.deinit(alloc);
var progress = try testProgress(alloc);
defer progress.deinit(alloc);
session.nameNextTurn("turn-1");
try session.appendProgress(alloc, alloc, "session", progress, "fake/model");
try session.appendProgress(alloc, alloc, "session", progress, "fake/model");
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, capture.frames.items, "\"turnId\":\"turn-1\""));
try std.testing.expectEqualStrings("turn-1", session.open_turn_id.slice().?);
try session.clearProgress(alloc, "session");
try std.testing.expect(session.open_turn_id.slice() == null);
try std.testing.expectEqualStrings("turn-1", session.last_turn_id.slice().?);
// A turn the host did not name ends with no last id.
session.nameNextTurn(null);
try session.appendProgress(alloc, alloc, "session", progress, "fake/model");
try session.clearProgress(alloc, "session");
try std.testing.expect(session.last_turn_id.slice() == null);
}
/// Runs again the calls in `ids`, except `stop_at`, which it reports as cancelled.
const TestRerunner = struct {
ids: []const []const u8,
stop_at: ?[]const u8 = null,
ran: *std.ArrayList([]const u8),
pub fn rerun(self: TestRerunner, call: types.ToolCall) bool {
for (self.ids) |id| if (std.mem.eql(u8, id, call.id)) return true;
return false;
}
pub fn run(self: TestRerunner, alloc: Allocator, call: types.ToolCall) !?tool_dispatch.ToolResult {
if (self.stop_at) |id| if (std.mem.eql(u8, call.id, id)) return null;
try self.ran.append(std.testing.allocator, call.id);
return .{ .success = try std.fmt.allocPrint(alloc, "ran again as {s}", .{call.id}) };
}
};
fn testAnswered(alloc: Allocator, calls: []types.ToolCall) !session_codec.RecoveryCheckpoint {
var progress = try testProgress(alloc);
defer progress.deinit(alloc);
var scratch = std.heap.ArenaAllocator.init(alloc);
defer scratch.deinit();
var answered = progress;
try answerRunning(scratch.allocator(), &answered, calls);
return answered.dupe(alloc);
}
test "a resumed turn runs again the calls the host asks for and leaves the rest to the model" {
const alloc = std.testing.allocator;
var calls = [_]types.ToolCall{
.{ .id = "call-1", .name = "read", .arguments_json = "{}" },
.{ .id = "call-2", .name = "send", .arguments_json = "{}" },
.{ .id = "call-3", .name = "read", .arguments_json = "{}" },
};
var answered = try testAnswered(alloc, &calls);
defer answered.deinit(alloc);
var ran: std.ArrayList([]const u8) = .empty;
defer ran.deinit(alloc);
const ids = [_][]const u8{ "call-1", "call-3" };
const reran = try rerunCalls(alloc, &answered, 0, TestRerunner{ .ids = &ids, .ran = &ran });
try std.testing.expectEqual(@as(usize, 2), reran);
try std.testing.expectEqual(@as(usize, 2), ran.items.len);
const results = answered.execution.tool_steps[0].tool_results;
// Every call keeps its place, so results reach the model in its order.
try std.testing.expectEqualStrings("call-1", results[0].tool_call_id);
try std.testing.expectEqualStrings("ran again as call-1", results[0].output);
try std.testing.expectEqual(types.PersistedToolStatus.success, results[0].status);
try std.testing.expectEqualStrings(session_adapter.unfinished_tool_output, results[1].output);
try std.testing.expectEqual(types.PersistedToolStatus.failure, results[1].status);
try std.testing.expectEqualStrings("ran again as call-3", results[2].output);
}
test "a resume cancelled during a rerun keeps that call and the later ones answered" {
const alloc = std.testing.allocator;
var calls = [_]types.ToolCall{
.{ .id = "call-1", .name = "read", .arguments_json = "{}" },
.{ .id = "call-2", .name = "read", .arguments_json = "{}" },
.{ .id = "call-3", .name = "read", .arguments_json = "{}" },
};
var answered = try testAnswered(alloc, &calls);
defer answered.deinit(alloc);
var ran: std.ArrayList([]const u8) = .empty;
defer ran.deinit(alloc);
const ids = [_][]const u8{ "call-1", "call-2", "call-3" };
const reran = try rerunCalls(alloc, &answered, 0, TestRerunner{ .ids = &ids, .stop_at = "call-2", .ran = &ran });
try std.testing.expectEqual(@as(usize, 1), reran);
const results = answered.execution.tool_steps[0].tool_results;
try std.testing.expectEqualStrings("ran again as call-1", results[0].output);
try std.testing.expectEqualStrings(session_adapter.unfinished_tool_output, results[1].output);
try std.testing.expectEqualStrings(session_adapter.unfinished_tool_output, results[2].output);
// A step index past the turn's steps runs nothing.
try std.testing.expectEqual(@as(usize, 0), try rerunCalls(alloc, &answered, 5, TestRerunner{ .ids = &ids, .ran = &ran }));
}
+104 -13
View File
@@ -10,6 +10,8 @@ pub const max_message_bytes: usize = 64 * 1024;
pub const max_messages: usize = 64;
pub const max_queued_bytes: usize = 1024 * 1024;
const max_input_id_bytes: usize = 128;
pub const EnqueueError = Allocator.Error || error{
EmptySteeringMessage,
SteeringMessageTooLarge,
@@ -17,19 +19,34 @@ pub const EnqueueError = Allocator.Error || error{
SteeringNotActive,
};
/// A libfx input id that is malformed or already queued.
const InputError = EnqueueError || error{InvalidInputId};
const Entry = struct {
text: []u8,
/// The ACP `session/prompt` request that carried the text, answered when
/// the turn that absorbed it ends. libfx steering carries no request.
request_id: ?RequestId = null,
/// The libfx input id a journaled session records the text under.
input_id: ?[]u8 = null,
};
/// Steering drained at one boundary, allocated in the caller's allocator.
pub const Drained = struct {
texts: [][]u8,
request_ids: []?RequestId,
input_ids: []?[]u8 = &.{},
};
/// A libfx input id: 1 to 128 letters, digits, `.`, `_`, or `-`.
pub fn validInputId(id: []const u8) bool {
if (id.len == 0 or id.len > max_input_id_bytes) return false;
for (id) |byte| {
if (!std.ascii.isAlphanumeric(byte) and byte != '.' and byte != '_' and byte != '-') return false;
}
return true;
}
/// Requests to answer when a turn ends. Allocated in the caller's allocator.
pub const Finished = struct {
/// Steering the turn delivered to the model.
@@ -62,6 +79,28 @@ pub const Runtime = struct {
return self.enqueueRequest(alloc, text, null);
}
/// Queues a libfx input under `input_id`, which no queued entry may share.
pub fn enqueueInput(self: *Runtime, alloc: Allocator, text: []const u8, input_id: []const u8) InputError!void {
if (!validInputId(input_id)) return error.InvalidInputId;
return self.enqueueEntry(alloc, text, null, input_id);
}
/// Removes the queued input `input_id`. False when it is not queued: the
/// turn already took it for a model request, or it never arrived.
pub fn withdraw(self: *Runtime, alloc: Allocator, input_id: []const u8) bool {
self.mutex.lockUncancelable(io_mod.getIo());
defer self.mutex.unlock(io_mod.getIo());
for (self.messages.items, 0..) |entry, index| {
const id = entry.input_id orelse continue;
if (!std.mem.eql(u8, id, input_id)) continue;
const removed = self.messages.orderedRemove(index);
self.queued_bytes -= removed.text.len;
freeEntry(alloc, removed);
return true;
}
return false;
}
/// Queues text for the running turn. A supplied request ID is copied and
/// reported by `finishTurn`.
pub fn enqueueRequest(
@@ -70,6 +109,20 @@ pub const Runtime = struct {
text: []const u8,
request_id: ?RequestId,
) EnqueueError!void {
return self.enqueueEntry(alloc, text, request_id, null) catch |err| switch (err) {
// Only an input id can be invalid, and none is given here.
error.InvalidInputId => unreachable,
else => |other| other,
};
}
fn enqueueEntry(
self: *Runtime,
alloc: Allocator,
text: []const u8,
request_id: ?RequestId,
input_id: ?[]const u8,
) InputError!void {
if (text.len == 0) return error.EmptySteeringMessage;
if (text.len > max_message_bytes) return error.SteeringMessageTooLarge;
@@ -81,12 +134,19 @@ pub const Runtime = struct {
{
return error.SteeringQueueFull;
}
if (input_id) |id| {
for (self.messages.items) |entry| {
if (entry.input_id) |queued| if (std.mem.eql(u8, queued, id)) return error.InvalidInputId;
}
}
try self.messages.ensureUnusedCapacity(alloc, 1);
try self.absorbed.ensureTotalCapacity(alloc, self.absorbed.items.len + self.messages.items.len + 1);
const owned = try alloc.dupe(u8, text);
errdefer alloc.free(owned);
const owned_input = if (input_id) |id| try alloc.dupe(u8, id) else null;
errdefer if (owned_input) |id| alloc.free(id);
const owned_id = if (request_id) |id| try dupeRequestId(alloc, id) else null;
self.messages.appendAssumeCapacity(.{ .text = owned, .request_id = owned_id });
self.messages.appendAssumeCapacity(.{ .text = owned, .request_id = owned_id, .input_id = owned_input });
self.queued_bytes += owned.len;
}
@@ -109,30 +169,31 @@ pub const Runtime = struct {
errdefer result_alloc.free(texts);
const request_ids = try result_alloc.alloc(?RequestId, count);
errdefer result_alloc.free(request_ids);
const input_ids = try result_alloc.alloc(?[]u8, count);
errdefer result_alloc.free(input_ids);
var copied: usize = 0;
errdefer for (0..copied) |index| {
result_alloc.free(texts[index]);
if (request_ids[index]) |id| freeRequestId(result_alloc, id);
if (input_ids[index]) |id| result_alloc.free(id);
};
for (self.messages.items, 0..) |entry, index| {
texts[index] = try result_alloc.dupe(u8, entry.text);
request_ids[index] = null;
if (entry.request_id) |id| {
request_ids[index] = dupeRequestId(result_alloc, id) catch |err| {
result_alloc.free(texts[index]);
return err;
};
}
errdefer result_alloc.free(texts[index]);
input_ids[index] = if (entry.input_id) |id| try result_alloc.dupe(u8, id) else null;
errdefer if (input_ids[index]) |id| result_alloc.free(id);
request_ids[index] = if (entry.request_id) |id| try dupeRequestId(result_alloc, id) else null;
copied += 1;
}
// Capacity for every queued request was reserved at enqueue.
for (self.messages.items) |entry| {
backing.free(entry.text);
if (entry.input_id) |id| backing.free(id);
if (entry.request_id) |id| self.absorbed.appendAssumeCapacity(id);
}
self.messages.clearRetainingCapacity();
self.queued_bytes = 0;
return .{ .texts = texts, .request_ids = request_ids };
return .{ .texts = texts, .request_ids = request_ids, .input_ids = input_ids };
}
/// Stops accepting steering for the finished turn and reports which
@@ -193,10 +254,7 @@ pub const Runtime = struct {
.{ self.messages.items.len, self.queued_bytes, reason },
);
}
for (self.messages.items) |entry| {
alloc.free(entry.text);
if (entry.request_id) |id| freeRequestId(alloc, id);
}
for (self.messages.items) |entry| freeEntry(alloc, entry);
self.messages.clearRetainingCapacity();
freeRequestIdItems(alloc, self.absorbed.items);
self.absorbed.clearRetainingCapacity();
@@ -216,6 +274,14 @@ pub fn freeDrained(alloc: Allocator, drained: Drained) void {
if (drained.texts.len > 0) alloc.free(drained.texts);
for (drained.request_ids) |maybe_id| if (maybe_id) |id| freeRequestId(alloc, id);
if (drained.request_ids.len > 0) alloc.free(drained.request_ids);
for (drained.input_ids) |maybe_id| if (maybe_id) |id| alloc.free(id);
if (drained.input_ids.len > 0) alloc.free(drained.input_ids);
}
fn freeEntry(alloc: Allocator, entry: Entry) void {
alloc.free(entry.text);
if (entry.request_id) |id| freeRequestId(alloc, id);
if (entry.input_id) |id| alloc.free(id);
}
fn dupeRequestId(alloc: Allocator, id: RequestId) Allocator.Error!RequestId {
@@ -303,3 +369,28 @@ test "steering runtime reports absorbed and dropped prompt requests when a turn
try std.testing.expectEqual(@as(usize, 0), runtime.messages.items.len);
try std.testing.expectEqual(@as(usize, 0), runtime.absorbed.items.len);
}
test "libfx steering withdraws a queued input until the turn takes it" {
const alloc = std.testing.allocator;
var runtime: Runtime = .{};
defer runtime.deinit(alloc);
runtime.open(alloc);
try std.testing.expectError(error.InvalidInputId, runtime.enqueueInput(alloc, "text", "bad id"));
try runtime.enqueueInput(alloc, "use tabs", "in_1");
try runtime.enqueueInput(alloc, "skip tests", "in_2");
try std.testing.expectError(error.InvalidInputId, runtime.enqueueInput(alloc, "again", "in_2"));
try runtime.enqueueInput(alloc, "be brief", "in_3");
try std.testing.expect(runtime.withdraw(alloc, "in_2"));
try std.testing.expect(!runtime.withdraw(alloc, "in_2"));
try std.testing.expectEqual(@as(usize, "use tabs".len + "be brief".len), runtime.queued_bytes);
const drained = try runtime.takeAll(alloc, alloc, false);
defer freeDrained(alloc, drained);
try std.testing.expectEqual(@as(usize, 2), drained.texts.len);
try std.testing.expectEqualStrings("in_1", drained.input_ids[0].?);
try std.testing.expectEqualStrings("be brief", drained.texts[1]);
try std.testing.expectEqualStrings("in_3", drained.input_ids[1].?);
// Taken for a model request: too late to withdraw.
try std.testing.expect(!runtime.withdraw(alloc, "in_1"));
}
+435 -4
View File
@@ -12,6 +12,10 @@ const js_host_tools = if (host_target.is_wasm)
@import("../core/hosts/js_host_tools.zig")
else
struct {};
const js_host_journal = if (host_target.is_wasm)
@import("../core/hosts/js_host_journal.zig")
else
struct {};
const js_host_steering = if (host_target.is_wasm)
@import("../core/hosts/js_host_steering.zig")
else
@@ -43,6 +47,9 @@ const session_codec = @import("../core/session/session_codec.zig");
const session_log = @import("../core/session/session_log.zig");
const session_store = @import("../core/session/session_store.zig");
const session_adapter = @import("../core/session/session_adapter.zig");
const libfx_journal = @import("libfx_journal.zig");
const journal_events = @import("../core/agent/runtime/journal.zig");
const libfx_steering = @import("libfx_steering.zig");
const session_child_store = @import("../core/session/session_child_store.zig");
const session_runtime = @import("../core/session/session.zig");
const session_usage = @import("../core/session/session_usage.zig");
@@ -522,6 +529,58 @@ fn activeToolSet(state: *const server.ServerState) tool_set_contract.ToolSet {
return tool_call_presentation.activeToolSet(state);
}
/// Runs again, through the host, the calls of a resumed turn the host asked
/// to run again, as the turn's tools run.
const ResumeRerun = struct {
ctx: *AcpContext,
cancel_flag: *std.atomic.Value(bool),
max_result_bytes: usize,
call_ids: []const []const u8,
pub fn rerun(self: ResumeRerun, call: types.ToolCall) bool {
for (self.call_ids) |id| {
if (!std.mem.eql(u8, id, call.id)) continue;
const tool = self.ctx.toolRegistry().lookup(call.name) orelse return false;
return tool.executor_kind == .host and !tool.provider_executed;
}
return false;
}
/// Null when the turn is cancelled or handed off, or no host can run it.
/// The client sees the call start and end as it would the first time.
pub fn run(self: ResumeRerun, alloc: Allocator, call: types.ToolCall) !?tool_dispatch.ToolResult {
const provider = hostToolProvider(self.ctx.state) orelse return null;
var arena_state = std.heap.ArenaAllocator.init(alloc);
defer arena_state.deinit();
const acp_id = self.ctx.sendToolCallPending(arena_state.allocator(), call) catch |err| blk: {
debug_trace.logf("session", "event=libfx_resume_rerun_unpublished call={s} err={s}", .{ call.id, @errorName(err) });
break :blk "call_unknown";
};
const outcome = provider.call(alloc, call.name, call.id, call.arguments_json, self.max_result_bytes, self.cancel_flag) catch |err| switch (err) {
error.Cancelled => return null,
else => |other| return other,
};
const failed = switch (outcome) {
.success => false,
.failure => true,
.rich => |content| content.is_error,
};
const text: []const u8 = switch (outcome) {
.success, .failure => |value| value,
.rich => |content| content.text,
};
const shown = tool_call_presentation.toolUpdateContentText(failed, text);
const sent = if (failed)
self.ctx.sendToolCallErrorWithCommandResult(acp_id, shown, null)
else
self.ctx.sendToolCallCompletedWithCommandResult(acp_id, shown, null);
sent catch |err| {
debug_trace.logf("session", "event=libfx_resume_rerun_result_unpublished call={s} err={s}", .{ call.id, @errorName(err) });
};
return outcome;
}
};
fn hostToolProvider(state: *server.ServerState) ?tool_dispatch.HostToolProvider {
if (state.host_tools.tools.len == 0) return null;
if (comptime host_target.is_wasm) return js_host_tools.provider();
@@ -535,6 +594,7 @@ fn callHostTool(
raw_state: *anyopaque,
alloc: Allocator,
name: []const u8,
call_id: []const u8,
arguments_json: []const u8,
max_result_bytes: usize,
cancel_flag: ?*std.atomic.Value(bool),
@@ -563,6 +623,8 @@ fn callHostTool(
) catch return error.OutOfMemory;
params.writer.writeAll(",\"name\":") catch return error.OutOfMemory;
std.json.Stringify.value(name, .{}, &params.writer) catch return error.OutOfMemory;
params.writer.writeAll(",\"toolCallId\":") catch return error.OutOfMemory;
std.json.Stringify.value(call_id, .{}, &params.writer) catch return error.OutOfMemory;
params.writer.writeAll(",\"input\":") catch return error.OutOfMemory;
params.writer.writeAll(arguments_json) catch return error.OutOfMemory;
params.writer.writeByte('}') catch return error.OutOfMemory;
@@ -770,6 +832,14 @@ pub fn handlePrompt(
prior_image_catalog = merged;
}
}
// A journaled session's open turn carries the images it was given.
if (session.journal) |journal| {
if (journal.pending_resume) |pending| {
const merged = try session_runtime.merge_image_catalog_history_turn(alloc, prior_image_catalog, pending.interruptedTurn());
types.freeImageAttachmentSlice(alloc, prior_image_catalog);
prior_image_catalog = merged;
}
}
const next_image_id = (try image_attachments.calculate_next_image_id(prior_image_catalog)).next_id;
var prompt_input = parsePromptInputWithFirstImageId(
alloc,
@@ -845,7 +915,47 @@ pub fn handlePrompt(
var recovery_checkpoint: ?session_codec.RecoveryCheckpoint = null;
defer if (recovery_checkpoint) |*checkpoint| checkpoint.deinit(alloc);
if (prompt_input.continue_recovery) {
if (session.journal) |*journal| {
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
try journal.endUnplaced(alloc, state.alloc, session.session_id);
}
if (prompt_input.continue_recovery and session.journal != null) {
// A journaled libfx session resumes the turn its journal left open.
const journal = &session.journal.?;
const pending = journal.pending_resume orelse return .{
.rpc_error = .{
.code = ErrorCode.invalid_params,
.message = "No interrupted turn to resume",
},
};
// Inputs the crashed turn accepted and never placed go to the model
// with the resume, and its first progress places them.
const inputs = journal.takePendingInputs();
defer journal_events.freePendingInputs(state.alloc, inputs);
recovery_checkpoint = try libfx_journal.resumeCheckpoint(alloc, pending, inputs, journal.pending_resume_yielded);
const answered_step = journal.pending_resume_answered;
{
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
const ids = try alloc.alloc([]const u8, inputs.len);
defer alloc.free(ids);
for (inputs, ids) |input, *id| id.* = input.id;
try journal.notePlaced(state.alloc, ids);
}
journal.dropPendingResume(state.alloc);
// Calls the crash left running that the host asked to run again do
// so before the turn continues, and their results replace the answer
// that they may have partly run.
if (answered_step) |step| if (prompt_input.rerun_call_ids.len > 0) {
_ = try libfx_journal.rerunCalls(alloc, &recovery_checkpoint.?, step, ResumeRerun{
.ctx = &ctx,
.cancel_flag = &session.cancel_flag,
.max_result_bytes = session.max_tool_result_bytes,
.call_ids = prompt_input.rerun_call_ids,
});
};
} else if (prompt_input.continue_recovery) {
const writable = if (session.writable) |*value| value else return .{
.rpc_error = .{
.code = ErrorCode.invalid_params,
@@ -859,6 +969,12 @@ pub fn handlePrompt(
},
};
recovery_checkpoint = try checkpoint.dupe(alloc);
} else if (session.journal) |*journal| {
// A new prompt instead of a resume ends the open turn as interrupted.
if (journal.pending_resume) |pending| {
try persistAcpHistoryTurn(alloc, session, pending.interruptedTurn(), null);
journal.dropPendingResume(state.alloc);
}
} else if (session.writable) |*writable| {
if (writable.conversation_writer.turn_open) {
const checkpoint = writable.state.recovery_checkpoint orelse
@@ -867,6 +983,27 @@ pub fn handlePrompt(
}
}
if (session.journal) |*journal| {
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
// A host that stores inputs first already holds the prompt.
journal.barrier_next_progress = !journal.inputs_durable;
journal.startYieldClock(prompt_input.yield_at_ms);
// A new turn's first progress records the host's id for it; a
// resumed turn keeps the id it started with.
journal.nameNextTurn(prompt_input.turn_id);
// A follow-up's turn places it in its first progress, a barrier. The
// web core records its acceptance only now.
if (prompt_input.input_id) |input_id| {
if (!prompt_input.input_accepted) try journal.append(alloc, session.session_id, .{ .input_accepted = .{
.id = input_id,
.text = prompt_input.text,
.kind = .follow_up,
} });
try journal.placeFollowUp(state.alloc, input_id);
}
}
if (comptime !host_target.is_wasm) connectHostChannelServers(session);
var tool_projection = try state.cfg.mode_registry.buildModelToolProjection(alloc, activeToolSet(state), captured_mode, .{
@@ -990,12 +1127,31 @@ pub fn handlePrompt(
},
.outcome_allocator = alloc,
}, agent_config, job) catch |err| {
if (err == error.TurnYielded) {
// The journal keeps the turn open for a later resume; nothing
// ends it here.
prompt_input.retainImageSnapshots();
return .{ .stop_reason = .yielded };
}
if (err == error.NonInteractivePermissionRequired) {
ctx.stop_reason = .refused;
} else if (err == error.Cancelled and session.cancel_flag.load(.seq_cst)) {
// A cancel that arrived while a journal barrier was pending ends
// the turn as interrupted, as a cancel anywhere else does.
ctx.stop_reason = .cancelled;
// If the commit fails, the turn still ends below.
commitCancelledJournalTurn(alloc, state.alloc, session, &prompt_input) catch |commit_err| {
debug_trace.logf("session", "event=libfx_journal_interrupted_commit_failed err={s}", .{@errorName(commit_err)});
};
} else {
// The turn's own failure is the one to report.
endJournalTurn(alloc, state.alloc, session) catch |end_err| {
debug_trace.logf("session", "event=libfx_journal_turn_end_failed err={s} turn_err={s}", .{ @errorName(end_err), @errorName(err) });
};
return promptExecutionFailure(err);
}
};
try endJournalTurn(alloc, state.alloc, session);
prompt_input.retainImageSnapshots();
completeAcpTitleTask(state, session, alloc);
try sessions.sendActiveSessionInfoUpdate(state, alloc);
@@ -1303,9 +1459,25 @@ const PendingPromptImage = struct {
}
};
/// The latest time a host may give, in milliseconds since the epoch: the
/// largest integer a JavaScript number holds exactly.
const max_host_time_ms: i64 = (1 << 53) - 1;
const ParsedPromptInput = struct {
text: []u8,
continue_recovery: bool = false,
/// The follow-up this prompt runs, which its first progress places.
input_id: ?[]u8 = null,
/// The host's id for the turn this prompt starts.
turn_id: ?[]u8 = null,
/// Calls a resumed turn's crash left running that the host asked to run
/// again instead of leaving them to the model.
rerun_call_ids: [][]u8 = &.{},
/// Whether the host already recorded the follow-up as accepted.
input_accepted: bool = false,
/// When a journaled turn stops, in milliseconds since the epoch: its
/// first model request at or after it yields instead of going out.
yield_at_ms: ?i64 = null,
targets: []context_contract.ApplicableTarget = &.{},
omissions: []context_contract.ContextOmissionInput = &.{},
omission_summary: ?context_contract.ContextOmissionSummary = null,
@@ -1380,6 +1552,10 @@ const ParsedPromptInput = struct {
fn deinit(self: *ParsedPromptInput, alloc: Allocator) void {
alloc.free(self.text);
if (self.input_id) |id| alloc.free(id);
if (self.turn_id) |id| alloc.free(id);
for (self.rerun_call_ids) |id| alloc.free(id);
if (self.rerun_call_ids.len > 0) alloc.free(self.rerun_call_ids);
for (self.targets) |target| alloc.free(@constCast(target.path));
if (self.targets.len > 0) alloc.free(self.targets);
for (self.omissions) |omission| alloc.free(@constCast(omission.source));
@@ -1445,6 +1621,29 @@ fn takePromptImageAttachment(
return bytes;
}
/// At most this many calls of a resumed turn run again; a turn's response
/// makes no more calls than its batch limit.
const max_rerun_calls = 128;
const max_call_id_bytes = 256;
/// The call ids in `value`, a JSON array of strings, owned by the caller.
/// Anything else, and ids that are empty or too long, are left out.
fn ownedCallIds(alloc: Allocator, value: std.json.Value) Allocator.Error![][]u8 {
if (value != .array) return &.{};
var ids: std.ArrayList([]u8) = .empty;
errdefer {
for (ids.items) |id| alloc.free(id);
ids.deinit(alloc);
}
for (value.array.items) |item| {
if (ids.items.len == max_rerun_calls) break;
if (item != .string or item.string.len == 0 or item.string.len > max_call_id_bytes) continue;
try ids.append(alloc, try alloc.dupe(u8, item.string));
}
if (ids.items.len == 0) return &.{};
return ids.toOwnedSlice(alloc);
}
fn parsePromptInputWithFirstImageId(
alloc: Allocator,
params_json: []const u8,
@@ -1459,6 +1658,21 @@ fn parsePromptInputWithFirstImageId(
if (parsed.value != .object) return .{ .text = try alloc.dupe(u8, "") };
const continue_recovery = acp_types.fxMetaBool(parsed.value.object, "continueRecovery") orelse false;
const input_id: ?[]const u8 = if (acp_types.fxMetaField(parsed.value.object, "inputId")) |value|
if (value == .string and libfx_steering.validInputId(value.string)) value.string else null
else
null;
const input_accepted = acp_types.fxMetaBool(parsed.value.object, "inputAccepted") orelse false;
const turn_id: ?[]const u8 = if (acp_types.fxMetaField(parsed.value.object, "turnId")) |value|
if (value == .string and libfx_steering.validInputId(value.string)) value.string else null
else
null;
const rerun_value = acp_types.fxMetaField(parsed.value.object, "rerun");
// A JavaScript host gives a time it can hold as a number.
const yield_at_ms: ?i64 = if (acp_types.fxMetaInteger(parsed.value.object, "yieldAt")) |ms|
if (ms > 0 and ms <= max_host_time_ms) ms else null
else
null;
const prompt_arr = parsed.value.object.get("prompt") orelse
return .{ .text = try alloc.dupe(u8, ""), .continue_recovery = continue_recovery };
@@ -1585,8 +1799,13 @@ fn parsePromptInputWithFirstImageId(
var result = ParsedPromptInput{
.text = try alloc.dupe(u8, text_buf.items),
.continue_recovery = continue_recovery,
.input_accepted = input_accepted,
.yield_at_ms = yield_at_ms,
};
errdefer result.deinit(alloc);
if (input_id) |id| result.input_id = try alloc.dupe(u8, id);
if (turn_id) |id| result.turn_id = try alloc.dupe(u8, id);
if (rerun_value) |value| result.rerun_call_ids = try ownedCallIds(alloc, value);
result.targets = try targets.toOwnedSlice(alloc);
result.omissions = try omissions.toOwnedSlice(alloc);
result.pending_images = try pending_images.toOwnedSlice(alloc);
@@ -1679,13 +1898,23 @@ fn agentRuntimeDeps(ctx: *AcpContext) agent_runtime.AgentRuntimeDeps {
.publish_committed_file_handoff = publishCommittedFileHandoff,
.publish_deferred_tool_completion = publishDeferredToolCompletion,
.propagate_history_turn = propagateHistoryTurn,
.append_turn_piece = if (session.v2 != null) appendTurnPiece else null,
.append_turn_piece = if (session.v2 != null)
appendTurnPiece
else if (session.journal != null)
appendJournalToolIntent
else
null,
.commit_context_compaction = .{ .commit = commitContextCompaction },
.recovery_checkpoint = if (session.writable != null)
.{
.set = setRecoveryCheckpoint,
.clear = clearRecoveryCheckpoint,
}
else if (session.journal != null)
.{
.set = setJournalProgress,
.clear = clearJournalProgress,
}
else
null,
.propagate_grant = retainAcpGrant,
@@ -1725,8 +1954,13 @@ fn takeSteeringBoundary(
const ctx: *AcpContext = @ptrCast(@alignCast(raw_ctx));
const close_if_empty = kind == .finalizing;
if (comptime host_target.is_wasm) {
const messages = try js_host_steering.takeAll(arena);
if (messages.len > 0) return .{ .continue_turn = messages };
const taken = try js_host_steering.takeAll(arena);
if (taken.len > 0) {
try acceptHostSteering(ctx, taken);
const texts = try arena.alloc([]u8, taken.len);
for (taken, texts) |entry, *text| text.* = entry.text;
return .{ .continue_turn = texts };
}
if (close_if_empty) js_host_steering.close();
return if (kind == .cancelled) .interrupt else .none;
}
@@ -1738,6 +1972,7 @@ fn takeSteeringBoundary(
// the connection reader, which must never wait on work that needs it.
const drained = try server.takeSteering(ctx.state, arena, close_if_empty and kernel);
if (drained.texts.len == 0) return if (kind == .cancelled) .interrupt else .none;
try noteJournalPlacement(ctx, drained.input_ids);
// libfx replays steering when it is queued; ACP replays it on delivery.
if (!kernel) {
for (drained.texts, drained.request_ids) |text, request_id| {
@@ -1747,6 +1982,33 @@ fn takeSteeringBoundary(
return .{ .continue_turn = drained.texts };
}
/// A journaled session's next progress places the inputs a boundary took.
fn noteJournalPlacement(ctx: *AcpContext, input_ids: []const ?[]u8) !void {
const session = if (ctx.state.active_session) |*value| value else return;
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
const journal = if (session.journal) |*value| value else return;
for (input_ids) |maybe_id| {
const id = maybe_id orelse continue;
try journal.notePlaced(ctx.state.alloc, &.{id});
}
}
/// The web core takes steering from the host at a boundary, so a journaled
/// session records each input as accepted when it arrives, and the next
/// progress places it in the same barrier.
fn acceptHostSteering(ctx: *AcpContext, taken: []const js_host_steering.Taken) !void {
const session = if (ctx.state.active_session) |*value| value else return;
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
const journal = if (session.journal) |*value| value else return;
for (taken) |entry| {
const id = entry.id orelse continue;
try journal.append(ctx.alloc, session.session_id, .{ .input_accepted = .{ .id = id, .text = entry.text } });
try journal.notePlaced(ctx.state.alloc, &.{id});
}
}
fn publishSteeringReplay(ctx: *AcpContext, text: []const u8, request_id: ?jsonrpc.RequestId) !void {
var message_id_storage: acp_types.MessageIdBuffer = undefined;
const message_id = acp_types.generateMessageId(&message_id_storage);
@@ -2431,6 +2693,96 @@ fn appendTurnPiece(raw_ctx: *anyopaque, progress: agent_runtime.TurnProgress) !v
};
}
/// `AgentRuntimeDeps.append_turn_piece` for a journaled libfx session. The
/// orchestrator calls it before any call in a batch runs: the calls reach the
/// journal first, and a batch with a call fx runs itself waits until the host
/// holds them, so no such call starts without a durable intent. Finished
/// pieces travel in `turn_progress` instead.
fn appendJournalToolIntent(raw_ctx: *anyopaque, progress: agent_runtime.TurnProgress) !void {
if (progress.running_calls.len == 0) return;
const ctx: *AcpContext = @ptrCast(@alignCast(raw_ctx));
const session = if (ctx.state.active_session) |*value| value else return error.SessionPersistenceUnavailable;
{
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
const journal = if (session.journal) |*value| value else return error.SessionPersistenceUnavailable;
try journal.append(ctx.alloc, session.session_id, .{ .tool_intent = progress.running_calls });
}
const registry = ctx.toolRegistry();
for (progress.running_calls) |call| {
const tool = registry.lookup(call.name) orelse continue;
// Every call fx runs itself waits for its intent to be stored; a
// call the provider runs has no effect here to guard, and one the
// host declares idempotent may simply run again.
if (!tool.provider_executed and !tool.host_idempotent) return flushJournal(ctx.state, ctx.alloc, session.session_id);
}
}
/// Waits until the host holds every journal event sent so far.
fn flushJournal(state: *server.ServerState, alloc: Allocator, session_id: []const u8) !void {
if (comptime host_target.is_wasm) return js_host_journal.flush();
const outbound_id = (server.beginOutboundRequest(state, .journal) catch
return error.JournalFlushFailed) orelse return error.JournalFlushFailed;
var awaiting = true;
errdefer if (awaiting) {
server.cancelOutboundRequest(state, outbound_id);
if (server.awaitOutboundResponse(state, outbound_id, .journal)) |owned| {
var abandoned = owned;
abandoned.deinit(state.alloc);
}
};
var params: std.Io.Writer.Allocating = .init(alloc);
defer params.deinit();
try params.writer.writeAll("{\"sessionId\":");
try jsonrpc.writeJsonStr(session_id, &params.writer);
try params.writer.writeByte('}');
state.writer.writeRequest(alloc, .{ .integer = @intCast(outbound_id) }, "libfx/journal_flush", params.written()) catch
return error.JournalFlushFailed;
var response = server.awaitOutboundResponse(state, outbound_id, .journal) orelse return error.JournalFlushFailed;
awaiting = false;
defer response.deinit(state.alloc);
if (response.cancelled) return error.Cancelled;
if (response.error_json != null or response.result_json == null) return error.JournalFlushFailed;
}
/// Ends a journaled turn in the journal as it ended in the session. One that
/// failed left no history entry, so its progress is cleared: resuming is for
/// turns a stopped process left open. Inputs it took and never placed are
/// settled. `session_alloc` owns the journal's state.
fn endJournalTurn(alloc: Allocator, session_alloc: Allocator, session: *server.ActiveSessionState) !void {
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
const journal = if (session.journal) |*value| value else return;
if (journal.progress_open) {
debug_trace.logf("session", "event=libfx_journal_turn_closed reason=ended_without_commit", .{});
try journal.clearProgress(alloc, session.session_id);
}
try journal.endUnplaced(alloc, session_alloc, session.session_id);
if (journal.takeCancelledProgress()) |checkpoint| {
var unused = checkpoint;
unused.deinit(session_alloc);
}
}
/// Commits the turn whose barrier a cancel interrupted as interrupted, from
/// the progress that barrier held, to the session and its journal. A turn
/// with finished steps was already committed on its way out.
fn commitCancelledJournalTurn(alloc: Allocator, session_alloc: Allocator, session: *server.ActiveSessionState, prompt_input: *ParsedPromptInput) !void {
var cancelled = cancelled: {
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
const journal = if (session.journal) |*value| value else return;
var checkpoint = journal.takeCancelledProgress() orelse return;
if (!journal.progress_open) {
checkpoint.deinit(session_alloc);
return;
}
break :cancelled checkpoint;
};
defer cancelled.deinit(session_alloc);
try persistAcpHistoryTurn(alloc, session, cancelled.interruptedTurn(), prompt_input);
}
fn sessionChildCapability(session: *server.ActiveSessionState) ?*session_child_store.SessionChildCapability {
if (session.writable) |*writable| return writable.childCapability() catch null;
if (session.v2) |v2| return v2.childCapability() catch null;
@@ -2448,6 +2800,7 @@ fn persistAcpHistoryTurn(
var prepared = try session.session_rt.prepareHistoryEntry(alloc, turn);
var prepared_owned = true;
defer if (prepared_owned) types.freeHistoryTurn(alloc, prepared);
if (session.journal) |*journal| try journal.append(alloc, session.session_id, .{ .turn_committed = prepared });
if (comptime host_target.is_wasm) {
session.session_rt.commitPreparedHistoryEntry(alloc, prepared);
prepared_owned = false;
@@ -2564,6 +2917,7 @@ fn commitContextCompaction(
}
}
}
if (session.journal) |*journal| try journal.append(ctx.alloc, session.session_id, .{ .history_replaced = prepared });
session.session_rt.commitCompactedHistory(ctx.alloc, prepared);
prepared_owned = false;
}
@@ -2606,6 +2960,70 @@ fn clearRecoveryCheckpoint(raw_ctx: *anyopaque) !void {
);
}
/// `recovery_checkpoint.set` for a journaled libfx session: the open turn
/// so far reaches the host before each model request.
/// The first progress of a turn is a barrier: it holds the prompt, or a
/// resumed turn's answers for calls a crash left running.
///
/// When the host gave the turn a time to stop at and it has passed, the
/// progress before a model request is where the turn yields: the journal
/// records the yield, the host stores both, and the request never goes out.
fn setJournalProgress(raw_ctx: *anyopaque, checkpoint: session_codec.RecoveryCheckpoint) !void {
const ctx: *AcpContext = @ptrCast(@alignCast(raw_ctx));
const session = if (ctx.state.active_session) |*value| value else return error.SessionPersistenceUnavailable;
var yields = false;
const barrier = barrier: {
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
const journal = if (session.journal) |*value| value else return error.SessionPersistenceUnavailable;
try journal.appendProgress(ctx.alloc, ctx.state.alloc, session.session_id, checkpoint, session.model);
// Only the progress right before a request can yield; a compaction
// or a paused retry finishes where it is.
if (checkpoint.outstanding_reservation and checkpoint.cause != .compaction_prepared and
journal.yieldsAt(io_mod.milliTimestamp()))
{
try journal.append(ctx.alloc, session.session_id, .turn_yielded);
yields = true;
}
const barrier = journal.barrier_next_progress or yields;
journal.barrier_next_progress = false;
break :barrier barrier;
};
if (barrier) flushJournal(ctx.state, ctx.alloc, session.session_id) catch |err| {
// The turn commits from this progress as interrupted.
if (err == error.Cancelled) try keepCancelledProgress(ctx.state.alloc, session, checkpoint);
return err;
};
if (!yields) return;
{
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
const journal = if (session.journal) |*value| value else return error.SessionPersistenceUnavailable;
try journal.keepYielded(ctx.state.alloc, checkpoint);
}
debug_trace.logf("session", "event=libfx_journal_turn_yielded session={s}", .{session.session_id});
return error.TurnYielded;
}
fn keepCancelledProgress(session_alloc: Allocator, session: *server.ActiveSessionState, checkpoint: session_codec.RecoveryCheckpoint) !void {
var owned = try checkpoint.dupe(session_alloc);
errdefer owned.deinit(session_alloc);
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
const journal = if (session.journal) |*value| value else return error.SessionPersistenceUnavailable;
if (journal.cancelled_progress) |*old| old.deinit(session_alloc);
journal.cancelled_progress = owned;
}
fn clearJournalProgress(raw_ctx: *anyopaque) !void {
const ctx: *AcpContext = @ptrCast(@alignCast(raw_ctx));
const session = if (ctx.state.active_session) |*value| value else return error.SessionPersistenceUnavailable;
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
const journal = if (session.journal) |*value| value else return error.SessionPersistenceUnavailable;
try journal.clearProgress(ctx.alloc, session.session_id);
}
/// Stores grants on the active ACP session without persisting them.
fn retainAcpGrant(raw_ctx: *anyopaque, tool_name: []const u8, target_path: []const u8) !void {
const ctx: *AcpContext = @ptrCast(@alignCast(raw_ctx));
@@ -3596,6 +4014,19 @@ test "parsePromptInput accepts explicit recovery continuation metadata" {
defer result.deinit(alloc);
try std.testing.expectEqual(@as(usize, 0), result.text.len);
try std.testing.expect(result.continue_recovery);
try std.testing.expectEqual(@as(usize, 0), result.rerun_call_ids.len);
}
test "parsePromptInput keeps the call ids a resume runs again and drops the rest" {
const alloc = std.testing.allocator;
const params =
"{\"sessionId\":\"s1\",\"prompt\":[],\"_meta\":{\"fx\":{\"continueRecovery\":true,\"rerun\":[\"call-1\",7,\"\",\"call-2\"]}}}";
var result = try parsePromptInput(alloc, params);
defer result.deinit(alloc);
try std.testing.expect(result.continue_recovery);
try std.testing.expectEqual(@as(usize, 2), result.rerun_call_ids.len);
try std.testing.expectEqualStrings("call-1", result.rerun_call_ids[0]);
try std.testing.expectEqualStrings("call-2", result.rerun_call_ids[1]);
}
test "parsePromptInput accepts image blocks as owned pending images" {
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more