mirror of
https://github.com/vercel-labs/fx.git
synced 2026-10-28 10:15:17 +08:00
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:
116 files changed
+13255
-351
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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")) } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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";
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Generated
+659
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"libfx": "0.0.11",
|
||||
"checkpoint": "RlhDUAEAAAAhBgAAAItE1RgiDQqoGoFp9lFgk70UKxT2XUFui5ul8zr/jnl7Imhpc3RvcnkiOlt7ImtpbmQiOiJhc3Npc3RhbnQiLCJ1c2VyIjp7InRleHQiOiJyZW1lbWJlciBwbHVtcyIsImltYWdlcyI6W119LCJhc3Npc3RhbnQiOiJhbnN3ZXIgdG8gcmVtZW1iZXIgcGx1bXMiLCJwcm92aWRlcl9yZXBsYXkiOm51bGwsImV4ZWN1dGlvbiI6eyJzY2hlbWFfdmVyc2lvbiI6MTAsInRvb2xfc3RlcHMiOltdLCJmaWxlcyI6W10sInN0ZWVyaW5nIjpbXSwidHVybl9zdW1tYXJ5Ijp7InN0YXJ0ZWRfYXRfbXMiOjE3OTA5MTM4NjAwNDAsImNvbXBsZXRlZF9hdF9tcyI6MTc5MDkxMzg2MDA1NywidGhpbmtpbmdfZHVyYXRpb25fbXMiOjMsInR1cm5fZHVyYXRpb25fbXMiOjE3LCJ0b2tlbl9wcm9ncmVzcyI6eyJpbnB1dF90b2tlbnMiOjQsIm91dHB1dF90b2tlbnMiOjEsImlucHV0X2V4YWN0IjpmYWxzZSwib3V0cHV0X2V4YWN0Ijp0cnVlfX19fSx7ImtpbmQiOiJhc3Npc3RhbnQiLCJ1c2VyIjp7InRleHQiOiJ1c2UgdGhlIHRvb2wiLCJpbWFnZXMiOltdfSwiYXNzaXN0YW50IjoidG9vbCBzYWlkIGJldGEiLCJwcm92aWRlcl9yZXBsYXkiOm51bGwsImV4ZWN1dGlvbiI6eyJzY2hlbWFfdmVyc2lvbiI6MTAsInRvb2xfc3RlcHMiOlt7ImFzc2lzdGFudCI6bnVsbCwicHJvdmlkZXJfcmVwbGF5IjpudWxsLCJ0b29sX2NhbGxzIjpbeyJpZCI6ImNhbGwtMSIsIm5hbWUiOiJsb29rdXAiLCJhcmd1bWVudHNfanNvbiI6IntcImtleVwiOlwiYWxwaGFcIn0iLCJwcm92aWRlcl9yZXN1bHQiOm51bGx9XSwidG9vbF9yZXN1bHRzIjpbeyJ0b29sX2NhbGxfaWQiOiJjYWxsLTEiLCJ0b29sX25hbWUiOiJsb29rdXAiLCJzdGF0dXMiOiJzdWNjZXNzIiwib3V0cHV0IjoidmFsdWUgb2YgYWxwaGEgaXMgYmV0YSIsIm91dHB1dF9oYW5kbGUiOm51bGwsInByZXZpZXciOm51bGwsIm91dHB1dF9ieXRlcyI6MjIsInN0b3JlZF9vdXRwdXRfYnl0ZXMiOjIyLCJ0cnVuY2F0ZWQiOmZhbHNlLCJwcm92aWRlcl9uYXRpdmUiOmZhbHNlLCJyZXZpZXdfZmVlZGJhY2siOmZhbHNlLCJjcmVhdGVkX2F0X21zIjoxNzkwOTEzODYwMDYxLCJwZXJtaXNzaW9uX2ZlZWRiYWNrIjpbXSwiY29tbWl0dGVkX2ZpbGVfcHJlc2VudGF0aW9uIjpudWxsLCJjb21tYW5kX291dHB1dF9yZXBsYXkiOm51bGwsImNvbW1hbmRfcHJvY2Vzc19wcmVzZW50YXRpb24iOm51bGwsInRlcm1pbmFsX2FjdGlvbl9wcmVzZW50YXRpb24iOm51bGx9XX1dLCJmaWxlcyI6W10sInN0ZWVyaW5nIjpbXSwidHVybl9zdW1tYXJ5Ijp7InN0YXJ0ZWRfYXRfbXMiOjE3OTA5MTM4NjAwNTcsImNvbXBsZXRlZF9hdF9tcyI6MTc5MDkxMzg2MDA2MSwidGhpbmtpbmdfZHVyYXRpb25fbXMiOjQsInR1cm5fZHVyYXRpb25fbXMiOjQsInRva2VuX3Byb2dyZXNzIjp7ImlucHV0X3Rva2VucyI6Mywib3V0cHV0X3Rva2VucyI6MiwiaW5wdXRfZXhhY3QiOmZhbHNlLCJvdXRwdXRfZXhhY3QiOnRydWV9fX19XSwidXNhZ2UiOnsiaW5wdXRfdG9rZW5zIjoyLCJvdXRwdXRfdG9rZW5zIjoyLCJjYWNoZV9yZWFkX3Rva2VucyI6bnVsbCwiY2FjaGVfd3JpdGVfdG9rZW5zIjpudWxsLCJyZWFzb25pbmdfdG9rZW5zIjpudWxsfX0="
|
||||
}
|
||||
+224
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
graphs/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
=============================================================================
|
||||
@@ -0,0 +1,8 @@
|
||||
CONSTANTS
|
||||
Workers = {"A", "B"}
|
||||
Tool = "send"
|
||||
MaxEpoch = 3
|
||||
MaxFreezes = 0
|
||||
SelfStop = FALSE
|
||||
SPECIFICATION Spec
|
||||
INVARIANT TwoActiveNeedsFreeze
|
||||
@@ -0,0 +1,8 @@
|
||||
CONSTANTS
|
||||
Workers = {"A", "B"}
|
||||
Tool = "send"
|
||||
MaxEpoch = 3
|
||||
MaxFreezes = 2
|
||||
SelfStop = TRUE
|
||||
SPECIFICATION Spec
|
||||
INVARIANT NoStaleShown
|
||||
@@ -0,0 +1,8 @@
|
||||
CONSTANTS
|
||||
Workers = {"A", "B"}
|
||||
Tool = "send"
|
||||
MaxEpoch = 3
|
||||
MaxFreezes = 2
|
||||
SelfStop = TRUE
|
||||
SPECIFICATION Spec
|
||||
INVARIANT NotTwoActive
|
||||
@@ -0,0 +1,8 @@
|
||||
CONSTANTS
|
||||
Workers = {"A", "B"}
|
||||
Tool = "send"
|
||||
MaxEpoch = 3
|
||||
MaxFreezes = 2
|
||||
SelfStop = TRUE
|
||||
SPECIFICATION Spec
|
||||
INVARIANT UiInOrder
|
||||
Executable
+47
@@ -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"
|
||||
@@ -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);
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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)),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,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" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
@@ -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")) } : {}),
|
||||
|
||||
@@ -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,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
@@ -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);
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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"]]) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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")) } : {}),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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, ¶ms.writer);
|
||||
try params.writer.writeAll(",\"events\":[");
|
||||
const next = try self.cursor.write(¶ms.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
@@ -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
@@ -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, .{}, ¶ms.writer) catch return error.OutOfMemory;
|
||||
params.writer.writeAll(",\"toolCallId\":") catch return error.OutOfMemory;
|
||||
std.json.Stringify.value(call_id, .{}, ¶ms.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, ¶ms.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
Reference in new issue
Block a user