diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index a36ce15b4..65c92f618 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -62,3 +62,47 @@ jobs: name: tui-performance path: ${{ runner.temp }}/tui-performance.json if-no-files-found: warn + + libfx-ttft: + name: libfx TTFT (report only) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Zig + uses: mlugg/setup-zig@v2 + with: + version: "0.16.0" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + + - name: Build libfx artifacts + run: | + zig build -Dnapi-surface=core -Doptimize=ReleaseSafe + zig build -Dwasm-surface=core -Doptimize=ReleaseSmall + + - name: Install pinned Pi benchmark target + run: npm install --prefix /tmp/libfx-pi @earendil-works/pi-coding-agent@0.84.4 + + - name: Measure libfx and Pi + run: | + node benchmarks/libfx/bench-suite.mjs --samples 20 --pi-root /tmp/libfx-pi --out benchmarks/results/libfx + node benchmarks/libfx/bench-bridge.mjs --backend native --samples 20 > benchmarks/results/libfx/bridge-node-native.json + node --experimental-wasm-jspi benchmarks/libfx/bench-bridge.mjs --backend wasm --samples 20 > benchmarks/results/libfx/bridge-node-wasm.json + bun benchmarks/libfx/bench-bridge.mjs --backend native --samples 20 > benchmarks/results/libfx/bridge-bun-native.json + bun benchmarks/libfx/bench-bridge.mjs --backend wasm --samples 20 > benchmarks/results/libfx/bridge-bun-wasm.json + + - name: Upload raw libfx TTFT samples + uses: actions/upload-artifact@v4 + with: + name: libfx-ttft-${{ github.sha }} + path: benchmarks/results/libfx/*.json + if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcf6e67a4..f0250acd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,13 @@ jobs: - name: Test Node + WASM run: npm run --prefix sdk test:node-wasm + - name: Test libfx Wasm integrations + run: | + node --experimental-wasm-jspi sdk/tests/test-host-tools.mjs wasm + node --experimental-wasm-jspi sdk/tests/test-host-tool-cancel.mjs wasm + node --experimental-wasm-jspi sdk/tests/test-mcp-adapter.mjs http wasm + node --experimental-wasm-jspi sdk/tests/test-skills-adapter.mjs host wasm + sdk-node-napi: name: SDK (Node + N-API) runs-on: ubuntu-latest @@ -109,6 +116,67 @@ jobs: - name: Test Node + N-API run: npm run --prefix sdk test:node-napi + - name: Test minimal native integrations + run: | + node sdk/tests/test-minimal-api.mjs + node sdk/tests/test-host-tools.mjs native + node sdk/tests/test-host-tool-cancel.mjs native + node sdk/tests/test-mcp-adapter.mjs stdio native + node sdk/tests/test-skills-adapter.mjs disk native + + sdk-bun: + name: SDK (Bun + N-API/WASM) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Zig + uses: mlugg/setup-zig@v2 + with: + version: "0.16.0" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + + - name: Build SDK artifacts + run: | + zig build -Dnapi-surface=core -Doptimize=ReleaseSafe + zig build -Dwasm-surface=core -Doptimize=ReleaseSmall + zig build -Dwasm-surface=term -Doptimize=ReleaseSmall + + - name: Install terminal test dependencies + run: npm ci --prefix sdk/node + + - name: Test Bun + N-API + run: bun sdk/tests/test-node-napi.mjs + + - name: Test Bun + WASM + run: bun sdk/tests/test-node-wasm.mjs + + - name: Test Bun benchmark path + run: bun sdk/tests/test-libfx-benchmark.mjs + + - name: Test Bun cross-backend integrations + run: | + bun sdk/tests/test-agent-bootstrap.mjs auto + bun sdk/tests/test-checkpoint.mjs native wasm + bun sdk/tests/test-checkpoint.mjs wasm native + bun sdk/tests/test-host-tools.mjs native + bun sdk/tests/test-host-tools.mjs wasm + bun sdk/tests/test-host-tool-cancel.mjs native + bun sdk/tests/test-host-tool-cancel.mjs wasm + bun sdk/tests/test-mcp-adapter.mjs stdio native + bun sdk/tests/test-mcp-adapter.mjs http wasm + bun sdk/tests/test-skills-adapter.mjs disk native + bun sdk/tests/test-skills-adapter.mjs host wasm + sdk-browser-wasm: name: SDK (Browser + WASM) runs-on: ubuntu-latest diff --git a/.github/workflows/publish-libfx.yml b/.github/workflows/publish-libfx.yml index 10d8424d8..81e618806 100644 --- a/.github/workflows/publish-libfx.yml +++ b/.github/workflows/publish-libfx.yml @@ -261,13 +261,18 @@ jobs: rm -rf sdk/dist/libfx mkdir -p sdk/dist/libfx - cp sdk/package.json sdk/README.md sdk/browser.js sdk/node.js sdk/fx-sdk.js LICENSE sdk/dist/libfx/ + cp sdk/package.json sdk/README.md sdk/browser.js sdk/node.js sdk/fx-sdk.js sdk/mcp.js sdk/skills.js sdk/skills-node.js LICENSE sdk/dist/libfx/ cp wasm-artifacts/fx-core.wasm wasm-artifacts/fx-term.wasm sdk/dist/libfx/ cp native-addons/*.node sdk/dist/libfx/ jq --arg version "$LIBFX_VERSION" '.version = $version | del(.files)' \ sdk/dist/libfx/package.json > sdk/dist/libfx/package.json.tmp mv sdk/dist/libfx/package.json.tmp sdk/dist/libfx/package.json + - name: Run packed public API example + run: | + node sdk/tests/test-packed-example.mjs sdk/dist/libfx native + node --experimental-wasm-jspi sdk/tests/test-packed-example.mjs sdk/dist/libfx wasm + - name: Validate package archive run: | set -euo pipefail @@ -277,6 +282,7 @@ jobs: const names = new Set(report.files.map(({ path }) => path)); const required = [ "package.json", "README.md", "LICENSE", "browser.js", "node.js", "fx-sdk.js", + "mcp.js", "skills.js", "skills-node.js", "fx-core.wasm", "fx-term.wasm", "libfx.linux-x64.node", "libfx.linux-arm64.node", "libfx.darwin-x64.node", "libfx.darwin-arm64.node", diff --git a/benchmarks/libfx/bench-bridge.mjs b/benchmarks/libfx/bench-bridge.mjs new file mode 100644 index 000000000..fb3332eab --- /dev/null +++ b/benchmarks/libfx/bench-bridge.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createFxAgent } from "../../sdk/node.js"; + +const args = process.argv.slice(2); +const value = (name, fallback) => { + const index = args.indexOf(name); + return index < 0 ? fallback : args[index + 1]; +}; +const backend = value("--backend", "native"); +const samples = Number(value("--samples", "20")); +if (!new Set(["native", "wasm"]).has(backend) || !Number.isInteger(samples) || samples < 1 || samples > 1000) { + throw new Error("usage: bench-bridge.mjs --backend native|wasm --samples 1..1000"); +} + +const root = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const stages = []; +let requestIndex = 0; +const server = createServer((request, response) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { body += chunk; }); + request.on("end", () => { + if (request.method === "GET") { + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"object":"list","data":[]}'); + return; + } + const sample = Math.floor(requestIndex / 2); + const followup = requestIndex % 2 === 1; + requestIndex += 1; + response.writeHead(200, { "content-type": "text/event-stream" }); + if (!followup) { + stages[sample] = { tool_event_at: performance.now() }; + response.end(`data: {"type":"tool-call","toolCallId":"bridge_${sample}","toolName":"bridge_echo","input":{"value":"${sample}"}}\n\ndata: {"type":"finish","finishReason":{"unified":"tool-calls","raw":"tool-calls"}}\n\ndata: [DONE]\n\n`); + return; + } + stages[sample].followup_fetch_at = performance.now(); + if (!body.includes(`bridge:${sample}`)) throw new Error("bridge result missing from follow-up request"); + response.end('data: {"type":"text-delta","delta":"ok"}\n\ndata: {"type":"finish","finishReason":{"unified":"stop","raw":"stop"}}\n\ndata: [DONE]\n\n'); + }); +}); +await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); + +let activeSample = 0; +const agent = await createFxAgent({ + backend, + nativeAddon: resolve(root, "zig-out/lib/libfx.node"), + ...(backend === "wasm" ? { wasm: await readFile(resolve(root, "zig-out/bin/fx-core.wasm")) } : {}), + fetch, + tools: [{ + name: "bridge_echo", + description: "Measure the host tool bridge", + inputSchema: { type: "object", properties: { value: { type: "string" } }, required: ["value"] }, + execute(input) { + stages[activeSample].callback_at = performance.now(); + return `bridge:${input.value}`; + }, + }], + env: { + AI_GATEWAY_API_KEY: "bridge-key", + FX_GATEWAY_CHAT_URL: `http://127.0.0.1:${server.address().port}/chat`, + FX_MODEL: "bridge/model", + }, +}); + +try { + for (activeSample = 0; activeSample < samples; activeSample++) { + const turn = agent.prompt(`bridge sample ${activeSample}`); + for await (const _ of turn) {} + if ((await turn.result).stopReason !== "end_turn") throw new Error("bridge sample did not finish"); + } + const report = stages.map((stage) => ({ + tool_event_to_callback_ms: stage.callback_at - stage.tool_event_at, + callback_to_followup_fetch_ms: stage.followup_fetch_at - stage.callback_at, + tool_round_trip_ms: stage.followup_fetch_at - stage.tool_event_at, + })); + process.stdout.write(`${JSON.stringify({ + format_version: 1, + runtime: typeof Bun === "undefined" ? "node" : "bun", + runtime_version: typeof Bun === "undefined" ? process.version : Bun.version, + backend, + samples: report, + }, null, 2)}\n`); +} finally { + await agent.close(); + server.closeAllConnections(); + await new Promise((resolveClose) => server.close(resolveClose)); +} diff --git a/benchmarks/libfx/bench-fx.mjs b/benchmarks/libfx/bench-fx.mjs new file mode 100644 index 000000000..f3a0270a5 --- /dev/null +++ b/benchmarks/libfx/bench-fx.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const args = process.argv.slice(2); +const option = (name, fallback) => { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : fallback; +}; +const backend = option("--backend", "auto"); +const samples = Number(option("--samples", "1")); +const childMode = args.includes("--child"); + +if (!new Set(["auto", "native", "wasm"]).has(backend)) throw new Error(`invalid backend: ${backend}`); +if (!Number.isSafeInteger(samples) || samples < 1 || samples > 1000) throw new Error(`invalid samples: ${samples}`); + +if (childMode) { + await runChild(); +} else { + await runParent(); +} + +async function runChild() { + const gatewayUrl = process.env.LIBFX_BENCH_GATEWAY_URL; + const diagnosticsPath = process.env.LIBFX_BENCH_DIAGNOSTICS; + if (!gatewayUrl || !diagnosticsPath) throw new Error("benchmark child environment is incomplete"); + + const startedAt = performance.now(); + const { createFxAgent } = await import(new URL("../../sdk/node.js", import.meta.url)); + const importedAt = performance.now(); + let fetchAt = null; + let firstBodyAt = null; + let firstTextAt = null; + const tracedFetch = async (_url, init = {}) => { + const isPrompt = (init.method ?? "GET") === "POST"; + if (!isPrompt) return fetch(gatewayUrl, init); + fetchAt ??= performance.now(); + const response = await fetch(gatewayUrl, init); + if (!response.body) return response; + const reader = response.body.getReader(); + const body = new ReadableStream({ + async pull(controller) { + const result = await reader.read(); + if (result.done) { + controller.close(); + return; + } + firstBodyAt ??= performance.now(); + controller.enqueue(result.value); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + }; + + const agent = await createFxAgent({ + backend, + nativeAddon: resolve(repoRoot, "zig-out/lib/libfx.node"), + wasm: resolve(repoRoot, "zig-out/bin/fx-core.wasm"), + fetch: tracedFetch, + home: repoRoot, + workspaceRoot: repoRoot, + env: { + AI_GATEWAY_API_KEY: "libfx-benchmark-key", + FX_GATEWAY_CHAT_URL: gatewayUrl, + FX_MODEL: "benchmark/model", + }, + }); + const agentReadyAt = performance.now(); + const promptAt = performance.now(); + const turn = agent.prompt("Reply with hello."); + let text = ""; + for await (const update of turn) { + if (update.type !== "text_delta") continue; + const chunk = update.delta; + firstTextAt ??= performance.now(); + text += chunk; + process.stdout.write(chunk); + } + const result = await turn.result; + await agent.close(); + const exitCode = 0; + const finishedAt = performance.now(); + await writeFile(diagnosticsPath, JSON.stringify({ + backend, + startedAt, + importedAt, + agentReadyAt, + promptAt, + fetchAt, + firstBodyAt, + firstTextAt, + finishedAt, + stopReason: result.stopReason, + exitCode, + text, + })); +} + +async function runParent() { + const encoded = new TextEncoder(); + const server = createServer((request, response) => { + request.resume(); + request.on("end", () => { + if (request.method === "GET") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ object: "list", data: [{ id: "benchmark/model", type: "language", released: 1, tags: ["tool-use"] }] })); + return; + } + response.writeHead(200, { "content-type": "text/event-stream" }); + response.write(encoded.encode('data: {"type":"text-delta","id":"bench","delta":"hello"}\n\n')); + response.write(encoded.encode('data: {"type":"finish","finishReason":{"unified":"stop","raw":"stop"},"usage":{"inputTokens":{"total":1},"outputTokens":{"total":1}}}\n\n')); + response.end(encoded.encode("data: [DONE]\n\n")); + }); + }); + await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); + const { port } = server.address(); + const gatewayUrl = `http://127.0.0.1:${port}/chat`; + const runDir = await mkdtemp(join(tmpdir(), "libfx-benchmark-")); + const measured = []; + try { + for (let index = 0; index < samples; index++) { + const diagnosticsPath = join(runDir, `sample-${index}.json`); + measured.push(await runSample(gatewayUrl, diagnosticsPath)); + } + } finally { + server.closeAllConnections(); + await new Promise((resolveClose) => server.close(resolveClose)); + await rm(runDir, { recursive: true, force: true }); + } + const report = { + format_version: 1, + runtime: process.versions.bun ? "bun" : "node", + runtime_version: process.versions.bun ?? process.version, + backend, + samples: measured, + }; + process.stdout.write(`${JSON.stringify(report, null, args.includes("--json") ? 2 : 0)}\n`); +} + +async function runSample(gatewayUrl, diagnosticsPath) { + const childArgs = []; + if (!process.versions.bun && backend === "wasm") childArgs.push("--experimental-wasm-jspi"); + childArgs.push(scriptPath, "--child", "--backend", backend); + const spawnedAt = performance.now(); + const child = spawn(process.execPath, childArgs, { + cwd: repoRoot, + env: { + ...process.env, + LIBFX_BENCH_GATEWAY_URL: gatewayUrl, + LIBFX_BENCH_DIAGNOSTICS: diagnosticsPath, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let firstStdoutAt = null; + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + firstStdoutAt ??= performance.now(); + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + const exitCode = await new Promise((resolveExit, reject) => { + child.once("error", reject); + child.once("exit", (code) => resolveExit(code)); + }); + const exitedAt = performance.now(); + if (exitCode !== 0) throw new Error(`benchmark child exited ${exitCode}: ${stderr}`); + const diagnostics = JSON.parse(await readFile(diagnosticsPath, "utf8")); + for (const field of ["fetchAt", "firstBodyAt", "firstTextAt"]) { + if (diagnostics[field] === null) throw new Error(`benchmark child omitted ${field}`); + } + return { + text: stdout, + spawn_to_first_stdout_ms: firstStdoutAt - spawnedAt, + prompt_to_fetch_ms: diagnostics.fetchAt - diagnostics.promptAt, + first_body_to_first_text_ms: diagnostics.firstTextAt - diagnostics.firstBodyAt, + total_ms: exitedAt - spawnedAt, + import_ms: diagnostics.importedAt - diagnostics.startedAt, + create_agent_ms: diagnostics.agentReadyAt - diagnostics.importedAt, + }; +} diff --git a/benchmarks/libfx/bench-pi.mjs b/benchmarks/libfx/bench-pi.mjs new file mode 100644 index 000000000..132cf8ab6 --- /dev/null +++ b/benchmarks/libfx/bench-pi.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const args = process.argv.slice(2); +const option = (name, fallback) => { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : fallback; +}; +const samples = Number(option("--samples", "1")); +const childMode = args.includes("--child"); + +if (!Number.isSafeInteger(samples) || samples < 1 || samples > 1000) throw new Error(`invalid samples: ${samples}`); +if (childMode) await runChild(); +else await runParent(); + +function piEntry() { + const root = process.env.LIBFX_BENCH_PI_ROOT; + if (!root) throw new Error("Install @earendil-works/pi-coding-agent and set LIBFX_BENCH_PI_ROOT to its npm prefix"); + return pathToFileURL(resolve(root, "node_modules/@earendil-works/pi-coding-agent/dist/index.js")).href; +} + +async function runChild() { + const gatewayOrigin = process.env.LIBFX_BENCH_GATEWAY_ORIGIN; + const diagnosticsPath = process.env.LIBFX_BENCH_DIAGNOSTICS; + if (!gatewayOrigin || !diagnosticsPath) throw new Error("benchmark child environment is incomplete"); + + const startedAt = performance.now(); + const { createAgentSession, SessionManager } = await import(piEntry()); + const importedAt = performance.now(); + const nativeFetch = globalThis.fetch; + let fetchAt = null; + let firstBodyAt = null; + let firstTextAt = null; + globalThis.fetch = async (url, init = {}) => { + const isPrompt = (init.method ?? "GET") === "POST"; + if (isPrompt) fetchAt ??= performance.now(); + const response = await nativeFetch(url, init); + if (!isPrompt || !response.body) return response; + const reader = response.body.getReader(); + const body = new ReadableStream({ + async pull(controller) { + const result = await reader.read(); + if (result.done) { + controller.close(); + return; + } + firstBodyAt ??= performance.now(); + controller.enqueue(result.value); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + }; + + const model = { + id: "benchmark/model", + name: "Benchmark Model", + api: "openai-completions", + provider: "openai", + baseUrl: `${gatewayOrigin}/v1`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4096, + }; + const { session } = await createAgentSession({ + cwd: repoRoot, + model, + noTools: "all", + sessionManager: SessionManager.inMemory(), + }); + const agentReadyAt = performance.now(); + let text = ""; + const observedEvents = []; + const unsubscribe = session.subscribe((event) => { + if (observedEvents.length < 32) { + observedEvents.push({ + type: event.type, + messageType: event.assistantMessageEvent?.type, + stopReason: event.message?.stopReason, + errorMessage: event.message?.errorMessage, + }); + } + if (event.type !== "message_update" || event.assistantMessageEvent.type !== "text_delta") return; + firstTextAt ??= performance.now(); + text += event.assistantMessageEvent.delta; + process.stdout.write(event.assistantMessageEvent.delta); + }); + const promptAt = performance.now(); + await session.prompt("Reply with hello."); + unsubscribe(); + session.dispose(); + const finishedAt = performance.now(); + globalThis.fetch = nativeFetch; + await writeFile(diagnosticsPath, JSON.stringify({ + startedAt, + importedAt, + agentReadyAt, + promptAt, + fetchAt, + firstBodyAt, + firstTextAt, + finishedAt, + text, + observedEvents, + })); +} + +async function runParent() { + const server = createServer((request, response) => { + request.resume(); + request.on("end", () => { + response.writeHead(200, { "content-type": "text/event-stream" }); + const item = { type: "message", id: "msg_bench", role: "assistant", status: "completed", phase: "final_answer", content: [{ type: "output_text", text: "hello", annotations: [] }] }; + response.write(`data: ${JSON.stringify({ type: "response.created", response: { id: "resp_bench", status: "in_progress", output: [] } })}\n\n`); + response.write(`data: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item: { ...item, status: "in_progress", content: [] } })}\n\n`); + response.write(`data: ${JSON.stringify({ type: "response.output_text.delta", output_index: 0, content_index: 0, delta: "hello", item_id: item.id })}\n\n`); + response.write(`data: ${JSON.stringify({ type: "response.output_item.done", output_index: 0, item })}\n\n`); + response.write(`data: ${JSON.stringify({ type: "response.completed", response: { id: "resp_bench", status: "completed", output: [item], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 } } } })}\n\n`); + response.end("data: [DONE]\n\n"); + }); + }); + await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); + const { port } = server.address(); + const gatewayOrigin = `http://127.0.0.1:${port}`; + const runDir = await mkdtemp(join(tmpdir(), "pi-benchmark-")); + const measured = []; + try { + for (let index = 0; index < samples; index++) { + measured.push(await runSample(gatewayOrigin, join(runDir, `sample-${index}.json`))); + } + } finally { + server.closeAllConnections(); + await new Promise((resolveClose) => server.close(resolveClose)); + await rm(runDir, { recursive: true, force: true }); + } + process.stdout.write(`${JSON.stringify({ + format_version: 1, + target: "pi", + runtime: process.versions.bun ? "bun" : "node", + runtime_version: process.versions.bun ?? process.version, + package_version: "0.84.4", + samples: measured, + }, null, args.includes("--json") ? 2 : 0)}\n`); +} + +async function runSample(gatewayOrigin, diagnosticsPath) { + const spawnedAt = performance.now(); + const child = spawn(process.execPath, [scriptPath, "--child"], { + cwd: repoRoot, + env: { + ...process.env, + OPENAI_API_KEY: "pi-benchmark-key", + LIBFX_BENCH_GATEWAY_ORIGIN: gatewayOrigin, + LIBFX_BENCH_DIAGNOSTICS: diagnosticsPath, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let firstStdoutAt = null; + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + firstStdoutAt ??= performance.now(); + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + const exitCode = await new Promise((resolveExit, reject) => { + child.once("error", reject); + child.once("exit", (code) => resolveExit(code)); + }); + const exitedAt = performance.now(); + if (exitCode !== 0) throw new Error(`pi benchmark child exited ${exitCode}: ${stderr}`); + const diagnostics = JSON.parse(await readFile(diagnosticsPath, "utf8")); + for (const field of ["fetchAt", "firstBodyAt", "firstTextAt"]) { + if (diagnostics[field] === null) { + throw new Error(`pi benchmark child omitted ${field}; stdout=${JSON.stringify(stdout)} stderr=${JSON.stringify(stderr)} diagnostics=${JSON.stringify(diagnostics)}`); + } + } + return { + text: stdout, + spawn_to_first_stdout_ms: firstStdoutAt - spawnedAt, + prompt_to_fetch_ms: diagnostics.fetchAt - diagnostics.promptAt, + first_body_to_first_text_ms: diagnostics.firstTextAt - diagnostics.firstBodyAt, + total_ms: exitedAt - spawnedAt, + import_ms: diagnostics.importedAt - diagnostics.startedAt, + create_agent_ms: diagnostics.agentReadyAt - diagnostics.importedAt, + }; +} diff --git a/benchmarks/libfx/bench-suite.mjs b/benchmarks/libfx/bench-suite.mjs new file mode 100644 index 000000000..aee8d08ba --- /dev/null +++ b/benchmarks/libfx/bench-suite.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { randomInt } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +const args = process.argv.slice(2); +const value = (name, fallback) => { + const index = args.indexOf(name); + return index < 0 ? fallback : args[index + 1]; +}; +const samples = Number(value("--samples", "20")); +const outDir = resolve(value("--out", "benchmarks/results/libfx")); +const piRoot = value("--pi-root", process.env.LIBFX_BENCH_PI_ROOT); +if (!Number.isInteger(samples) || samples < 1 || samples > 1000) throw new Error("samples must be 1..1000"); + +const cases = [ + { name: "fx-node-native", command: "node", args: ["benchmarks/libfx/bench-fx.mjs", "--backend", "native", "--samples", "1", "--json"] }, + { name: "fx-node-wasm", command: "node", args: ["--experimental-wasm-jspi", "benchmarks/libfx/bench-fx.mjs", "--backend", "wasm", "--samples", "1", "--json"] }, + { name: "fx-bun-native", command: "bun", args: ["benchmarks/libfx/bench-fx.mjs", "--backend", "native", "--samples", "1", "--json"] }, + { name: "fx-bun-wasm", command: "bun", args: ["benchmarks/libfx/bench-fx.mjs", "--backend", "wasm", "--samples", "1", "--json"] }, + ...(piRoot ? [ + { name: "pi-node", command: "node", args: ["benchmarks/libfx/bench-pi.mjs", "--samples", "1", "--json"], env: { LIBFX_BENCH_PI_ROOT: piRoot } }, + { name: "pi-bun", command: "bun", args: ["benchmarks/libfx/bench-pi.mjs", "--samples", "1", "--json"], env: { LIBFX_BENCH_PI_ROOT: piRoot } }, + ] : []), +]; +const reports = new Map(cases.map((entry) => [entry.name, null])); +const order = []; + +for (let round = 0; round < samples; round++) { + const shuffled = [...cases]; + for (let index = shuffled.length - 1; index > 0; index--) { + const swap = randomInt(index + 1); + [shuffled[index], shuffled[swap]] = [shuffled[swap], shuffled[index]]; + } + for (const entry of shuffled) { + order.push(entry.name); + const { stdout } = await run(entry.command, entry.args, { + cwd: resolve("."), + env: { ...process.env, ...entry.env }, + maxBuffer: 4 * 1024 * 1024, + }); + const sample = JSON.parse(stdout); + const report = reports.get(entry.name) ?? { ...sample, samples: [] }; + report.samples.push(sample.samples[0]); + reports.set(entry.name, report); + } +} + +await mkdir(outDir, { recursive: true }); +for (const [name, report] of reports) { + await writeFile(resolve(outDir, `${name}.json`), `${JSON.stringify(report, null, 2)}\n`); +} +await writeFile(resolve(outDir, "sample-order.json"), `${JSON.stringify({ format_version: 1, order }, null, 2)}\n`); +process.stdout.write(`wrote ${reports.size} interleaved reports with ${samples} samples each\n`); diff --git a/sdk/NAPI.md b/sdk/NAPI.md index 015ee84b1..49f200010 100644 --- a/sdk/NAPI.md +++ b/sdk/NAPI.md @@ -82,7 +82,7 @@ The adapter checks fetch control and drains ACP output on its existing timer. Wh This ABI is internal. Consumers should use `createFxAgent()` from `sdk/node.js`; exposing the primitive functions keeps the native boundary small and testable. -Response operations return numeric outcomes: `0` means the operation was stale and ignored, `1` means it was applied, and `2` means a response push encountered bounded backpressure. Stale callbacks never mutate a newer fetch. They emit one payload-free `napi` trace containing only the operation, numeric fetch handle, and drop reason. +Response operations return numeric outcomes: `0` means the operation was stale and ignored, `1` means it was applied, and `2` means a response push encountered bounded backpressure. Stale callbacks never mutate a newer fetch. The addon does not write ambient diagnostics for these outcomes; the JavaScript adapter observes the numeric result and owns any explicit host reporting. Each handle is a JavaScript object wrapped around a `RuntimeHandle`. It is branded with `napi_type_tag` and checked before every operation. A structurally similar object cannot be substituted for a real handle. The wrapper owns a finalizer, so garbage collection invokes the same destruction path as explicit `destroyCore()`. @@ -101,7 +101,7 @@ Creating a core performs these steps: The runtime thread never calls N-API. It blocks on the fetch bridge while the Node event-loop poller owns `fetch`, response-body iteration, and `AbortController`. Destruction marks the bridge shutting down and wakes every wait before joining the runtime thread, so worker teardown does not depend on further JavaScript callbacks. -The addon initializes one process-wide `std.Io.Threaded` instance. Atomic state protects one-time initialization when the addon is loaded in multiple Node worker environments. The same initialization installs the inherited process environment and configures the existing `debug_trace` owner before any runtime thread starts; individual runtimes do not shut global tracing down. +The addon initializes one process-wide `std.Io.Threaded` instance. Atomic state protects one-time initialization when the addon is loaded in multiple Node worker environments. The same initialization installs inherited process-environment access before any runtime thread starts. It does not configure fx product tracing from ambient `FX_TRACE_*` variables; libfx remains silent unless its JavaScript host explicitly requests SDK observability. Input and output queues have independent `std.Io.Mutex` protection. The input queue also has a condition variable so the ACP reader sleeps while no input is available. Closing input broadcasts the condition and allows the server thread to terminate. @@ -256,6 +256,7 @@ The lane covers: - malformed arguments, oversized values, fake handles, and use after close; - input backpressure and the process-wide runtime cap; +- ambient fx trace isolation for stdout, stderr, and trace files; - repeated failed construction without file descriptor leakage; - blocked ACP MCP servers and absent native tool advertisement; - same-environment concurrency and Node worker isolation; diff --git a/sdk/README.md b/sdk/README.md index 0117c60f6..3dbab65bd 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -1,361 +1,162 @@ # libfx -`libfx` embeds fx agents and interactive terminals in JavaScript -applications. It supports Node.js hosts and browser environments with -JavaScript Promise Integration (JSPI). - -## Installation +`libfx` is the small fx agent kernel for JavaScript hosts. One agent is one +in-memory conversation with three operations: `prompt`, `checkpoint`, and +`close`. ```sh npm install libfx ``` -Requirements: - -- Node.js 20 or later -- Chrome or Edge 137 or later for browser WebAssembly -- JSPI when using the WebAssembly backend -- A Vercel AI Gateway credential or a host-provided authenticated `fetch` - -The package includes: - -- Native Node addons for Linux and macOS on x64 and arm64 -- `fx-core.wasm` for headless agents -- `fx-term.wasm` for interactive terminals -- A dependency-free JavaScript host layer - -## Exports - -| Import | Environment | Description | -| --- | --- | --- | -| `libfx` | Node.js or browser | Environment-aware default | -| `libfx/node` | Node.js | Native-first Node entry point | -| `libfx/browser` | Browser | WebAssembly browser entry point | -| `libfx/wasm` | Browser or Node.js | Direct WebAssembly host layer | +Node.js uses the native addon when available and falls back to WebAssembly. +Browsers use WebAssembly with JSPI. The default package has no runtime +dependencies and performs no MCP connection, skill scan, process spawn, or +filesystem read when imported. -Public exports: - -- `createFxAgent()` creates a headless ACP agent. -- `createFxTerminal()` runs the interactive fx terminal. -- `supportsJspi()` detects WebAssembly JSPI support. -- `xtermAdapter()` connects fx to an xterm.js terminal. -- `encodeXtermKeyEvent()` translates browser keyboard events into terminal input. - -## Headless agent - -The default Node entry point prefers the native addon and falls back to -WebAssembly when necessary. +## Agent ```js import { createFxAgent } from "libfx"; const agent = await createFxAgent({ - env: { - AI_GATEWAY_API_KEY: process.env.AI_GATEWAY_API_KEY, - }, - onEvent(event) { - console.log(event.type); - }, - async onPermission(request) { - // Return one of request.options[*].optionId to approve it. - // Returning null or undefined cancels the request. - return null; - }, + env: { AI_GATEWAY_API_KEY: process.env.AI_GATEWAY_API_KEY }, }); -const session = await agent.createSession(); -const turn = session.prompt("Explain the files in this project."); +const turn = agent.prompt("Explain this project."); -for await (const update of turn) { - console.log(update); +for await (const event of turn) { + if (event.type === "text_delta") process.stdout.write(event.delta); } -console.log("Stopped:", await turn.stopReason); - -await session.close(); +console.log(await turn.result); // { stopReason, usage } +const checkpoint = await agent.checkpoint(); await agent.close(); ``` -A prompt may be a string or an array of text and resource blocks: +`prompt(input, { signal? })` accepts a string or text/resource blocks. It +returns an async iterable of normalized events: -```js -const turn = session.prompt([ - { type: "text", text: "Summarize this file." }, - { - type: "resource", - resource: { - uri: "file:///workspace/README.md", - text: readmeContents, - }, - }, -]); -``` - -Image prompt blocks are not currently supported. - -### Agent lifecycle - -The object returned by `createFxAgent()` provides: +- `text_delta` +- `reasoning_delta` when supplied by the provider +- `tool_start` +- `tool_end` -| Member | Description | -| --- | --- | -| `createSession()` | Creates a new active session | -| `openSession(id)` | Loads a stored session | -| `listSessions()` | Lists stored sessions | -| `close()` | Closes the active session and shuts down cleanly | -| `abort()` | Immediately aborts the runtime | -| `exited` | Promise that resolves with the process exit code | - -A session provides: - -| Member | Description | -| --- | --- | -| `prompt(input, options?)` | Starts an async iterable turn | -| `setModel(model)` | Changes the active model | -| `setMode(mode)` | Changes the active mode | -| `setConfig(config)` | Applies multiple configuration values | -| `close()` | Closes the active session | -| `remove()` | Removes the stored session | -| `history` | Previously loaded session updates | -| `configOptions` | Current configurable values | - -Each session allows one active prompt at a time. Cancel a turn directly or -with an `AbortSignal`: +Only one prompt may run at a time. `checkpoint()` is idle-only and returns +opaque, bounded, versioned bytes. Restore them only when creating a fresh +agent: ```js -const controller = new AbortController(); -const turn = session.prompt("Wait for more instructions.", { - signal: controller.signal, -}); - -controller.abort(); -console.log(await turn.stopReason); // "cancelled" +const restored = await createFxAgent({ checkpoint, env }); ``` -## Browser agent +The checkpoint contains conversation history and usage only. The host owns +durable storage and must resupply models, credentials, instructions, tools, +MCP clients, and skill records. -Browser hosts always use WebAssembly. +## JavaScript tools and instructions ```js -import { - createFxAgent, - supportsJspi, -} from "libfx/browser"; - -if (!supportsJspi()) { - throw new Error("This browser does not support WebAssembly JSPI."); -} - const agent = await createFxAgent({ - env: { - AI_GATEWAY_API_KEY: "", - }, + instructions: "Keep answers concise.", + tools: [{ + name: "lookup", + description: "Look up a value.", + inputSchema: { + type: "object", + properties: { key: { type: "string" } }, + required: ["key"], + }, + async execute(input, { signal }) { + return database.get(input.key, { signal }); + }, + }], + env, }); - -const session = await agent.createSession(); -const turn = session.prompt("Describe this workspace."); - -for await (const update of turn) { - console.log(update); -} ``` -The browser entry point resolves `fx-core.wasm` and `fx-term.wasm` relative to -the installed package. Pass `wasm` explicitly to provide a URL, `Response`, -`ArrayBuffer`, typed array, or precompiled `WebAssembly.Module`. - -Do not embed a long-lived API key in public browser code. Use a short-lived -credential or an authenticated server-side proxy. - -## Interactive terminal +The JavaScript host is the authority for tool effects. The same descriptors, +schemas, cancellation, results, and events are used by N-API and WebAssembly. +Instructions are limited to 64 KiB of UTF-8 text, including text assembled by +the MCP and skills adapters. -Install xterm.js in the host application: +## MCP -```sh -npm install @xterm/xterm @xterm/addon-fit -``` - -Create the terminal and connect it to fx: +`libfx/mcp` accepts a host-owned MCP client. Transport, authentication, +elicitation, and cleanup remain outside the kernel. ```js -import { Terminal } from "@xterm/xterm"; -import { FitAddon } from "@xterm/addon-fit"; -import "@xterm/xterm/css/xterm.css"; -import { - createFxTerminal, - supportsJspi, - xtermAdapter, -} from "libfx/browser"; - -if (!supportsJspi()) { - throw new Error("This browser does not support WebAssembly JSPI."); -} - -const terminal = new Terminal({ - cursorBlink: true, - scrollback: 10_000, -}); - -const fit = new FitAddon(); -terminal.loadAddon(fit); -terminal.open(document.querySelector("#terminal")); -fit.fit(); - -const runtime = await createFxTerminal({ - terminal: xtermAdapter(terminal), - env: { - AI_GATEWAY_API_KEY: "", - }, -}); - -await runtime.interactive; +import { createMcpAdapter } from "libfx/mcp"; -window.addEventListener("resize", () => { - fit.fit(); - runtime.resize(); +const mcp = await createMcpAdapter(client, { + prefix: "github_", + resources: ["repo://instructions"], + prompts: ["review"], }); -``` - -The terminal runtime provides: - -| Member | Description | -| --- | --- | -| `interactive` | Resolves after the terminal is ready for input | -| `exited` | Resolves with the terminal exit code | -| `write(data)` | Writes input directly to fx | -| `resize()` | Notifies fx of terminal geometry changes | -| `abort()` | Stops the terminal and releases subscriptions | -Try the hosted terminal at [fx.sh/try](https://fx.sh/try). - -## Backend selection - -Node hosts may select a backend explicitly: - -```js const agent = await createFxAgent({ - backend: "native", + tools: mcp.tools, + instructions: mcp.instructions, + env, }); -``` -| Backend | Behavior | -| --- | --- | -| `auto` | Prefer a compatible native addon and fall back to WebAssembly | -| `native` | Require the native backend and fail if it cannot load | -| `wasm` | Require WebAssembly and JSPI | - -The native loader checks `libfx.node` followed by the platform-specific addon: - -```text -libfx.-.node +// ... +await agent.close(); +await mcp.close(); ``` -Supported packaged targets: +## Skills -- `linux-x64` -- `linux-arm64` -- `darwin-x64` -- `darwin-arm64` - -If no compatible native backend is available and JSPI cannot run, startup -rejects with: +Use `libfx/skills` for already-loaded records or `libfx/skills/node` to load a +`SKILL.md` explicitly in Node or Bun. ```js -error.code === "LIBFX_JSPI_REQUIRED" -``` +import { loadSkillFile } from "libfx/skills/node"; +import { createSkillsAdapter } from "libfx/skills"; -On Node versions where JSPI remains behind a flag, start the process with: - -```sh -node --experimental-wasm-jspi app.mjs +const record = await loadSkillFile("./skills/review/SKILL.md"); +const skills = createSkillsAdapter([record]); +const agent = await createFxAgent({ ...skills, env }); ``` -## Host integrations - -Hosts may provide adapters for runtime state and external effects: - -| Option | Purpose | -| --- | --- | -| `fetch` | Routes Gateway requests through the host | -| `env` | Supplies runtime configuration without changing process globals | -| `onEvent` | Receives runtime, ACP, terminal, and lifecycle events | -| `onPermission` | Resolves agent permission requests | -| `configStore` | Persists accepted configuration values | -| `sessionStore` | Persists agent or terminal sessions | -| `oauthSessionStore` | Persists browser device-login sessions | -| `promptHistoryStore` | Stores terminal prompt history | -| `openUrl` | Opens authentication and verification URLs | -| `workspace` | Provides the constrained browser workspace adapter | - -## Security boundaries - -`nativeAddon` and `env.FX_GATEWAY_CHAT_URL` are trusted host configuration. Do -not populate them from request, tenant, or other untrusted input. - -The native backend sends production credentials only to the canonical Vercel -AI Gateway endpoint. Custom Gateway endpoints are limited to explicit loopback -HTTP URLs for local development. - -The WebAssembly runtime intentionally does not provide: - -- Native processes -- OS sandboxing -- Native MCP servers -- Subagents or skills -- Automatic upgrades -- Clipboard integration -- Arbitrary WASI filesystem access -- Public web fetch, web search, and general outbound network access - -The embedded runtime tells the model not to retry unavailable network work -through shell commands. Use locally installed fx when the full native tool -suite is required. - -The optional browser workspace exposes completion-only shell execution through -the typed contract: +## Backends ```js -{ action: "run", command } +await createFxAgent({ backend: "auto" }); // native, then Wasm fallback +await createFxAgent({ backend: "native" }); // require N-API +await createFxAgent({ backend: "wasm" }); // require Wasm + JSPI ``` -The host remains responsible for admitting commands, enforcing limits, and -returning bounded output. - -## Local development - -From the fx repository root, build the native addon and both WebAssembly -surfaces: - -```sh -zig build -Dnapi-surface=core -Doptimize=ReleaseSafe -zig build -Dwasm-surface=core -Doptimize=ReleaseSmall -zig build -Dwasm-surface=term -Doptimize=ReleaseSmall -``` +Within one JavaScript realm, libfx compiles each stable Wasm source once and +creates a separate WebAssembly instance for every Agent. Agent memory, history, +tools, cancellation, and shutdown remain isolated. Workers and separate +processes maintain their own module caches. -Run the SDK test suites: +Node.js 20+ is supported. Browser WebAssembly requires a JSPI-capable browser. +Some Node versions require `--experimental-wasm-jspi`. -```sh -npm ci --prefix sdk/node -npm run --prefix sdk test:node-napi -npm run --prefix sdk test:node-wasm -``` +## Interactive terminal -Serve the repository: +`createFxTerminal()` remains a separate terminal harness API. In browsers, +connect it to xterm.js with `xtermAdapter()`: -```sh -python3 -m http.server 8080 -``` +```js +import { createFxTerminal, xtermAdapter } from "libfx/browser"; -After starting the server, open these local URLs: +const runtime = await createFxTerminal({ + terminal: xtermAdapter(term), + env: { AI_GATEWAY_API_KEY: "" }, +}); -```text -Core debugger: http://localhost:8080/sdk/index.html -Interactive terminal: http://localhost:8080/sdk/term-demo.html +await runtime.interactive; ``` -These are local development pages and are not publicly hosted links. +The terminal runtime exposes `interactive`, `exited`, `write`, `resize`, and +`abort`. Terminal session, config, OAuth, prompt-history, URL, and workspace +stores remain terminal-only host integrations. -Maintainer references: +## Security -- [SDK contributor guide](https://github.com/vercel-labs/fx/blob/main/sdk/AGENTS.md) -- [Native Node-API design and security model](https://github.com/vercel-labs/fx/blob/main/sdk/NAPI.md) +Treat `nativeAddon` and `env.FX_GATEWAY_CHAT_URL` as trusted host +configuration. Do not embed long-lived credentials in public browser code. +Host tool functions, MCP clients, and skill loaders retain their own authority; +libfx validates and sequences them but does not grant operating-system access. diff --git a/sdk/fx-sdk.js b/sdk/fx-sdk.js index eb7ac9091..63da9d120 100644 --- a/sdk/fx-sdk.js +++ b/sdk/fx-sdk.js @@ -4,6 +4,7 @@ const strictDecoder = new TextDecoder("utf-8", { fatal: true }); const workspaceInfoLimit = 4 * 1024; const workspaceCommandLimit = 64 * 1024; const workspaceOutputLimit = 64 * 1024; +const maxInstructionsBytes = 64 * 1024; const streamReadsPerTaskYield = 32; function validWorkspacePath(path) { @@ -50,7 +51,7 @@ function utf8Prefix(value, limit) { return value.subarray(0, end); } -export const fxSdkApiVersion = 1; +export const fxSdkApiVersion = 2; export function supportsJspi() { return typeof WebAssembly.Suspending === "function" && @@ -92,35 +93,6 @@ export function xtermAdapter(term) { }; } -function createMemorySessionStore() { - const records = new Map(); - let nextRevision = 1; - return { - async load(id) { - const record = records.get(id); - return record ? { bytes: record.bytes.slice(), revision: record.revision } : null; - }, - async commit(id, bytes, expectedRevision) { - const current = records.get(id); - if ((current?.revision) !== expectedRevision) throw revisionConflict(); - const revision = String(nextRevision++); - records.set(id, { bytes: bytes.slice(), revision, updatedAtMs: Date.now() }); - return { revision }; - }, - async list() { - return [...records.entries()].map(([id, record]) => ({ id, updatedAtMs: record.updatedAtMs })) - .sort((a, b) => b.updatedAtMs - a.updatedAtMs); - }, - async remove(id) { records.delete(id); }, - }; -} - -function revisionConflict() { - const error = new Error("session revision conflict"); - error.code = "FX_SESSION_REVISION_CONFLICT"; - return error; -} - class ByteQueue { chunks = []; waiters = []; @@ -176,7 +148,10 @@ class ByteQueue { } } -async function loadModule(input) { +const modulePromisesBySource = new Map(); +const modulePromisesByObject = new WeakMap(); + +async function compileModule(input) { if (input instanceof WebAssembly.Module) return input; if (typeof input === "string") input = fetch(input); if (input instanceof Promise) input = await input; @@ -195,6 +170,21 @@ async function loadModule(input) { throw new TypeError("wasm must be a URL, Response, ArrayBuffer, typed array, or WebAssembly.Module"); } +function loadModule(input) { + if (input instanceof WebAssembly.Module) return Promise.resolve(input); + const isString = typeof input === "string"; + if (!isString && (typeof input !== "object" || input === null)) return compileModule(input); + const cache = isString ? modulePromisesBySource : modulePromisesByObject; + const cached = cache.get(input); + if (cached) return cached; + const pending = compileModule(input); + cache.set(input, pending); + pending.catch(() => { + if (cache.get(input) === pending) cache.delete(input); + }); + return pending; +} + function raceWithTimeout(promise, timeoutMs, timeoutValue) { let timer; return new Promise((resolve, reject) => { @@ -455,6 +445,22 @@ function createRuntime(options) { }).catch(() => -1); } + function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr) { + if (typeof options.hostToolExecutor !== "function") return -1; + if (options.traceWasi) console.error("fx host tool call start"); + let input; + try { input = JSON.parse(text(argumentsPtr, argumentsLen)); } catch { return -1; } + return Promise.resolve(options.hostToolExecutor(text(namePtr, nameLen), input)).then((result) => { + if (options.traceWasi) console.error("fx host tool call settled", result.cancelled, result.isError); + if (result.cancelled) return -2; + const output = encoder.encode(result.content); + if (output.length > outputCap) return -3; + bytes(outputPtr, output.length).set(output); + bytes(statusPtr, 1)[0] = result.isError ? 1 : 0; + return output.length; + }).catch(() => -1); + } + function openUrl(urlPtr, urlLen) { if (typeof options.openUrl !== "function") return 0; return Promise.resolve().then(() => options.openUrl(text(urlPtr, urlLen))).then((accepted) => @@ -774,6 +780,7 @@ function createRuntime(options) { fx_http_stream_next: new WebAssembly.Suspending(streamNext), fx_http_stream_close(handle) { const state = streams.get(handle); state?.controller.abort(); streams.delete(handle); }, fx_http_request: new WebAssembly.Suspending(httpRequest), + fx_host_tool_call: new WebAssembly.Suspending(hostToolCall), fx_open_url: new WebAssembly.Suspending(openUrl), fx_oauth_session_load: new WebAssembly.Suspending(oauthSessionLoad), fx_oauth_session_commit: new WebAssembly.Suspending(oauthSessionCommit), @@ -930,20 +937,117 @@ function normalizePromptInput(input) { }); } -export async function createFxAgent(options) { - options = { ...options, sessionStore: options.sessionStore || createMemorySessionStore() }; +function normalizeHostTools(value) { + if (value === undefined) return { descriptors: [], executors: new Map() }; + if (!Array.isArray(value)) throw new TypeError("tools must be an array"); + if (value.length > 64) throw new RangeError("tools cannot contain more than 64 entries"); + const descriptors = []; + const executors = new Map(); + for (const [index, tool] of value.entries()) { + if (!tool || typeof tool !== "object") throw new TypeError(`tool ${index} must be an object`); + const { name, description, inputSchema, execute } = tool; + if (typeof name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) { + throw new TypeError(`tool ${index} has an invalid name`); + } + if (executors.has(name)) throw new TypeError(`duplicate tool name: ${name}`); + if (typeof description !== "string") throw new TypeError(`tool ${name} requires a description`); + if (typeof execute !== "function") throw new TypeError(`tool ${name} requires execute()`); + if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) { + throw new TypeError(`tool ${name} requires an object inputSchema`); + } + let schema; + try { schema = JSON.parse(JSON.stringify(inputSchema)); } catch { + throw new TypeError(`tool ${name} inputSchema must be JSON-serializable`); + } + descriptors.push({ name, description, inputSchema: schema }); + executors.set(name, execute); + } + return { descriptors, executors }; +} + +function normalizeInstructions(value) { + let instructions; + if (value === undefined) instructions = ""; + else if (typeof value === "string") instructions = value; + if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) { + instructions = value.filter(Boolean).join("\n\n"); + } + if (instructions === undefined) { + throw new TypeError("instructions must be a string or an array of strings"); + } + if (encoder.encode(instructions).length > maxInstructionsBytes) { + throw new RangeError(`instructions exceed the ${maxInstructionsBytes} byte libfx limit`); + } + return instructions; +} + +function hostToolContent(value) { + if (typeof value === "string") return value; + if (value === undefined) return "null"; + const encoded = JSON.stringify(value); + return encoded === undefined ? "null" : encoded; +} + +function checkpointBytes(value) { + if (value === undefined) return null; + if (value instanceof Uint8Array) return value.slice(); + if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0)); + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)); + } + throw new TypeError("checkpoint must be an ArrayBuffer or typed array"); +} + +function bytesToBase64(value) { + let binary = ""; + for (let offset = 0; offset < value.length; offset += 0x8000) { + binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +function base64ToBytes(value) { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +export async function createFxAgent(options = {}) { + options = { ...options }; + const hostTools = normalizeHostTools(options.tools); + const instructions = normalizeInstructions(options.instructions); + const initialCheckpoint = checkpointBytes(options.checkpoint); const pending = new Map(); - const turns = new Map(); let nextId = 1; - let activeSession = null; - let loadingSessionId = null; - let loadingUpdates = []; + let sessionId = null; + let activeTurn = null; let closing = false; const emit = (type, detail = {}) => { try { options.onEvent?.({ type, timestamp: performance.now(), ...detail }); } catch {} }; + const executeHostTool = async (name, input, requestedSessionId) => { + const execute = hostTools.executors.get(name); + const turn = requestedSessionId === undefined || requestedSessionId === sessionId + ? activeTurn + : null; + const controller = new AbortController(); + turn?.toolControllers.add(controller); + let content; + let isError = false; + try { + if (!execute) throw new Error(`unknown host tool: ${String(name)}`); + content = hostToolContent(await execute(input, { signal: controller.signal })); + } catch (error) { + isError = true; + content = error instanceof Error ? error.message : String(error); + } finally { + turn?.toolControllers.delete(controller); + } + return { content, isError, cancelled: controller.signal.aborted }; + }; emit("runtime.start"); - const runtimeOptions = { ...options, args: ["acp"] }; + const runtimeOptions = { ...options, args: ["acp"], hostToolExecutor: executeHostTool }; const runtime = options.runtimeFactory ? await options.runtimeFactory(runtimeOptions) : await instantiate(runtimeOptions); @@ -967,9 +1071,7 @@ export async function createFxAgent(options) { runtime.setLineHandler(async (message) => { emit("acp.receive", { message }); if (message.method === "session/update") { - const turn = turns.get(message.params.sessionId); - if (turn) turn.push(message.params.update); - else if (loadingSessionId === message.params.sessionId) loadingUpdates.push(message.params.update); + if (message.params.sessionId === sessionId) activeTurn?.push(message.params.update); return; } if (message.method === "session/request_permission") { @@ -980,150 +1082,169 @@ export async function createFxAgent(options) { send({ jsonrpc: "2.0", id: message.id, result: optionId ? { outcome: { outcome: "selected", optionId } } : { outcome: { outcome: "cancelled" } } }); return; } + if (message.method === "libfx/tool_call") { + const { content, isError, cancelled } = await executeHostTool( + message.params?.name, + message.params?.input, + message.params?.sessionId, + ); + if (cancelled || closing) return; + send({ jsonrpc: "2.0", id: message.id, result: { content, isError } }); + return; + } const waiter = pending.get(message.id); if (!waiter) return; pending.delete(message.id); if (message.error) waiter.reject(new Error(message.error.message)); else waiter.resolve(message.result); }); - await request("initialize", { protocolVersion: 1, clientCapabilities: {} }); + try { + await request("initialize", { + protocolVersion: 1, + clientCapabilities: { + ...(hostTools.descriptors.length || instructions + ? { libfx: { tools: hostTools.descriptors, instructions } } + : {}), + }, + }); + + const sessionResult = await request("libfx/new"); + sessionId = sessionResult.sessionId; + if (initialCheckpoint) { + await request("libfx/restore", { + sessionId, + checkpoint: bytesToBase64(initialCheckpoint), + }); + } + } catch (error) { + closing = true; + try { runtime.abortHostEffects(); } catch {} + try { runtime.closeStdin(); } catch {} + try { await runtime.exited; } catch {} + throw error; + } const agent = { - exited: runtime.exited, - abort() { closing = true; runtime.abort(); }, + prompt(input, promptOptions = {}) { + if (closing) throw new Error("fx agent is closed"); + if (activeTurn) throw new Error("a prompt is already in progress for this session"); + return normalizeTurn(startTurn(input, promptOptions)); + }, + async checkpoint() { + if (closing) throw new Error("fx agent is closed"); + if (activeTurn) throw new Error("cannot checkpoint while a prompt is active"); + const response = await request("libfx/checkpoint", { sessionId }); + if (typeof response?.checkpoint !== "string") throw new Error("fx returned an invalid checkpoint"); + return base64ToBytes(response.checkpoint); + }, async close() { - if (closing) return runtime.exited; - if (activeSession) await activeSession.close(); + if (closing) { await runtime.exited; return; } + const turn = activeTurn; + turn?.cancel(); + if (turn) await turn.result.catch(() => {}); closing = true; runtime.closeStdin(); - return runtime.exited; - }, - async createSession() { - if (activeSession) await activeSession.close(); - const result = await request("session/new"); - activeSession = await makeSession(result); - return activeSession; - }, - async listSessions() { - return (await request("session/list")).sessions || []; - }, - async openSession(id) { - if (activeSession) await activeSession.close(); - loadingSessionId = id; - loadingUpdates = []; - try { - const result = await request("session/load", { sessionId: id }); - activeSession = await makeSession({ sessionId: id, history: loadingUpdates, ...result }); - return activeSession; - } finally { - loadingSessionId = null; - loadingUpdates = []; - } + await runtime.exited; }, }; return agent; - async function makeSession(result) { - let configOptions = result.configOptions || []; - let closed = false; - let activeTurn = null; - const assertOpen = () => { - if (closed) throw new Error("fx session is closed"); - if (activeSession !== session) throw new Error("fx session is no longer active"); - }; - const updateConfig = (response) => { - configOptions = response.configOptions || configOptions; - return configOptions; + function normalizeTurn(rawTurn) { + const toolNames = new Map(); + const started = new Set(); + const eventFor = (update) => { + if (update.sessionUpdate === "agent_message_chunk") { + const delta = update.content?.text; + if (!delta || delta.startsWith("[context]")) return null; + return { type: "text_delta", delta }; + } + if (update.sessionUpdate === "agent_thought_chunk") { + const delta = update.content?.text; + return delta ? { type: "reasoning_delta", delta } : null; + } + if (update.sessionUpdate === "tool_call") { + toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool"); + if (started.has(update.toolCallId)) return null; + started.add(update.toolCallId); + return { + type: "tool_start", + id: update.toolCallId, + name: toolNames.get(update.toolCallId), + }; + } + if (update.sessionUpdate === "tool_call_update" && + (update.status === "completed" || update.status === "failed")) { + const content = update.content?.find((entry) => entry.content?.type === "text")?.content?.text; + return { + type: "tool_end", + id: update.toolCallId, + name: toolNames.get(update.toolCallId) || "tool", + ...(content === undefined ? {} : { content }), + isError: update.status === "failed", + }; + } + return null; }; - const session = { - id: result.sessionId, - modes: result.modes, - history: result.history || [], - get configOptions() { return configOptions; }, - async setConfigOption(configId, value, source = "sdk") { - assertOpen(); - const previousValue = configOptions.find((option) => option.id === configId)?.currentValue; - const updated = updateConfig(await request("session/set_config_option", { sessionId: result.sessionId, configId, value })); - const accepted = updated.find((option) => option.id === configId)?.currentValue; - if (configId === "mode" && accepted) this.modes.currentModeId = accepted; - if (accepted === value) { - if (options.configStore?.set) { - try { await options.configStore.set(configId, value); } catch (error) { emit("config.persist_error", { configId, error }); } - } - emit("config.changed", { configId, previousValue, value: accepted, source }); + return { + cancel() { rawTurn.cancel(); }, + async *[Symbol.asyncIterator]() { + for await (const update of rawTurn) { + const event = eventFor(update); + if (event) yield event; } - return updated; - }, - setModel(value) { return this.setConfigOption("model", value); }, - setMode(value) { return this.setConfigOption("mode", value); }, - async setConfig(config) { - for (const [key, value] of Object.entries(config)) await this.setConfigOption(key, value); - return configOptions; - }, - async close() { - if (closed) return; - activeTurn?.cancel(); - if (activeTurn) await activeTurn.result.catch(() => {}); - closed = true; - activeTurn = null; - if (activeSession === session) activeSession = null; - }, - async remove() { - if (activeTurn) throw new Error("cannot remove a session while a prompt is active"); - await request("session/remove", { sessionId: result.sessionId }); - closed = true; - if (activeSession === session) activeSession = null; }, - prompt(input, promptOptions = {}) { - assertOpen(); - if (activeTurn) throw new Error("a prompt is already in progress for this session"); - const prompt = normalizePromptInput(input); - const signal = promptOptions.signal; - if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal"); - const queue = []; - const waiters = []; - let finished = false; - let cancelled = false; - const turn = { - push(update) { const waiter = waiters.shift(); if (waiter) waiter({ value: update, done: false }); else queue.push(update); }, - cancel() { - if (finished || cancelled) return; - cancelled = true; - send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId: result.sessionId } }); - runtime.abortHostEffects(); - }, - [Symbol.asyncIterator]() { return { next() { if (queue.length) return Promise.resolve({ value: queue.shift(), done: false }); if (finished) return Promise.resolve({ done: true }); return new Promise((resolve) => waiters.push(resolve)); } }; }, - }; - turns.set(result.sessionId, turn); - activeTurn = turn; - const abort = () => turn.cancel(); - signal?.addEventListener("abort", abort, { once: true }); - turn.result = request("session/prompt", { sessionId: result.sessionId, prompt }) - .then((response) => ({ stopReason: response.stopReason })) - .catch((error) => { - if (error.message === "Cancelled") return { stopReason: "cancelled" }; - throw error; - }) - .finally(() => { - finished = true; - signal?.removeEventListener("abort", abort); - turns.delete(result.sessionId); - if (activeTurn === turn) activeTurn = null; - waiters.splice(0).forEach((resolve) => resolve({ done: true })); - }); - turn.stopReason = turn.result.then((turnResult) => turnResult.stopReason); - void turn.stopReason.catch(() => {}); - if (signal?.aborted) turn.cancel(); - return turn; + result: rawTurn.result.then((result) => ({ + stopReason: result.stopReason, + usage: normalizeTurnUsage(result.usage), + })), + }; + } + + function normalizeTurnUsage(usage) { + const result = {}; + if (Number.isSafeInteger(usage?.inputTokens)) result.inputTokens = usage.inputTokens; + if (Number.isSafeInteger(usage?.outputTokens)) result.outputTokens = usage.outputTokens; + if (Number.isSafeInteger(usage?.cacheReadTokens)) result.cacheReadTokens = usage.cacheReadTokens; + if (Number.isSafeInteger(usage?.cacheWriteTokens)) result.cacheWriteTokens = usage.cacheWriteTokens; + if (Number.isSafeInteger(usage?.reasoningTokens)) result.reasoningTokens = usage.reasoningTokens; + return result; + } + + function startTurn(input, promptOptions) { + const prompt = normalizePromptInput(input); + const signal = promptOptions.signal; + if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal"); + const queue = []; + const waiters = []; + const toolControllers = new Set(); + let finished = false; + let cancelled = false; + const turn = { + push(update) { const waiter = waiters.shift(); if (waiter) waiter({ value: update, done: false }); else queue.push(update); }, + toolControllers, + cancel() { + if (finished || cancelled) return; + cancelled = true; + send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } }); + for (const controller of toolControllers) controller.abort(); + runtime.abortHostEffects(); }, + [Symbol.asyncIterator]() { return { next() { if (queue.length) return Promise.resolve({ value: queue.shift(), done: false }); if (finished) return Promise.resolve({ done: true }); return new Promise((resolve) => waiters.push(resolve)); } }; }, }; - if (options.configStore?.get) { - activeSession = session; - for (const config of [...configOptions]) { - let value; - try { value = await options.configStore.get(config.id); } catch (error) { emit("config.restore_error", { configId: config.id, error }); continue; } - if (typeof value !== "string" || value === config.currentValue) continue; - try { await session.setConfigOption(config.id, value, "restore"); } catch (error) { emit("config.restore_error", { configId: config.id, error }); } - } - } - session.modes.currentModeId = configOptions.find((option) => option.id === "mode")?.currentValue || session.modes.currentModeId; - return session; + activeTurn = turn; + const abort = () => turn.cancel(); + signal?.addEventListener("abort", abort, { once: true }); + turn.result = request("session/prompt", { sessionId, prompt }) + .then((response) => ({ stopReason: response.stopReason, usage: response.usage })) + .catch((error) => { + if (error.message === "Cancelled") return { stopReason: "cancelled" }; + throw error; + }) + .finally(() => { + finished = true; + signal?.removeEventListener("abort", abort); + if (activeTurn === turn) activeTurn = null; + toolControllers.clear(); + waiters.splice(0).forEach((resolve) => resolve({ done: true })); + }); + if (signal?.aborted) turn.cancel(); + return turn; } } diff --git a/sdk/index.html b/sdk/index.html index 5644b4bb2..4abd0b2ef 100644 --- a/sdk/index.html +++ b/sdk/index.html @@ -57,6 +57,7 @@