Reuse resolved model metadata in libfx

Resolve selected capabilities during configuration and carry a scoped,
expiring descriptor into fresh agents. Preserve kernel validation without
per-message catalog discovery.

Complete consumed native HTTP responses under bounded cleanup instead of
aborting successful streams before EOF.
This commit is contained in:
Pranit committed 2026-10-02 13:08:37 -04:00
1 parent 14893f6460
commit b031612036
41 files changed
+9659 -87

No files matched your search

+25
View File
@@ -619,6 +619,31 @@ node benchmarks/libfx/bench-competitive.mjs --server /tmp/libfx-bench-server --p
Build the SDK artifacts and install the pinned Pi package first, as shown in
`.github/workflows/bench.yml`. Raw per-prompt samples remain in the output directory.
### Resolved-model cold processes
`benchmarks/libfx/bench-resolved-model.mjs` compares a clean baseline worktree
with the current SDK. Each sample creates a separate Node process and restores
the same checkpoint. Three unmeasured warmups per mode warm OS caches, not an
agent or catalog cache. Paired alternating samples retain stage timings, request
counts, failures, asset hashes, and raw JSONL. Use at least 30 samples for p95;
p99 is reported only at 100 samples.
Build native and core Wasm artifacts in both worktrees first. For a deterministic
run, set `BASELINE` to the untouched checkout and `OUT` to an absolute new or
empty directory:
```sh
node benchmarks/libfx/bench-resolved-model.mjs --baseline "$BASELINE" --candidate "$PWD" --backend native --samples 50 --model benchmark/model --effort high --fast --output "$OUT"
```
Use `--backend wasm` to measure the JSPI surface. An optional
`--catalog-delay-ms` sets a synthetic delay. Live runs require `--live`, an
explicit sample count, a real model ID, and equal nonempty
`AI_GATEWAY_API_KEY` and `AI_GATEWAY_TEST_API_KEY`. Select the test billing lane
before launching the command. Live samples include catalog and provider latency;
local fresh processes are not measurements of deployed Vercel function startup.
The output never includes credential values.
## Before Marking a PR Ready
Minimum checklist:
+4
View File
@@ -63,6 +63,10 @@ fx preserves those tmux views while resizing, including when the switcher zooms
Visit [fx.sh/docs](https://fx.sh/docs) for the full manual: sessions, models, custom model connections, permissions, configuration, skills, MCP, subagents, embedding, and the complete CLI and slash command references. Agents can read any page as Markdown by appending `.md` to its URL, or fetch [llms-full.txt](https://fx.sh/llms-full.txt) for everything in one file.
Server-side libfx hosts can resolve model capabilities during setup and pass
that metadata to fresh agents without a catalog request on each message. See
[Resolve before handling messages](sdk/README.md#resolve-before-handling-messages).
## Custom model connections
Add named connections for any OpenAI Chat Completions endpoint, including local servers such as Ollama and gateways such as OpenRouter, in `~/.fx/settings.json`, then select one for the profile or a single invocation:
+545
View File
@@ -0,0 +1,545 @@
#!/usr/bin/env node
import { strict as assert } from "node:assert";
import { execFile, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { arch, cpus, platform, release } from "node:os";
import { isAbsolute, resolve } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseArgs, promisify } from "node:util";
const script = fileURLToPath(import.meta.url);
const run = promisify(execFile);
const prompt = "Reply with exactly OK.";
const catalogUrl = "https://ai-gateway.vercel.sh/coding-agent/v1/models";
const chatUrl = "https://ai-gateway.vercel.sh/v4/ai/language-model";
const fakeKey = "resolved-model-benchmark-fixture-key";
const warmups = 3;
const timeoutMs = 120_000;
const outputLimit = 64 * 1024;
const metrics = [
"launch_to_first_text_ms", "module_import_ms", "create_to_post_ms",
"prompt_to_post_ms", "post_to_first_text_ms", "create_to_first_text_ms",
];
const frames = [
{ type: "text-delta", id: "answer", delta: "O" },
{ type: "text-delta", id: "answer", delta: "K" },
{ type: "finish", finishReason: { unified: "stop", raw: "stop" },
usage: { inputTokens: { total: 3 }, outputTokens: { total: 2 } } },
].map(frame => `data: ${JSON.stringify(frame)}\n\n`).concat("data: [DONE]\n\n");
let interrupted = false;
const hash = value => createHash("sha256").update(value).digest("hex");
function canonical(value) {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === "object") {
return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
}
return value;
}
const jsonHash = value => hash(JSON.stringify(canonical(value)));
function plainModel(descriptor) {
const { metadata, ...model } = descriptor;
return model;
}
function apiKey(live) {
if (!live) return fakeKey;
const key = process.env.AI_GATEWAY_API_KEY;
const testKey = process.env.AI_GATEWAY_TEST_API_KEY;
if (!key?.trim() || !testKey?.trim() || key !== testKey) {
throw new Error("Live runs require matching nonempty AI_GATEWAY_API_KEY and AI_GATEWAY_TEST_API_KEY; invoke through aig test");
}
return key;
}
function safeError(error) {
let message = String(error?.message ?? "Unknown benchmark error");
for (const secret of [process.env.AI_GATEWAY_API_KEY, process.env.AI_GATEWAY_TEST_API_KEY, fakeKey, prompt]) {
if (secret) message = message.split(secret).join("[redacted]");
}
return {
name: /^[A-Za-z]+$/.test(error?.name) ? error.name : "Error",
code: /^[A-Za-z0-9_]+$/.test(error?.code) ? error.code : null,
message: message.slice(0, 2048),
};
}
function keyFree(value) {
const text = JSON.stringify(value);
for (const secret of [process.env.AI_GATEWAY_API_KEY, process.env.AI_GATEWAY_TEST_API_KEY, fakeKey]) {
assert.ok(!secret || !text.includes(secret), "Artifact contains a credential");
}
return text;
}
const saveJson = (path, value) => writeFile(path, `${keyFree(value)}\n`, { mode: 0o600 });
function argumentsForRun() {
const { values } = parseArgs({ options: {
baseline: { type: "string" }, candidate: { type: "string" },
backend: { type: "string" }, samples: { type: "string" },
live: { type: "boolean", default: false }, model: { type: "string" },
output: { type: "string" }, effort: { type: "string" }, fast: { type: "boolean" },
"catalog-delay-ms": { type: "string", default: "0" },
worker: { type: "boolean", default: false }, repo: { type: "string" }, mode: { type: "string" },
} });
assert.ok(["native", "wasm"].includes(values.backend), "--backend native|wasm is required");
const delay = Number(values["catalog-delay-ms"]);
assert.ok(Number.isSafeInteger(delay) && delay >= 0 && delay <= 30_000, "--catalog-delay-ms must be 0..30000");
assert.ok(!values.live || delay === 0, "--catalog-delay-ms is a synthetic fixture option only");
apiKey(values.live);
if (values.worker) {
assert.ok(values.repo && ["baseline", "resolved", "checkpoint"].includes(values.mode), "Worker requires --repo and --mode baseline|resolved|checkpoint");
assert.ok(values.mode !== "checkpoint" || !values.live, "Checkpoint preparation must be synthetic");
return { ...values, repo: resolve(values.repo), delay };
}
assert.ok(values.baseline && values.candidate && values.model, "--baseline, --candidate, and --model are required");
assert.ok(values.output && isAbsolute(values.output), "--output must be an absolute directory");
assert.ok(!values.live || values.samples !== undefined, "Live runs require explicit --samples (30 or more)");
const samples = Number(values.samples ?? 50);
assert.ok(Number.isSafeInteger(samples) && samples >= 30 && samples <= 1000, "--samples must be 30..1000 for p50/p95");
const model = { id: values.model, ...(values.effort === undefined ? {} : { effort: values.effort }),
...(values.fast === undefined ? {} : { fast: values.fast }) };
keyFree(model);
return { ...values, baseline: resolve(values.baseline), candidate: resolve(values.candidate),
output: resolve(values.output), samples, delay, model };
}
function syntheticCatalog(model) {
return { object: "list", data: [{
id: model.id, type: "language", tags: ["reasoning", "tool-use"],
context_window: 131_072, max_tokens: 1234,
reasoning_options: [{ type: "effort", values: [...new Set(["low", "medium", "high", "max", model.effort].filter(Boolean))] }],
fast_options: [{ type: "toggle" }],
}] };
}
function backendOptions(repo, backend) {
return backend === "native"
? { backend, nativeAddon: resolve(repo, "zig-out/lib/libfx.node") }
: { backend, wasm: resolve(repo, "zig-out/bin/fx-core.wasm") };
}
function requestInfo(input, init) {
const url = String(input?.url ?? input);
const method = String(init.method ?? input?.method ?? "GET").toUpperCase();
return { url, method };
}
function gateway({ live, model, catalog, delay, userTurns }, state) {
const nativeFetch = globalThis.fetch.bind(globalThis);
const key = apiKey(live);
return async (input, init = {}) => {
const started = performance.now();
const { url, method } = requestInfo(input, init);
const headers = new Headers(init.headers ?? input?.headers);
assert.ok(headers.get("authorization") === `Bearer ${key}`, "Unexpected Gateway credential");
if (method === "GET") {
const row = { endpoint: url, method, duration_ms: null, status: null };
state.gets.push(row);
assert.equal(url, catalogUrl, "Unexpected catalog endpoint");
try {
if (!live && delay) await new Promise(done => setTimeout(done, delay));
const response = live ? await nativeFetch(input, init) : Response.json(catalog);
row.status = response.status;
return response;
} finally { row.duration_ms = performance.now() - started; }
}
// Keep every attempted POST, including retries or requests rejected by the oracle.
state.postAt ??= started;
const row = { endpoint: url, method, status: null };
state.posts.push(row);
assert.equal(method, "POST", "Unexpected Gateway method");
assert.equal(url, chatUrl, "Unexpected chat endpoint");
const text = typeof init.body === "string" ? init.body : new TextDecoder().decode(init.body);
const body = JSON.parse(text);
const users = body.prompt?.filter(message => message.role === "user") ?? [];
const lastUser = users.at(-1)?.content;
const userText = typeof lastUser === "string" ? lastUser : lastUser?.map(part => part.text ?? "").join("");
assert.equal(userText, prompt, "Request omitted the benchmark prompt");
if (userTurns !== undefined) assert.equal(users.length, userTurns, "Checkpoint history was not restored exactly once");
assert.ok(!body.tools?.length, "Benchmark must not advertise tools");
assert.equal(headers.get("ai-language-model-id"), model.id, "Request selected a different model");
assert.equal(headers.get("ai-language-model-streaming"), "true", "Expected a streaming POST");
assert.ok(!text.includes(key), "Request body contains a credential");
Object.assign(row, {
model: headers.get("ai-language-model-id"),
specification: headers.get("ai-language-model-specification-version"),
streaming: headers.get("ai-language-model-streaming"),
protocol: headers.get("ai-gateway-protocol-version"),
content_type: headers.get("content-type"),
body_sha256: jsonHash(body), request_bytes: Buffer.byteLength(text),
max_output_tokens: body.maxOutputTokens ?? null, reasoning: body.reasoning ?? null,
fast: body.providerOptions?.gateway?.speed ?? null,
user_turns: users.length,
});
let response;
if (live) response = await nativeFetch(input, init);
else {
let frame = 0;
response = new Response(new ReadableStream({
pull(controller) {
if (frame === frames.length) return controller.close();
controller.enqueue(new TextEncoder().encode(frames[frame++]));
},
}), { headers: { "content-type": "text/event-stream" } });
}
row.status = response.status;
return response;
};
}
function timings(state) {
const between = (end, start) => end === null || start === null ? null : end - start;
return {
module_import_ms: state.importMs,
create_to_post_ms: between(state.postAt, state.createAt),
prompt_to_post_ms: between(state.postAt, state.promptAt),
post_to_first_text_ms: between(state.firstTextAt, state.postAt),
create_to_first_text_ms: between(state.firstTextAt, state.createAt),
};
}
function transport(state) {
return { get_count: state.gets.length, get_duration_ms: state.gets.reduce((n, get) => n + (get.duration_ms ?? 0), 0),
gets: state.gets, post_count: state.posts.length, posts: state.posts };
}
function tokenUsage(usage) {
return Object.fromEntries(Object.entries(usage ?? {}).filter(([, value]) => Number.isFinite(value)));
}
async function worker(options) {
const state = { importMs: null, createAt: null, promptAt: null, postAt: null, firstTextAt: null, gets: [], posts: [] };
let agent;
let reply = "";
let result;
let checkpoint;
let error;
let stage = "stdin";
try {
let bytes = 0;
const chunks = [];
for await (const chunk of process.stdin) {
bytes += chunk.length;
assert.ok(bytes <= 8 * 1024 * 1024, "Worker fixture exceeds 8 MiB");
chunks.push(chunk);
}
const fixture = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const descriptor = fixture.descriptor;
const model = options.mode === "resolved" ? descriptor : plainModel(descriptor);
const catalog = fixture.catalog ?? syntheticCatalog(model);
stage = "module-import";
const importedAt = performance.now();
const sdk = await import(pathToFileURL(resolve(options.repo, "sdk/node.js")).href);
state.importMs = performance.now() - importedAt;
const fetch = gateway({ live: options.live, model, catalog, delay: options.delay,
userTurns: fixture.checkpoint ? 2 : 1 }, state);
if (!options.live) globalThis.fetch = async () => { throw new Error("Uninjected synthetic fetch"); };
const agentOptions = {
...backendOptions(options.repo, options.backend), apiKey: apiKey(options.live), model,
gatewayChatUrl: chatUrl, fetch, instructions: prompt, tools: [],
...(fixture.checkpoint ? { checkpoint: Buffer.from(fixture.checkpoint, "base64") } : {}),
};
stage = "create";
state.createAt = performance.now();
agent = await sdk.createFxAgent(agentOptions);
stage = "prompt";
state.promptAt = performance.now();
const turn = agent.prompt(prompt);
for await (const event of turn) {
assert.ok(!event.type.startsWith("tool_"), "Benchmark attempted tool execution");
if (event.type !== "text_delta" || !event.delta) continue;
if (state.firstTextAt === null) {
state.firstTextAt = performance.now();
// The parent timestamps this line as it arrives, not when the child exits.
process.stdout.write(`${keyFree({ type: "first-text", timings_ms: timings(state), ...transport(state) })}\n`);
}
reply += event.delta;
assert.ok(Buffer.byteLength(reply) <= outputLimit, "Reply exceeds benchmark limit");
}
result = await turn.result;
assert.ok(reply.trim(), "Empty reply");
assert.equal(result.stopReason, "end_turn", "Refused or incomplete result");
assert.equal(state.posts.length, 1, "Expected exactly one chat POST");
if (options.mode === "resolved") assert.equal(state.gets.length, 0, "Resolved model performed a catalog GET");
if (!options.live) assert.equal(reply, "OK", "Synthetic text sentinel was lost");
if (options.mode === "checkpoint") {
stage = "checkpoint";
if (typeof agent.checkpoint === "function") {
const bytes = Buffer.from(await agent.checkpoint());
assert.equal(bytes.subarray(0, 4).toString(), "FXCP", "Invalid checkpoint fixture");
for (const secret of [fakeKey, process.env.AI_GATEWAY_API_KEY, process.env.AI_GATEWAY_TEST_API_KEY]) {
assert.ok(!secret || !bytes.includes(Buffer.from(secret)), "Checkpoint contains a credential");
}
checkpoint = bytes.toString("base64");
}
}
stage = "close";
} catch (caught) { error = safeError(caught); }
finally {
try { await agent?.close(); }
catch (caught) { error ??= safeError(caught); stage = "close"; }
}
const row = {
type: "result", status: error ? "error" : "ok", stage, error: error ?? null,
timings_ms: timings(state), ...transport(state),
stop_reason: result?.stopReason ?? null, usage: tokenUsage(result?.usage), reply_bytes: Buffer.byteLength(reply),
...(options.mode === "checkpoint" ? { checkpoint: checkpoint ?? null } : {}),
};
process.stdout.write(`${keyFree(row)}\n`);
process.exitCode = error ? 1 : 0;
}
async function execute(options, repo, mode, fixture) {
const input = keyFree(fixture);
const args = [...(options.backend === "wasm" ? ["--experimental-wasm-jspi"] : []), script,
"--worker", "--repo", repo, "--mode", mode, "--backend", options.backend,
"--catalog-delay-ms", String(mode === "checkpoint" ? 0 : options.delay),
...(options.live && mode !== "checkpoint" ? ["--live"] : [])];
const launchedAt = performance.now();
const child = spawn(process.execPath, args, { cwd: repo, env: process.env, stdio: ["pipe", "pipe", "pipe"] });
let firstText;
let received;
let markerAt = null;
let fault;
let timedOut = false;
let stdoutBytes = 0;
let stderrBytes = 0;
let killTimer;
const terminate = () => {
child.kill("SIGTERM");
killTimer ??= setTimeout(() => child.kill("SIGKILL"), 1000);
};
const signal = () => { interrupted = true; terminate(); };
process.once("SIGINT", signal);
process.once("SIGTERM", signal);
const timer = setTimeout(() => { timedOut = true; terminate(); }, timeoutMs);
const lines = createInterface({ input: child.stdout });
lines.on("line", line => {
const observedAt = performance.now();
try {
const message = JSON.parse(line);
if (message.type === "first-text") {
assert.equal(markerAt, null, "Duplicate first-text marker");
markerAt = observedAt;
firstText = message;
} else if (message.type === "result") {
assert.equal(received, undefined, "Duplicate worker result");
received = message;
} else throw new Error("Unexpected worker stdout");
} catch (error) { fault ??= safeError(error); terminate(); }
});
child.stdout.on("data", chunk => {
stdoutBytes += chunk.length;
if (stdoutBytes > outputLimit) { fault ??= safeError(new Error("Worker stdout exceeds limit")); terminate(); }
});
// Drain diagnostics without storing raw stderr, which can contain provider text.
child.stderr.on("data", chunk => {
stderrBytes += chunk.length;
if (stderrBytes > outputLimit) { fault ??= safeError(new Error("Worker stderr exceeds limit")); terminate(); }
});
child.stdin.on("error", error => { fault ??= safeError(error); });
child.stdin.end(input);
const exit = await new Promise(done => {
child.once("error", error => { fault ??= safeError(error); });
child.once("close", (code, signal) => done({ code, signal }));
});
clearTimeout(timer);
clearTimeout(killTimer);
process.removeListener("SIGINT", signal);
process.removeListener("SIGTERM", signal);
lines.close();
const row = received ?? firstText ?? { timings_ms: {}, get_count: 0, post_count: 0, gets: [], posts: [] };
const { type, ...data } = row;
const error = fault ?? received?.error ?? (timedOut ? safeError(new Error("Worker timed out")) :
exit.code !== 0 || !received ? safeError(new Error("Worker exited without a successful result")) :
markerAt === null || stderrBytes ? safeError(new Error("Missing first-text marker or unexpected stderr")) : null);
return { ...data, mode, repo, status: timedOut ? "timeout" : error ? "error" : data.status,
error, exit_code: exit.code, signal: exit.signal, stdout_bytes: stdoutBytes, stderr_bytes: stderrBytes,
timings_ms: { ...data.timings_ms, launch_to_first_text_ms: markerAt === null ? null : markerAt - launchedAt } };
}
async function repoEvidence(repo, backend) {
const git = async args => (await run("git", ["-C", repo, ...args], { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 })).stdout;
const sha = (await git(["rev-parse", "HEAD"])).toString().trim();
const status = (await git(["status", "--porcelain=v1", "-z"])).toString().split("\0").filter(Boolean);
const dirty = createHash("sha256").update(await git(["diff", "--binary", "HEAD", "--", "."]));
const untracked = (await git(["ls-files", "--others", "--exclude-standard", "-z"])).toString().split("\0").filter(Boolean).sort();
for (const path of untracked) dirty.update(path).update("\0").update(await readFile(resolve(repo, path)));
const assetPath = backend === "native" ? "zig-out/lib/libfx.node" : "zig-out/bin/fx-core.wasm";
const asset = await readFile(resolve(repo, assetPath));
const assetStat = await stat(resolve(repo, assetPath));
const build = await readFile(resolve(repo, "build.zig"), "utf8");
const owner = build.split(backend === "native" ? "fn addNapiArtifact(" : "fn addWasmArtifact(")[1]?.split("\nfn ")[0];
const mode = owner?.match(/\.optimize\s*=\s*\.(\w+)/)?.[1];
assert.equal(mode, backend === "native" ? "ReleaseSafe" : "ReleaseSmall", "Unexpected backend build mode");
return {
repo, target_sha: sha, dirty_status: status, dirty_diff_sha256: dirty.digest("hex"),
build_mode: mode, build_mode_evidence: "Hardcoded build.zig backend definition; caller must freshly build artifacts",
build_zig_sha256: hash(build), asset: { path: assetPath, bytes: asset.length, sha256: hash(asset), modified_at: assetStat.mtime.toISOString() },
sdk: Object.fromEntries(await Promise.all(["sdk/node.js", "sdk/fx-sdk.js"].map(async path => [path, hash(await readFile(resolve(repo, path)))]))),
};
}
function distribution(values) {
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
if (!sorted.length) return { count: 0 };
const percentile = fraction => sorted[Math.ceil(sorted.length * fraction) - 1];
return { count: sorted.length, p50: percentile(0.5), p95: percentile(0.95),
...(sorted.length >= 100 ? { p99: percentile(0.99) } : {}), min: sorted[0], max: sorted.at(-1) };
}
function countHistogram(values) {
const counts = {};
for (const value of values) counts[value ?? "missing"] = (counts[value ?? "missing"] ?? 0) + 1;
return counts;
}
function summary(rows, samples) {
const measured = rows.filter(row => row.phase === "measured");
const modes = Object.fromEntries(["baseline", "resolved"].map(mode => {
const attempts = measured.filter(row => row.mode === mode);
return [mode, {
planned: samples, attempted: attempts.length, ok: attempts.filter(row => row.status === "ok").length,
errors: attempts.filter(row => row.status === "error").length, timeouts: attempts.filter(row => row.status === "timeout").length,
timings_ms: Object.fromEntries(metrics.map(metric => [metric, {
...distribution(attempts.map(row => row.timings_ms?.[metric])),
missing: attempts.filter(row => !Number.isFinite(row.timings_ms?.[metric])).length,
failed_with_value: attempts.filter(row => row.status !== "ok" && Number.isFinite(row.timings_ms?.[metric])).length,
}])),
get_counts: countHistogram(attempts.map(row => row.get_count)), post_counts: countHistogram(attempts.map(row => row.post_count)),
get_duration_ms: distribution(attempts.map(row => row.get_duration_ms)),
}];
}));
const pairs = Array.from({ length: samples }, (_, pair) => measured.filter(row => row.pair === pair));
const valid = pairs.filter(pair => pair.length === 2 && pair.every(row => row.status === "ok"));
return {
modes, paired_valid: valid.length, paired_missing_or_failed: samples - valid.length,
paired_delta_ms_candidate_minus_baseline: Object.fromEntries(metrics.map(metric => [metric, distribution(valid.map(pair =>
pair.find(row => row.mode === "resolved").timings_ms[metric] - pair.find(row => row.mode === "baseline").timings_ms[metric]))])),
warmup_attempts: rows.filter(row => row.phase === "warmup").length,
warmup_failures: rows.filter(row => row.phase === "warmup" && row.status !== "ok").length,
statistics: "Nearest-rank percentiles; p99 only at count >= 100. All available failed-attempt timings are included and counted; missing values remain explicit. Paired deltas use successful equivalent pairs only.",
};
}
function assertPair(pair) {
if (pair.length !== 2 || pair.some(row => row.status !== "ok")) return;
try {
const signature = row => row.posts.map(({ status, ...request }) => request);
assert.deepEqual(signature(pair[0]), signature(pair[1]), "Baseline and candidate POST endpoint/method/body/options differ");
for (const row of pair) for (const metric of metrics) {
assert.ok(Number.isFinite(row.timings_ms[metric]) && row.timings_ms[metric] >= 0, `Missing or invalid ${metric}`);
}
} catch (error) {
for (const row of pair) { row.status = "error"; row.error = safeError(error); }
}
}
async function driver(options) {
await mkdir(options.output, { recursive: true, mode: 0o700 });
// Refuse accidental artifact reuse instead of overwriting an earlier experiment.
assert.equal((await readdir(options.output)).length, 0, "--output must be a new or empty directory");
await writeFile(resolve(options.output, "rows.jsonl"), "", { flag: "wx", mode: 0o600 });
const manifestPath = resolve(options.output, "manifest.json");
const rows = [];
const manifest = {
format_version: 1, status: "preparing", started_at: new Date().toISOString(),
evidence_scope: "Local serial fresh Node processes, not deployed functions; warmups warm OS caches, not a shared runtime",
command: [process.execPath, script, ...process.argv.slice(2)],
backend: options.backend, live: options.live, samples_per_mode: options.samples, warmups_per_mode: warmups,
order: "Paired alternating: baseline/resolved on even pairs, resolved/baseline on odd pairs",
timeout_ms: timeoutMs, concurrency: 1,
host: { os: platform(), release: release(), architecture: arch(), cpu: cpus()[0]?.model, node: process.version },
credential_source: options.live ? "Inherited matching AI_GATEWAY_API_KEY and AI_GATEWAY_TEST_API_KEY via aig test" : "Synthetic literal; no network",
boundaries: { launch_to_first_text_ms: "Parent spawn invocation to receipt of child first-text stdout marker (includes startup and pipe observation)",
create_to_post_ms: "createFxAgent call to first POST fetch entry, including checkpoint restore",
prompt_to_post_ms: "agent.prompt call to first POST fetch entry",
post_to_first_text_ms: "First POST fetch entry to first nonempty text_delta",
create_to_first_text_ms: "createFxAgent call to first nonempty text_delta",
module_import_ms: "Dynamic import of sdk/node.js in each child",
get_duration_ms: "Sum of GET fetch entry to response headers, including synthetic catalog delay, not JSON parsing/body consumption" },
artifacts: ["manifest.json", "descriptor.json", "fixture.json", "rows.jsonl", "summary.json"],
};
let error;
try {
await saveJson(manifestPath, manifest);
try { manifest.host.zig = (await run("zig", ["version"])).stdout.trim(); }
catch { manifest.host.zig = "unavailable"; }
manifest.targets = {
baseline: await repoEvidence(options.baseline, options.backend),
candidate: await repoEvidence(options.candidate, options.backend),
};
assert.notEqual(options.baseline, options.candidate, "Baseline and candidate must be different worktrees");
assert.equal(manifest.targets.baseline.dirty_status.length, 0, "Baseline must be untouched (clean git status)");
const catalog = syntheticCatalog(options.model);
const discovery = { gets: [], posts: [], postAt: null };
const fetch = gateway({ live: options.live, model: options.model, catalog, delay: options.delay }, discovery);
const importAt = performance.now();
const sdk = await import(pathToFileURL(resolve(options.candidate, "sdk/node.js")).href);
manifest.preparation = { module_import_ms: performance.now() - importAt };
assert.equal(typeof sdk.resolveModel, "function", "Candidate SDK is missing resolveModel()");
const resolveAt = performance.now();
const descriptor = JSON.parse(JSON.stringify(await sdk.resolveModel({ apiKey: apiKey(options.live), model: options.model, fetch })));
assert.equal(descriptor.metadata?.version, 1, "Expected version 1 serializable model metadata");
assert.deepEqual(plainModel(descriptor), options.model, "resolveModel changed requested model options");
Object.assign(manifest.preparation, { resolve_ms: performance.now() - resolveAt, ...transport(discovery) });
assert.equal(discovery.posts.length, 0, "Discovery must not send a chat POST");
await saveJson(resolve(options.output, "descriptor.json"), descriptor);
// Checkpoint construction always uses the fake key and fake stream, even in live mode.
const prepared = await execute(options, options.candidate, "checkpoint", { descriptor: plainModel(descriptor), catalog });
let checkpoint = prepared.status === "ok" ? prepared.checkpoint : null;
manifest.checkpoint = checkpoint ? { kind: "restored", bytes: Buffer.from(checkpoint, "base64").length,
sha256: hash(Buffer.from(checkpoint, "base64")), preparation: "One synthetic candidate turn, no live model call" } :
{ kind: "first-turn", reason: prepared.error ?? "Checkpoint API unavailable" };
if (checkpoint) {
await writeFile(resolve(options.output, "checkpoint.bin"), Buffer.from(checkpoint, "base64"), { mode: 0o600 });
manifest.artifacts.push("checkpoint.bin");
}
const fixture = { descriptor, checkpoint, catalog };
const fixtureEvidence = {
model: plainModel(descriptor), descriptor_sha256: jsonHash(descriptor),
prompt_sha256: hash(prompt), prompt_bytes: Buffer.byteLength(prompt), instructions_sha256: hash(prompt), tools: [],
catalog: options.live ? { source: "Live Gateway", selected_sha256: jsonHash(descriptor.metadata.data) } :
{ source: "Synthetic", sha256: jsonHash(catalog), data: catalog, delay_ms: options.delay },
synthetic_stream_sha256: hash(frames.join("")), checkpoint: manifest.checkpoint,
};
manifest.fixture_sha256 = jsonHash(fixtureEvidence);
await saveJson(resolve(options.output, "fixture.json"), fixtureEvidence);
manifest.status = "running";
await saveJson(manifestPath, manifest);
for (const phase of ["warmup", "measured"]) {
for (let pair = 0; pair < (phase === "warmup" ? warmups : options.samples); pair++) {
const order = pair % 2 === 0 ? ["baseline", "resolved"] : ["resolved", "baseline"];
const completed = [];
for (const mode of order) {
if (interrupted) break;
const repo = mode === "baseline" ? options.baseline : options.candidate;
completed.push({ ...await execute(options, repo, mode, fixture), phase, pair, order });
}
assertPair(completed);
for (const row of completed) {
if (phase === "warmup") row.timings_ms = null;
rows.push(row);
await appendFile(resolve(options.output, "rows.jsonl"), `${keyFree(row)}\n`);
}
assert.ok(!interrupted, "Benchmark interrupted; retained attempted rows");
}
}
manifest.targets_after = {
baseline: await repoEvidence(options.baseline, options.backend),
candidate: await repoEvidence(options.candidate, options.backend),
};
assert.deepEqual(manifest.targets_after, manifest.targets, "Repository or assets changed during measurement");
manifest.status = rows.every(row => row.status === "ok") ? "ok" : "error";
} catch (caught) {
error = safeError(caught);
manifest.status = "error";
manifest.error = error;
}
manifest.finished_at = new Date().toISOString();
const report = { format_version: 1, status: manifest.status, evidence_scope: manifest.evidence_scope,
backend: options.backend, live: options.live, error: error ?? null, ...summary(rows, options.samples) };
await saveJson(manifestPath, manifest);
await saveJson(resolve(options.output, "summary.json"), report);
process.stdout.write(`${keyFree(report)}\n`);
process.exitCode = manifest.status === "ok" ? 0 : 1;
}
try {
const options = argumentsForRun();
if (options.worker) await worker(options);
else await driver(options);
} catch (error) {
process.stderr.write(`${keyFree({ status: "error", error: safeError(error) })}\n`);
process.exitCode = 1;
}
+5 -2
View File
@@ -57,7 +57,9 @@ shared createFxAgent() logic in sdk/fx-sdk.js
- `exited` settles when the native runtime exits.
- `abort()` aborts Node fetch and closes native input.
Each core has one nonblocking Unix socketpair for readiness. The adapter takes ownership of the reader with `takeCoreReadyFd()` and watches it through `node:net`; Bun adopts the descriptor through `connect()`. The native writer signals when a fetch request becomes pending, output changes from empty to non-empty, or the core exits. Wake bytes carry no state: JavaScript drains the bounded fetch and output queues to quiescence and then checks exit state. While a body pump exists it calls `coreFetchActive()` for that fetch handle. An inactive handle aborts its matching `AbortController` on the next readiness event. ACP output is accumulated to newline boundaries and parsed as JSON. Gateway requests are transferred to Node as bounded request records; Node runs the configured `fetch`, streams bounded response chunks back to Zig, and owns the `AbortController`. This matches the WebAssembly host-fetch boundary and ensures the N-API core never uses the native `std.http` Gateway transport.
Each core has one nonblocking Unix socketpair for readiness. The adapter takes ownership of the reader with `takeCoreReadyFd()` and watches it through `node:net`; Bun adopts the descriptor through `connect()`. The native writer signals when a fetch request becomes pending, output changes from empty to non-empty, semantic consumption completes, or the core exits. Wake bytes carry no state: JavaScript drains the bounded fetch and output queues to quiescence and then checks exit state. ACP output is accumulated to newline boundaries and parsed as JSON. Gateway requests are transferred to Node as bounded request records; Node runs the configured `fetch`, streams bounded response chunks back to Zig, and owns the `AbortController`. This matches the WebAssembly host-fetch boundary and ensures the N-API core never uses the native `std.http` Gateway transport.
The body pump observes the matching fetch through `coreFetchDisposition()` under one bridge lock. Separate consumption and active queries can produce a torn observation if the native worker completes between them. Explicit semantic consumption takes precedence over active or retired state and survives normal close and shutdown; HTTP EOF alone does not grant it. JavaScript stops forwarding consumed bytes and drains the remaining HTTP body within 100 ms and 64 KiB without delaying text or result delivery. Cancellation, failure, or an exceeded cleanup bound aborts the body. Runtime destruction waits for owned cleanup to settle. Older addons without the disposition export retain the active-only conservative abort behavior.
The shared JavaScript Agent wrapper emits bounded `transport.start`, `transport.response`, and `transport.error` diagnostics around that host-owned fetch. It allowlists request, generation, model, provider, status, attempt, endpoint, and elapsed-time fields rather than exposing credentials or arbitrary headers.
@@ -79,6 +81,7 @@ The native kernel installs host-stream and host model-catalog providers. It does
| `drainCore(handle)` | Returns up to 1 MiB of queued output as a Node Buffer. |
| `takeCoreFetch(runtime)` | Takes the pending bounded Gateway request, including its positive `fetchHandle`, for Node-owned fetch. |
| `coreFetchActive(runtime, fetchHandle)` | Reports whether that request may still receive host response operations. |
| `coreFetchDisposition(runtime, fetchHandle)` | Atomically reports retired (`0`), active (`1`), or explicitly consumed (`2`) for the exact handle. Matching consumption takes precedence. This additive export is optional for version 3 addons. |
| `startCoreFetchResponse(runtime, fetchHandle, status)` | Publishes the matching fetch response status. |
| `pushCoreFetchResponse(runtime, fetchHandle, buffer)` | Appends a bounded matching response chunk. |
| `finishCoreFetch(runtime, fetchHandle)` / `failCoreFetch(runtime, fetchHandle)` | Completes only the matching host stream successfully or with transport failure. |
@@ -140,7 +143,7 @@ The native core is intentionally more restricted than the native `fx` CLI. Its A
As a result, the model receives no native tool advertisement, cannot launch commands, cannot read workspace files through fx tools, cannot start ACP-provided MCP servers, and cannot access the native secret store. `home` and `workspaceRoot` still provide identity and session context to shared ACP code, but they do not grant a tool capability by themselves.
Agent creation does not fetch the model catalog unless the host sets a named reasoning `effort` or enables `fast`; those overrides are validated against the catalog at creation. When a prompt needs model capabilities or context capacity, the shared resolver obtains the catalog through the supplied host fetch and caches its metadata for that agent. Initial model-visible system context comes only from the host's explicit `instructions`, including text assembled by the MCP and skills adapters.
With an ordinary model string or model object, agent creation does not fetch the model catalog unless the host sets a named reasoning `effort` or enables `fast`; those overrides are validated against the catalog at creation. When a prompt needs model capabilities or context capacity, the shared resolver obtains the catalog through the supplied host fetch and caches its metadata for that agent. A model returned by `resolveModel()` instead supplies bounded selected-model metadata over ACP before initialization and prompting, with local lease and model validation and no implicit catalog fetch. It grants no tool or credential authority and is not included in checkpoints. Initial model-visible system context comes only from the host's explicit `instructions`, including text assembled by the MCP and skills adapters.
Host-stream requests do not opt into the Gateway extended-time header. Live paired testing showed that header caused a recurring multi-second pre-header tail for embedded requests. Session identity and affinity headers remain enabled. The shared JavaScript fetch edge retries a thrown host transport error at most once, before any response reaches the Agent. Cancellation prevents the retry, and a second failure keeps the existing rejection behavior.
+70 -4
View File
@@ -70,10 +70,12 @@ The new codes replace `LIBFX_UNSUPPORTED_EFFORT` and `LIBFX_UNSUPPORTED_FAST`
for both nested and legacy top-level settings. Callers that check the old codes
must update their error handling.
The host selects the model. Agent creation does not fetch the Gateway model
catalog unless effort requests a named level or fast is enabled. Prompting
can resolve model capabilities and context capacity through the supplied
`fetch`; fx caches that metadata for the agent.
The host selects the model. With a string model or an ordinary model object,
agent creation does not fetch the Gateway model catalog unless effort requests
a named level or fast is enabled. Prompting can resolve model capabilities and
context capacity through the supplied `fetch`; fx caches that metadata for the
agent. To avoid catalog requests from fresh agents, pass a model returned by
`resolveModel()`, as described under [Models](#models).
`onEvent` receives runtime diagnostics separately from model output. Transport
events report request start, response status and elapsed time, safe Gateway
@@ -210,6 +212,70 @@ const models = await listModels({
language-model IDs. It accepts the same optional `fetch` override as the Agent
API.
### Resolve before handling messages
`resolveModel()` obtains the selected model's capability metadata without
creating an agent. It returns a JSON-serializable model object that you can
store in server-owned configuration and pass to a fresh agent on any function
instance. A valid resolved model makes no catalog request during creation,
checkpoint restoration, or prompting on native and Wasm backends.
```js
import { createFxAgent, resolveModel } from "libfx";
// During setup or model selection, outside the message handler:
const configuredModel = await resolveModel({
apiKey,
model: { id: "moonshotai/kimi-k3-fast", effort: "low" },
});
const serializedModel = JSON.stringify(configuredModel);
// In each message handler, load this with your existing server-owned settings:
const agent = await createFxAgent({
apiKey,
model: JSON.parse(serializedModel),
checkpoint,
tools,
});
try {
const turn = agent.prompt(message);
for await (const event of turn) {
if (event.type === "text_delta") process.stdout.write(event.delta);
}
await turn.result;
} finally {
await agent.close();
}
```
Resolution makes one explicit, bounded catalog GET through the optional `fetch`
callback. Its options are `apiKey`, a required string `model` or model object,
optional `fetch`, `signal`, and `gatewayChatUrl`. The returned object has `id`,
any requested `effort` and `fast` values, and a versioned `metadata` object.
Capability controls are validated by the kernel when you create the agent.
Metadata is usable for one hour from resolution. Its absolute `resolvedAt` and
`expiresAt` timestamps survive JSON serialization; recreating an agent does not
renew them. Resolve again outside message handling before expiry, or call
`resolveModel({ apiKey, model: configuredModel })` to renew the descriptor.
There is no background task or implicit catalog fetch in the resolved-model
agent. Expired metadata rejects with `LIBFX_MODEL_METADATA_EXPIRED`; malformed,
missing-model, or wrong-connection data rejects with
`LIBFX_MODEL_METADATA_INVALID`. A core that does not acknowledge this feature
rejects with `LIBFX_MODEL_METADATA_UNSUPPORTED` before any model POST; use
matching SDK and core artifacts. These errors carry `model` and
`capability: "modelMetadata"`. Unsupported effort or fast controls keep their
existing model-specific error codes.
The descriptor contains only selected capability fields, not the API key or
conversation. It is bounded to 64 KiB and 64 selected rows, and its scope binds
the explicit API key and Gateway chat endpoint. A scope is not a signature or
an authorization grant. Keep descriptors in trusted server-owned settings;
do not accept client-supplied capability data. When a custom `fetch` uses
additional tenant or connection state, the host must preserve that scope as
well. Checkpoints remain conversation and usage only. Ordinary model strings
retain automatic discovery and do not share data between agents.
## JavaScript tools and instructions
```js
+2 -1
View File
@@ -4,11 +4,12 @@ import {
encodeXtermKeyEvent,
fxSdkApiVersion,
listModels,
resolveModel,
supportsJspi,
xtermAdapter,
} from "./fx-sdk.js";
export { encodeXtermKeyEvent, fxSdkApiVersion, listModels, supportsJspi, xtermAdapter };
export { encodeXtermKeyEvent, fxSdkApiVersion, listModels, resolveModel, supportsJspi, xtermAdapter };
export const libfxApiVersion = 2;
const defaultCoreWasm = new URL("./fx-core.wasm", import.meta.url).href;
+12
View File
@@ -0,0 +1,12 @@
export const cleanupTimeoutMs = 100;
export const cleanupByteLimit = 64 * 1024;
// Policy only; the host owns the reader, clock, cancellation and teardown.
export function fetchCleanupAction(state, maxAge = cleanupTimeoutMs, maxBytes = cleanupByteLimit) {
if (state.eof) return "done";
if (state.canceled || state.failed) return "abort";
if (state.consumed) {
return state.age >= maxAge || state.discarded >= maxBytes ? "abort" : "drain";
}
return state.alive && state.active && !state.closing ? "forward" : "abort";
}
+116 -12
View File
@@ -15,6 +15,11 @@ const maxEffortBytes = 64;
const maxUrlBytes = 16 * 1024;
const maxModelCatalogBytes = 4 * 1024 * 1024;
const maxModelCatalogEntries = 10_000;
const maxSelectedModelBytes = 64 * 1024;
const modelMetadataLeaseMs = 60 * 60 * 1000;
const modelMetadataKey = Symbol("resolved model metadata");
const catalogUrl = "https://ai-gateway.vercel.sh/coding-agent/v1/models";
const defaultGatewayChatUrl = "https://ai-gateway.vercel.sh/v4/ai/language-model";
const streamReadsPerTaskYield = 32;
const transportActivityIntervalMs = 250;
const maxUnreadEventBytes = 1024 * 1024;
@@ -84,6 +89,53 @@ function normalizeFast(value) {
return value;
}
function modelMetadataError(code, model, message) {
const error = new Error(message);
error.code = code;
error.model = model;
error.capability = "modelMetadata";
return error;
}
function normalizeModelMetadata(value, model, now) {
const invalid = () => modelMetadataError("LIBFX_MODEL_METADATA_INVALID", model, "Model metadata is invalid; resolve the model again");
if (!value || typeof value !== "object" || Array.isArray(value) || value.version !== 1 ||
!Number.isSafeInteger(value.resolvedAt) || !Number.isSafeInteger(value.expiresAt) ||
value.resolvedAt < 0 || value.expiresAt <= value.resolvedAt ||
value.expiresAt - value.resolvedAt > modelMetadataLeaseMs || now < value.resolvedAt ||
typeof value.scope !== "string" || !/^[a-f0-9]{64}$/.test(value.scope) ||
!Array.isArray(value.data) || value.data.length === 0 || value.data.length > 64) throw invalid();
if (now >= value.expiresAt) {
throw modelMetadataError("LIBFX_MODEL_METADATA_EXPIRED", model, "Model metadata has expired; resolve the model again before creating an agent");
}
for (const row of value.data) {
if (!row || typeof row !== "object" || Array.isArray(row) || row.id !== model ||
(row.type !== undefined && (typeof row.type !== "string" || row.type.toLowerCase() !== "language"))) throw invalid();
}
const metadata = { version: 1, resolvedAt: value.resolvedAt, expiresAt: value.expiresAt, scope: value.scope, data: value.data };
let json;
try { json = JSON.stringify(metadata); } catch { throw invalid(); }
const snapshot = JSON.parse(json);
const envelope = selectedModelEnvelope(model, snapshot, now);
if (encoder.encode(json).length > maxSelectedModelBytes || encoder.encode(JSON.stringify(envelope)).length > maxSelectedModelBytes) {
throw new RangeError(`model metadata exceeds the ${maxSelectedModelBytes} byte libfx limit`);
}
return snapshot;
}
async function modelMetadataScope(apiKey, gatewayChatUrl) {
const bytes = encoder.encode(JSON.stringify([catalogUrl, gatewayChatUrl ?? defaultGatewayChatUrl, apiKey]));
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
return Array.from(digest, byte => byte.toString(16).padStart(2, "0")).join("");
}
function selectedModelEnvelope(model, metadata, now) {
if (now < metadata.resolvedAt || now >= metadata.expiresAt) {
throw modelMetadataError("LIBFX_MODEL_METADATA_EXPIRED", model, "Model metadata has expired; resolve the model again before prompting");
}
return { model, revision: String(metadata.resolvedAt), validForMs: metadata.expiresAt - now, data: metadata.data };
}
export function normalizeAgentOptions(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError("createFxAgent() options must be an object");
@@ -99,11 +151,12 @@ export function normalizeAgentOptions(value) {
}
const model = options.model;
for (const name of Object.keys(model)) {
if (name !== "id" && name !== "effort" && name !== "fast") {
if (name !== "id" && name !== "effort" && name !== "fast" && name !== "metadata") {
throw new TypeError(`unsupported model option: ${name}`);
}
}
options.model = boundedString(model.id, "model.id", maxModelBytes, true);
if (Object.hasOwn(model, "metadata")) options[modelMetadataKey] = normalizeModelMetadata(model.metadata, options.model, Date.now());
options.effort = normalizeEffort(model.effort);
options.fast = normalizeFast(model.fast);
} else {
@@ -128,9 +181,9 @@ function agentEnvironment(options) {
function agentRpcError(response) {
const error = new Error(response.message);
const data = response.data;
if (data && ["LIBFX_MODEL_UNSUPPORTED_EFFORT", "LIBFX_MODEL_UNSUPPORTED_FAST"].includes(data.code) &&
if (data && ["LIBFX_MODEL_UNSUPPORTED_EFFORT", "LIBFX_MODEL_UNSUPPORTED_FAST", "LIBFX_MODEL_METADATA_INVALID", "LIBFX_MODEL_METADATA_EXPIRED"].includes(data.code) &&
typeof data.model === "string" &&
data.capability === (data.code === "LIBFX_MODEL_UNSUPPORTED_FAST" ? "fast" : "effort")) {
data.capability === (data.code.startsWith("LIBFX_MODEL_METADATA_") ? "modelMetadata" : data.code === "LIBFX_MODEL_UNSUPPORTED_FAST" ? "fast" : "effort")) {
error.code = data.code;
error.model = data.model;
error.capability = data.capability;
@@ -181,16 +234,14 @@ async function readBoundedResponseText(response, limit) {
return strictDecoder.decode(bytes);
}
export async function listModels(options = {}) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("listModels() options must be an object");
}
async function fetchModelCatalog(options) {
const apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, true);
const fetchModels = options.fetch ?? globalThis.fetch?.bind(globalThis);
if (typeof fetchModels !== "function") throw new TypeError("fetch is unavailable");
const response = await fetchModels("https://ai-gateway.vercel.sh/coding-agent/v1/models", {
const response = await fetchModels(catalogUrl, {
method: "GET",
headers: { authorization: `Bearer ${apiKey}` },
...(options.signal === undefined ? {} : { signal: options.signal }),
});
if (!response.ok) {
await cancelResponseBody(response);
@@ -210,9 +261,45 @@ export async function listModels(options = {}) {
if (catalog.data.length > maxModelCatalogEntries) {
throw new RangeError(`model catalog exceeds the ${maxModelCatalogEntries} entry libfx limit`);
}
return catalog.data;
}
export async function resolveModel(options = {}) {
// Explicit resolution can renew an expired descriptor; consumption cannot.
if (options?.model && typeof options.model === "object" && !Array.isArray(options.model)) {
const model = { ...options.model };
delete model.metadata;
options = { ...options, model };
}
const normalized = normalizeAgentOptions(options);
const id = boundedString(normalized.model, "model", maxModelBytes, true);
const rows = (await fetchModelCatalog(normalized)).filter(row => row?.id === id &&
(row.type === undefined || typeof row.type === "string" && row.type.toLowerCase() === "language"));
if (rows.length === 0) {
throw modelMetadataError("LIBFX_MODEL_METADATA_INVALID", id, `Model ${id} is not present in the model catalog`);
}
// Keep only fields consumed by the core's catalog capability parser.
const data = rows.map(row => {
const selected = { id: row.id, type: "language" };
for (const key of ["tags", "context_window", "max_tokens", "reasoning_options", "fast_options"]) {
if (row[key] !== undefined) selected[key] = row[key];
}
return selected;
});
const resolvedAt = Date.now();
const metadata = normalizeModelMetadata({ version: 1, resolvedAt, expiresAt: resolvedAt + modelMetadataLeaseMs,
scope: await modelMetadataScope(normalized.apiKey, normalized.gatewayChatUrl), data }, id, resolvedAt);
return { id, ...(normalized.effort === undefined ? {} : { effort: normalized.effort }),
...(normalized.fast === undefined ? {} : { fast: normalized.fast }), metadata };
}
export async function listModels(options = {}) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("listModels() options must be an object");
}
const data = await fetchModelCatalog(options);
const ids = new Set();
for (const entry of catalog.data) {
for (const entry of data) {
if (!entry || typeof entry !== "object") continue;
if (typeof entry.type === "string" && entry.type.toLowerCase() !== "language") continue;
if (typeof entry.id !== "string" || entry.id.length === 0) continue;
@@ -1512,6 +1599,13 @@ function base64ToBytes(value) {
export async function createFxAgent(options = {}) {
options = normalizeAgentOptions(options);
const metadata = options[modelMetadataKey];
if (metadata) {
selectedModelEnvelope(options.model, metadata, Date.now());
if (metadata.scope !== await modelMetadataScope(options.apiKey, options.gatewayChatUrl)) {
throw modelMetadataError("LIBFX_MODEL_METADATA_INVALID", options.model, "Model metadata belongs to a different Gateway connection; resolve the model with this connection");
}
}
const hostTools = normalizeHostTools(options.tools);
const instructions = normalizeInstructions(options.instructions);
const initialCheckpoint = checkpointBytes(options.checkpoint);
@@ -1533,6 +1627,9 @@ export async function createFxAgent(options = {}) {
const url = new URL(endpoint);
endpoint = `${url.origin}${url.pathname}`;
} catch {}
if (metadata && method === "GET" && endpoint === catalogUrl) {
throw modelMetadataError("LIBFX_MODEL_METADATA_INVALID", options.model, "Resolved-model agents cannot fetch the model catalog; resolve the model explicitly before creating an agent");
}
for (let attemptIndex = 0; attemptIndex < 2; attemptIndex++) {
const startedAt = performance.now();
const attempt = activeTurn ? ++activeTurn.transportAttempts : attemptIndex + 1;
@@ -1649,6 +1746,9 @@ export async function createFxAgent(options = {}) {
emit("runtime.ready");
const send = (message) => {
if (closing) throw new Error("fx agent is closing");
if (metadata && message.method === "session/prompt") {
message.params.modelMetadata = selectedModelEnvelope(options.model, metadata, Date.now());
}
emit("acp.send", { message });
if (message.method === "session/prompt" && activeTurn?.cancelled) throw new Error("Cancelled");
runtime.write(`${JSON.stringify(message)}\n`);
@@ -1708,14 +1808,18 @@ export async function createFxAgent(options = {}) {
if (message.error) waiter.reject(agentRpcError(message.error)); else waiter.resolve(message.result);
}
try {
await request("initialize", {
const initialized = await request("initialize", {
protocolVersion: 1,
clientCapabilities: {
...(hostTools.descriptors.length || instructions
? { libfx: { tools: hostTools.descriptors, instructions } }
...(hostTools.descriptors.length || instructions || metadata
? { libfx: { tools: hostTools.descriptors, instructions,
...(metadata ? { modelMetadata: true, initialModelMetadata: selectedModelEnvelope(options.model, metadata, Date.now()) } : {}) } }
: {}),
},
});
if (metadata && initialized?.agentCapabilities?._meta?.fx?.modelMetadata !== true) {
throw modelMetadataError("LIBFX_MODEL_METADATA_UNSUPPORTED", options.model, "This libfx core does not support resolved model metadata; use matching SDK and core artifacts");
}
const sessionResult = await request("libfx/new");
sessionId = sessionResult.sessionId;
+122 -41
View File
@@ -7,18 +7,20 @@ 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 {
createFxAgent as createWasmAgent,
createFxTerminal as createWasmTerminal,
encodeXtermKeyEvent,
fxSdkApiVersion,
listModels,
resolveModel,
normalizeAgentOptions,
supportsJspi,
xtermAdapter,
} from "./fx-sdk.js";
export { encodeXtermKeyEvent, fxSdkApiVersion, listModels, supportsJspi, xtermAdapter };
export { encodeXtermKeyEvent, fxSdkApiVersion, listModels, resolveModel, supportsJspi, xtermAdapter };
export const libfxApiVersion = 2;
const nativeCoreApiVersion = 3;
@@ -377,76 +379,154 @@ function createNativeCoreRuntime(addon, options) {
let settled = false;
let fetchState = null;
const exited = new Promise((resolve) => { exitedResolve = resolve; });
function refreshFetch(state) {
if (settled) return;
if (typeof addon.coreFetchDisposition !== "function") {
state.active = addon.coreFetchActive(core, state.handle);
return;
}
// One native lock observes consumption and retirement together.
const disposition = addon.coreFetchDisposition(core, state.handle);
if (disposition !== 0 && disposition !== 1 && disposition !== 2) {
throw new Error(`invalid native fetch disposition ${disposition}`);
}
state.consumed ||= disposition === 2;
state.active = disposition === 1;
}
function cleanupAction(state) {
return fetchCleanupAction({ ...state, alive: !settled,
age: state.drainStarted === null ? 0 : performance.now() - state.drainStarted });
}
function beginDrain(state) {
if (state.drainStarted !== null) return;
state.drainStarted = performance.now();
state.timer = setTimeout(() => state.controller.abort(), cleanupTimeoutMs);
}
const abortHostEffects = () => {
fetchState?.controller.abort();
try { addon.abortCoreFetch(core); } catch {}
if (fetchState) {
fetchState.canceled = true;
fetchState.controller.abort();
}
if (!settled) { try { addon.abortCoreFetch(core); } catch {} }
};
const finish = (code, error) => {
if (settled) return;
const state = fetchState;
if (state) {
refreshFetch(state);
state.closing = true;
state.failed ||= code !== 0 || error !== undefined;
}
settled = true;
outputError = error;
output.close();
abortHostEffects();
try { addon.destroyCore(core); } catch {}
readySocket.destroy();
void readyClosed.then(() => exitedResolve(code));
const destroy = () => {
try { addon.destroyCore(core); } catch {}
readySocket.destroy();
void readyClosed.then(() => exitedResolve(code));
};
if (state && cleanupAction(state) === "drain") {
beginDrain(state);
void state.done.then(destroy);
} else {
if (state && cleanupAction(state) !== "done") state.controller.abort();
destroy();
}
};
const pumpFetch = async (request) => {
const controller = new AbortController();
const state = { handle: request.handle, controller };
let complete, reader;
const state = { handle: request.handle, controller, consumed: false, active: true,
canceled: false, failed: false, closing: false, eof: false, discarded: 0,
drainStarted: null, timer: null, done: new Promise(resolve => { complete = resolve; }) };
fetchState = state;
let onAbort;
const aborted = new Promise((_, reject) => {
onAbort = () => reject(new DOMException("Aborted", "AbortError"));
controller.signal.addEventListener("abort", onAbort, { once: true });
});
void aborted.catch(() => {});
const wait = task => Promise.race([task, aborted]);
try {
const response = await (options.fetch ?? globalThis.fetch)(request.url, {
const responseTask = Promise.resolve().then(() => (options.fetch ?? globalThis.fetch)(request.url, {
method: request.method,
headers: new Headers(JSON.parse(request.headers).map(({ name, value }) => [name, value])),
body: request.body?.length ? Buffer.from(request.body, "base64") : undefined,
signal: controller.signal,
})).then(response => {
if (controller.signal.aborted) {
void response.body?.cancel().catch(() => {});
throw new DOMException("Aborted", "AbortError");
}
return response;
});
const started = addon.startCoreFetchResponse(core, state.handle, response.status);
if (started === fetchOperationStale) return;
if (started !== fetchOperationApplied) throw new Error(`invalid native fetch start result ${started}`);
if (response.body) {
for await (const chunk of response.body) {
const buffer = Buffer.from(chunk);
options.onTransportChunk?.(buffer.length);
let offset = 0;
while (offset < buffer.length) {
const end = Math.min(offset + 64 * 1024, buffer.length);
const pushed = addon.pushCoreFetchResponse(core, state.handle, buffer.subarray(offset, end));
if (pushed === fetchOperationApplied) {
offset = end;
continue;
}
if (pushed === fetchOperationStale) return;
if (pushed !== fetchOperationBackpressure) throw new Error(`invalid native fetch push result ${pushed}`);
await new Promise((resolve) => setTimeout(resolve, 2));
const response = await wait(responseTask);
if (settled && !state.consumed) throw new DOMException("Aborted", "AbortError");
const started = settled ? fetchOperationStale : addon.startCoreFetchResponse(core, state.handle, response.status);
if (started !== fetchOperationApplied && started !== fetchOperationStale) throw new Error(`invalid native fetch start result ${started}`);
state.active = started === fetchOperationApplied;
reader = response.body?.getReader();
while (reader) {
refreshFetch(state);
const action = cleanupAction(state);
if (action === "abort") { controller.abort(); throw new DOMException("Aborted", "AbortError"); }
if (action === "drain") beginDrain(state);
const { done, value } = await wait(reader.read());
if (done) break;
const buffer = Buffer.from(value);
options.onTransportChunk?.(buffer.length);
let offset = 0;
while (offset < buffer.length) {
refreshFetch(state);
const next = cleanupAction(state);
if (next === "drain") {
beginDrain(state);
state.discarded += buffer.length - offset;
if (state.discarded >= cleanupByteLimit) controller.abort();
break;
}
if (next !== "forward") { controller.abort(); throw new DOMException("Aborted", "AbortError"); }
const end = Math.min(offset + 64 * 1024, buffer.length);
const pushed = addon.pushCoreFetchResponse(core, state.handle, buffer.subarray(offset, end));
if (pushed === fetchOperationApplied) { offset = end; continue; }
if (pushed === fetchOperationStale) {
refreshFetch(state);
if (cleanupAction(state) === "drain") continue;
controller.abort(); throw new DOMException("Aborted", "AbortError");
}
if (pushed !== fetchOperationBackpressure) throw new Error(`invalid native fetch push result ${pushed}`);
await wait(new Promise(resolve => setTimeout(resolve, 2)));
}
}
const finished = addon.finishCoreFetch(core, state.handle);
if (finished !== fetchOperationApplied && finished !== fetchOperationStale) {
throw new Error(`invalid native fetch finish result ${finished}`);
state.eof = true;
if (!settled && state.active) {
const finished = addon.finishCoreFetch(core, state.handle);
if (finished !== fetchOperationApplied && finished !== fetchOperationStale) throw new Error(`invalid native fetch finish result ${finished}`);
}
} catch (error) {
if (error?.name !== "AbortError" || !controller.signal.aborted) {
try {
if (addon.coreFetchActive(core, state.handle)) addon.failCoreFetch(core, state.handle);
} catch {}
if (!settled && !controller.signal.aborted) {
state.failed = true;
try { if (addon.coreFetchActive(core, state.handle)) addon.failCoreFetch(core, state.handle); } catch {}
}
} finally {
if (fetchState === state) {
fetchState = null;
queueMicrotask(drainReady);
clearTimeout(state.timer);
controller.signal.removeEventListener("abort", onAbort);
if (reader) {
if (!state.eof) { try { void reader.cancel().catch(() => {}); } catch {} }
try { reader.releaseLock(); } catch {}
}
complete();
if (fetchState === state) { fetchState = null; queueMicrotask(drainReady); }
}
};
function drainReady() {
if (settled) return;
try {
if (fetchState) {
if (!fetchState.controller.signal.aborted && !addon.coreFetchActive(core, fetchState.handle)) {
fetchState.controller.abort();
}
refreshFetch(fetchState);
const action = cleanupAction(fetchState);
if (action === "drain") beginDrain(fetchState);
if (action === "abort") fetchState.controller.abort();
} else {
const fetchRequest = addon.takeCoreFetch(core);
if (fetchRequest) void pumpFetch(JSON.parse(fetchRequest.toString("utf8")));
@@ -533,7 +613,8 @@ async function createWithFallback(surface, nativeMethod, wasmFactory, defaultWas
} catch (error) {
nativeError = error;
if (backend === "native" || error?.code === "LIBFX_MODEL_UNSUPPORTED_FAST" ||
error?.code === "LIBFX_MODEL_UNSUPPORTED_EFFORT") throw error;
error?.code === "LIBFX_MODEL_UNSUPPORTED_EFFORT" ||
typeof error?.code === "string" && error.code.startsWith("LIBFX_MODEL_METADATA_")) throw error;
}
}
if (backend === "native") {
+1
View File
@@ -41,6 +41,7 @@
"fx-sdk.js",
"wasm-module.js",
"core-output.js",
"fetch-cleanup.js",
"mcp.js",
"skills.js",
"skills-node.js",
+1
View File
@@ -31,6 +31,7 @@ const files = [
["sdk/fx-sdk.js", "fx-sdk.js"],
["sdk/wasm-module.js", "wasm-module.js"],
["sdk/core-output.js", "core-output.js"],
["sdk/fetch-cleanup.js", "fetch-cleanup.js"],
["sdk/mcp.js", "mcp.js"],
["sdk/skills.js", "skills.js"],
["sdk/skills-node.js", "skills-node.js"],
@@ -0,0 +1,10 @@
SPECIFICATION Spec
CONSTANTS
MaxOps = 2
MaxAge = 2
MaxBytes = 2
Legacy = FALSE
DisableTimer = FALSE
UnsafeCallbacks = FALSE
INVARIANTS TypeOK NoPrematureAbort NoNativeCallsAfterDestroy NoForeignHandleMutation NoDeadCoreConsumer CancelWins DrainRequiresSuccess PolicyOracle
CHECK_DEADLOCK FALSE
@@ -0,0 +1,142 @@
----------------------------- MODULE FetchCleanup -----------------------------
EXTENDS Naturals, TLC
CONSTANTS MaxOps, MaxAge, MaxBytes, Legacy, DisableTimer, UnsafeCallbacks
VARIABLES op, coreAlive, coreActive, consumed, http, mode, firstWire,
firstText, result, closing, canceled, failed, age, discarded,
prematureAbort, badCoreCall, foreignMutation, event
vars == <<op, coreAlive, coreActive, consumed, http, mode, firstWire,
firstText, result, closing, canceled, failed, age, discarded,
prematureAbort, badCoreCall, foreignMutation, event>>
Init == /\ op = 0 /\ coreAlive = TRUE /\ coreActive = FALSE
/\ consumed = FALSE /\ http = "none" /\ mode = "idle"
/\ firstWire = FALSE /\ firstText = FALSE /\ result = FALSE
/\ closing = FALSE /\ canceled = FALSE /\ failed = FALSE
/\ age = 0 /\ discarded = 0 /\ prematureAbort = FALSE
/\ badCoreCall = FALSE /\ foreignMutation = FALSE /\ event = "init"
Open == /\ coreAlive /\ ~closing /\ ~coreActive
/\ mode \in {"idle", "done"} /\ http # "open" /\ op < MaxOps
/\ op' = op + 1 /\ coreActive' = TRUE /\ consumed' = FALSE
/\ http' = "open" /\ mode' = "forward"
/\ firstWire' = FALSE /\ firstText' = FALSE /\ result' = FALSE
/\ canceled' = FALSE /\ failed' = FALSE /\ age' = 0 /\ discarded' = 0
/\ event' = "open"
/\ UNCHANGED <<coreAlive, closing, prematureAbort, badCoreCall, foreignMutation>>
WireText == /\ coreAlive /\ coreActive /\ http = "open" /\ ~firstWire
/\ ~canceled /\ ~failed /\ firstWire' = TRUE /\ event' = "wire_text"
/\ UNCHANGED <<op, coreAlive, coreActive, consumed, http, mode, firstText,
result, closing, canceled, failed, age, discarded,
prematureAbort, badCoreCall, foreignMutation>>
Deliver == /\ coreAlive /\ coreActive /\ firstWire /\ ~firstText
/\ ~canceled /\ ~failed /\ firstText' = TRUE /\ event' = "deliver_text"
/\ UNCHANGED <<op, coreAlive, coreActive, consumed, http, mode, firstWire,
result, closing, canceled, failed, age, discarded,
prematureAbort, badCoreCall, foreignMutation>>
Complete == /\ coreAlive /\ coreActive /\ firstText /\ ~result
/\ ~canceled /\ ~failed /\ consumed' = TRUE /\ result' = TRUE
/\ coreActive' = FALSE /\ event' = "model_complete"
/\ UNCHANGED <<op, coreAlive, http, mode, firstWire, firstText, closing,
canceled, failed, age, discarded, prematureAbort,
badCoreCall, foreignMutation>>
EOF == /\ http = "open" /\ firstWire /\ http' = "eof" /\ event' = "http_eof"
/\ UNCHANGED <<op, coreAlive, coreActive, consumed, mode, firstWire,
firstText, result, closing, canceled, failed, age, discarded,
prematureAbort, badCoreCall, foreignMutation>>
Cancel == /\ op > 0 /\ http = "open" /\ ~canceled
/\ canceled' = TRUE /\ coreActive' = FALSE /\ event' = "cancel"
/\ UNCHANGED <<op, coreAlive, consumed, http, mode, firstWire, firstText,
result, closing, failed, age, discarded, prematureAbort,
badCoreCall, foreignMutation>>
Fail == /\ op > 0 /\ http = "open" /\ ~failed /\ ~canceled
/\ failed' = TRUE /\ coreActive' = FALSE /\ event' = "failure"
/\ UNCHANGED <<op, coreAlive, consumed, http, mode, firstWire, firstText,
result, closing, canceled, age, discarded, prematureAbort,
badCoreCall, foreignMutation>>
Close == /\ coreAlive /\ ~closing /\ closing' = TRUE /\ event' = "close"
/\ canceled' = IF coreActive THEN TRUE ELSE canceled
/\ coreActive' = FALSE
/\ UNCHANGED <<op, coreAlive, consumed, http, mode, firstWire, firstText,
result, failed, age, discarded, prematureAbort,
badCoreCall, foreignMutation>>
Policy == IF http # "open" THEN "done"
ELSE IF canceled \/ failed THEN "abort"
ELSE IF consumed THEN
IF Legacy \/ age >= MaxAge \/ discarded >= MaxBytes
THEN "abort" ELSE "drain"
ELSE IF coreAlive /\ coreActive /\ ~closing THEN "forward"
ELSE "abort"
Observe == /\ mode # "idle" /\ (Policy # mode \/ Policy = "abort")
/\ mode' = IF Policy = "abort" THEN "done" ELSE Policy
/\ http' = IF Policy = "abort" THEN "aborted" ELSE http
/\ prematureAbort' = (prematureAbort \/
(Policy = "abort" /\ consumed /\ ~canceled /\ ~failed /\
age < MaxAge /\ discarded < MaxBytes /\ http = "open"))
/\ event' = "observe"
/\ UNCHANGED <<op, coreAlive, coreActive, consumed, firstWire, firstText,
result, closing, canceled, failed, age, discarded,
badCoreCall, foreignMutation>>
Tick == /\ ~DisableTimer /\ mode = "drain" /\ http = "open" /\ age < MaxAge
/\ age' = age + 1 /\ event' = "tick"
/\ UNCHANGED <<op, coreAlive, coreActive, consumed, http, mode, firstWire,
firstText, result, closing, canceled, failed, discarded,
prematureAbort, badCoreCall, foreignMutation>>
Tail == /\ mode = "drain" /\ http = "open" /\ discarded < MaxBytes
/\ discarded' = discarded + 1 /\ event' = "tail_byte"
/\ UNCHANGED <<op, coreAlive, coreActive, consumed, http, mode, firstWire,
firstText, result, closing, canceled, failed, age,
prematureAbort, badCoreCall, foreignMutation>>
Destroy == /\ closing /\ coreAlive /\ ~coreActive /\ http # "open"
/\ mode \in {"idle", "done"} /\ coreAlive' = FALSE /\ event' = "destroy"
/\ UNCHANGED <<op, coreActive, consumed, http, mode, firstWire, firstText,
result, closing, canceled, failed, age, discarded,
prematureAbort, badCoreCall, foreignMutation>>
LateCallback(h) == /\ op > 0 /\ h \in 1..MaxOps
/\ (h # op \/ ~coreAlive \/ ~coreActive)
/\ badCoreCall' = (badCoreCall \/ (UnsafeCallbacks /\ ~coreAlive))
/\ foreignMutation' = (foreignMutation \/ (UnsafeCallbacks /\ h # op))
/\ event' = "late_callback"
/\ UNCHANGED <<op, coreAlive, coreActive, consumed, http, mode,
firstWire, firstText, result, closing, canceled,
failed, age, discarded, prematureAbort>>
Next == Open \/ WireText \/ Deliver \/ Complete \/ EOF \/ Cancel \/ Fail \/ Close
\/ Observe \/ Tick \/ Tail \/ Destroy \/ (\E h \in 1..MaxOps : LateCallback(h))
Spec == Init /\ [][Next]_vars /\ WF_vars(Observe) /\ WF_vars(Tick)
/\ WF_vars(Deliver) /\ WF_vars(Complete) /\ WF_vars(Destroy)
TypeOK == /\ op \in 0..MaxOps /\ age \in 0..MaxAge /\ discarded \in 0..MaxBytes
/\ http \in {"none", "open", "eof", "aborted"}
/\ mode \in {"idle", "forward", "drain", "done"}
/\ \A b \in {coreAlive, coreActive, consumed, firstWire, firstText, result,
closing, canceled, failed, prematureAbort, badCoreCall,
foreignMutation} : b \in BOOLEAN
PolicyOracle == PrintT(<<"POLICY", coreAlive, coreActive, consumed, http, closing,
canceled, failed, age, discarded, Policy>>)
NoPrematureAbort == ~prematureAbort
NoNativeCallsAfterDestroy == ~badCoreCall
NoForeignHandleMutation == ~foreignMutation
NoDeadCoreConsumer == ~coreActive \/ coreAlive
CancelWins == canceled => ~coreActive
DrainRequiresSuccess == mode = "drain" => consumed
CleanupProgress == (consumed /\ http = "open") ~> (http # "open")
CancelProgress == (canceled /\ http = "open") ~> (http # "open")
CloseProgress == closing ~> ~coreAlive
TextProgress == (firstWire /\ coreActive) ~> (firstText \/ canceled \/ failed)
=============================================================================
@@ -0,0 +1,179 @@
--------------------------- MODULE FetchObservation ---------------------------
EXTENDS Naturals, TLC
CONSTANT Observation
VARIABLES method, handle, nativeActive, nativeConsumed, nativeClosed,
semanticComplete, textPublished, resultPublished, textDelivered,
resultDelivered, canceled, failed, http, phase, cachedConsumed,
priorConsumed, seenConsumed, seenActive, disposition, prematureAbort,
decision, event
vars == <<method, handle, nativeActive, nativeConsumed, nativeClosed,
semanticComplete, textPublished, resultPublished, textDelivered,
resultDelivered, canceled, failed, http, phase, cachedConsumed,
priorConsumed, seenConsumed, seenActive, disposition, prematureAbort,
decision, event>>
Init == /\ method \in IF Observation = "all"
THEN {"coherent", "split", "fallback"} ELSE {Observation}
/\ handle \in {1, 2}
/\ nativeActive = TRUE /\ nativeConsumed = FALSE /\ nativeClosed = FALSE
/\ semanticComplete = FALSE /\ textPublished = FALSE /\ resultPublished = FALSE
/\ textDelivered = FALSE /\ resultDelivered = FALSE
/\ canceled = FALSE /\ failed = FALSE /\ http = "open" /\ phase = "ready"
/\ cachedConsumed = FALSE /\ priorConsumed = FALSE
/\ seenConsumed = FALSE /\ seenActive = TRUE /\ disposition = 0
/\ prematureAbort = FALSE /\ decision = "none" /\ event = "init"
\* One live native handle (1), plus a foreign query handle (2). The lifecycle
\* model separately explores successive operations and late callbacks.
MatchingConsumed == handle = 1 /\ nativeConsumed
MatchingActive == handle = 1 /\ nativeActive
Disposition == IF MatchingConsumed THEN 2 ELSE IF MatchingActive THEN 1 ELSE 0
\* Semantic completion, native marking, retirement, host publication, and JS
\* delivery are separate transitions. Only result publication needs completion.
SemanticComplete == /\ nativeActive /\ ~semanticComplete /\ ~canceled /\ ~failed
/\ semanticComplete' = TRUE /\ event' = "semantic_complete"
/\ UNCHANGED <<method, handle, nativeActive, nativeConsumed, nativeClosed,
textPublished, resultPublished, textDelivered, resultDelivered,
canceled, failed, http, phase, cachedConsumed, priorConsumed,
seenConsumed, seenActive, disposition, prematureAbort, decision>>
Consume == /\ semanticComplete /\ nativeActive /\ ~nativeConsumed
/\ ~canceled /\ ~failed /\ http = "open"
/\ nativeConsumed' = TRUE /\ event' = "native_consume"
/\ UNCHANGED <<method, handle, nativeActive, nativeClosed, semanticComplete,
textPublished, resultPublished, textDelivered, resultDelivered,
canceled, failed, http, phase, cachedConsumed, priorConsumed,
seenConsumed, seenActive, disposition, prematureAbort, decision>>
Retire == /\ nativeActive /\ nativeConsumed
/\ nativeActive' = FALSE /\ event' = "native_retire"
/\ UNCHANGED <<method, handle, nativeConsumed, nativeClosed, semanticComplete,
textPublished, resultPublished, textDelivered, resultDelivered,
canceled, failed, http, phase, cachedConsumed, priorConsumed,
seenConsumed, seenActive, disposition, prematureAbort, decision>>
Close == /\ ~nativeClosed /\ nativeClosed' = TRUE /\ nativeActive' = FALSE
/\ event' = "native_close"
/\ UNCHANGED <<method, handle, nativeConsumed, semanticComplete, textPublished,
resultPublished, textDelivered, resultDelivered, canceled, failed,
http, phase, cachedConsumed, priorConsumed, seenConsumed, seenActive,
disposition, prematureAbort, decision>>
PublishText == /\ ~textPublished /\ textPublished' = TRUE /\ event' = "publish_text"
/\ UNCHANGED <<method, handle, nativeActive, nativeConsumed, nativeClosed,
semanticComplete, resultPublished, textDelivered, resultDelivered,
canceled, failed, http, phase, cachedConsumed, priorConsumed,
seenConsumed, seenActive, disposition, prematureAbort, decision>>
PublishResult == /\ semanticComplete /\ ~resultPublished
/\ resultPublished' = TRUE /\ event' = "publish_result"
/\ UNCHANGED <<method, handle, nativeActive, nativeConsumed, nativeClosed,
semanticComplete, textPublished, textDelivered, resultDelivered,
canceled, failed, http, phase, cachedConsumed, priorConsumed,
seenConsumed, seenActive, disposition, prematureAbort, decision>>
DeliverText == /\ textPublished /\ ~textDelivered
/\ textDelivered' = TRUE /\ event' = "deliver_text"
/\ UNCHANGED <<method, handle, nativeActive, nativeConsumed, nativeClosed,
semanticComplete, textPublished, resultPublished, resultDelivered,
canceled, failed, http, phase, cachedConsumed, priorConsumed,
seenConsumed, seenActive, disposition, prematureAbort, decision>>
DeliverResult == /\ resultPublished /\ ~resultDelivered
/\ resultDelivered' = TRUE /\ event' = "deliver_result"
/\ UNCHANGED <<method, handle, nativeActive, nativeConsumed, nativeClosed,
semanticComplete, textPublished, resultPublished, textDelivered,
canceled, failed, http, phase, cachedConsumed, priorConsumed,
seenConsumed, seenActive, disposition, prematureAbort, decision>>
Cancel == /\ ~canceled /\ canceled' = TRUE /\ nativeActive' = FALSE
/\ nativeConsumed' = FALSE /\ event' = "cancel"
/\ UNCHANGED <<method, handle, nativeClosed, semanticComplete, textPublished,
resultPublished, textDelivered, resultDelivered, failed, http, phase,
cachedConsumed, priorConsumed, seenConsumed, seenActive, disposition,
prematureAbort, decision>>
Fail == /\ ~failed /\ failed' = TRUE /\ nativeActive' = FALSE /\ event' = "failure"
/\ UNCHANGED <<method, handle, nativeConsumed, nativeClosed, semanticComplete,
textPublished, resultPublished, textDelivered, resultDelivered, canceled,
http, phase, cachedConsumed, priorConsumed, seenConsumed, seenActive,
disposition, prematureAbort, decision>>
EOF == /\ http = "open" /\ http' = "eof" /\ event' = "http_eof"
/\ UNCHANGED <<method, handle, nativeActive, nativeConsumed, nativeClosed,
semanticComplete, textPublished, resultPublished, textDelivered,
resultDelivered, canceled, failed, phase, cachedConsumed, priorConsumed,
seenConsumed, seenActive, disposition, prematureAbort, decision>>
\* Only split mode composes booleans from two different native lock acquisitions.
ReadConsumed == /\ method = "split" /\ phase = "ready"
/\ priorConsumed' = cachedConsumed /\ seenConsumed' = MatchingConsumed
/\ cachedConsumed' = (cachedConsumed \/ MatchingConsumed)
/\ phase' = "read_active" /\ event' = "read_consumed"
/\ UNCHANGED <<method, handle, nativeActive, nativeConsumed, nativeClosed,
semanticComplete, textPublished, resultPublished, textDelivered,
resultDelivered, canceled, failed, http, seenActive, disposition,
prematureAbort, decision>>
ReadActive == /\ method = "split" /\ phase = "read_active"
/\ seenActive' = MatchingActive /\ phase' = "decide" /\ event' = "read_active"
/\ UNCHANGED <<method, handle, nativeActive, nativeConsumed, nativeClosed,
semanticComplete, textPublished, resultPublished, textDelivered,
resultDelivered, canceled, failed, http, cachedConsumed, priorConsumed,
seenConsumed, disposition, prematureAbort, decision>>
ReadDisposition == /\ method = "coherent" /\ phase = "ready"
/\ disposition' = Disposition /\ priorConsumed' = cachedConsumed
/\ cachedConsumed' = (cachedConsumed \/ Disposition = 2)
/\ seenConsumed' = (Disposition = 2) /\ seenActive' = (Disposition = 1)
/\ phase' = "decide" /\ event' = "read_disposition"
/\ UNCHANGED <<method, handle, nativeActive, nativeConsumed, nativeClosed,
semanticComplete, textPublished, resultPublished, textDelivered,
resultDelivered, canceled, failed, http, prematureAbort, decision>>
ReadFallback == /\ method = "fallback" /\ phase = "ready"
/\ priorConsumed' = cachedConsumed /\ seenConsumed' = FALSE
/\ seenActive' = MatchingActive /\ phase' = "decide" /\ event' = "read_fallback"
/\ UNCHANGED <<method, handle, nativeActive, nativeConsumed, nativeClosed,
semanticComplete, textPublished, resultPublished, textDelivered,
resultDelivered, canceled, failed, http, cachedConsumed, disposition,
prematureAbort, decision>>
\* Observation is synchronous on the JS side; native transitions may interleave.
\* Budgets are unspent here. FetchCleanup checks EOF, shutdown, and bounded drain.
Policy == IF http # "open" THEN "done"
ELSE IF canceled \/ failed THEN "abort"
ELSE IF cachedConsumed THEN "drain"
ELSE IF seenActive THEN "forward" ELSE "abort"
Decide == /\ phase = "decide" /\ phase' = "ready" /\ decision' = Policy
/\ http' = IF Policy = "abort" THEN "aborted" ELSE http
/\ prematureAbort' = (prematureAbort \/
(Policy = "abort" /\ MatchingConsumed /\ ~canceled /\ ~failed /\ http = "open"))
/\ event' = "decide"
/\ UNCHANGED <<method, handle, nativeActive, nativeConsumed, nativeClosed,
semanticComplete, textPublished, resultPublished, textDelivered,
resultDelivered, canceled, failed, cachedConsumed, priorConsumed,
seenConsumed, seenActive, disposition>>
Next == SemanticComplete \/ Consume \/ Retire \/ Close \/ PublishText \/ PublishResult
\/ DeliverText \/ DeliverResult \/ Cancel \/ Fail \/ EOF \/ ReadConsumed
\/ ReadActive \/ ReadDisposition \/ ReadFallback \/ Decide
Spec == Init /\ [][Next]_vars
TypeOK == /\ method \in {"coherent", "split", "fallback"} /\ handle \in {1, 2}
/\ disposition \in 0..2 /\ phase \in {"ready", "read_active", "decide"}
/\ http \in {"open", "eof", "aborted"}
/\ decision \in {"none", "forward", "drain", "abort", "done"}
/\ \A b \in {nativeActive, nativeConsumed, nativeClosed, semanticComplete,
textPublished, resultPublished, textDelivered, resultDelivered,
canceled, failed, cachedConsumed, priorConsumed, seenConsumed,
seenActive, prematureAbort} : b \in BOOLEAN
NoPrematureAbort == ~prematureAbort
CachedSuccessSound == cachedConsumed => (handle = 1 /\ semanticComplete)
NoUnsupportedDrain == method = "fallback" => ~cachedConsumed
PublicationOrder == /\ (textDelivered => textPublished)
/\ (resultDelivered => resultPublished)
/\ (resultPublished => semanticComplete)
\* Export also explores the unsafe mutant; its failure is checked separately.
CoherentSafe == method = "coherent" => NoPrematureAbort
ObservationOracle == IF phase = "decide" THEN
PrintT(<<"OBSERVATION", method, handle, nativeActive, nativeConsumed, nativeClosed,
semanticComplete, textPublished, resultPublished, textDelivered, resultDelivered,
canceled, failed, http, priorConsumed, seenConsumed, seenActive,
disposition, cachedConsumed, event, Policy>>)
ELSE TRUE
=============================================================================
@@ -0,0 +1,11 @@
SPECIFICATION Spec
CONSTANTS
MaxOps = 2
MaxAge = 2
MaxBytes = 2
Legacy = FALSE
DisableTimer = FALSE
UnsafeCallbacks = FALSE
INVARIANTS TypeOK NoPrematureAbort NoNativeCallsAfterDestroy NoForeignHandleMutation NoDeadCoreConsumer CancelWins DrainRequiresSuccess
PROPERTIES CleanupProgress CancelProgress CloseProgress TextProgress
CHECK_DEADLOCK FALSE
@@ -0,0 +1,10 @@
SPECIFICATION Spec
CONSTANTS
MaxOps = 2
MaxAge = 2
MaxBytes = 2
Legacy = TRUE
DisableTimer = FALSE
UnsafeCallbacks = FALSE
INVARIANTS TypeOK NoPrematureAbort
CHECK_DEADLOCK FALSE
@@ -0,0 +1,11 @@
SPECIFICATION Spec
CONSTANTS
MaxOps = 2
MaxAge = 2
MaxBytes = 2
Legacy = FALSE
DisableTimer = TRUE
UnsafeCallbacks = FALSE
INVARIANTS TypeOK NoPrematureAbort
PROPERTIES CleanupProgress CloseProgress
CHECK_DEADLOCK FALSE
@@ -0,0 +1,4 @@
SPECIFICATION Spec
CONSTANT Observation = "all"
INVARIANTS TypeOK CachedSuccessSound NoUnsupportedDrain PublicationOrder CoherentSafe ObservationOracle
CHECK_DEADLOCK FALSE
@@ -0,0 +1,4 @@
SPECIFICATION Spec
CONSTANT Observation = "fallback"
INVARIANTS TypeOK CachedSuccessSound NoUnsupportedDrain PublicationOrder
CHECK_DEADLOCK FALSE
@@ -0,0 +1,4 @@
SPECIFICATION Spec
CONSTANT Observation = "coherent"
INVARIANTS TypeOK NoPrematureAbort CachedSuccessSound NoUnsupportedDrain PublicationOrder
CHECK_DEADLOCK FALSE
@@ -0,0 +1,64 @@
# Native fetch cleanup model
These TLA+ models check native SDK fetch cleanup, not model quality or provider latency. `FetchCleanup.tla` retains the lifecycle proof: two operation handles, cancellation, failure, close, late callbacks, and bounded tail cleanup. `FetchObservation.tla` adds the native observation boundary that the lifecycle model's atomic `Observe` action does not represent.
## Run
TLC and Java are development tools, not libfx runtime dependencies. Set `JAVA` and `TLA2TOOLS_JAR` if they are not in the default locations:
```sh
JAVA=/opt/homebrew/opt/openjdk/bin/java \
TLA2TOOLS_JAR="$HOME/.local/share/tla/tla2tools.jar" \
/opt/homebrew/opt/node@24/bin/node sdk/tests/formal/native-fetch-cleanup/check.mjs
```
The runner qualifies each configuration independently:
| Configuration | Module | Expected TLC exit | Check |
| --- | --- | --- | --- |
| `Legacy` | `FetchCleanup` | 12 | Premature abort before HTTP EOF |
| `Fixed` | `FetchCleanup` | 0 | Original safety and liveness invariants |
| `NoTimer` | `FetchCleanup` | 13 | Stalled-tail liveness failure |
| `UnsafeCallbacks` | `FetchCleanup` | 12 | Foreign-handle mutation |
| `Export` | `FetchCleanup` | 0 | Reachable lifecycle policy cases |
| `SplitRead` | `FetchObservation` | 12 | Torn boolean observation causes premature abort |
| `ObservationFixed` | `FetchObservation` | 0 | Coherent enum observation safety |
| `ObservationFallback` | `FetchObservation` | 0 | Active-only fallback never infers success |
| `ObservationExport` | `FetchObservation` | 0 | Reachable observations for all three methods |
Expected negative runs qualify the checker; do not weaken their invariants. The observation export deliberately includes the unsafe split-read mutation and checks premature-abort safety only for the coherent method. `SplitRead` checks that same invariant unconditionally and must fail. Logs and state counts go to a fresh temporary directory.
Use `--update` after changing a model to regenerate `policy-cases.json` and `observation-cases.json` from TLC's reachable-state output. Do not hand-edit either oracle. The observation fixture projects reachable observations to sampler inputs and policy cases, and retains ordering witnesses selected from those observations. Policy expectations come from TLA+, not JavaScript. Ordinary SDK CI needs no Java: `test-fetch-cleanup-model.mjs` checks the model hashes and observation export configuration hash, verifies the sampler projection, and compares the imported production `fetchCleanupAction` against every recorded case.
## Code correspondence
| Model action/state | Implementation owner |
| --- | --- |
| `Open` and operation identity | `src/napi_fetch_state.zig` and `FetchBridge` in `src/napi_core_main.zig` |
| Lifecycle `WireText` / `Deliver` | Native body pump and normalized SDK event delivery |
| Lifecycle `Complete`, independent of `EOF` | Coarse abstraction of provider completion, native consumption, and result publication |
| Observation `SemanticComplete` / `Consume` / `Retire` / `Close` | Separate semantic completion, exact-handle marker publication, native retirement, and native close |
| Observation `PublishText` / `PublishResult` / `DeliverText` / `DeliverResult` | Separate host output publication and JS delivery; neither supplies the native consumption marker |
| Observation `ReadDisposition` | Single lock-protected `coreFetchDisposition(core, handle)` contract: retired = 0, active = 1, consumed = 2 |
| `Observe` / `Policy` / `Decide` | `fetchCleanupAction()` in `sdk/fetch-cleanup.js`, called by the SDK reader and close path in `sdk/node.js` |
| `Tick` / `Tail` | Host cleanup timer and discarded-byte limit |
| `Cancel` / `Fail` | Existing abort controller, native abort revocation and error handling |
| `Destroy` / `LateCallback` | Deferred runtime destruction and exact-handle/settled guards |
The legacy counterexample is open, wire text, delivered text, logical completion, then abort before EOF without cancellation/failure or exceeded bounds. Inline native tests replay that release ordering. `test-native-core-http-cleanup.mjs` drives it with a real loopback HTTP server whose EOF is delayed, then checks socket reuse across fresh agents. It also exercises actual cancellation, stalled tails, the byte cap and a second model step.
## Observation race
The split-read counterexample reads consumed as false, then the native worker completes consumption and retires the handle before JS reads active as false. The production policy receives `{ consumed: false, active: false }` and returns `abort`, although the matching native marker is true and the HTTP body remains open. `SplitRead` must report `NoPrematureAbort` at exit 12. This is an observation bug, not a different cleanup policy.
The coherent query samples one native state under one lock. A matching consumed marker returns 2 even while native active is true or after native close. JS caches consumed monotonically and derives active from that same query result. A capable addon must not compose separate boolean reads. If the export is absent, the model samples only active and never infers consumed from semantic completion, published output, or delivered output; a retired operation without observed success conservatively aborts. Explicit cancellation and failure still override cached success.
The focused model starts with one open native handle and permits a matching or foreign query handle. Completion, consumption, retirement, native close, text/result publication, and JS delivery interleave independently. Result publication requires semantic completion, and delivery requires the corresponding publication. There is no assumption that JS sees text or a result before native retirement, or that host publication orders the consumption query. `observation-cases.json` includes reachable witnesses on both sides of these orderings, consumed precedence before and after close, cache retention after cancellation, the foreign handle, fallback behavior, and the torn read.
## Scope and assumptions
TLC exhaustively checks the configured finite abstractions, not arbitrary production execution. The original lifecycle invariants and three negative oracles remain unchanged. Its `Observe`, `Tick`, `Deliver`, `Complete`, and `Destroy` actions have weak fairness assumptions; a blocked event loop cannot satisfy them. The two abstract budget units do not prove a 100 ms wall-clock deadline or 64 KiB physical limit. Runtime tests check those concrete guards. Network EOF is not assumed to arrive: the timer must release a stalled drain.
The observation model checks safety without fairness assumptions, with unspent cleanup budgets. Its native actions are atomic lock-boundary transitions; the enum query must be linearizable with those transitions. Native close preserves the exact consumed marker, cancellation revokes it, and a foreign handle cannot borrow it. The JS read and decision do not yield to other JS callbacks, but native actions can run between them. A snapshot that saw active may become stale before a native push; the caller must handle a stale push and refresh before deciding cleanup. Native close is distinct from JS runtime shutdown, which the lifecycle model covers. The focused model checks one query identity per run and does not compose both modules into a single refinement proof.
The test executes the actual imported policy on TLC-derived inputs and reproduces the split-read abort. Its sampler projection checks the declared observation contract, not the execution of `refreshFetch`, the addon lock, or output delivery. This is bounded policy agreement and observation safety, not a full refinement proof of the native addon, JavaScript runtime, HTTP stack, or memory allocator. Production enum wiring, integration, cancellation, bounds, lifetime, and live connection measurements still require runtime verification.
@@ -0,0 +1,4 @@
SPECIFICATION Spec
CONSTANT Observation = "split"
INVARIANTS TypeOK NoPrematureAbort CachedSuccessSound NoUnsupportedDrain PublicationOrder
CHECK_DEADLOCK FALSE
@@ -0,0 +1,10 @@
SPECIFICATION Spec
CONSTANTS
MaxOps = 2
MaxAge = 2
MaxBytes = 2
Legacy = FALSE
DisableTimer = FALSE
UnsafeCallbacks = TRUE
INVARIANTS TypeOK NoNativeCallsAfterDestroy NoForeignHandleMutation
CHECK_DEADLOCK FALSE
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir, homedir } from "node:os";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const directory = fileURLToPath(new URL(".", import.meta.url));
let java = process.env.JAVA ?? "java";
if (!process.env.JAVA) {
try { await access("/opt/homebrew/opt/openjdk/bin/java"); java = "/opt/homebrew/opt/openjdk/bin/java"; } catch {}
}
const jar = process.env.TLA2TOOLS_JAR ?? resolve(homedir(), ".local/share/tla/tla2tools.jar");
await access(jar);
const artifacts = await mkdtemp(resolve(tmpdir(), "libfx-tlc-"));
console.log(`TLC logs: ${artifacts}`);
const expected = [["Legacy", 12, "NoPrematureAbort"], ["Fixed", 0, "No error has been found"],
["NoTimer", 13, "Temporal properties were violated"], ["UnsafeCallbacks", 12, "NoForeignHandleMutation"],
["Export", 0, "No error has been found"],
["SplitRead", 12, "NoPrematureAbort", "FetchObservation"],
["ObservationFixed", 0, "No error has been found", "FetchObservation"],
["ObservationFallback", 0, "No error has been found", "FetchObservation"],
["ObservationExport", 0, "No error has been found", "FetchObservation"]];
const outputs = new Map();
for (const [config, exit, message, module = "FetchCleanup"] of expected) {
const result = spawnSync(java, ["-XX:+UseParallelGC", "-Xmx2g", "-cp", jar, "tlc2.TLC", "-workers", "1",
"-metadir", resolve(artifacts, config), "-config", resolve(directory, `${config}.cfg`),
resolve(directory, `${module}.tla`)], { cwd: directory, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
if (result.error) throw result.error;
const output = result.stdout + result.stderr;
await writeFile(resolve(artifacts, `${config}.log`), output);
assert.equal(result.status, exit, `${config}: unexpected TLC result; see ${artifacts}`);
assert.ok(output.includes(message), `${config}: missing expected checker result; see ${artifacts}`);
outputs.set(config, output);
const counts = output.match(/([\d,]+) states generated, ([\d,]+) distinct states found/);
assert.ok(counts, `${config}: missing TLC counts; see ${artifacts}`);
console.log(`${config}: expected TLC exit ${exit} observed; ${counts[1]} generated, ${counts[2]} distinct`);
}
const splitTrace = outputs.get("SplitRead");
for (const event of ["read_consumed", "native_consume", "read_active", "decide"]) {
assert.ok(splitTrace.includes(`event = "${event}"`), `SplitRead: missing ${event} in counterexample`);
}
assert.match(splitTrace, /event = "native_(retire|close)"/);
const parseTuple = line => JSON.parse(`[${line.slice(2, -2).replaceAll("TRUE", "true").replaceAll("FALSE", "false")}]`);
const cases = new Map();
for (const line of outputs.get("Export").split("\n")) {
if (!line.startsWith('<<"POLICY",')) continue;
const values = parseTuple(line);
const [, alive, active, consumed, http, closing, canceled, failed, age, discarded, expected] = values;
const state = { alive, active, consumed, eof: http !== "open", closing, canceled, failed, age, discarded };
cases.set(JSON.stringify(state), { state, expected });
}
const model = await readFile(resolve(directory, "FetchCleanup.tla"));
const fixture = { origin: "TLC Export.cfg reachable states; not a JS-derived oracle",
modelSha256: createHash("sha256").update(model).digest("hex"), maxAge: 2, maxBytes: 2,
cases: [...cases.values()].sort((a, b) => JSON.stringify(a.state).localeCompare(JSON.stringify(b.state))) };
async function recordOrVerify(name, value) {
const path = resolve(directory, name);
if (process.argv.includes("--update")) {
await writeFile(path, JSON.stringify(value, null, 2) + "\n");
} else {
assert.deepEqual(JSON.parse(await readFile(path, "utf8")), value,
`${name} differs from TLC; run check.mjs --update, never hand-edit it`);
}
}
const observations = new Map();
// TLC pretty-prints long tuples across lines; accept both compact and wrapped output.
for (const [line] of outputs.get("ObservationExport").matchAll(/<<\s*"OBSERVATION",[\s\S]*?>>/g)) {
const [, method, handle, nativeActive, nativeConsumed, nativeClosed, semanticComplete,
textPublished, resultPublished, textDelivered, resultDelivered, canceled, failed, http,
priorConsumed, seenConsumed, seenActive, disposition, consumed, event, expected] = parseTuple(line);
const observation = { method, handle, priorConsumed,
read: { consumed: seenConsumed, active: seenActive, disposition },
state: { alive: true, active: seenActive, consumed, eof: http !== "open", closing: false,
canceled, failed, age: 0, discarded: 0 }, expected,
native: { active: nativeActive, consumed: nativeConsumed, closed: nativeClosed },
host: { semanticComplete, textPublished, resultPublished, textDelivered, resultDelivered }, event };
observations.set(JSON.stringify(observation), observation);
}
const sortedObservations = [...observations.values()].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
const observationCases = new Map();
for (const { native, host, event, ...policyCase } of sortedObservations) {
observationCases.set(JSON.stringify(policyCase), policyCase);
}
// Select reachable ordering witnesses, never manufacture states or policy results.
const matchingSuccess = o => o.method === "coherent" && o.handle === 1 &&
o.native.consumed && !o.state.canceled && !o.state.failed && !o.state.eof;
const sampledSuccess = o => matchingSuccess(o) && o.event === "read_disposition";
const witnesses = Object.fromEntries(Object.entries({
consumedWhileActive: o => sampledSuccess(o) && o.native.active,
consumedAfterClose: o => sampledSuccess(o) && o.native.closed,
consumedBeforePublication: o => matchingSuccess(o) && !o.host.textPublished && !o.host.resultPublished,
consumedBeforeDelivery: o => matchingSuccess(o) && o.host.textPublished && o.host.resultPublished &&
!o.host.textDelivered && !o.host.resultDelivered,
publicationBeforeConsume: o => o.method === "coherent" && o.handle === 1 &&
o.event === "read_disposition" && o.host.textPublished && o.host.resultPublished && !o.native.consumed,
deliveryBeforeConsume: o => o.method === "coherent" && o.handle === 1 &&
o.event === "read_disposition" && o.host.textDelivered && o.host.resultDelivered && !o.native.consumed,
retirementBeforePublication: o => matchingSuccess(o) && !o.native.active &&
!o.host.textPublished && !o.host.resultPublished,
retirementBeforeDelivery: o => matchingSuccess(o) && !o.native.active &&
o.host.textPublished && o.host.resultPublished && !o.host.textDelivered && !o.host.resultDelivered,
foreignHandle: o => o.method === "coherent" && o.handle === 2 && o.native.consumed &&
o.event === "read_disposition" && !o.state.eof && !o.state.canceled && !o.state.failed,
cachedAfterCancel: o => o.method === "coherent" && o.priorConsumed && o.state.canceled &&
!o.native.consumed && o.event === "read_disposition" && !o.state.eof,
fallbackNoSuccess: o => o.method === "fallback" && o.handle === 1 && o.native.consumed &&
!o.native.active && o.host.resultDelivered && !o.state.eof && !o.state.canceled && !o.state.failed,
splitReadRace: o => o.method === "split" && o.handle === 1 && o.native.consumed &&
!o.native.active && !o.priorConsumed && !o.read.consumed && !o.read.active &&
!o.state.eof && !o.state.canceled && !o.state.failed,
}).map(([name, predicate]) => {
const witness = sortedObservations.find(predicate);
assert.ok(witness, `TLC did not reach ${name}`);
return [name, witness];
}));
const sha256 = bytes => createHash("sha256").update(bytes).digest("hex");
const observationFixture = { origin: "TLC ObservationExport.cfg reachable observations; not a JS-derived oracle",
modelSha256: sha256(await readFile(resolve(directory, "FetchObservation.tla"))),
configSha256: sha256(await readFile(resolve(directory, "ObservationExport.cfg"))),
maxAge: 2, maxBytes: 2, reachableObservations: observations.size,
cases: [...observationCases.values()].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), witnesses };
await recordOrVerify("policy-cases.json", fixture);
await recordOrVerify("observation-cases.json", observationFixture);
const policy = spawnSync(process.execPath, [resolve(directory, "../../test-fetch-cleanup-model.mjs")],
{ encoding: "utf8" });
assert.equal(policy.status, 0, policy.stderr || policy.stdout);
console.log(policy.stdout.trim());
console.log(`TLC logs: ${artifacts}`);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+49
View File
@@ -211,6 +211,55 @@ try {
expect(result.state === "unsupported", `unexpected state ${result.state}`);
});
const resolvedTarget = await command("Target.createTarget", { url: `http://127.0.0.1:${port}/sdk/index.html?force-unsupported=1` });
const resolvedSession = await command("Target.attachToTarget", { targetId: resolvedTarget.targetId, flatten: true });
try {
await command("Runtime.enable", {}, resolvedSession.sessionId);
await waitFor("location.pathname === '/sdk/index.html'", resolvedSession.sessionId);
const evaluated = await command("Runtime.evaluate", {
awaitPromise: true, returnByValue: true,
expression: `(async () => {
const {createFxAgent, resolveModel} = await import('/sdk/browser.js');
const apiKey = 'browser-resolved-fixture';
const id = 'browser/resolved-model';
let gets = 0, posts = 0;
const fetch = async (_, init) => {
if (init.method === 'GET') {
gets++;
return Response.json({data:[{id,type:'language',max_tokens:2048,tags:['reasoning'],reasoning_options:[{type:'effort',values:['high']}]}]});
}
posts++;
const body = JSON.parse(typeof init.body === 'string' ? init.body : new TextDecoder().decode(init.body));
if (body.reasoning !== 'high' || body.maxOutputTokens !== 2048) throw new Error('selected capabilities missing');
return new Response('data: {"type":"text-delta","delta":"ok"}\\n\\n' +
'data: {"type":"finish","finishReason":{"unified":"stop","raw":"stop"}}\\n\\ndata: [DONE]\\n\\n',
{headers:{'content-type':'text/event-stream'}});
};
const model = await resolveModel({apiKey, model:{id,effort:'high'}, fetch});
let checkpoint;
const output = [];
for (let index = 0; index < 2; index++) {
const agent = await createFxAgent({apiKey,model:JSON.parse(JSON.stringify(model)),fetch,
wasm:new URL('/zig-out/bin/fx-core.wasm',location.href).href,...(checkpoint ? {checkpoint} : {})});
try {
const turn = agent.prompt('say ok');
let text = '';
for await (const event of turn) if (event.type === 'text_delta') text += event.delta;
if ((await turn.result).stopReason !== 'end_turn') throw new Error('turn did not finish');
output.push(text);
checkpoint = await agent.checkpoint();
} finally { await agent.close(); }
}
return {gets,posts,output,checkpointBytes:checkpoint.length};
})()`,
}, resolvedSession.sessionId);
expect(!evaluated.exceptionDetails, JSON.stringify(evaluated.exceptionDetails));
const value = evaluated.result.value;
expect(value.gets === 1 && value.posts === 2, 'resolved browser agents fetched a catalog after explicit discovery');
expect(value.output.join('') === 'okok' && value.checkpointBytes > 0, 'resolved browser restore or output failed');
console.log('browser resolved model creation, prompt, and restore passed');
} finally { await command("Target.closeTarget", { targetId: resolvedTarget.targetId }); }
const { targetId } = await command("Target.createTarget", { url: "about:blank" });
const { sessionId } = await command("Target.attachToTarget", { targetId, flatten: true });
try {
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { fetchCleanupAction } from "../fetch-cleanup.js";
const directory = new URL("./formal/native-fetch-cleanup/", import.meta.url);
const fixture = JSON.parse(await readFile(new URL("policy-cases.json", directory), "utf8"));
const model = await readFile(new URL("FetchCleanup.tla", directory));
assert.equal(createHash("sha256").update(model).digest("hex"), fixture.modelSha256,
"TLA model changed: regenerate TLC policy cases, do not hand-edit the oracle");
assert.ok(fixture.cases.length >= 100);
for (const { state, expected } of fixture.cases) {
assert.equal(fetchCleanupAction(state, fixture.maxAge, fixture.maxBytes), expected, JSON.stringify(state));
}
// TLC's legacy counterexample reaches this state after model completion, before HTTP EOF.
const completed = { alive: true, active: false, consumed: true, eof: false, closing: false,
canceled: false, failed: false, age: 0, discarded: 0 };
assert.equal(fetchCleanupAction(completed, 2, 2), "drain");
assert.equal(fetchCleanupAction({ ...completed, canceled: true }, 2, 2), "abort");
assert.equal(fetchCleanupAction({ ...completed, age: 2 }, 2, 2), "abort");
assert.equal(fetchCleanupAction({ ...completed, discarded: 2 }, 2, 2), "abort");
assert.equal(fetchCleanupAction({ ...completed, eof: true }, 2, 2), "done");
assert.equal(fetchCleanupAction({ ...completed, consumed: false }, 2, 2), "abort");
const observationFixture = JSON.parse(await readFile(new URL("observation-cases.json", directory), "utf8"));
for (const [name, hash] of [["FetchObservation.tla", observationFixture.modelSha256],
["ObservationExport.cfg", observationFixture.configSha256]]) {
assert.equal(createHash("sha256").update(await readFile(new URL(name, directory))).digest("hex"), hash,
`${name} changed: regenerate TLC observation cases, do not hand-edit the oracle`);
}
assert.ok(observationFixture.cases.length > 0);
const methods = new Set();
const dispositions = new Set();
for (const { method, priorConsumed, read, state, expected } of observationFixture.cases) {
methods.add(method);
// This checks the declared sampler contract, not the SDK adapter's execution.
const consumed = priorConsumed || (method === "coherent" ? read.disposition === 2 :
method === "split" && read.consumed);
const active = method === "coherent" ? read.disposition === 1 : read.active;
assert.equal(state.consumed, consumed);
assert.equal(state.active, active);
if (method === "coherent") {
dispositions.add(read.disposition);
assert.equal(read.consumed, read.disposition === 2);
assert.equal(read.active, read.disposition === 1);
}
if (method === "fallback") assert.equal(consumed, false, "fallback must not invent success");
assert.equal(fetchCleanupAction(state, observationFixture.maxAge, observationFixture.maxBytes), expected,
JSON.stringify({ method, priorConsumed, read, state }));
}
assert.deepEqual([...methods].sort(), ["coherent", "fallback", "split"]);
assert.deepEqual([...dispositions].sort(), [0, 1, 2]);
const witnesses = observationFixture.witnesses;
assert.deepEqual(Object.keys(witnesses).sort(), ["consumedWhileActive", "consumedAfterClose",
"consumedBeforePublication", "consumedBeforeDelivery", "publicationBeforeConsume", "deliveryBeforeConsume",
"retirementBeforePublication", "retirementBeforeDelivery", "foreignHandle", "cachedAfterCancel",
"fallbackNoSuccess", "splitReadRace"].sort());
for (const witness of Object.values(witnesses)) {
assert.equal(fetchCleanupAction(witness.state, 2, 2), witness.expected, JSON.stringify(witness));
}
for (const name of ["consumedWhileActive", "consumedAfterClose"]) {
const witness = witnesses[name];
assert.equal(witness.event, "read_disposition");
assert.equal(witness.read.disposition, 2, "matching consumed marker wins over native active/close");
assert.equal(witness.expected, "drain");
}
assert.equal(witnesses.consumedWhileActive.native.active, true);
assert.equal(witnesses.consumedAfterClose.native.closed, true);
for (const name of ["consumedBeforePublication", "retirementBeforePublication"]) {
assert.equal(witnesses[name].native.consumed, true);
assert.equal(witnesses[name].host.textPublished, false);
assert.equal(witnesses[name].host.resultPublished, false);
}
for (const name of ["consumedBeforeDelivery", "retirementBeforeDelivery"]) {
assert.equal(witnesses[name].native.consumed, true);
assert.equal(witnesses[name].host.textPublished, true);
assert.equal(witnesses[name].host.resultPublished, true);
assert.equal(witnesses[name].host.textDelivered, false);
assert.equal(witnesses[name].host.resultDelivered, false);
}
for (const name of ["retirementBeforePublication", "retirementBeforeDelivery"]) {
assert.equal(witnesses[name].native.active, false);
}
for (const name of ["publicationBeforeConsume", "deliveryBeforeConsume"]) {
assert.equal(witnesses[name].native.consumed, false);
assert.equal(witnesses[name].host.textPublished, true);
assert.equal(witnesses[name].host.resultPublished, true);
}
assert.equal(witnesses.deliveryBeforeConsume.host.textDelivered, true);
assert.equal(witnesses.deliveryBeforeConsume.host.resultDelivered, true);
assert.equal(witnesses.foreignHandle.read.disposition, 0);
assert.equal(witnesses.foreignHandle.expected, "abort");
assert.equal(witnesses.cachedAfterCancel.state.consumed, true, "JS caches consumed monotonically");
assert.equal(witnesses.cachedAfterCancel.expected, "abort", "cancellation still wins");
assert.equal(witnesses.fallbackNoSuccess.state.consumed, false);
assert.equal(witnesses.fallbackNoSuccess.expected, "abort", "delivered result is not a native success marker");
const race = witnesses.splitReadRace;
assert.equal(race.native.consumed, true);
assert.equal(race.state.consumed, false);
assert.equal(race.state.active, false);
assert.equal(race.expected, "abort", "production policy reproduces the torn-observation abort");
const repaired = observationFixture.cases.find(c => c.method === "coherent" && c.handle === race.handle &&
c.read.disposition === 2 && Object.entries(race.state).every(([key, value]) =>
c.state[key] === (key === "consumed" ? true : value)));
assert.ok(repaired, "TLC must reach the corresponding coherent consumed observation");
assert.equal(repaired.expected, "drain");
assert.equal(fetchCleanupAction(repaired.state, 2, 2), repaired.expected,
"the coherent matching disposition repairs the observation, not the policy");
console.log(`fetch cleanup policy matches ${fixture.cases.length} lifecycle and ${observationFixture.cases.length} ` +
`observation TLC-derived cases (${observationFixture.reachableObservations} reachable observations, ` +
`${Object.keys(witnesses).length} ordering witnesses); split-read abort reproduced, coherent observation qualified`);
+464
View File
@@ -0,0 +1,464 @@
#!/usr/bin/env node
import { strict as assert } from "node:assert";
import { createServer } from "node:http";
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, resolveModel } from "../node.js";
// Legacy.cfg counterexample rows, with their FetchCleanup.tla source owners:
// 1 Init (13), 2 Open (20), 3 WireText (29), 4 Deliver (35),
// 5 Complete (41), 6 Observe (80) -> prematureAbort / NoPrematureAbort.
// HTTP EOF is deliberately withheld through rows 4 and 5. Normal completion
// must drain, not abort, even when a fresh agent closes before HTTP EOF.
const apiKey = "http-cleanup-fixture-key";
const modelId = "native/http-cleanup-model";
const addon = resolve(process.argv[2] || fileURLToPath(new URL("../../zig-out/lib/libfx.node", import.meta.url)));
const native = createRequire(import.meta.url)(addon);
assert.equal(typeof native.coreFetchDisposition, "function", "rebuild libfx.node with coreFetchDisposition before running this suite");
const eofDelayMs = 30;
const cleanupBoundMs = 500; // 100 ms policy budget plus scheduling allowance.
const realFetch = globalThis.fetch; // No dispatcher override: all agents share Node's default TCP pool.
const sse = (value) => `data: ${typeof value === "string" ? value : JSON.stringify(value)}\n\n`;
const textFrame = (delta) => sse({ type: "text-delta", delta });
const finishFrame = (reason) => sse({ type: "finish", finishReason: { unified: reason, raw: reason } });
const successFrames = textFrame("OK") + finishFrame("stop") + sse("[DONE]");
function deferred() {
let resolve, reject;
const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
void promise.catch(() => {});
return { promise, resolve, reject };
}
const serverFailure = deferred();
async function bounded(promise, label, ms = 2000) {
let timer;
try {
return await Promise.race([
promise,
serverFailure.promise,
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label}: exceeded ${ms} ms`)), ms); }),
]);
} finally {
clearTimeout(timer);
}
}
const requests = [];
let clientPosts = 0;
let serverPosts = 0;
let nextSocketId = 0;
const socketIds = new WeakMap();
function plan(mode, input) {
const record = {
id: requests.length + 1, mode, input, text: "", aborts: 0,
posted: deferred(), firstText: deferred(), aborted: deferred(), closed: deferred(),
};
requests.push(record);
return record;
}
function check(record, expectation) {
return `request ${record.id} (${record.mode}): ${expectation}`;
}
function endAfterDelay(record, tail = "") {
assert.ok(record.response, check(record, "server must have accepted the POST"));
assert.equal(record.response.writableEnded, false, check(record, "HTTP EOF must still be withheld"));
record.endTimer = setTimeout(() => {
record.endAt = performance.now();
record.response.end(tail);
}, eofDelayMs);
}
const server = createServer((request, response) => {
void serve(request, response).catch((error) => {
serverFailure.reject(error);
response.destroy();
});
});
server.on("connection", (socket) => { socketIds.set(socket, ++nextSocketId); });
async function serve(request, response) {
assert.equal(request.method, "POST", "resolved models must eliminate HTTP catalog GETs");
assert.equal(request.url, "/chat");
const record = requests[serverPosts++];
assert.ok(record, "unexpected HTTP POST");
record.socketId = socketIds.get(request.socket);
record.response = response;
response.once("close", () => {
clearTimeout(record.endTimer);
record.closedAt = performance.now();
record.closed.resolve();
});
let body = "";
for await (const chunk of request) body += chunk.toString("utf8");
assert.ok(!body.includes(apiKey), check(record, "credentials must not appear in model payloads"));
const payload = JSON.parse(body);
if (record.mode === "tool-step") {
assert.ok(payload.tools?.some((tool) => tool.name === "lookup"), "lookup must be advertised");
} else if (record.mode === "tool-result") {
assert.ok(body.includes("value:alpha"), "the second POST must include the tool result");
} else {
assert.ok(payload.tools == null || payload.tools.length === 0, "no native tools must be advertised");
}
response.writeHead(200, { "content-type": "text/event-stream" });
record.wireAt = performance.now();
const frames = record.mode === "active"
? textFrame("PARTIAL")
: record.mode === "tool-step"
? sse({ type: "tool-call", toolCallId: "cleanup_lookup", toolName: "lookup", input: { key: "alpha" } })
+ finishFrame("tool-calls") + sse("[DONE]")
: successFrames;
record.frames = frames;
response.write(frames);
record.posted.resolve();
}
await new Promise((done, fail) => { server.once("error", fail); server.listen(0, "127.0.0.1", done); });
const gatewayChatUrl = `http://127.0.0.1:${server.address().port}/chat`;
const agents = new Set();
const results = [];
let currentCase;
let model;
let catalogCalls = 0;
async function newAgent(extra = {}) {
const agent = await bounded(createFxAgent({
backend: "native", nativeAddon: addon, apiKey, model, gatewayChatUrl,
fetch(input, init) {
// Observe the SDK's own signal, then return the real Response unchanged.
assert.equal(String(input), gatewayChatUrl, "no external request is allowed");
assert.equal(init.method, "POST", "descriptor consumption must not discover models");
const record = requests[clientPosts++];
assert.ok(record, "unexpected client POST");
record.signal = init.signal;
assert.equal(init.signal.aborted, false, check(record, "POST must start with a live signal"));
init.signal.addEventListener("abort", () => {
record.aborts++;
record.abortAt = performance.now();
record.aborted.resolve();
}, { once: true });
return realFetch(input, init);
},
...extra,
}), "fresh native agent initialization");
agents.add(agent);
return agent;
}
function readTurn(turn, record) {
const task = (async () => {
for await (const update of turn) {
if (update.type !== "text_delta") continue;
record.text += update.delta;
if (record.firstTextAt === undefined) {
record.firstTextAt = performance.now();
assert.equal(record.response?.writableEnded, false,
check(record, "legacy row 4 Deliver: first text must arrive before HTTP EOF"));
record.firstText.resolve();
}
}
})();
void task.catch((error) => record.firstText.reject(error));
return task;
}
async function completedTurn(turn, record, reading) {
record.result = await bounded(turn.result, check(record, "legacy row 5 Complete: model result before HTTP EOF"));
record.resultAt = performance.now();
await bounded(reading, check(record, "event iterator must finish before HTTP EOF"));
assert.equal(record.result.stopReason, "end_turn", check(record, "model must succeed"));
assert.equal(record.text, "OK", check(record, "exact streamed model text"));
assert.equal(record.response.writableEnded, false, check(record, "model completion must not depend on HTTP EOF"));
assert.equal(record.aborts, 0, check(record, "legacy row 6 Observe: no premature successful signal abort"));
}
async function closedAgent(agent, closing = agent.close()) {
assert.equal(await bounded(closing, "native agent.close cleanup", cleanupBoundMs), undefined);
agents.delete(agent);
// Give the shared default fetch pool its ordinary idle/reuse turn.
await nextLoop();
}
function passed(name, details) {
results.push({ name, ...details });
console.log(`PASS ${name}: ${JSON.stringify(details)}`);
}
try {
model = await resolveModel({
apiKey, model: modelId, gatewayChatUrl,
fetch(_input, init) {
assert.equal(init.method, "GET");
catalogCalls++;
return Promise.resolve(Response.json({ object: "list", data: [{ id: modelId, type: "language", tags: ["tool-use"] }] }));
},
});
currentCase = "coherent disposition across native completion between legacy reads";
{
const record = plan("split-read", currentCase);
const wire = Buffer.from(successFrames);
const textBytes = Buffer.byteLength(textFrame("OK"));
const sleeper = new Int32Array(new SharedArrayBuffer(4));
// Hold only finish frames at the addon boundary. This guarantees the old
// getter reads false after text delivery, regardless of worker scheduling.
const held = [];
let pushedBytes = 0;
let released = false;
let handle;
const observations = record.interleaving = { dispositionReads: 0, consumedReads: 0, activeReads: 0, forcedSplitReads: 0, destroyed: 0 };
function releaseFinish(core, fetchHandle) {
assert.equal(fetchHandle, handle, "the completion gate must target the exact pushed handle");
assert.equal(native.pushCoreFetchResponse(core, fetchHandle, Buffer.concat(held)), 1);
released = true;
held.length = 0;
// Only the native worker runs here. JS cannot deliver EOF or process the
// completion wake until the getter returns, so retirement is the barrier.
const deadline = performance.now() + cleanupBoundMs;
while (native.coreFetchActive(core, fetchHandle)) {
assert.ok(performance.now() < deadline, "native completion must retire the handle within the bounded getter wait");
Atomics.wait(sleeper, 0, 0, 1);
}
assert.equal(native.coreFetchDisposition(core, fetchHandle), 2, "normal retirement must retain consumed disposition");
}
const wrapped = Object.assign(Object.create(native), {
pushCoreFetchResponse(core, fetchHandle, bytes) {
handle ??= fetchHandle;
assert.equal(fetchHandle, handle);
assert.equal(native.coreFetchDisposition(core, fetchHandle), 1, "withheld finish must keep this fetch active");
assert.deepEqual(bytes, wire.subarray(pushedBytes, pushedBytes + bytes.length), "forward only the real loopback response bytes");
const prefixLength = Math.min(bytes.length, Math.max(0, textBytes - pushedBytes));
if (prefixLength) assert.equal(native.pushCoreFetchResponse(core, fetchHandle, bytes.subarray(0, prefixLength)), 1);
if (prefixLength < bytes.length) held.push(Buffer.from(bytes.subarray(prefixLength)));
pushedBytes += bytes.length;
return 1;
},
coreFetchConsumed(core, fetchHandle) {
observations.consumedReads++;
const prior = native.coreFetchDisposition(core, fetchHandle) === 2;
if (!prior && !released && pushedBytes === wire.length) {
// Old refreshFetch reads false after text was pushed. Complete and
// retire on the native thread before returning that same false, so
// its separate coreFetchActive read sees false and aborts success.
observations.forcedSplitReads++;
releaseFinish(core, fetchHandle);
}
return prior;
},
coreFetchActive(core, fetchHandle) {
observations.activeReads++;
return native.coreFetchActive(core, fetchHandle);
},
coreFetchDisposition(core, fetchHandle) {
observations.dispositionReads++;
if (!released && pushedBytes === wire.length) releaseFinish(core, fetchHandle);
return native.coreFetchDisposition(core, fetchHandle);
},
destroyCore(core) {
observations.destroyed++;
return native.destroyCore(core);
},
});
const agent = await newAgent({ nativeAddon: wrapped });
const turn = agent.prompt(record.input);
await completedTurn(turn, record, readTurn(turn, record));
assert.equal(released, true, "the real finish frames must cross the native completion gate");
assert.equal(pushedBytes, wire.length);
assert.ok(observations.dispositionReads > 0, "the SDK must use the coherent native disposition getter");
assert.equal(observations.consumedReads, 0, "the new SDK path must never call the obsolete consumed getter");
assert.equal(observations.activeReads, 0, "refreshFetch must not split a coherent observation into an active read");
assert.equal(observations.forcedSplitReads, 0, "only the old split-read implementation enters the interleaving trap");
const closing = agent.close();
endAfterDelay(record);
await closedAgent(agent, closing);
await bounded(record.closed.promise, check(record, "successful drain must reach real HTTP EOF"), cleanupBoundMs);
assert.equal(record.aborts, 0, "coherent consumed state must drain without any successful signal abort");
assert.equal(record.signal.aborted, false);
assert.equal(record.response.writableEnded, true);
assert.equal(observations.consumedReads, 0, "normal close must not consult the obsolete consumed getter");
assert.equal(observations.activeReads, 0, "normal close must also use the coherent disposition getter");
assert.equal(observations.destroyed, 1, "closing must release the wrapped native core exactly once");
passed("coherent disposition interleaving", { posts: 1, ...observations, signalAborts: record.aborts });
}
currentCase = "legacy rows 1-6: fresh agents, delayed EOF, shared TCP pool";
let sharedSocketId;
let maxCloseMs = 0;
for (let index = 0; index < 30; index++) {
const record = plan("success", `cleanup success ${index + 1}`);
const agent = await newAgent();
const turn = agent.prompt(record.input);
const reading = readTurn(turn, record);
await bounded(record.firstText.promise, check(record, "first streamed text before EOF"));
await completedTurn(turn, record, reading);
// Start close with the real body still open; EOF follows 30 ms later.
const closeAt = performance.now();
const closing = agent.close();
endAfterDelay(record);
await closedAgent(agent, closing);
maxCloseMs = Math.max(maxCloseMs, performance.now() - closeAt);
assert.equal(record.aborts, 0, check(record, "NoPrematureAbort: successful close must never abort its fetch signal"));
assert.equal(record.signal.aborted, false);
assert.equal(record.response.writableEnded, true, check(record, "normal HTTP EOF must be reached"));
sharedSocketId ??= record.socketId;
assert.equal(record.socketId, sharedSocketId, check(record, "all 30 fresh agents must reuse the same request.socket"));
}
passed("delayed EOF socket reuse", { requests: 30, socketId: sharedSocketId, signalAborts: 0, maxCloseMs });
// FetchCleanup.tla Cancel (53): a real unfinished body must abort immediately,
// not enter the successful-consumption drain path.
for (const operation of ["turn.cancel", "agent.close"]) {
currentCase = `${operation} while a live POST is unfinished`;
const record = plan("active", currentCase);
const agent = await newAgent();
const turn = agent.prompt(record.input);
const reading = readTurn(turn, record);
await bounded(record.firstText.promise, check(record, "unfinished response text"));
const cancelAt = performance.now();
let closing;
if (operation === "turn.cancel") turn.cancel();
else closing = agent.close(); // FetchCleanup.tla Close (65).
assert.equal(record.signal.aborted, true, check(record, `${operation} must synchronously abort the client signal`));
assert.equal(record.aborts, 1, check(record, "exactly one abort event"));
record.result = await bounded(turn.result, check(record, "cancelled turn result"), cleanupBoundMs);
await bounded(reading, check(record, "cancelled event iterator"), cleanupBoundMs);
assert.equal(record.result.stopReason, "cancelled");
assert.equal(record.text, "PARTIAL");
await closedAgent(agent, closing);
await bounded(record.closed.promise, check(record, "server must observe the aborted response"), cleanupBoundMs);
assert.equal(record.response.writableEnded, false, "cancellation must not wait for server EOF");
passed(operation, { signalAborts: record.aborts, abortMs: record.abortAt - cancelAt });
}
// FetchCleanup.tla Tick (91): no more data or readiness events arrive.
currentCase = "successful model, HTTP tail never reaches EOF";
{
const record = plan("stalled-tail", currentCase);
const agent = await newAgent();
const turn = agent.prompt(record.input);
await completedTurn(turn, record, readTurn(turn, record));
const closeAt = performance.now();
await closedAgent(agent);
assert.equal(record.aborts, 1, check(record, "the 100 ms cleanup timer must abort a never-ending tail"));
assert.ok(record.abortAt - record.resultAt >= 50, check(record, "successful consumption must drain, not abort immediately"));
assert.equal(record.response.writableEnded, false);
await bounded(record.closed.promise, check(record, "timer abort must close the server response"), cleanupBoundMs);
passed("never-EOF cleanup", { signalAborts: record.aborts, closeMs: performance.now() - closeAt, abortAfterResultMs: record.abortAt - record.resultAt });
}
// FetchCleanup.tla Tail (97): >64 KiB after explicit model consumption.
// EOF is offered after 30 ms so a timer-only implementation cannot pass by
// aborting a stalled response after 100 ms instead of enforcing the byte cap.
currentCase = "consumed HTTP tail exceeds 64 KiB";
{
const record = plan("capped-tail", currentCase);
const agent = await newAgent();
const turn = agent.prompt(record.input);
await completedTurn(turn, record, readTurn(turn, record));
const tail = `: ${"x".repeat(96 * 1024)}\n\n`;
record.tailBytes = Buffer.byteLength(tail);
record.tailAt = performance.now();
record.response.write(tail);
endAfterDelay(record);
const closing = agent.close();
await bounded(record.aborted.promise, check(record, ">64 KiB tail must abort before the offered 30 ms EOF"), cleanupBoundMs);
assert.equal(record.response.writableEnded, false, check(record, "byte-limit abort must precede HTTP EOF"));
assert.equal(record.aborts, 1);
await closedAgent(agent, closing);
await bounded(record.closed.promise, check(record, "byte-limit abort must close the server response"), cleanupBoundMs);
passed("tail byte cap", { tailBytes: record.tailBytes, signalAborts: record.aborts, abortMs: record.abortAt - record.tailAt });
}
// FetchCleanup.tla Open (20) / LateCallback (109): one tool step changes the
// native fetch handle. Broader tool semantics remain in test-host-tools.mjs;
// this fixture only checks delayed cleanup cannot pollute the next POST.
currentCase = "tool-step completion drains before the next POST";
{
const first = plan("tool-step", currentCase);
const second = plan("tool-result", currentCase);
const toolStarted = deferred();
let toolCalls = 0;
const agent = await newAgent({
tools: [{
name: "lookup", description: "Look up a fixture value.",
inputSchema: { type: "object", properties: { key: { type: "string" } }, required: ["key"], additionalProperties: false },
execute(input, { signal }) {
assert.equal(signal.aborted, false);
assert.deepEqual(input, { key: "alpha" });
toolCalls++;
toolStarted.resolve();
return "value:alpha";
},
}],
});
const turn = agent.prompt(currentCase);
const reading = readTurn(turn, second);
await bounded(toolStarted.promise, "host tool after finish(tool-calls)");
assert.equal(first.aborts, 0, "tool completion is successful consumption, not cancellation");
// Bytes after finish must be discarded, never become text on the next handle.
endAfterDelay(first, textFrame("STALE-FIRST-RESPONSE"));
await bounded(second.posted.promise, "next POST after successful tool-step cleanup");
assert.ok(first.endAt <= second.wireAt, "next POST must not overtake the first body's EOF");
await completedTurn(turn, second, reading);
const closing = agent.close();
endAfterDelay(second);
await closedAgent(agent, closing);
assert.equal(toolCalls, 1);
assert.equal(first.aborts, 0);
assert.equal(second.aborts, 0);
assert.equal(second.text, "OK", "late first-response text must not reach the next model step");
passed("tool-step cleanup isolation", { posts: 2, toolCalls, signalAborts: 0 });
}
currentCase = "older addon without a consumed disposition keeps conservative abort";
{
const record = plan("legacy-addon", currentCase);
let activeReads = 0;
const wrapped = Object.assign(Object.create(native), {
coreFetchDisposition: undefined,
coreFetchConsumed() { throw new Error("an older addon must not compose a split observation"); },
coreFetchActive(core, handle) {
activeReads++;
return native.coreFetchActive(core, handle);
},
});
const agent = await newAgent({ nativeAddon: wrapped });
const turn = agent.prompt(record.input);
const reading = readTurn(turn, record);
record.result = await bounded(turn.result, "legacy addon successful model result");
await bounded(reading, "legacy addon event iterator");
await bounded(record.aborted.promise, "legacy addon conservative fetch abort");
assert.equal(record.result.stopReason, "end_turn");
assert.equal(record.text, "OK");
assert.ok(activeReads > 0, "missing disposition must use the existing active-only query");
assert.equal(record.aborts, 1, "without an authoritative consumed query the old addon must abort, not infer success");
assert.equal(record.response.writableEnded, false);
await closedAgent(agent);
await bounded(record.closed.promise, "legacy addon response release", cleanupBoundMs);
passed("legacy addon fallback", { posts: 1, activeReads, signalAborts: record.aborts });
}
assert.equal(catalogCalls, 1, "one fake discovery must serve every fresh agent");
assert.equal(clientPosts, 38);
assert.equal(serverPosts, clientPosts);
console.log(`native core real HTTP cleanup passed: ${results.length} cases, ${serverPosts} POSTs, fake credentials only`);
} catch (error) {
// Preserve the first failing fixture input, wire frames and result without
// headers, credentials, or a replacement oracle. Do not retry a failure.
console.error("FIRST HTTP CLEANUP FAILURE", JSON.stringify({
case: currentCase,
expectation: error.message,
passed: results,
clientPosts, serverPosts,
requests: requests.map((record) => ({
id: record.id, mode: record.mode, input: record.input, socketId: record.socketId,
frames: record.frames, text: record.text, result: record.result, aborts: record.aborts,
signalAborted: record.signal?.aborted, httpEnded: record.response?.writableEnded,
wireAt: record.wireAt, firstTextAt: record.firstTextAt, resultAt: record.resultAt,
endAt: record.endAt, abortAt: record.abortAt, tailBytes: record.tailBytes, interleaving: record.interleaving,
})),
}));
throw error;
} finally {
for (const record of requests) clearTimeout(record.endTimer);
// Release real server bodies first so failure cleanup cannot hang on a read.
server.closeAllConnections();
for (const agent of agents) await bounded(agent.close().catch(() => {}), "failure teardown", cleanupBoundMs).catch(() => {});
await new Promise((done) => server.close(done));
}
+38 -1
View File
@@ -18,6 +18,7 @@ if (!ambientTraceChild) {
delete process.env.FX_TRACE_STDERR;
}
const addon = require(addonPath);
assert.equal(typeof addon.coreFetchDisposition, "function", "rebuild libfx.node with coreFetchDisposition before running this suite");
if (ambientTraceChild) {
const traceCore = addon.createCore({
@@ -43,6 +44,7 @@ for (const [name, args] of [
["drainCore", []],
["takeCoreFetch", []],
["coreFetchActive", []],
["coreFetchDisposition", []],
["startCoreFetchResponse", []],
["pushCoreFetchResponse", []],
["finishCoreFetch", []],
@@ -84,6 +86,7 @@ for (const fakeHandle of [null, undefined, {}, Buffer.alloc(0), 0, "handle"]) {
() => addon.coreExited(fakeHandle),
(error) => error instanceof TypeError || error.code === "LIBFX_INVALID_ARGUMENT" || error.code === "LIBFX_NAPI",
);
assert.throws(() => addon.coreFetchDisposition(fakeHandle, 1), { name: "TypeError" });
}
const core = addon.createCore({ apiKey: "misuse-test-key", home: "/tmp", workspaceRoot: "/tmp" });
@@ -98,6 +101,7 @@ assert.throws(
() => addon.coreExited(core),
(error) => error.code === "LIBFX_NATIVE_CLOSED",
);
assert.throws(() => addon.coreFetchDisposition(core, 1), (error) => error.code === "LIBFX_NATIVE_CLOSED");
const lifecycleCore = addon.createCore({
apiKey: "lifecycle-test-key",
@@ -173,13 +177,28 @@ try {
const futureHandle = firstHandle + 1;
assert.equal(addon.coreFetchActive(lifecycleCore, firstHandle), true);
assert.equal(addon.coreFetchActive(lifecycleCore, futureHandle), false);
assert.equal(addon.coreFetchDisposition(lifecycleCore, firstHandle), 1, "the exact live handle must be active");
assert.equal(addon.coreFetchDisposition(lifecycleCore, futureHandle), 0, "a different positive handle must be retired, never active or consumed");
assert.equal(addon.coreFetchDisposition(lifecycleCore, 0x7fffffff), 0, "the maximum positive int32 handle must remain exact");
assert.throws(() => addon.coreFetchDisposition(lifecycleCore), {
name: "TypeError", code: "LIBFX_INVALID_ARGUMENT", message: "missing required argument",
});
for (const invalidHandle of [0, -1, 1.5, NaN, Infinity, -Infinity, 0x80000000, 0x100000000 + firstHandle, null, undefined, {}, String(firstHandle), 1n]) {
assert.throws(() => addon.coreFetchDisposition(lifecycleCore, invalidHandle), {
name: "TypeError", code: "LIBFX_INVALID_ARGUMENT",
});
}
assert.equal(addon.startCoreFetchResponse(lifecycleCore, futureHandle, 200), 0);
assert.equal(addon.coreFetchActive(lifecycleCore, firstHandle), true, "stale start must not mutate the active handle");
assert.equal(addon.coreFetchDisposition(lifecycleCore, firstHandle), 1, "invalid or stale handles must not mutate the live disposition");
assert.equal(addon.startCoreFetchResponse(lifecycleCore, firstHandle, 200), 1);
assert.equal(addon.pushCoreFetchResponse(lifecycleCore, firstHandle, Buffer.alloc(8 * 1024 * 1024 + 1)), 2);
assert.equal(addon.pushCoreFetchResponse(lifecycleCore, firstHandle, responseBytes("first")), 1);
assert.equal(addon.finishCoreFetch(lifecycleCore, firstHandle), 1);
assert.equal((await Promise.race([waitForResponse(firstPrompt), timeout("first prompt result")])).result.stopReason, "end_turn");
assert.equal(addon.coreFetchActive(lifecycleCore, firstHandle), false);
assert.equal(addon.coreFetchDisposition(lifecycleCore, firstHandle), 2, "logical consumption must survive the normal fetch close");
assert.equal(addon.coreFetchDisposition(lifecycleCore, futureHandle), 0, "consumption must not grant a future handle");
const secondPrompt = sendPrompt(sessionId, "second low-level prompt");
const secondFetch = await Promise.race([takeFetch(), timeout("second host fetch")]);
@@ -191,13 +210,19 @@ try {
assert.equal(addon.finishCoreFetch(lifecycleCore, firstHandle), 0);
assert.equal(addon.failCoreFetch(lifecycleCore, firstHandle), 0);
assert.equal(addon.coreFetchActive(lifecycleCore, secondHandle), true, "stale operations must not mutate the newer handle");
assert.equal(addon.coreFetchDisposition(lifecycleCore, secondHandle), 1, "the next handle must not inherit earlier consumption");
assert.equal(addon.coreFetchDisposition(lifecycleCore, firstHandle), 2, "the previous exact consumed identity must survive until replaced");
assert.equal(addon.startCoreFetchResponse(lifecycleCore, secondHandle, 200), 1);
assert.equal(addon.pushCoreFetchResponse(lifecycleCore, secondHandle, responseBytes("second")), 1);
assert.equal(addon.finishCoreFetch(lifecycleCore, secondHandle), 1);
assert.equal((await Promise.race([waitForResponse(secondPrompt), timeout("second prompt result")])).result.stopReason, "end_turn");
assert.equal(addon.coreFetchDisposition(lifecycleCore, firstHandle), 0, "new consumption replaces only the previous consumed identity");
assert.equal(addon.coreFetchDisposition(lifecycleCore, secondHandle), 2);
assert.equal(addon.coreFetchDisposition(lifecycleCore, secondHandle + 1), 0);
for (const [name, args] of [
["coreFetchActive", [lifecycleCore, 0]],
["coreFetchDisposition", [lifecycleCore, 0]],
["startCoreFetchResponse", [lifecycleCore, 0, 200]],
["pushCoreFetchResponse", [lifecycleCore, 0, Buffer.alloc(0)]],
["finishCoreFetch", [lifecycleCore, 0]],
@@ -208,6 +233,18 @@ try {
code: "LIBFX_INVALID_ARGUMENT",
});
}
addon.closeCore(lifecycleCore);
await Promise.race([(async () => {
while (!addon.coreExited(lifecycleCore)) await new Promise((resolveWait) => setTimeout(resolveWait, 2));
})(), timeout("normal core close")]);
assert.equal(addon.coreExitCode(lifecycleCore), 0);
assert.equal(addon.coreFetchActive(lifecycleCore, secondHandle), false);
assert.equal(addon.coreFetchDisposition(lifecycleCore, secondHandle), 2, "normal core shutdown must retain the exact consumed identity");
assert.equal(addon.coreFetchDisposition(lifecycleCore, secondHandle + 1), 0);
addon.abortCoreFetch(lifecycleCore);
assert.equal(addon.coreFetchDisposition(lifecycleCore, secondHandle), 0, "explicit cancellation must revoke consumption even after normal close");
assert.equal(addon.coreFetchActive(lifecycleCore, secondHandle), false);
} finally {
addon.closeCore(lifecycleCore);
addon.destroyCore(lifecycleCore);
@@ -236,4 +273,4 @@ try {
rmSync(traceDir, { recursive: true, force: true });
}
console.log("native core misuse passed: argument, handle, stale, ambient trace isolation, backpressure, and closed-handle checks are enforced");
console.log("native core misuse passed: argument, exact-handle disposition, consumed persistence, cancellation revocation, stale, ambient trace isolation, backpressure, and closed-handle checks are enforced");
+3
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
const scripts = [
"test-core-output.mjs",
"test-fetch-cleanup-model.mjs",
"test-core-output-pressure.mjs",
"test-native-core-ready.mjs",
"test-native-core-misuse.mjs",
@@ -17,6 +18,7 @@ const scripts = [
"test-agent-request-context.mjs",
"test-agent-transport-retry.mjs",
"test-native-core-stream.mjs",
"test-native-core-http-cleanup.mjs",
"test-native-core-fetch-failure.mjs",
"test-native-core-image-framing.mjs",
"test-native-host-tool-frame-limit.mjs",
@@ -25,6 +27,7 @@ const scripts = [
"test-native-host-tool-late-settle.mjs",
"test-default-import.mjs",
"test-list-models.mjs",
"test-resolved-model.mjs",
"test-libfx-loader.mjs",
"test-agent-bootstrap.mjs",
"test-agent-step-limit.mjs",
+1
View File
@@ -19,6 +19,7 @@ const commands = [
[process.execPath, [fileURLToPath(new URL("test-core-output.mjs", import.meta.url))]],
[process.execPath, ["--experimental-wasm-jspi", fileURLToPath(new URL("test-core-output-pressure.mjs", import.meta.url)), "wasm"]],
[process.execPath, [fileURLToPath(new URL("test-wasm-memory.mjs", import.meta.url))]],
[process.execPath, ["--experimental-wasm-jspi", fileURLToPath(new URL("test-resolved-model.mjs", import.meta.url)), "wasm"]],
[process.execPath, ["--experimental-wasm-jspi", fileURLToPath(new URL("test-agent-bootstrap.mjs", import.meta.url)), "wasm"]],
[process.execPath, ["--experimental-wasm-jspi", fileURLToPath(new URL("test-agent-step-limit.mjs", import.meta.url)), "wasm"]],
[process.execPath, ["--experimental-wasm-jspi", fileURLToPath(new URL("test-agent-effort.mjs", import.meta.url)), "wasm"]],
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env node
import { strict as assert } from "node:assert";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxAgent, resolveModel, supportsJspi } from "../node.js";
import { resolveModel as resolveBrowserModel } from "../browser.js";
import { createFxAgent as createSharedAgent } from "../fx-sdk.js";
const backend = process.argv[2] || "native";
if (!new Set(["native", "wasm", "discovery"]).has(backend)) {
throw new Error("usage: test-resolved-model.mjs [native|wasm|discovery] [--cold-child]");
}
const scriptPath = fileURLToPath(import.meta.url);
const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
const apiKey = "resolved-model-fixture-key";
const modelId = "sdk/resolved-model";
const plainId = "sdk/plain-model";
const catalogUrl = "https://ai-gateway.vercel.sh/coding-agent/v1/models";
const gatewayChatUrl = "http://127.0.0.1:43210/chat";
const defaultChatUrl = "https://ai-gateway.vercel.sh/v4/ai/language-model";
const pngData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jP0cAAAAASUVORK5CYII=";
const selectedRow = {
id: modelId,
type: "language",
tags: ["reasoning", "tool-use", "vision", "file-input"],
reasoning_options: [{ type: "effort", values: ["low", "high"] }],
fast_options: [{ type: "toggle" }],
context_window: 131_072,
max_tokens: 1234,
};
const catalog = { object: "list", data: [
{ ...selectedRow, name: "Ignored display name" },
{ id: plainId, type: "language" },
{ id: modelId, type: "image" },
] };
const clone = (value) => JSON.parse(JSON.stringify(value));
const encoder = new TextEncoder();
const decoder = new TextDecoder();
// A missing injected fetch must fail locally, never call a real Gateway.
globalThis.fetch = async () => { throw new Error("resolved-model test attempted an uninjected fetch"); };
function mockGateway({ allowCatalog = false, data = catalog, key = apiKey, chatUrl = gatewayChatUrl } = {}) {
const state = { get: 0, post: 0, chatBodies: [], events: [] };
const fetch = async (input, init = {}) => {
const url = String(input?.url ?? input);
const method = String(init.method ?? input?.method ?? "GET").toUpperCase();
assert.equal(new Headers(init.headers).get("authorization"), `Bearer ${key}`);
if (method === "GET") {
state.get += 1;
assert.equal(url, catalogUrl);
assert.ok(allowCatalog, "descriptor consumption must not discover models");
return Response.json(data);
}
state.post += 1;
assert.equal(method, "POST");
assert.equal(url, chatUrl);
state.chatBodies.push(JSON.parse(typeof init.body === "string" ? init.body : decoder.decode(init.body)));
return new Response(encoder.encode([
'data: {"type":"text-delta","delta":"ok"}',
'data: {"type":"finish","finishReason":{"unified":"stop","raw":"stop"},"usage":{"inputTokens":{"total":3},"outputTokens":{"total":2}}}',
"data: [DONE]",
"",
].join("\n\n")), { headers: { "content-type": "text/event-stream" } });
};
return { state, fetch, onEvent: (event) => state.events.push(structuredClone(event)) };
}
function typedError(code, model = modelId, capability = "modelMetadata") {
return (error) => {
assert.ok(error instanceof Error);
assert.equal(error.code, code);
assert.equal(error.model, model);
assert.equal(error.capability, capability);
assert.ok(!error.message.includes(apiKey), "errors must not include the credential");
return true;
};
}
async function discover(resolveFn = resolveModel, model = { id: modelId, effort: "high", fast: true }, overrides = {}) {
const gateway = mockGateway({ allowCatalog: true, ...overrides });
const descriptor = await resolveFn({ apiKey, model, gatewayChatUrl, fetch: gateway.fetch });
assert.equal(gateway.state.get, 1, "discovery must perform one explicit models GET");
assert.equal(gateway.state.post, 0);
return descriptor;
}
async function testDiscovery() {
for (const [surface, resolveFn] of [["node", resolveModel], ["browser", resolveBrowserModel]]) {
const before = Date.now();
const descriptor = await discover(resolveFn);
const after = Date.now();
assert.equal(descriptor.id, modelId);
assert.equal(descriptor.effort, "high");
assert.equal(descriptor.fast, true);
assert.equal(descriptor.metadata.version, 1);
assert.ok(Number.isSafeInteger(descriptor.metadata.resolvedAt));
assert.ok(descriptor.metadata.resolvedAt >= before && descriptor.metadata.resolvedAt <= after);
assert.ok(Number.isSafeInteger(descriptor.metadata.expiresAt));
assert.ok(descriptor.metadata.expiresAt > descriptor.metadata.resolvedAt);
assert.match(descriptor.metadata.scope, /^[a-f0-9]{64}$/);
assert.equal(descriptor.metadata.scope, createHash("sha256")
.update(JSON.stringify([catalogUrl, gatewayChatUrl, apiKey])).digest("hex"));
assert.deepEqual(descriptor.metadata.data, [selectedRow], `${surface} must retain only selected language metadata`);
assert.deepEqual(clone(descriptor), descriptor, "descriptor must survive JSON serialization without hidden state");
assert.ok(!JSON.stringify(descriptor).includes(apiKey));
const stringDescriptor = await discover(resolveFn, modelId);
assert.equal(stringDescriptor.id, modelId);
assert.ok(!Object.hasOwn(stringDescriptor, "effort"));
assert.ok(!Object.hasOwn(stringDescriptor, "fast"));
assert.equal(stringDescriptor.metadata.scope, descriptor.metadata.scope);
const defaultGateway = mockGateway({ allowCatalog: true });
const defaultDescriptor = await resolveFn({ apiKey, model: modelId, fetch: defaultGateway.fetch });
assert.equal(defaultGateway.state.get, 1);
assert.equal(defaultDescriptor.metadata.scope, createHash("sha256")
.update(JSON.stringify([catalogUrl, defaultChatUrl, apiKey])).digest("hex"));
assert.notEqual(defaultDescriptor.metadata.scope, descriptor.metadata.scope, "chat endpoint must bind the scope");
const otherKey = "other-resolved-model-fixture-key";
const otherGateway = mockGateway({ allowCatalog: true, key: otherKey });
const otherDescriptor = await resolveFn({ apiKey: otherKey, model: modelId, gatewayChatUrl, fetch: otherGateway.fetch });
assert.notEqual(otherDescriptor.metadata.scope, descriptor.metadata.scope, "explicit credential must bind the scope");
const invalidInputGateway = mockGateway({ allowCatalog: true });
for (const options of [{}, { apiKey }, { apiKey, model: "" }, { apiKey, model: { id: modelId, fast: "yes" } }]) {
await assert.rejects(resolveFn({ ...options, fetch: invalidInputGateway.fetch }), TypeError);
}
assert.equal(invalidInputGateway.state.get, 0, "invalid discovery inputs must have no effect");
assert.equal(invalidInputGateway.state.post, 0);
for (const [name, response, errorCheck] of [
["malformed JSON", () => new Response("{"), TypeError],
["missing data", () => Response.json({ object: "list" }), TypeError],
["empty catalog", () => Response.json({ data: [] }), typedError("LIBFX_MODEL_METADATA_INVALID")],
["missing selected model", () => Response.json({ data: [{ id: plainId, type: "language" }] }), typedError("LIBFX_MODEL_METADATA_INVALID")],
["non-language model", () => Response.json({ data: [{ id: modelId, type: "image" }] }), typedError("LIBFX_MODEL_METADATA_INVALID")],
["oversize catalog", () => new Response("{}", { headers: { "content-length": String(4 * 1024 * 1024 + 1) } }), RangeError],
["oversize selected metadata", () => Response.json({ data: [{ ...selectedRow, tags: ["x".repeat(64 * 1024)] }] }), RangeError],
]) {
let gets = 0;
await assert.rejects(resolveFn({ apiKey, model: modelId, gatewayChatUrl, fetch: async (url, init) => {
gets += 1;
assert.equal(String(url), catalogUrl);
assert.equal(init.method, "GET");
return response();
} }), errorCheck, `${surface}: ${name}`);
assert.equal(gets, 1);
}
}
const descriptor = await discover();
const invalid = typedError("LIBFX_MODEL_METADATA_INVALID");
const expired = typedError("LIBFX_MODEL_METADATA_EXPIRED");
for (const [name, change, check = invalid, overrides = {}] of [
["null metadata", (d) => { d.metadata = null; }],
["array metadata", (d) => { d.metadata = []; }],
["unknown version", (d) => { d.metadata.version = 2; }],
["invalid resolution time", (d) => { d.metadata.resolvedAt = NaN; }],
["future resolution time", (d) => { d.metadata.resolvedAt = Date.now() + 60_000; d.metadata.expiresAt = d.metadata.resolvedAt + 60_000; }],
["invalid expiry", (d) => { d.metadata.expiresAt = String(d.metadata.expiresAt); }],
["invalid scope", (d) => { d.metadata.scope = "not-a-sha256"; }],
["empty selected rows", (d) => { d.metadata.data = []; }],
["malformed selected row", (d) => { d.metadata.data = [null]; }],
["mismatched selected row", (d) => { d.metadata.data[0].id = plainId; }],
["mismatched configured ID", (d) => { d.id = plainId; }, typedError("LIBFX_MODEL_METADATA_INVALID", plainId)],
["non-language selected row", (d) => { d.metadata.data[0].type = "image"; }],
["too many selected rows", (d) => { d.metadata.data = Array.from({ length: 65 }, () => clone(selectedRow)); }],
["oversize selected rows", (d) => { d.metadata.data[0].tags = ["x".repeat(64 * 1024)]; }, RangeError],
["cyclic selected row", (d) => { d.metadata.data[0].tags = d.metadata.data; }],
["expired metadata", (d) => { d.metadata.resolvedAt = Date.now() - 2000; d.metadata.expiresAt = Date.now() - 1; }, expired],
["wrong credential", () => {}, invalid, { apiKey: "other-resolved-model-fixture-key" }],
["wrong endpoint", () => {}, invalid, { gatewayChatUrl: "http://127.0.0.1:43211/chat" }],
]) {
const candidate = clone(descriptor);
change(candidate);
const gateway = mockGateway();
let runtimes = 0;
await assert.rejects(createSharedAgent({
apiKey, model: candidate, gatewayChatUrl, fetch: gateway.fetch, onEvent: gateway.onEvent, ...overrides,
runtimeFactory() { runtimes += 1; throw new Error("invalid descriptor started a runtime"); },
}), check, name);
assert.equal(runtimes, 0, `${name}: reject before runtime construction`);
assertNoTransport(gateway);
assert.equal(gateway.state.events.length, 0, `${name}: reject before ACP initialization`);
}
const oldDescriptor = clone(descriptor);
oldDescriptor.metadata.resolvedAt = Date.now() - 2000;
oldDescriptor.metadata.expiresAt = Date.now() - 1;
const refreshed = await discover(resolveModel, oldDescriptor);
assert.equal(refreshed.id, modelId);
assert.equal(refreshed.effort, descriptor.effort);
assert.equal(refreshed.fast, descriptor.fast);
assert.ok(refreshed.metadata.expiresAt > oldDescriptor.metadata.expiresAt);
let finish, handler;
const requests = [];
const unsupportedGateway = mockGateway();
const oldRuntime = {
exited: new Promise(done => { finish = done; }),
setLineHandler(value) { handler = value; },
write(line) {
const message = JSON.parse(line);
requests.push(message.method);
queueMicrotask(() => handler({ id: message.id, result: { agentCapabilities: {} } }));
},
abortHostEffects() {},
closeStdin() { finish(0); },
};
await assert.rejects(createSharedAgent({ apiKey, model: descriptor, gatewayChatUrl,
fetch: unsupportedGateway.fetch, runtimeFactory: async () => oldRuntime }),
typedError("LIBFX_MODEL_METADATA_UNSUPPORTED"));
assert.deepEqual(requests, ["initialize"], "unsupported core must reject before session creation or POST");
assertNoTransport(unsupportedGateway);
console.log("Node and browser resolved-model discovery and admission passed");
}
function assertNoTransport(gateway) {
assert.equal(gateway.state.get, 0, "descriptor consumption must issue zero models GETs");
assert.equal(gateway.state.post, 0, "rejection must not issue a chat POST");
assert.equal(gateway.state.events.filter((event) => event.type === "transport.start").length, 0);
}
async function backendOptions() {
if (backend === "wasm") {
if (!supportsJspi()) throw new Error("Node JSPI is disabled. Run with --experimental-wasm-jspi");
return { backend, wasm: await readFile(resolve(repoRoot, "zig-out/bin/fx-core.wasm")) };
}
return { backend, nativeAddon: resolve(repoRoot, "zig-out/lib/libfx.node") };
}
function optionsFor(base, gateway, model, overrides = {}) {
return { ...base, apiKey, model, gatewayChatUrl, fetch: gateway.fetch, onEvent: gateway.onEvent, ...overrides };
}
async function runPrompt(agent, input = "say ok") {
const turn = agent.prompt(input);
let text = "";
for await (const event of turn) if (event.type === "text_delta") text += event.delta;
const result = await turn.result;
assert.equal(result.stopReason, "end_turn");
assert.equal(text.trim(), "ok");
return text.trim();
}
function messages(gateway, method) {
return gateway.state.events.filter((event) => event.type === "acp.send" && event.message.method === method)
.map((event) => event.message);
}
function assertEnvelope(envelope, descriptor) {
assert.equal(envelope.model, descriptor.id);
assert.equal(envelope.revision, String(descriptor.metadata.resolvedAt));
assert.deepEqual(envelope.data, descriptor.metadata.data);
assert.ok(envelope.validForMs > 0 && envelope.validForMs <= descriptor.metadata.expiresAt - descriptor.metadata.resolvedAt);
assert.ok(!JSON.stringify(envelope).includes(apiKey));
}
function assertRequest(body) {
assert.equal(body.reasoning, "high");
assert.equal(body.providerOptions?.gateway?.speed, "fast");
assert.equal(body.maxOutputTokens, selectedRow.max_tokens);
}
function fileParts(body) {
return body.prompt.flatMap((message) => Array.isArray(message.content) ? message.content : [])
.filter((part) => part.type === "file");
}
async function testRuntime() {
const base = await backendOptions();
const descriptor = await discover();
const savedDescriptor = clone(descriptor);
const gateway = mockGateway();
let agent;
let restored;
try {
agent = await createFxAgent(optionsFor(base, gateway, descriptor));
assertNoTransport(gateway);
const libfx = messages(gateway, "initialize")[0]?.params.clientCapabilities.libfx;
assert.equal(libfx?.modelMetadata, true);
assertEnvelope(libfx.initialModelMetadata, savedDescriptor);
// Mutate every capability-bearing layer after creation; the agent owns its snapshot.
descriptor.id = plainId;
descriptor.effort = "low";
descriptor.fast = false;
descriptor.metadata.expiresAt = 0;
descriptor.metadata.scope = "invalid";
descriptor.metadata.data[0].id = plainId;
descriptor.metadata.data[0].tags.length = 0;
descriptor.metadata.data[0].reasoning_options[0].values.length = 0;
descriptor.metadata.data[0].fast_options.length = 0;
descriptor.metadata.data[0].max_tokens = 1;
await runPrompt(agent, [
{ type: "text", text: "remember this image" },
{ type: "image", data: pngData, mimeType: "image/png" },
]);
assert.equal(gateway.state.get, 0);
assert.equal(gateway.state.post, 1);
assertRequest(gateway.state.chatBodies[0]);
assert.deepEqual(fileParts(gateway.state.chatBodies[0]), [
{ type: "file", mediaType: "image/png", data: { type: "data", data: pngData } },
]);
assertEnvelope(messages(gateway, "session/prompt")[0].params.modelMetadata, savedDescriptor);
// An unrelated discovery in the same process must not replace this agent's metadata.
await discover(resolveModel, plainId, { data: { data: [{ id: plainId, type: "language", max_tokens: 7 }] } });
await runPrompt(agent, "continue after unrelated discovery");
assert.equal(gateway.state.get, 0);
assert.equal(gateway.state.post, 2);
assertRequest(gateway.state.chatBodies[1]);
assertEnvelope(messages(gateway, "session/prompt")[1].params.modelMetadata, savedDescriptor);
const checkpoint = await agent.checkpoint();
assert.ok(checkpoint instanceof Uint8Array && checkpoint.length > 44);
assert.equal(decoder.decode(checkpoint.subarray(0, 4)), "FXCP");
await agent.close();
agent = null;
// FXCP holds history and usage, not model provenance; the new descriptor supplies capabilities.
const restoredGateway = mockGateway();
restored = await createFxAgent(optionsFor(base, restoredGateway, clone(savedDescriptor), { checkpoint }));
assertNoTransport(restoredGateway);
assert.equal(messages(restoredGateway, "libfx/restore").length, 1);
assertEnvelope(messages(restoredGateway, "initialize")[0].params.clientCapabilities.libfx.initialModelMetadata, savedDescriptor);
await runPrompt(restored, "continue from checkpoint");
assert.equal(restoredGateway.state.get, 0);
assert.equal(restoredGateway.state.post, 1);
const body = restoredGateway.state.chatBodies[0];
assertRequest(body);
assert.deepEqual(fileParts(body), [{ type: "file", mediaType: "image/png", data: { type: "data", data: pngData } }]);
const textParts = body.prompt.flatMap((message) => Array.isArray(message.content) ? message.content : [])
.filter((part) => part.type === "text").map((part) => part.text).join("\n");
for (const text of ["remember this image", "continue after unrelated discovery", "continue from checkpoint"]) {
assert.equal(textParts.split(text).length - 1, 1, `restored history must include ${text} exactly once`);
}
assertEnvelope(messages(restoredGateway, "session/prompt")[0].params.modelMetadata, savedDescriptor);
} finally {
await agent?.close();
await restored?.close();
}
const plainDescriptor = await discover(resolveModel, plainId);
for (const [model, code, capability] of [
[{ ...savedDescriptor, effort: "max" }, "LIBFX_MODEL_UNSUPPORTED_EFFORT", "effort"],
[{ ...plainDescriptor, effort: "high" }, "LIBFX_MODEL_UNSUPPORTED_EFFORT", "effort"],
[{ ...plainDescriptor, fast: true }, "LIBFX_MODEL_UNSUPPORTED_FAST", "fast"],
]) {
const rejectedGateway = mockGateway();
await assert.rejects(createFxAgent(optionsFor(base, rejectedGateway, clone(model))), typedError(code, model.id, capability));
assertNoTransport(rejectedGateway);
}
const noVisionGateway = mockGateway();
const noVisionAgent = await createFxAgent(optionsFor(base, noVisionGateway, plainDescriptor));
try {
await assert.rejects(runPrompt(noVisionAgent, [{ type: "image", data: pngData, mimeType: "image/png" }]),
/Image prompts are unavailable for the selected model/);
assertNoTransport(noVisionGateway);
} finally { await noVisionAgent.close(); }
const expiryGateway = mockGateway();
const expiryAgent = await createFxAgent(optionsFor(base, expiryGateway, clone(savedDescriptor)));
const realNow = Date.now;
try {
Date.now = () => savedDescriptor.metadata.expiresAt;
await assert.rejects(runPrompt(expiryAgent), typedError("LIBFX_MODEL_METADATA_EXPIRED"));
assertNoTransport(expiryGateway);
assert.equal(messages(expiryGateway, "session/prompt").length, 0, "absolute expiry rejects before ACP prompt send");
} finally {
Date.now = realNow;
await expiryAgent.close();
}
// Legacy string mode still discovers through the core on its first prompt.
const legacyGateway = mockGateway({ allowCatalog: true });
const legacyAgent = await createFxAgent(optionsFor(base, legacyGateway, modelId));
try {
assert.equal(legacyGateway.state.get, 0);
await runPrompt(legacyAgent);
assert.equal(legacyGateway.state.get, 1);
assert.equal(legacyGateway.state.post, 1);
assert.equal(messages(legacyGateway, "initialize")[0].params.clientCapabilities.libfx?.modelMetadata, undefined);
assert.equal(messages(legacyGateway, "session/prompt")[0].params.modelMetadata, undefined);
} finally { await legacyAgent.close(); }
const coldDescriptor = await discover();
const args = [...(backend === "wasm" ? ["--experimental-wasm-jspi"] : []), scriptPath, backend, "--cold-child"];
const child = spawnSync(process.execPath, args, {
cwd: repoRoot,
input: JSON.stringify({ apiKey, model: clone(coldDescriptor), gatewayChatUrl }),
encoding: "utf8",
timeout: 20_000,
env: Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("AI_GATEWAY_") && !key.startsWith("FX_"))),
});
if (child.error) throw child.error;
assert.equal(child.status, 0, `cold ${backend} process failed: ${child.stderr}`);
assert.equal(child.signal, null);
assert.deepEqual(JSON.parse(child.stdout), { backend, get: 0, post: 1, text: "ok" });
console.log(`${backend} resolved-model creation, prompt, restore, and cold process passed (GET 0, POST 1, text ok)`);
}
async function coldChild() {
const fixture = JSON.parse(readFileSync(0, "utf8"));
assert.deepEqual(Object.keys(fixture).sort(), ["apiKey", "gatewayChatUrl", "model"]);
assert.equal(fixture.apiKey, apiKey, "cold fixture must contain only the fake credential");
assert.equal(fixture.gatewayChatUrl, gatewayChatUrl);
const gateway = mockGateway();
const agent = await createFxAgent(optionsFor(await backendOptions(), gateway, fixture.model));
try {
assertNoTransport(gateway);
const text = await runPrompt(agent);
assert.equal(gateway.state.get, 0);
assert.equal(gateway.state.post, 1);
assertRequest(gateway.state.chatBodies[0]);
process.stdout.write(JSON.stringify({ backend, get: gateway.state.get, post: gateway.state.post, text }));
} finally { await agent.close(); }
}
if (process.argv[3] === "--cold-child") {
assert.notEqual(backend, "discovery");
await coldChild();
} else {
await testDiscovery();
if (backend !== "discovery") await testRuntime();
}
+202
View File
@@ -0,0 +1,202 @@
const std = @import("std");
const gateway = @import("../builtins/gateway.zig");
const catalog = @import("../core/gateway/model_catalog.zig");
const metadata = @import("../core/gateway/model_catalog_metadata.zig");
const capabilities = @import("../core/config/model_capabilities.zig");
const jsonrpc = @import("jsonrpc.zig");
const Allocator = std.mem.Allocator;
const max_bytes = 64 * 1024;
const max_entries = 64;
const max_lease_ms = 3_600_000;
pub const ValidationError = error{ InvalidModelMetadata, ModelMetadataExpired };
const ParseError = Allocator.Error || error{InvalidModelMetadata};
/// Connection-owned capability description, never credential or persistence authority.
pub const Snapshot = struct {
model: []u8 = &.{},
revision: []u8 = &.{},
entries: std.ArrayList(catalog.ModelCatalogEntry) = .empty,
received_at_ms: i64 = 0,
expires_at_ms: i64 = 0,
pub fn deinit(self: *Snapshot, alloc: Allocator) void {
if (self.model.len > 0) alloc.free(self.model);
if (self.revision.len > 0) alloc.free(self.revision);
catalog.freeModelCatalog(alloc, &self.entries);
self.* = .{};
}
pub fn resolve(self: *const Snapshot, model: []const u8, fallback: capabilities.Capabilities, now_ms: i64) ValidationError!capabilities.Capabilities {
if (self.entries.items.len == 0 or !std.mem.eql(u8, self.model, model) or now_ms < self.received_at_ms) {
return error.InvalidModelMetadata;
}
if (now_ms >= self.expires_at_ms) return error.ModelMetadataExpired;
return capabilities.mergeCapabilities(fallback, metadata.fromCatalogEntry(self.entries.items[0]));
}
/// Returns an owned snapshot. Time is supplied by the connection boundary.
pub fn parse(alloc: Allocator, value: std.json.Value, now_ms: i64) ParseError!Snapshot {
if (value != .object) return error.InvalidModelMetadata;
const model = value.object.get("model") orelse return error.InvalidModelMetadata;
const revision = value.object.get("revision") orelse return error.InvalidModelMetadata;
const lease = value.object.get("validForMs") orelse return error.InvalidModelMetadata;
const data = value.object.get("data") orelse return error.InvalidModelMetadata;
if (model != .string or model.string.len == 0 or model.string.len > 1024 or
revision != .string or revision.string.len == 0 or revision.string.len > 128 or
lease != .integer or lease.integer < 0 or lease.integer > max_lease_ms or
data != .array or data.array.items.len == 0 or data.array.items.len > max_entries) return error.InvalidModelMetadata;
for (data.array.items) |row| {
if (row != .object) return error.InvalidModelMetadata;
const id = row.object.get("id") orelse return error.InvalidModelMetadata;
if (id != .string or !std.mem.eql(u8, id.string, model.string)) return error.InvalidModelMetadata;
}
// Bound serialization before the canonical parser allocates catalog rows.
var buffer: [max_bytes]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buffer);
std.json.Stringify.value(value, .{}, &writer) catch return error.InvalidModelMetadata;
var result = Snapshot{};
errdefer result.deinit(alloc);
result.model = try alloc.dupe(u8, model.string);
result.revision = try alloc.dupe(u8, revision.string);
result.entries = gateway.parseModelCatalogForView(alloc, writer.buffered(), .full) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.InvalidModelMetadata,
};
// The catalog parser skips non-language or malformed rows. They cannot
// establish a usable host description for this strict selected-model path.
if (result.entries.items.len != data.array.items.len) return error.InvalidModelMetadata;
result.received_at_ms = now_ms;
result.expires_at_ms = std.math.add(i64, now_ms, lease.integer) catch return error.InvalidModelMetadata;
return result;
}
};
/// Omission retains the lease; an explicit value must be a valid envelope.
/// A non-null result is owned by the caller.
pub fn parseUpdate(alloc: Allocator, params: ?[]const u8, now_ms: i64) ParseError!?Snapshot {
const raw = params orelse return null;
const parsed = std.json.parseFromSlice(std.json.Value, alloc, raw, .{}) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.InvalidModelMetadata,
};
defer parsed.deinit();
if (parsed.value != .object) return error.InvalidModelMetadata;
const value = parsed.value.object.get("modelMetadata") orelse return null;
return try Snapshot.parse(alloc, value, now_ms);
}
pub fn rpcError(err: ValidationError, model: []const u8) jsonrpc.RpcError {
return .{
.code = jsonrpc.ErrorCode.invalid_params,
.message = switch (err) {
error.InvalidModelMetadata => "Selected model metadata is missing, invalid, or does not match the active model",
error.ModelMetadataExpired => "Selected model metadata lease has expired",
},
.data = .{
.code = switch (err) {
error.InvalidModelMetadata => "LIBFX_MODEL_METADATA_INVALID",
error.ModelMetadataExpired => "LIBFX_MODEL_METADATA_EXPIRED",
},
.model = model,
.capability = "modelMetadata",
},
};
}
test "selected metadata resolves token image effort and fast capabilities with a bounded lease" {
const alloc = std.testing.allocator;
const parsed = try std.json.parseFromSlice(std.json.Value, alloc,
\\{"model":"catalog/model","revision":"7","validForMs":1000,"data":[{"id":"catalog/model","type":"language","tags":["vision","file-input","tool-use"],"reasoning_options":[{"type":"effort","values":["high"]}],"fast_options":[{"type":"toggle"}],"context_window":128000,"max_tokens":4096}]}
, .{});
defer parsed.deinit();
var snapshot = try Snapshot.parse(alloc, parsed.value, 100);
defer snapshot.deinit(alloc);
const actual = try snapshot.resolve("catalog/model", .{}, 101);
try std.testing.expectEqual(@as(?u32, 128000), actual.context_window);
try std.testing.expectEqual(@as(?u32, 4096), actual.max_output_tokens);
try std.testing.expectEqual(capabilities.ImageInputSupport.native, actual.image_input_support);
try std.testing.expect(actual.supports_fast_mode);
try std.testing.expect(capabilities.reasoningEffortSupported(actual, .literal("high")));
try std.testing.expectError(error.InvalidModelMetadata, snapshot.resolve("other", .{}, 101));
try std.testing.expectError(error.InvalidModelMetadata, snapshot.resolve("catalog/model", .{}, 99));
try std.testing.expectError(error.ModelMetadataExpired, snapshot.resolve("catalog/model", .{}, 1100));
try std.testing.expectError(error.InvalidModelMetadata, (Snapshot{}).resolve("catalog/model", .{}, 0));
}
test "selected metadata rejects invalid envelopes empty skipped and wrong-model rows" {
const alloc = std.testing.allocator;
for ([_][]const u8{
"null", "{}",
\\{"model":"one","revision":"1","validForMs":1000,"data":[]}
,
\\{"model":"one","revision":"1","validForMs":1000,"data":[{"id":"other","type":"language"}]}
,
\\{"model":"one","revision":"1","validForMs":1000,"data":[{"id":"one","type":"embedding"}]}
,
\\{"model":"one","revision":"1","validForMs":1000,"data":[null]}
,
\\{"model":"one","revision":"","validForMs":1000,"data":[{"id":"one"}]}
,
\\{"model":"one","revision":"1","validForMs":3600001,"data":[{"id":"one"}]}
,
\\{"model":"one","revision":"1","validForMs":-1,"data":[{"id":"one"}]}
,
\\{"model":"one","revision":"1","validForMs":1.5,"data":[{"id":"one"}]}
,
}) |raw| {
const parsed = try std.json.parseFromSlice(std.json.Value, alloc, raw, .{});
defer parsed.deinit();
try std.testing.expectError(error.InvalidModelMetadata, Snapshot.parse(alloc, parsed.value, 0));
}
}
test "selected metadata enforces row and serialized byte limits" {
const alloc = std.testing.allocator;
var raw: std.Io.Writer.Allocating = .init(alloc);
defer raw.deinit();
try raw.writer.writeAll("{\"model\":\"one\",\"revision\":\"1\",\"validForMs\":3600000,\"data\":[");
for (0..65) |index| {
if (index > 0) try raw.writer.writeByte(',');
try raw.writer.writeAll("{\"id\":\"one\"}");
}
try raw.writer.writeAll("]}");
const parsed = try std.json.parseFromSlice(std.json.Value, alloc, raw.written(), .{});
defer parsed.deinit();
try std.testing.expectError(error.InvalidModelMetadata, Snapshot.parse(alloc, parsed.value, 0));
const padding = try alloc.alloc(u8, max_bytes);
defer alloc.free(padding);
@memset(padding, 'x');
const oversized = try std.fmt.allocPrint(alloc, "{{\"model\":\"one\",\"revision\":\"1\",\"validForMs\":1,\"data\":[{{\"id\":\"one\",\"description\":\"{s}\"}}]}}", .{padding});
defer alloc.free(oversized);
const too_large = try std.json.parseFromSlice(std.json.Value, alloc, oversized, .{});
defer too_large.deinit();
try std.testing.expectError(error.InvalidModelMetadata, Snapshot.parse(alloc, too_large.value, 0));
}
test "selected metadata request updates distinguish omission from invalid null and expire zero leases" {
const alloc = std.testing.allocator;
try std.testing.expect(try parseUpdate(alloc, null, 0) == null);
try std.testing.expect(try parseUpdate(alloc, "{}", 0) == null);
try std.testing.expectError(error.InvalidModelMetadata, parseUpdate(alloc, "{\"modelMetadata\":null}", 0));
var snapshot = (try parseUpdate(alloc,
\\{"modelMetadata":{"model":"one","revision":"1","validForMs":0,"data":[{"id":"one"}]}}
, 0)).?;
defer snapshot.deinit(alloc);
try std.testing.expectError(error.ModelMetadataExpired, snapshot.resolve("one", .{}, 0));
try std.testing.expectEqualStrings("LIBFX_MODEL_METADATA_EXPIRED", rpcError(error.ModelMetadataExpired, "one").data.?.code);
}
fn allocationFailureCase(alloc: Allocator) !void {
var snapshot = (try parseUpdate(alloc,
\\{"modelMetadata":{"model":"one","revision":"1","validForMs":1000,"data":[{"id":"one","type":"language","reasoning_options":[{"type":"effort","values":["high"]}],"pricing":{"web_search":"0.01"}}]}}
, 0)).?;
defer snapshot.deinit(alloc);
_ = try snapshot.resolve("one", .{}, 1);
}
test "selected metadata releases owned storage across allocation failures" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, allocationFailureCase, .{});
}
+88 -3
View File
@@ -21,6 +21,7 @@ const image_attachments = @import("../core/images/image_attachments.zig");
const jsonrpc = @import("jsonrpc.zig");
const acp_types = @import("types.zig");
const server = @import("server.zig");
const host_model_metadata = @import("model_metadata.zig");
const sessions = @import("sessions.zig");
const client_instructions = @import("client_instructions.zig");
const agent_runtime = @import("../core/agent/agent_runtime.zig");
@@ -167,6 +168,9 @@ const AcpContext = struct {
captured_mode: ?[]const u8 = null,
captured_permission_mode: ?PermissionMode = null,
current_prompt_input: ?*ParsedPromptInput = null,
/// Capability callbacks have a cancellation-only error contract. Retain the
/// strict host error here so the ACP boundary reports it without fetching.
model_metadata_error: ?host_model_metadata.ValidationError = null,
fn deinitPublishedToolCalls(self: *AcpContext) void {
var keys = self.published_tool_calls.keyIterator();
@@ -692,6 +696,10 @@ pub fn handlePrompt(
const session = if (state.active_session) |*active| active else return .{
.rpc_error = no_active_session_rpc_error,
};
server.applyHostModelMetadata(state, msg.params_raw, session.model) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.InvalidModelMetadata, error.ModelMetadataExpired => return .{ .rpc_error = host_model_metadata.rpcError(@errorCast(err), session.model) },
};
{
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
@@ -938,6 +946,9 @@ pub fn handlePrompt(
},
.outcome_allocator = alloc,
}, agent_config, job) catch |err| {
if (ctx.model_metadata_error) |metadata_error| {
if (err == error.Cancelled) return .{ .rpc_error = host_model_metadata.rpcError(metadata_error, session.model) };
}
if (err == error.NonInteractivePermissionRequired) {
ctx.stop_reason = .refused;
} else {
@@ -945,9 +956,13 @@ pub fn handlePrompt(
}
};
prompt_input.retainImageSnapshots();
if (ctx.model_metadata_error) |metadata_error| return .{ .rpc_error = host_model_metadata.rpcError(metadata_error, session.model) };
completeAcpTitleTask(state, session, alloc);
try sessions.sendActiveSessionInfoUpdate(state, alloc);
try sessions.sendActiveSessionUsageUpdate(state, alloc);
sessions.sendActiveSessionUsageUpdate(state, alloc) catch |err| switch (err) {
error.InvalidModelMetadata, error.ModelMetadataExpired => return .{ .rpc_error = host_model_metadata.rpcError(@errorCast(err), session.model) },
else => return err,
};
if (session.cancel_flag.load(.seq_cst)) {
ctx.stop_reason = .cancelled;
@@ -1686,6 +1701,7 @@ fn persistUsageCheckpoint(
fn modelCatalogUnavailable(raw_ctx: *anyopaque) bool {
const ctx: *AcpContext = @ptrCast(@alignCast(raw_ctx));
if (ctx.state.host_metadata_enabled and ctx.state.cfg.minimal_kernel) return false;
return ctx.state.capability_resolver.state == .failed;
}
@@ -1697,6 +1713,10 @@ fn resolveModelCapabilities(
const ctx: *AcpContext = @ptrCast(@alignCast(raw_ctx));
const session = if (ctx.state.active_session) |*active| active else return .{};
const bundle = ctx.state.cfg.provider_set.select(session.provider);
if (server.hostModelCapabilities(ctx.state, model, bundle.fallbackModelCapabilities(model)) catch |err| {
ctx.model_metadata_error = err;
return error.Cancelled;
}) |provided| return provided;
return ctx.state.capability_resolver.resolve(
ctx.state.alloc,
bundle.model_catalog orelse return bundle.fallbackModelCapabilities(model),
@@ -1724,6 +1744,7 @@ fn resolveModelOverride(
raw_model: []const u8,
) Allocator.Error!subagent_model_contract.ModelCatalogMatch {
const ctx: *AcpContext = @ptrCast(@alignCast(raw_ctx.?));
if (ctx.state.host_metadata_enabled and ctx.state.cfg.minimal_kernel) return .no_catalog;
const session = if (ctx.state.active_session) |*active| active else return .no_catalog;
const bundle = ctx.state.cfg.provider_set.select(session.provider);
const catalog_provider = bundle.model_catalog orelse return .no_catalog;
@@ -1758,10 +1779,74 @@ fn availableModelCapabilities(
) model_capabilities.Capabilities {
const ctx: *AcpContext = @ptrCast(@alignCast(raw_ctx));
const session = if (ctx.state.active_session) |*active| active else return .{};
return ctx.state.capability_resolver.available(
return server.availableModelCapabilities(
ctx.state,
model,
ctx.state.cfg.provider_set.select(session.provider).fallbackModelCapabilities(model),
);
) catch |err| {
ctx.model_metadata_error = err;
return .{};
};
}
test "selected metadata prompt rejects an expired lease before credentials or generation" {
const alloc = std.testing.allocator;
var state = server.ServerState{ .alloc = alloc, .cfg = undefined, .writer = undefined };
state.cfg.minimal_kernel = true;
state.selected_model = @constCast("one");
state.host_metadata_enabled = true;
state.active_session = @as(server.ActiveSessionState, undefined);
state.active_session.?.model = @constCast("one");
defer state.host_metadata.deinit(alloc);
try server.applyHostModelMetadata(&state,
\\{"modelMetadata":{"model":"one","revision":"1","validForMs":3600000,"data":[{"id":"one"}]}}
, "one");
state.host_metadata.expires_at_ms = 0;
var msg = jsonrpc.Message{ .params_raw = "{\"sessionId\":\"test\",\"prompt\":[{\"type\":\"text\",\"text\":\"hello\"}]}" };
const outcome = try handlePrompt(&state, alloc, &msg, "default", .ask);
try std.testing.expect(outcome == .rpc_error);
try std.testing.expectEqual(ErrorCode.invalid_params, outcome.rpc_error.code);
try std.testing.expectEqualStrings("LIBFX_MODEL_METADATA_EXPIRED", outcome.rpc_error.data.?.code);
}
test "selected metadata turn resolver and usage callbacks never fetch the catalog" {
const alloc = std.testing.allocator;
const Fixture = struct {
fetches: usize = 0,
fn fetch(raw: ?*anyopaque, _: Allocator, _: gateway_model_catalog.FetchInput) Allocator.Error!gateway_model_catalog.ProviderResult {
const self: *@This() = @ptrCast(@alignCast(raw.?));
self.fetches += 1;
return .{ .failure = .{ .category = .runtime } };
}
};
var fixture: Fixture = .{};
var state = server.ServerState{ .alloc = alloc, .cfg = undefined, .writer = undefined };
state.cfg.minimal_kernel = true;
state.cfg.provider_set = provider_set.gateway_only(.{ .model_catalog = .{ .context = &fixture, .fetch_fn = Fixture.fetch } });
state.selected_model = @constCast("one");
state.host_metadata_enabled = true;
state.active_session = @as(server.ActiveSessionState, undefined);
state.active_session.?.provider = .gateway;
defer state.host_metadata.deinit(alloc);
var ctx = AcpContext{ .alloc = alloc, .state = &state, .session_id = "test" };
try std.testing.expectError(error.Cancelled, resolveModelCapabilities(@ptrCast(&ctx), alloc, "one"));
try std.testing.expectEqual(error.InvalidModelMetadata, ctx.model_metadata_error.?);
try server.applyHostModelMetadata(&state,
\\{"modelMetadata":{"model":"one","revision":"1","validForMs":3600000,"data":[{"id":"one","tags":["vision","file-input"],"context_window":128000,"max_tokens":8192}]}}
, "one");
ctx.model_metadata_error = null;
const resolved = try resolveModelCapabilities(@ptrCast(&ctx), alloc, "one");
try std.testing.expectEqual(@as(?u32, 8192), resolved.max_output_tokens);
try std.testing.expectEqual(model_capabilities.ImageInputSupport.native, resolved.image_input_support);
try std.testing.expectEqual(@as(?u32, 128000), availableModelCapabilities(@ptrCast(&ctx), "one").context_window);
try std.testing.expectError(error.Cancelled, resolveModelCapabilities(@ptrCast(&ctx), alloc, "other"));
try std.testing.expectEqual(error.InvalidModelMetadata, ctx.model_metadata_error.?);
state.host_metadata.expires_at_ms = 0;
try std.testing.expectError(error.Cancelled, resolveModelCapabilities(@ptrCast(&ctx), alloc, "one"));
try std.testing.expectEqual(error.ModelMetadataExpired, ctx.model_metadata_error.?);
try std.testing.expectEqual(@as(?u32, null), availableModelCapabilities(@ptrCast(&ctx), "one").context_window);
try std.testing.expect(!modelCatalogUnavailable(@ptrCast(&ctx)));
try std.testing.expectEqual(@as(usize, 0), fixture.fetches);
}
fn finalizeTurn(raw_ctx: *anyopaque, turn_id: u64, outcome: types.TurnPresentationOutcome, disposition: ?types.ProviderCompletionDisposition) !void {
+258 -16
View File
@@ -48,6 +48,7 @@ const host_tool_runtime = @import("../core/tooling/host_tool_runtime.zig");
const tool_dispatch = @import("../core/tooling/tool_dispatch.zig");
const agent_checkpoint = @import("../core/agent/runtime/checkpoint.zig");
const libfx_steering = @import("libfx_steering.zig");
const host_model_metadata = @import("model_metadata.zig");
const tool_call_identities = @import("tool_call_identities.zig");
const Allocator = std.mem.Allocator;
@@ -322,6 +323,8 @@ pub const ServerState = struct {
/// than on v1, as one process uses one backend.
sessions_v2_requested: bool = false,
capability_resolver: gateway_provider.CapabilityResolver = .{},
host_metadata_enabled: bool = false,
host_metadata: host_model_metadata.Snapshot = .{},
terminate_connection: bool = false,
web_fetch_runtime: web_fetch_runtime.Runtime = web_fetch_runtime.Runtime.init(.{}),
web_search_runtime: web_search_runtime.Runtime = web_search_runtime.Runtime.init(.{}),
@@ -367,6 +370,7 @@ pub const ServerState = struct {
self.host_tools.deinit();
if (self.host_instructions.len > 0) self.alloc.free(self.host_instructions);
self.capability_resolver.deinit(self.alloc);
self.host_metadata.deinit(self.alloc);
var pending = self.pending_outbound.valueIterator();
while (pending.next()) |entry| {
if (entry.response) |*response| response.deinit(self.alloc);
@@ -533,6 +537,46 @@ pub fn catalogProviderFor(
return state.cfg.provider_set.select(provider).model_catalog;
}
pub fn hostModelCapabilities(
state: *const ServerState,
model: []const u8,
fallback: model_capabilities.Capabilities,
) host_model_metadata.ValidationError!?model_capabilities.Capabilities {
if (!state.host_metadata_enabled or !state.cfg.minimal_kernel) return null;
return try state.host_metadata.resolve(model, fallback, io_mod.milliTimestamp());
}
pub fn availableModelCapabilities(
state: *const ServerState,
model: []const u8,
fallback: model_capabilities.Capabilities,
) host_model_metadata.ValidationError!model_capabilities.Capabilities {
if (try hostModelCapabilities(state, model, fallback)) |provided| return provided;
return state.capability_resolver.available(model, fallback);
}
/// Refreshes only this connection's selected-model lease. No catalog or auth fallback.
/// Callers stop the prior prompt before replacing its borrowed metadata.
pub fn applyHostModelMetadata(
state: *ServerState,
params: ?[]const u8,
model: []const u8,
) (Allocator.Error || host_model_metadata.ValidationError)!void {
if (!state.host_metadata_enabled or !state.cfg.minimal_kernel) return;
var update = try host_model_metadata.parseUpdate(state.alloc, params, io_mod.milliTimestamp());
defer if (update) |*snapshot| snapshot.deinit(state.alloc);
if (update) |*snapshot| {
if (!std.mem.eql(u8, snapshot.model, state.selected_model)) return error.InvalidModelMetadata;
_ = try snapshot.resolve(model, .{}, io_mod.milliTimestamp());
debug_trace.logf("catalog", "replacing selected model metadata model={s}", .{state.host_metadata.model});
state.host_metadata.deinit(state.alloc);
state.host_metadata = snapshot.*;
snapshot.* = .{};
} else {
_ = try state.host_metadata.resolve(model, .{}, io_mod.milliTimestamp());
}
}
pub fn refreshModelCredential(
raw: *anyopaque,
alloc: Allocator,
@@ -1641,6 +1685,10 @@ fn handleKernelRestore(
.code = ErrorCode.invalid_params,
.message = "Unknown libfx session",
});
applyHostModelMetadata(state, msg.params_raw, active.model) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.InvalidModelMetadata, error.ModelMetadataExpired => return state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(@errorCast(err), active.model)),
};
const checkpoint = parsed.value.object.get("checkpoint") orelse
return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_params,
@@ -1958,10 +2006,13 @@ const InitializeRequest = struct {
client_elicitation: elicitation.Capabilities = .{},
host_tools: host_tool_runtime.Runtime = .{},
host_instructions: []u8 = &.{},
host_metadata_enabled: bool = false,
host_metadata: host_model_metadata.Snapshot = .{},
fn deinit(self: *InitializeRequest, alloc: Allocator) void {
self.host_tools.deinit();
if (self.host_instructions.len > 0) alloc.free(self.host_instructions);
self.host_metadata.deinit(alloc);
self.* = .{};
}
};
@@ -1972,8 +2023,10 @@ fn parseInitializeRequest(
allow_libfx: bool,
) !InitializeRequest {
const raw = params orelse return error.InvalidInitializeParams;
const parsed = std.json.parseFromSlice(std.json.Value, alloc, raw, .{}) catch
return error.InvalidInitializeParams;
const parsed = std.json.parseFromSlice(std.json.Value, alloc, raw, .{}) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.InvalidInitializeParams,
};
defer parsed.deinit();
if (parsed.value != .object) return error.InvalidInitializeParams;
@@ -1986,6 +2039,7 @@ fn parseInitializeRequest(
var request = InitializeRequest{
.terminal_ui = acp_types.fxMetaBool(parsed.value.object, "terminal") orelse true,
};
errdefer request.deinit(alloc);
const capabilities = parsed.value.object.get("clientCapabilities") orelse
return request;
if (capabilities != .object) return request;
@@ -2011,7 +2065,14 @@ fn parseInitializeRequest(
libfx.object.get("tools"),
libfx_provider_tool_registry,
);
errdefer request.host_tools.deinit();
if (libfx.object.get("modelMetadata")) |supported| {
if (supported != .bool) return error.InvalidModelMetadata;
request.host_metadata_enabled = supported.bool;
}
if (request.host_metadata_enabled) {
const initial = libfx.object.get("initialModelMetadata") orelse return error.InvalidModelMetadata;
request.host_metadata = try host_model_metadata.Snapshot.parse(alloc, initial, io_mod.milliTimestamp());
}
if (libfx.object.get("instructions")) |instructions| {
if (instructions != .string or instructions.string.len > 64 * 1024) {
return error.InvalidInitializeParams;
@@ -2023,6 +2084,145 @@ fn parseInitializeRequest(
return request;
}
test "selected metadata initialize opt-in is private to the minimal kernel" {
const alloc = std.testing.allocator;
const raw =
\\{"protocolVersion":1,"clientCapabilities":{"libfx":{"modelMetadata":true,"initialModelMetadata":{"model":"one","revision":"1","validForMs":1000,"data":[{"id":"one","context_window":4096}]}}}}
;
var request = try parseInitializeRequest(alloc, raw, true);
defer request.deinit(alloc);
try std.testing.expect(request.host_metadata_enabled);
try std.testing.expectEqualStrings("one", request.host_metadata.model);
var ordinary = try parseInitializeRequest(alloc, raw, false);
defer ordinary.deinit(alloc);
try std.testing.expect(!ordinary.host_metadata_enabled);
try std.testing.expectEqual(@as(usize, 0), ordinary.host_metadata.entries.items.len);
var legacy = try parseInitializeRequest(alloc,
\\{"protocolVersion":1,"clientCapabilities":{"libfx":{"modelMetadata":false,"initialModelMetadata":null}}}
, true);
defer legacy.deinit(alloc);
try std.testing.expect(!legacy.host_metadata_enabled);
for ([_][]const u8{
\\{"protocolVersion":1,"clientCapabilities":{"libfx":{"modelMetadata":true}}}
,
\\{"protocolVersion":1,"clientCapabilities":{"libfx":{"modelMetadata":"true"}}}
,
\\{"protocolVersion":1,"clientCapabilities":{"libfx":{"modelMetadata":true,"initialModelMetadata":null}}}
,
}) |invalid| {
try std.testing.expectError(error.InvalidModelMetadata, parseInitializeRequest(alloc, invalid, true));
}
}
test "selected metadata initialize returns the precise invalid params error before startup" {
const alloc = std.testing.allocator;
const Capture = struct {
output: std.Io.Writer.Allocating,
fn write(raw: ?*anyopaque, frame: []const u8) !void {
const self: *@This() = @ptrCast(@alignCast(raw.?));
try self.output.writer.writeAll(frame);
}
};
var capture = Capture{ .output = .init(alloc) };
defer capture.output.deinit();
var state = ServerState{ .alloc = alloc, .cfg = undefined, .writer = jsonrpc.Writer.initCallback(&capture, Capture.write) };
state.cfg.minimal_kernel = true;
state.cfg.model_override = "one";
var msg = jsonrpc.Message{ .id = .{ .integer = 1 }, .params_raw =
\\{"protocolVersion":1,"clientCapabilities":{"libfx":{"modelMetadata":true}}}
};
try handleInitialize(&state, alloc, &msg);
try std.testing.expect(!state.initialized);
try std.testing.expect(std.mem.find(u8, capture.output.written(), "LIBFX_MODEL_METADATA_INVALID") != null);
try std.testing.expect(std.mem.find(u8, capture.output.written(), "-32602") != null);
}
test "selected metadata initialize cleans up before rejecting later fields" {
try std.testing.expectError(error.InvalidInitializeParams, parseInitializeRequest(std.testing.allocator,
\\{"protocolVersion":1,"clientCapabilities":{"libfx":{"modelMetadata":true,"initialModelMetadata":{"model":"one","revision":"1","validForMs":1000,"data":[{"id":"one"}]} ,"instructions":false}}}
, true));
}
fn initializeMetadataAllocationCase(alloc: Allocator) !void {
var request = try parseInitializeRequest(alloc,
\\{"protocolVersion":1,"clientCapabilities":{"libfx":{"modelMetadata":true,"initialModelMetadata":{"model":"one","revision":"1","validForMs":1000,"data":[{"id":"one"}]},"instructions":"hello"}}}
, true);
defer request.deinit(alloc);
}
test "selected metadata initialize propagates allocation failures and frees partial ownership" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, initializeMetadataAllocationCase, .{});
}
test "selected metadata connection refresh rejects model changes and never uses legacy fallback" {
const alloc = std.testing.allocator;
var state = ServerState{ .alloc = alloc, .cfg = undefined, .writer = undefined };
state.cfg.minimal_kernel = true;
state.host_metadata_enabled = true;
state.selected_model = @constCast("one");
defer state.host_metadata.deinit(alloc);
try std.testing.expectError(error.InvalidModelMetadata, availableModelCapabilities(&state, "one", .{ .context_window = 999 }));
try applyHostModelMetadata(&state,
\\{"modelMetadata":{"model":"one","revision":"1","validForMs":3600000,"data":[{"id":"one","context_window":4096}]}}
, "one");
try std.testing.expectEqual(@as(?u32, 4096), (try availableModelCapabilities(&state, "one", .{})).context_window);
try std.testing.expectError(error.InvalidModelMetadata, applyHostModelMetadata(&state,
\\{"modelMetadata":{"model":"other","revision":"2","validForMs":1000,"data":[{"id":"other"}]}}
, "other"));
try std.testing.expectEqualStrings("1", state.host_metadata.revision);
try std.testing.expectError(error.InvalidModelMetadata, applyHostModelMetadata(&state, null, "other"));
try applyHostModelMetadata(&state,
\\{"modelMetadata":{"model":"one","revision":"2","validForMs":3600000,"data":[{"id":"one","context_window":8192}]}}
, "one");
try std.testing.expectEqual(@as(?u32, 8192), (try availableModelCapabilities(&state, "one", .{})).context_window);
state.host_metadata.expires_at_ms = 0;
try std.testing.expectError(error.ModelMetadataExpired, availableModelCapabilities(&state, "one", .{ .context_window = 999 }));
state.cfg.minimal_kernel = false;
try std.testing.expectEqual(@as(?u32, 999), (try availableModelCapabilities(&state, "one", .{ .context_window = 999 })).context_window);
}
test "selected metadata creation gates preserve effort and fast errors without catalog fetches" {
const alloc = std.testing.allocator;
const Capture = struct {
output: std.Io.Writer.Allocating,
fetches: usize = 0,
fn write(raw: ?*anyopaque, frame: []const u8) !void {
const self: *@This() = @ptrCast(@alignCast(raw.?));
try self.output.writer.writeAll(frame);
}
fn fetch(raw: ?*anyopaque, _: Allocator, _: model_catalog.FetchInput) Allocator.Error!model_catalog.ProviderResult {
const self: *@This() = @ptrCast(@alignCast(raw.?));
self.fetches += 1;
return .{ .failure = .{ .category = .runtime } };
}
};
var capture = Capture{ .output = .init(alloc) };
defer capture.output.deinit();
var state = ServerState{ .alloc = alloc, .cfg = undefined, .writer = jsonrpc.Writer.initCallback(&capture, Capture.write) };
state.cfg.minimal_kernel = true;
state.cfg.provider_set = @import("../core/gateway/provider_set.zig").gateway_only(.{ .model_catalog = .{ .context = &capture, .fetch_fn = Capture.fetch } });
state.selected_model = @constCast("one");
state.host_metadata_enabled = true;
defer state.host_metadata.deinit(alloc);
var msg = jsonrpc.Message{ .id = .{ .integer = 1 } };
try std.testing.expect(!try applyEffortOverride(&state, alloc, &msg, .literal("high")));
try std.testing.expect(std.mem.find(u8, capture.output.written(), "LIBFX_MODEL_METADATA_INVALID") != null);
try applyHostModelMetadata(&state,
\\{"modelMetadata":{"model":"one","revision":"1","validForMs":3600000,"data":[{"id":"one","reasoning_options":[{"type":"effort","values":["high"]}]}]}}
, "one");
try std.testing.expect(try applyEffortOverride(&state, alloc, &msg, .literal("high")));
try std.testing.expect(!try applyEffortOverride(&state, alloc, &msg, .literal("max")));
try std.testing.expect(std.mem.find(u8, capture.output.written(), "LIBFX_MODEL_UNSUPPORTED_EFFORT") != null);
try std.testing.expect(std.mem.find(u8, capture.output.written(), "available: high") != null);
try std.testing.expect(!try applyFastOverride(&state, alloc, &msg, true));
try std.testing.expect(std.mem.find(u8, capture.output.written(), "LIBFX_MODEL_UNSUPPORTED_FAST") != null);
try std.testing.expect(try applyFastOverride(&state, alloc, &msg, false));
state.host_metadata.expires_at_ms = 0;
try std.testing.expect(!try applyFastOverride(&state, alloc, &msg, true));
try std.testing.expect(std.mem.find(u8, capture.output.written(), "LIBFX_MODEL_METADATA_EXPIRED") != null);
try std.testing.expectEqual(@as(usize, 0), capture.fetches);
}
test "ACP initialize owns libfx tools and instructions" {
const alloc = std.testing.allocator;
var request = try parseInitializeRequest(
@@ -2111,11 +2311,13 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message
alloc,
msg.params_raw,
state.cfg.minimal_kernel,
) catch {
return state.writer.writeError(alloc, msg.id, .{
) catch |err| switch (err) {
error.OutOfMemory => return err,
error.InvalidModelMetadata => return state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(error.InvalidModelMetadata, state.cfg.model_override orelse "")),
else => return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_params,
.message = "Invalid initialize params",
});
}),
};
defer request.deinit(alloc);
@@ -2287,6 +2489,17 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message
);
}
state.host_metadata_enabled = request.host_metadata_enabled;
if (request.host_metadata_enabled) {
_ = request.host_metadata.resolve(state.selected_model, .{}, io_mod.milliTimestamp()) catch |err|
return state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(err, state.selected_model));
state.host_metadata.deinit(alloc);
state.host_metadata = request.host_metadata;
request.host_metadata = .{};
} else {
state.host_metadata.deinit(alloc);
}
if (state.cfg.effort_override) |raw| {
const effort = types.ReasoningEffort.parse(raw) orelse
return state.writer.writeError(alloc, msg.id, .{
@@ -2325,6 +2538,7 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message
.steering = !host_target.is_wasm and !state.cfg.minimal_kernel,
.system_prompt = !host_target.is_wasm and !state.cfg.minimal_kernel,
.mcp_over_acp = state.cfg.allow_acp_mcp and !host_target.is_wasm,
.model_metadata = state.cfg.minimal_kernel and state.host_metadata_enabled,
});
try state.writer.writeResponse(alloc, msg.id, out.writer.buffered());
}
@@ -2341,7 +2555,13 @@ fn applyEffortOverride(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Mess
const bundle = state.cfg.provider_set.select(state.provider);
const fallback = bundle.fallbackModelCapabilities(state.selected_model);
var capabilities: model_capabilities.Capabilities = undefined;
if (state.cfg.minimal_kernel and state.capability_resolver.state == .idle) {
const provided = hostModelCapabilities(state, state.selected_model, fallback) catch |err| {
try state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(err, state.selected_model));
return false;
};
if (provided) |selected| {
capabilities = selected;
} else if (state.cfg.minimal_kernel and state.capability_resolver.state == .idle) {
// libfx cores skip the startup catalog resolve; explicit effort and
// fast overrides are the creation-time consumers that need it.
const catalog_provider = catalogProviderFor(state, state.provider) orelse return true;
@@ -2362,9 +2582,9 @@ fn applyEffortOverride(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Mess
} else {
capabilities = state.capability_resolver.available(state.selected_model, fallback);
}
// A failed catalog lookup cannot name the supported set; the turn-time
// capability check remains the backstop.
if (state.capability_resolver.state == .failed) return true;
// Legacy catalog outages retain the turn-time fallback. Host descriptions
// are already resolved and must still enforce their advertised efforts.
if (provided == null and state.capability_resolver.state == .failed) return true;
const rejection = effortOverrideRejection(alloc, capabilities, effort, state.selected_model) catch {
try state.writer.writeError(alloc, msg.id, .{
@@ -2461,7 +2681,13 @@ fn applyFastOverride(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Messag
const bundle = state.cfg.provider_set.select(state.provider);
const fallback = bundle.fallbackModelCapabilities(state.selected_model);
var capabilities: model_capabilities.Capabilities = undefined;
if (state.cfg.minimal_kernel and state.capability_resolver.state == .idle) {
const provided = hostModelCapabilities(state, state.selected_model, fallback) catch |err| {
try state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(err, state.selected_model));
return false;
};
if (provided) |selected| {
capabilities = selected;
} else if (state.cfg.minimal_kernel and state.capability_resolver.state == .idle) {
// Shares the effort override's one-shot catalog resolve: creation is
// the only point that can reject before any turn runs.
const catalog_provider = catalogProviderFor(state, state.provider) orelse return true;
@@ -2482,9 +2708,8 @@ fn applyFastOverride(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Messag
} else {
capabilities = state.capability_resolver.available(state.selected_model, fallback);
}
// A failed catalog lookup cannot confirm a fast path; the turn-time
// capability gate remains the backstop.
if (state.capability_resolver.state == .failed) return true;
// Legacy catalog outages retain the turn-time fallback, not host metadata.
if (provided == null and state.capability_resolver.state == .failed) return true;
const rejection = fastOverrideRejection(alloc, capabilities, state.selected_model) catch {
try state.writer.writeError(alloc, msg.id, .{
@@ -2627,6 +2852,19 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me
return state.writer.writeError(alloc, msg.id, .{ .code = ErrorCode.invalid_params, .message = "Missing value" });
};
if (state.cfg.minimal_kernel and state.host_metadata_enabled) {
const active = &state.active_session.?;
applyHostModelMetadata(state, msg.params_raw, active.model) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.InvalidModelMetadata, error.ModelMetadataExpired => return state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(@errorCast(err), active.model)),
};
if ((std.mem.eql(u8, config_id, "model") and !std.mem.eql(u8, value, state.selected_model)) or
(std.mem.eql(u8, config_id, "provider") and !std.mem.eql(u8, value, active.provider.label())))
{
return state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(error.InvalidModelMetadata, active.model));
}
}
if (std.mem.eql(u8, config_id, "model")) {
const session = if (state.active_session) |*active| active else return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_request,
@@ -2845,7 +3083,9 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me
.code = ErrorCode.invalid_params,
.message = "Invalid reasoning effort",
});
const config = sessions.effortConfigState(state) orelse
const effort_config = sessions.effortConfigState(state) catch |err|
return state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(err, session.model));
const config = effort_config orelse
return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_params,
.message = "Reasoning effort is unavailable for the active model",
@@ -2886,7 +3126,9 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me
);
try out.writer.writeAll(",");
try sessions.writeModeConfigOption(&out.writer, state.cfg.mode_registry, current_mode);
if (sessions.effortConfigState(state)) |config| {
const effort_config = sessions.effortConfigState(state) catch |err|
return state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(err, current_model));
if (effort_config) |config| {
try out.writer.writeAll(",");
try sessions.writeEffortConfigOption(&out.writer, config.efforts, config.current);
}
+29 -5
View File
@@ -7,6 +7,7 @@ const jsonrpc = @import("jsonrpc.zig");
const acp_types = @import("types.zig");
const mcp_servers = @import("mcp_servers.zig");
const client_instructions = @import("client_instructions.zig");
const host_model_metadata = @import("model_metadata.zig");
const tool_call_identities = @import("tool_call_identities.zig");
const workspace_binding = @import("workspace_binding.zig");
const mcp_carrier = @import("mcp_carrier.zig");
@@ -57,7 +58,12 @@ pub fn handleNewLibfxSession(
alloc: Allocator,
msg: *jsonrpc.Message,
) !void {
if (state.host_metadata_enabled and state.cfg.minimal_kernel and state.active_prompt != null) server.cancelAndReapActivePrompt(state);
try server.releaseActiveSession(state);
server.applyHostModelMetadata(state, msg.params_raw, state.selected_model) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.InvalidModelMetadata, error.ModelMetadataExpired => return state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(@errorCast(err), state.selected_model)),
};
const session_id = try session_store.generateSessionId(alloc);
var session_id_owned = true;
defer if (session_id_owned) alloc.free(session_id);
@@ -99,6 +105,10 @@ pub fn handleNewLibfxSession(
pub fn handleNewWasmSession(state: *server.ServerState, alloc: Allocator, msg: *jsonrpc.Message) !void {
try server.releaseActiveSession(state);
server.applyHostModelMetadata(state, msg.params_raw, state.selected_model) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.InvalidModelMetadata, error.ModelMetadataExpired => return state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(@errorCast(err), state.selected_model)),
};
var durable = try freshAcpState(state, alloc, state.workspace_root);
var durable_owned = true;
@@ -202,6 +212,14 @@ pub fn handleNewSession(state: *server.ServerState, alloc: Allocator, msg: *json
}),
};
defer if (client_system_prompt) |text| alloc.free(text);
if (state.host_metadata_enabled and state.cfg.minimal_kernel) {
if (state.active_prompt != null) server.cancelAndReapActivePrompt(state);
try server.releaseActiveSession(state);
server.applyHostModelMetadata(state, msg.params_raw, state.selected_model) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.InvalidModelMetadata, error.ModelMetadataExpired => return state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(@errorCast(err), state.selected_model)),
};
}
var workspace = workspace_binding.prepare(state, alloc, msg.params_raw) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return state.writer.writeError(alloc, msg.id, .{
@@ -482,7 +500,9 @@ fn writeNewSessionResponse(
state.cfg.mode_registry,
state.cfg.mode_registry.default_mode_id,
);
if (effortConfigState(state)) |config| {
const effort_config = effortConfigState(state) catch |err|
return state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(err, state.active_session.?.model));
if (effort_config) |config| {
try out.writer.writeAll(",");
try writeEffortConfigOption(&out.writer, config.efforts, config.current);
}
@@ -1134,7 +1154,9 @@ fn writeLoadSessionResponse(
state.cfg.mode_registry,
state.cfg.mode_registry.default_mode_id,
);
if (effortConfigState(state)) |config| {
const effort_config = effortConfigState(state) catch |err|
return state.writer.writeError(alloc, msg.id, host_model_metadata.rpcError(err, model));
if (effort_config) |config| {
try out.writer.writeAll(",");
try writeEffortConfigOption(&out.writer, config.efforts, config.current);
}
@@ -2080,7 +2102,8 @@ pub fn sendActiveSessionUsageUpdate(state: *server.ServerState, alloc: Allocator
const active = if (state.active_session) |*session| session else return;
const usage = active.session_rt.usage.liveContextSnapshot() orelse return;
const provider_bundle = state.cfg.provider_set.select(active.provider);
const capabilities = state.capability_resolver.available(
const capabilities = try server.availableModelCapabilities(
state,
active.model,
provider_bundle.fallbackModelCapabilities(active.model),
);
@@ -2216,10 +2239,11 @@ pub const EffortConfigState = struct {
/// Reasoning-effort selector state for the active session, or null when the
/// active model advertises no effort options (matching the TUI, which hides
/// the effort picker for those models).
pub fn effortConfigState(state: *server.ServerState) ?EffortConfigState {
pub fn effortConfigState(state: *server.ServerState) host_model_metadata.ValidationError!?EffortConfigState {
const active = if (state.active_session) |*session| session else return null;
const bundle = state.cfg.provider_set.select(active.provider);
const capabilities = state.capability_resolver.available(
const capabilities = try server.availableModelCapabilities(
state,
active.model,
bundle.fallbackModelCapabilities(active.model),
);
+21 -1
View File
@@ -278,6 +278,7 @@ pub const AgentCapabilities = struct {
system_prompt: bool = false,
/// Serves `type: "acp"` MCP servers over this connection.
mcp_over_acp: bool = false,
model_metadata: bool = false,
};
pub fn writeInitializeResponse(w: *std.Io.Writer, capabilities: AgentCapabilities) !void {
@@ -295,13 +296,32 @@ pub fn writeInitializeResponse(w: *std.Io.Writer, capabilities: AgentCapabilitie
try w.writeAll("\"sessionCapabilities\":{\"list\":{},\"resume\":{},\"close\":{}");
if (capabilities.system_prompt) try w.writeAll(",\"systemPrompt\":{}");
try w.writeByte('}');
try w.print(",\"_meta\":{{\"fx\":{{\"steering\":{s}}}}}", .{if (capabilities.steering) "true" else "false"});
try w.print(",\"_meta\":{{\"fx\":{{\"steering\":{s}", .{if (capabilities.steering) "true" else "false"});
if (capabilities.model_metadata) try w.writeAll(",\"modelMetadata\":true");
try w.writeAll("}}");
try w.writeAll("},\"agentInfo\":{\"name\":\"fx\",\"title\":\"fx\",\"version\":");
try writeJsonStr(build_options.app_version, w);
try w.writeAll("},");
try w.writeAll("\"authMethods\":[]}");
}
test "selected metadata initialize response acknowledges opt-in only" {
const alloc = std.testing.allocator;
for ([_]bool{ false, true }) |enabled| {
var out: std.Io.Writer.Allocating = .init(alloc);
defer out.deinit();
try writeInitializeResponse(&out.writer, .{ .image_prompts = false, .model_metadata = enabled });
const parsed = try std.json.parseFromSlice(std.json.Value, alloc, out.written(), .{});
defer parsed.deinit();
const fx = parsed.value.object.get("agentCapabilities").?.object.get("_meta").?.object.get("fx").?.object;
if (enabled) {
try std.testing.expect(fx.get("modelMetadata").?.bool);
} else {
try std.testing.expect(fx.get("modelMetadata") == null);
}
}
}
pub fn writePromptResponse(w: *std.Io.Writer, reason: StopReason) !void {
try w.writeAll("{\"stopReason\":");
try writeJsonStr(reason.jsonString(), w);
+155
View File
@@ -15,6 +15,8 @@ pub const Transport = struct {
status_fn: *const fn (?*anyopaque, i32, *u16) i32,
next_fn: *const fn (?*anyopaque, i32, []u8) i32,
close_fn: *const fn (?*anyopaque, i32) void,
// Optional logical-completion signal, independent of HTTP EOF or close.
consumed_fn: ?*const fn (?*anyopaque, i32) void = null,
fn open(self: Transport, method: []const u8, url: []const u8, headers: []const u8, body: []const u8) !i32 {
return self.open_fn(self.context, method, url, headers, body);
@@ -31,6 +33,10 @@ pub const Transport = struct {
fn close(self: Transport, handle: i32) void {
self.close_fn(self.context, handle);
}
fn consumed(self: Transport, handle: i32) void {
if (self.consumed_fn) |notify| notify(self.context, handle);
}
};
pub const ProviderContext = struct {
@@ -211,6 +217,15 @@ fn stream(raw: ?*anyopaque, alloc: Allocator, request: stream_provider.ModelRequ
error.HostStreamFailed,
else => return err,
};
const types = @import("../core/shared/types.zig");
if (transport.consumed_fn != null and
!request.cancel_flag.load(.seq_cst) and
types.classifyProviderCompletion(completion) == .completed and
types.authoritativeToolAdmission(completion) == .admitted and
completion.provider_failure_cause == null)
{
transport.consumed(handle);
}
return .{ .completed = .{
.completion = completion,
.usage = gatewayUsageOutcome(request, completion),
@@ -218,6 +233,146 @@ fn stream(raw: ?*anyopaque, alloc: Allocator, request: stream_provider.ModelRequ
} };
}
test "host provider signals logical consumption only after valid uncancelled completion" {
const FakeTransport = struct {
body: []const u8,
status_code: u16 = 200,
offset: usize = 0,
read_error: ?i32 = null,
cancel_on_content: bool = false,
cancel_flag: std.atomic.Value(bool) = .init(false),
consumed_calls: usize = 0,
consumed_handle: ?i32 = null,
consumed_after_close: bool = false,
eof_calls: usize = 0,
close_calls: usize = 0,
content_calls: usize = 0,
content_calls_at_consumption: usize = 0,
fn build(_: Allocator, _: stream_provider.RequestData) ![]u8 {
return error.UnexpectedRequest;
}
fn open(_: ?*anyopaque, _: []const u8, _: []const u8, _: []const u8, _: []const u8) !i32 {
return 7;
}
fn status(raw: ?*anyopaque, _: i32, status_out: *u16) i32 {
const self: *@This() = @ptrCast(@alignCast(raw.?));
status_out.* = self.status_code;
return 1;
}
fn next(raw: ?*anyopaque, _: i32, out: []u8) i32 {
const self: *@This() = @ptrCast(@alignCast(raw.?));
const len = @min(out.len, self.body.len - self.offset);
if (len == 0) {
if (self.read_error) |err| return err;
self.eof_calls += 1;
return 0;
}
@memcpy(out[0..len], self.body[self.offset..][0..len]);
self.offset += len;
return @intCast(len);
}
fn close(raw: ?*anyopaque, _: i32) void {
const self: *@This() = @ptrCast(@alignCast(raw.?));
self.close_calls += 1;
}
fn consumed(raw: ?*anyopaque, handle: i32) void {
const self: *@This() = @ptrCast(@alignCast(raw.?));
self.consumed_calls += 1;
self.consumed_handle = handle;
self.consumed_after_close = self.close_calls != 0;
self.content_calls_at_consumption = self.content_calls;
}
fn emit(raw: *anyopaque, event: stream_provider.Event) void {
const self: *@This() = @ptrCast(@alignCast(raw));
if (event == .content_delta) {
self.content_calls += 1;
if (self.cancel_on_content) self.cancel_flag.store(true, .seq_cst);
}
}
fn admit(_: *anyopaque) !void {}
};
const text = "data: {\"type\":\"text-delta\",\"id\":\"t\",\"delta\":\"answer\"}\n\n";
const stop = "data: {\"type\":\"finish\",\"finishReason\":{\"unified\":\"stop\"}}\n\n";
const cases = [_]struct {
body: []const u8,
status: u16 = 200,
read_error: ?i32 = null,
cancel_on_content: bool = false,
optional_callback: bool = true,
expected_consumed: bool = false,
expected_error: ?anyerror = null,
}{
.{ .body = text ++ stop, .expected_consumed = true },
.{ .body = "data: {\"type\":\"tool-call\",\"toolCallId\":\"c\",\"toolName\":\"test_tool\",\"input\":{}}\n\n" ++
"data: {\"type\":\"finish\",\"finishReason\":{\"unified\":\"tool-calls\"}}\n\n", .expected_consumed = true },
.{ .body = text ++ "data: {\"type\":\"finish\",\"finishReason\":{\"unified\":\"other\"}}\n\n", .expected_consumed = true },
.{ .body = text ++ stop, .optional_callback = false },
.{ .body = text },
.{ .body = "data: [DONE]\n\n" },
.{ .body = text ++ "data: {\"type\":\"finish\",\"finishReason\":{\"unified\":\"error\"}}\n\n" },
.{ .body = text ++ "data: {\"type\":\"finish\",\"finishReason\":{\"unified\":\"content-filter\"}}\n\n" },
.{ .body = text ++ "data: {\"type\":\"finish\",\"finishReason\":{\"unified\":\"length\"}}\n\n" },
.{ .body = "data: {\"type\":\"finish\",\"finishReason\":{\"unified\":\"tool-calls\"}}\n\n" },
.{ .body = text ++ "data: {\"type\":\"finish\",\"finishReason\":{\"unified\":\"stop\"},\"code\":\"gateway_stream_timeout\"}\n\n" },
.{ .body = "data: {\"type\":\"tool-call\",\"toolCallId\":\"\",\"toolName\":\"test_tool\",\"input\":{}}\n\n" ++
"data: {\"type\":\"finish\",\"finishReason\":{\"unified\":\"tool-calls\"}}\n\n" },
.{ .body = text ++ stop, .cancel_on_content = true },
.{ .body = "upstream failure", .status = 500 },
.{ .body = text, .read_error = -1, .expected_error = error.HostStreamFailed },
.{ .body = "data: {not-json}\n\n", .expected_error = error.InvalidGatewaySseEvent },
};
for (cases, 0..) |case, index| {
var fake: FakeTransport = .{
.body = case.body,
.status_code = case.status,
.read_error = case.read_error,
.cancel_on_content = case.cancel_on_content,
};
errdefer std.debug.print("host consumption case={d} consumed={d} eof={d} closed={d}\n", .{ index, fake.consumed_calls, fake.eof_calls, fake.close_calls });
var context = initContext(FakeTransport.build, .{ .fixed = "https://example.invalid" }, .{
.context = &fake,
.open_fn = FakeTransport.open,
.status_fn = FakeTransport.status,
.next_fn = FakeTransport.next,
.close_fn = FakeTransport.close,
.consumed_fn = if (case.optional_callback) FakeTransport.consumed else null,
});
var delivery: stream_provider.DeliveryCertainty = .init();
var attempt: stream_provider.AttemptEvidence = .{};
const result = stream(&context, std.testing.allocator, .{
.credential = .host_managed,
.model = "fixture-model",
.retry_count = 0,
.messages = &.{},
.tool_choice = .auto,
.provider_options = .{},
.prepared_request_body = "{}",
.trace_ctx = .{},
.content_capture_limit = null,
.delivery = &delivery,
.attempt_evidence = &attempt,
.events = .{ .context = &fake, .emit_fn = FakeTransport.emit },
.admission = .{ .context = &fake, .admit_fn = FakeTransport.admit },
.cancel_flag = &fake.cancel_flag,
});
if (case.expected_error) |expected| {
try std.testing.expectError(expected, result);
} else {
var owned = try result;
defer owned.deinit(std.testing.allocator);
}
try std.testing.expectEqual(@as(usize, @intFromBool(case.expected_consumed)), fake.consumed_calls);
try std.testing.expectEqual(if (case.expected_consumed) @as(?i32, 7) else null, fake.consumed_handle);
try std.testing.expect(!fake.consumed_after_close);
try std.testing.expectEqual(@as(usize, 1), fake.close_calls);
if (case.expected_consumed) {
try std.testing.expectEqual(fake.content_calls, fake.content_calls_at_consumption);
try std.testing.expectEqual(@as(usize, 0), fake.eof_calls);
}
}
}
fn buildRequest(
raw: ?*anyopaque,
alloc: Allocator,
+37
View File
@@ -238,6 +238,8 @@ const FetchBridge = struct {
response: std.ArrayList(u8) = .empty,
response_offset: usize = 0,
phase: fetch_state.Phase = .idle,
// Logical consumption survives HTTP EOF, close, and normal runtime shutdown.
last_consumed_handle: ?fetch_state.Handle = null,
next_handle: fetch_state.Handle = 1,
status: u16 = 0,
ready: ?*ReadyNotifier = null,
@@ -385,6 +387,24 @@ const FetchBridge = struct {
self.ready.?.notify();
}
fn markConsumed(raw: ?*anyopaque, handle: i32) void {
const self: *FetchBridge = @ptrCast(@alignCast(raw.?));
const io = io_mod.getIo();
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const consumed = fetch_state.consumed_after(self.phase, self.last_consumed_handle, .{ .consumed = handle });
if (consumed == self.last_consumed_handle) return;
self.last_consumed_handle = consumed;
self.ready.?.notify();
}
fn disposition(self: *FetchBridge, handle: fetch_state.Handle) fetch_state.Disposition {
const io = io_mod.getIo();
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return fetch_state.disposition(self.phase, self.last_consumed_handle, handle);
}
fn startResponse(self: *FetchBridge, handle: fetch_state.Handle, status: u16) FetchOperationResult {
const io = io_mod.getIo();
self.mutex.lockUncancelable(io);
@@ -449,9 +469,11 @@ const FetchBridge = struct {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const decision = fetch_state.decide(self.phase, .cancel);
self.last_consumed_handle = fetch_state.consumed_after(self.phase, self.last_consumed_handle, .cancel);
self.clearPendingRequest();
self.phase = decision.phase;
self.wake.broadcast(io);
self.ready.?.notify();
}
fn shutdown(self: *FetchBridge) void {
@@ -561,6 +583,7 @@ const Runtime = struct {
) catch {
self.exit_code.store(1, .seq_cst);
};
self.fetch.shutdown();
self.exited.store(true, .seq_cst);
self.ready.notify();
}
@@ -827,6 +850,7 @@ fn createRuntime(env: c.napi_env, options: c.napi_value) CreateError!*Runtime {
.status_fn = FetchBridge.statusFn,
.next_fn = FetchBridge.next,
.close_fn = FetchBridge.close,
.consumed_fn = FetchBridge.markConsumed,
});
runtime.thread = std.Thread.spawn(.{}, Runtime.run, .{runtime}) catch return error.ThreadFailed;
return runtime;
@@ -1041,6 +1065,18 @@ fn coreFetchActive(env: c.napi_env, info: c.napi_callback_info) callconv(.c) c.n
return value;
}
fn coreFetchDisposition(env: c.napi_env, info: c.napi_callback_info) callconv(.c) c.napi_value {
var argv: [2]c.napi_value = undefined;
const runtime_handle = runtimeHandleArg(env, info, &argv) orelse return null;
const fetch_handle = fetch_handle_arg(env, argv[1]) orelse return null;
const runtime = lockRuntime(env, runtime_handle) orelse return null;
defer unlockRuntime(runtime_handle);
const disposition = runtime.fetch.disposition(fetch_handle);
var value: c.napi_value = undefined;
if (!statusOk(env, c.napi_create_int32(env, @intFromEnum(disposition), &value), "could not query fetch disposition")) return null;
return value;
}
fn startCoreFetchResponse(env: c.napi_env, info: c.napi_callback_info) callconv(.c) c.napi_value {
var argv: [3]c.napi_value = undefined;
const runtime_handle = runtimeHandleArg(env, info, &argv) orelse return null;
@@ -1145,6 +1181,7 @@ export fn napi_register_module_v1(env: c.napi_env, exports: c.napi_value) callco
if (!exportFunction(env, exports, "drainCore", drainCore)) return null;
if (!exportFunction(env, exports, "takeCoreFetch", takeCoreFetch)) return null;
if (!exportFunction(env, exports, "coreFetchActive", coreFetchActive)) return null;
if (!exportFunction(env, exports, "coreFetchDisposition", coreFetchDisposition)) return null;
if (!exportFunction(env, exports, "startCoreFetchResponse", startCoreFetchResponse)) return null;
if (!exportFunction(env, exports, "pushCoreFetchResponse", pushCoreFetchResponse)) return null;
if (!exportFunction(env, exports, "finishCoreFetch", finishCoreFetch)) return null;
+233 -1
View File
@@ -28,6 +28,7 @@ pub const Event = union(enum) {
start: Handle,
push: Handle,
finish: Handle,
consumed: Handle,
fail: Handle,
close: Handle,
cancel,
@@ -98,6 +99,17 @@ pub fn decide(phase: Phase, event: Event) Decision {
stale(phase, handle),
else => stale(phase, handle),
},
.consumed => |handle| switch (phase) {
.streaming => |active| if (active == handle)
.{ .phase = phase, .action = .applied }
else
stale(phase, handle),
.terminal => |terminal| if (terminal.handle == handle and terminal.kind == .success)
.{ .phase = phase, .action = .applied }
else
stale(phase, handle),
else => stale(phase, handle),
},
.fail => |handle| switch (phase) {
.awaiting_response, .streaming => |active| if (active == handle)
.{
@@ -125,8 +137,14 @@ pub fn decide(phase: Phase, event: Event) Decision {
.phase = .{ .terminal = .{ .handle = handle, .kind = .aborted } },
.action = .applied,
},
.terminal => |terminal| if (terminal.kind == .success)
.{
.phase = .{ .terminal = .{ .handle = terminal.handle, .kind = .aborted } },
.action = .applied,
}
else
.{ .phase = phase, .action = .no_request },
.shutdown => .{ .phase = .shutdown, .action = .shutting_down },
else => .{ .phase = phase, .action = .no_request },
},
.shutdown => switch (phase) {
.shutdown => .{ .phase = .shutdown, .action = .no_request },
@@ -135,6 +153,28 @@ pub fn decide(phase: Phase, event: Event) Decision {
};
}
/// Keeps one exact logical-consumption identity independently of the HTTP phase.
/// Explicit cancellation revokes it even after close or shutdown; EOF does not grant it.
pub fn consumed_after(phase: Phase, last_consumed_handle: ?Handle, event: Event) ?Handle {
return switch (event) {
.consumed => |handle| if (decide(phase, event).action == .applied) handle else last_consumed_handle,
.cancel => null,
else => last_consumed_handle,
};
}
pub const Disposition = enum(i32) {
retired = 0,
active = 1,
consumed = 2,
};
/// Call with phase and consumption identity from the same synchronized observation.
pub fn disposition(phase: Phase, last_consumed_handle: ?Handle, handle: Handle) Disposition {
if (last_consumed_handle == handle) return .consumed;
return if (is_active(phase, handle)) .active else .retired;
}
pub fn is_active(phase: Phase, handle: Handle) bool {
return switch (phase) {
.request_pending, .awaiting_response, .streaming => |active| active == handle,
@@ -280,3 +320,195 @@ test "N-API fetch state handles failure cancellation and shutdown" {
.action = .no_request,
}, decide(.shutdown, .shutdown));
}
const ConsumptionTrace = struct {
phase: Phase = .idle,
last_consumed_handle: ?Handle = null,
fn apply(self: *@This(), event: Event) Decision {
const decision = decide(self.phase, event);
self.last_consumed_handle = consumed_after(self.phase, self.last_consumed_handle, event);
self.phase = decision.phase;
return decision;
}
};
test "N-API fetch consumption exhaustively checks phase and exact handle" {
const phases = [_]Phase{
.idle,
.{ .request_pending = 41 },
.{ .awaiting_response = 41 },
.{ .streaming = 41 },
.{ .terminal = .{ .handle = 41, .kind = .success } },
.{ .terminal = .{ .handle = 41, .kind = .failure } },
.{ .terminal = .{ .handle = 41, .kind = .aborted } },
.shutdown,
};
for (phases, 0..) |phase, index| {
for ([_]?Handle{ null, 41, 42 }) |previous| {
for ([_]Handle{ 41, 42, 43 }) |handle| {
const eligible = handle == 41 and (index == 3 or index == 4);
const event: Event = .{ .consumed = handle };
const decision = decide(phase, event);
try std.testing.expectEqualDeep(phase, decision.phase);
try std.testing.expectEqual(if (eligible) Action.applied else Action.stale, decision.action);
try std.testing.expectEqual(if (eligible) handle else previous, consumed_after(phase, previous, event));
}
}
}
}
test "N-API fetch consumption only model completion grants and cancellation revokes" {
const phases = [_]Phase{
.idle,
.{ .request_pending = 1 },
.{ .awaiting_response = 1 },
.{ .streaming = 1 },
.{ .terminal = .{ .handle = 1, .kind = .success } },
.{ .terminal = .{ .handle = 1, .kind = .failure } },
.{ .terminal = .{ .handle = 1, .kind = .aborted } },
.shutdown,
};
const events = [_]Event{
.{ .open = 2 },
.take_request,
.{ .start = 1 },
.{ .start = 2 },
.{ .push = 1 },
.{ .push = 2 },
.{ .finish = 1 },
.{ .finish = 2 },
.{ .fail = 1 },
.{ .fail = 2 },
.{ .close = 1 },
.{ .close = 2 },
.shutdown,
.cancel,
};
for (phases) |phase| {
for ([_]?Handle{ null, 1, 2 }) |previous| {
for (events) |event| {
try std.testing.expectEqual(if (event == .cancel) null else previous, consumed_after(phase, previous, event));
}
var trace: ConsumptionTrace = .{ .phase = phase, .last_consumed_handle = previous };
_ = trace.apply(.cancel);
try std.testing.expect(trace.last_consumed_handle == null);
_ = trace.apply(.{ .consumed = 1 });
_ = trace.apply(.{ .consumed = 2 });
try std.testing.expect(trace.last_consumed_handle == null);
}
}
}
test "N-API fetch disposition uses one exact phase and consumption observation" {
const phases = [_]Phase{
.idle,
.{ .request_pending = 41 },
.{ .awaiting_response = 41 },
.{ .streaming = 41 },
.{ .terminal = .{ .handle = 41, .kind = .success } },
.{ .terminal = .{ .handle = 41, .kind = .failure } },
.{ .terminal = .{ .handle = 41, .kind = .aborted } },
.shutdown,
};
for (phases) |phase| {
for ([_]?Handle{ null, 41, 42 }) |consumed| {
for ([_]Handle{ 41, 42, 43 }) |handle| {
const expected: Disposition = if (consumed == handle)
.consumed
else if (is_active(phase, handle))
.active
else
.retired;
try std.testing.expectEqual(expected, disposition(phase, consumed, handle));
}
}
}
}
test "N-API fetch disposition replays completion between legacy boolean reads" {
var trace: ConsumptionTrace = .{ .phase = .{ .streaming = 1 } };
const legacy_consumed = trace.last_consumed_handle == 1;
try std.testing.expectEqual(Disposition.active, disposition(trace.phase, trace.last_consumed_handle, 1));
_ = trace.apply(.{ .consumed = 1 });
try std.testing.expectEqual(Disposition.consumed, disposition(trace.phase, trace.last_consumed_handle, 1));
_ = trace.apply(.{ .close = 1 });
// The old two-query observation loses completion while the single query preserves it.
try std.testing.expect(!legacy_consumed and !is_active(trace.phase, 1));
try std.testing.expectEqual(Disposition.consumed, disposition(trace.phase, trace.last_consumed_handle, 1));
try std.testing.expectEqual(Disposition.retired, disposition(trace.phase, trace.last_consumed_handle, 2));
_ = trace.apply(.shutdown);
try std.testing.expectEqual(Disposition.consumed, disposition(trace.phase, trace.last_consumed_handle, 1));
_ = trace.apply(.cancel);
try std.testing.expectEqual(Disposition.retired, disposition(trace.phase, trace.last_consumed_handle, 1));
}
test "N-API fetch consumption replays model complete before HTTP EOF and normal close" {
var trace: ConsumptionTrace = .{};
for ([_]Event{ .{ .open = 1 }, .take_request, .{ .start = 1 }, .{ .push = 1 } }) |event| {
try std.testing.expectEqual(Action.applied, trace.apply(event).action);
try std.testing.expect(trace.last_consumed_handle == null);
}
try std.testing.expectEqual(Action.applied, trace.apply(.{ .consumed = 1 }).action);
try std.testing.expectEqual(@as(?Handle, 1), trace.last_consumed_handle);
// The consumer's close precedes HTTP EOF in the legacy counterexample.
_ = trace.apply(.{ .close = 1 });
try std.testing.expectEqualDeep(Phase.idle, trace.phase);
try std.testing.expect(!is_active(trace.phase, 1));
try std.testing.expectEqual(@as(?Handle, 1), trace.last_consumed_handle);
try std.testing.expectEqual(Action.stale, trace.apply(.{ .finish = 1 }).action);
_ = trace.apply(.shutdown);
try std.testing.expectEqualDeep(Phase.shutdown, trace.phase);
try std.testing.expectEqual(@as(?Handle, 1), trace.last_consumed_handle);
try std.testing.expectEqual(Action.stale, trace.apply(.{ .consumed = 2 }).action);
try std.testing.expectEqual(@as(?Handle, 1), trace.last_consumed_handle);
_ = trace.apply(.cancel);
try std.testing.expect(trace.last_consumed_handle == null);
}
test "N-API fetch consumption replays HTTP EOF before consumer completion" {
var trace: ConsumptionTrace = .{ .phase = .{ .streaming = 1 } };
_ = trace.apply(.{ .finish = 1 });
try std.testing.expectEqualDeep(Phase{ .terminal = .{ .handle = 1, .kind = .success } }, trace.phase);
try std.testing.expect(!is_active(trace.phase, 1));
try std.testing.expect(trace.last_consumed_handle == null);
_ = trace.apply(.{ .consumed = 1 });
try std.testing.expectEqual(@as(?Handle, 1), trace.last_consumed_handle);
_ = trace.apply(.{ .close = 1 });
_ = trace.apply(.shutdown);
try std.testing.expectEqual(@as(?Handle, 1), trace.last_consumed_handle);
}
test "N-API fetch consumption cancellation wins after HTTP EOF before completion" {
var trace: ConsumptionTrace = .{ .phase = .{ .streaming = 1 } };
_ = trace.apply(.{ .finish = 1 });
try std.testing.expectEqual(Action.applied, trace.apply(.cancel).action);
try std.testing.expectEqualDeep(Phase{ .terminal = .{ .handle = 1, .kind = .aborted } }, trace.phase);
try std.testing.expectEqual(Action.stale, trace.apply(.{ .consumed = 1 }).action);
try std.testing.expect(trace.last_consumed_handle == null);
}
test "N-API fetch consumption replays failure and old new handle transitions" {
var trace: ConsumptionTrace = .{ .phase = .{ .streaming = 1 } };
_ = trace.apply(.{ .consumed = 1 });
_ = trace.apply(.{ .close = 1 });
for ([_]Event{ .{ .open = 2 }, .take_request, .{ .start = 2 } }) |event| _ = trace.apply(event);
try std.testing.expectEqual(Action.stale, trace.apply(.{ .consumed = 1 }).action);
try std.testing.expectEqualDeep(Phase{ .streaming = 2 }, trace.phase);
try std.testing.expectEqual(@as(?Handle, 1), trace.last_consumed_handle);
_ = trace.apply(.{ .fail = 2 });
try std.testing.expectEqual(Action.stale, trace.apply(.{ .consumed = 2 }).action);
try std.testing.expectEqual(@as(?Handle, 1), trace.last_consumed_handle);
_ = trace.apply(.{ .close = 2 });
for ([_]Event{ .{ .open = 3 }, .take_request, .{ .start = 3 }, .{ .consumed = 3 } }) |event| _ = trace.apply(event);
try std.testing.expectEqual(@as(?Handle, 3), trace.last_consumed_handle);
const active = trace.phase;
for ([_]Event{ .{ .consumed = 1 }, .{ .consumed = 2 }, .{ .close = 1 }, .{ .fail = 2 }, .{ .finish = 1 } }) |event| {
try std.testing.expectEqual(Action.stale, trace.apply(event).action);
try std.testing.expectEqualDeep(active, trace.phase);
try std.testing.expectEqual(@as(?Handle, 3), trace.last_consumed_handle);
}
_ = trace.apply(.cancel);
_ = trace.apply(.{ .consumed = 3 });
try std.testing.expect(trace.last_consumed_handle == null);
}