From 8f3def2f8c14824f4e94c23e1d50f55ff7ba8e08 Mon Sep 17 00:00:00 2001 From: Pranit Date: Mon, 31 Aug 2026 13:45:40 -0400 Subject: [PATCH 01/10] Build the libfx agent kernel - Expose one conversation through prompt, checkpoint, and close. - Run JavaScript tools and optional MCP and skills adapters at the host edge. - Align native and WebAssembly behavior across Node, Bun, and browsers. - Add TTFT, bridge, Pi, Bun, and packed-package coverage. --- .github/workflows/bench.yml | 44 ++ .github/workflows/ci.yml | 67 +++ .github/workflows/publish-libfx.yml | 8 +- benchmarks/libfx/bench-bridge.mjs | 92 ++++ benchmarks/libfx/bench-fx.mjs | 194 +++++++++ benchmarks/libfx/bench-pi.mjs | 202 +++++++++ benchmarks/libfx/bench-suite.mjs | 57 +++ sdk/README.md | 386 ++++------------- sdk/fx-sdk.js | 328 +++++++++----- sdk/index.html | 86 ++-- sdk/mcp.js | 89 ++++ sdk/node/test-xterm-adapter.mjs | 2 +- sdk/package.json | 8 +- sdk/skills-node.js | 35 ++ sdk/skills.js | 44 ++ sdk/tests/fixtures/mcp-stdio-server.mjs | 16 + sdk/tests/test-checkpoint.mjs | 92 ++++ sdk/tests/test-core-browser.mjs | 16 +- sdk/tests/test-core-cancel.mjs | 3 +- sdk/tests/test-core-home-unavailable.mjs | 8 +- sdk/tests/test-core-live.mjs | 16 +- sdk/tests/test-core.mjs | 203 ++------- sdk/tests/test-default-import.mjs | 21 + sdk/tests/test-host-tool-cancel.mjs | 73 ++++ sdk/tests/test-host-tools.mjs | 103 +++++ sdk/tests/test-libfx-benchmark.mjs | 32 ++ sdk/tests/test-libfx-loader.mjs | 21 +- sdk/tests/test-mcp-adapter.mjs | 143 ++++++ sdk/tests/test-minimal-api.mjs | 65 +++ sdk/tests/test-native-core-cancel.mjs | 6 +- .../test-native-core-config-isolation.mjs | 17 +- sdk/tests/test-native-core-fetch-failure.mjs | 8 +- sdk/tests/test-native-core-stream.mjs | 18 +- .../test-native-core-worker-termination.mjs | 3 +- sdk/tests/test-native-core.mjs | 14 +- sdk/tests/test-node-napi.mjs | 1 + sdk/tests/test-node-wasm.mjs | 20 +- sdk/tests/test-packed-example.mjs | 51 +++ sdk/tests/test-pi-benchmark.mjs | 29 ++ sdk/tests/test-skills-adapter.mjs | 75 ++++ src/acp/prompt.zig | 136 +++++- src/acp/server.zig | 265 ++++++++++-- src/acp/sessions.zig | 51 ++- src/acp/types.zig | 32 ++ src/core/agent/agent_runtime.zig | 7 +- src/core/agent/runtime/agent.zig | 349 +++++++++++++++ src/core/agent/runtime/assistant_stream.zig | 5 + src/core/agent/runtime/checkpoint.zig | 151 +++++++ src/core/agent/runtime/config.zig | 2 + src/core/agent/runtime/deps.zig | 1 + src/core/agent/runtime/finalization.zig | 2 + src/core/agent/runtime/orchestrator.zig | 60 ++- src/core/agent/runtime/state_machine.zig | 408 ++++++++++++++++++ src/core/agent/runtime/tests/support.zig | 7 +- src/core/app/app_agent_runtime.zig | 2 +- src/core/app/app_callbacks.zig | 4 +- src/core/app/app_commands.zig | 4 +- src/core/app/app_lifecycle.zig | 24 ++ src/core/app/app_session_runtime.zig | 4 +- src/core/cli/acp_runner.zig | 2 + src/core/cli/cli_ask.zig | 63 +-- src/core/hosts/js_host_tools.zig | 65 +++ src/core/session/session.zig | 102 ++--- src/core/subagent/agent_adapter.zig | 1 + src/core/subagent/execution.zig | 4 +- src/core/tooling/host_tool_runtime.zig | 225 ++++++++++ src/core/tooling/tool_dispatch.zig | 34 ++ src/core/tooling/tool_runtime.zig | 33 ++ src/main.zig | 2 +- src/napi_core_main.zig | 25 +- src/wasm_core_main.zig | 7 + 71 files changed, 3942 insertions(+), 831 deletions(-) create mode 100644 benchmarks/libfx/bench-bridge.mjs create mode 100644 benchmarks/libfx/bench-fx.mjs create mode 100644 benchmarks/libfx/bench-pi.mjs create mode 100644 benchmarks/libfx/bench-suite.mjs create mode 100644 sdk/mcp.js create mode 100644 sdk/skills-node.js create mode 100644 sdk/skills.js create mode 100644 sdk/tests/fixtures/mcp-stdio-server.mjs create mode 100644 sdk/tests/test-checkpoint.mjs create mode 100644 sdk/tests/test-default-import.mjs create mode 100644 sdk/tests/test-host-tool-cancel.mjs create mode 100644 sdk/tests/test-host-tools.mjs create mode 100644 sdk/tests/test-libfx-benchmark.mjs create mode 100644 sdk/tests/test-mcp-adapter.mjs create mode 100644 sdk/tests/test-minimal-api.mjs create mode 100644 sdk/tests/test-packed-example.mjs create mode 100644 sdk/tests/test-pi-benchmark.mjs create mode 100644 sdk/tests/test-skills-adapter.mjs create mode 100644 src/core/agent/runtime/agent.zig create mode 100644 src/core/agent/runtime/checkpoint.zig create mode 100644 src/core/agent/runtime/state_machine.zig create mode 100644 src/core/hosts/js_host_tools.zig create mode 100644 src/core/tooling/host_tool_runtime.zig 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..53a0354ed 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,66 @@ 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-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/README.md b/sdk/README.md index 0117c60f6..1ef58f15e 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -1,361 +1,155 @@ # 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`. +The JavaScript host is the authority for tool effects. The same descriptors, +schemas, cancellation, results, and events are used by N-API and WebAssembly. -Do not embed a long-lived API key in public browser code. Use a short-lived -credential or an authenticated server-side proxy. +## MCP -## Interactive terminal - -Install xterm.js in the host application: - -```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(); +import { createMcpAdapter } from "libfx/mcp"; -const runtime = await createFxTerminal({ - terminal: xtermAdapter(terminal), - env: { - AI_GATEWAY_API_KEY: "", - }, +const mcp = await createMcpAdapter(client, { + prefix: "github_", + resources: ["repo://instructions"], + prompts: ["review"], }); -await runtime.interactive; - -window.addEventListener("resize", () => { - fit.fit(); - runtime.resize(); -}); -``` - -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: - -- `linux-x64` -- `linux-arm64` -- `darwin-x64` -- `darwin-arm64` +## Skills -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" -``` - -On Node versions where JSPI remains behind a flag, start the process with: +import { loadSkillFile } from "libfx/skills/node"; +import { createSkillsAdapter } from "libfx/skills"; -```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 } -``` - -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 +await createFxAgent({ backend: "auto" }); // native, then Wasm fallback +await createFxAgent({ backend: "native" }); // require N-API +await createFxAgent({ backend: "wasm" }); // require Wasm + JSPI ``` -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..3a96bfe50 100644 --- a/sdk/fx-sdk.js +++ b/sdk/fx-sdk.js @@ -50,7 +50,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 +92,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 = []; @@ -455,6 +426,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 +761,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 +918,108 @@ 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) { + if (value === undefined) return ""; + if (typeof value === "string") return value; + if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) { + return value.filter(Boolean).join("\n\n"); + } + throw new TypeError("instructions must be a string or an array of strings"); +} + +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 closing = false; const emit = (type, detail = {}) => { try { options.onEvent?.({ type, timestamp: performance.now(), ...detail }); } catch {} }; + const executeHostTool = async (name, input, sessionId) => { + const execute = hostTools.executors.get(name); + const turn = sessionId ? turns.get(sessionId) : turns.values().next().value; + 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); @@ -969,7 +1045,6 @@ export async function createFxAgent(options) { 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); return; } if (message.method === "session/request_permission") { @@ -980,83 +1055,125 @@ 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 } = await executeHostTool( + message.params?.name, + message.params?.input, + message.params?.sessionId, + ); + 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: {} }); + await request("initialize", { + protocolVersion: 1, + clientCapabilities: { + ...(hostTools.descriptors.length || instructions + ? { libfx: { tools: hostTools.descriptors, instructions } } + : {}), + }, + }); + + const sessionResult = await request("libfx/new"); + activeSession = await makeSession(sessionResult); + if (initialCheckpoint) { + await request("libfx/restore", { + sessionId: activeSession.id, + checkpoint: bytesToBase64(initialCheckpoint), + }); + } const agent = { - exited: runtime.exited, - abort() { closing = true; runtime.abort(); }, + prompt(input, promptOptions) { + if (closing || !activeSession) throw new Error("fx agent is closed"); + return normalizeTurn(activeSession.prompt(input, promptOptions)); + }, + async checkpoint() { + if (closing || !activeSession) throw new Error("fx agent is closed"); + return activeSession.checkpoint(); + }, async close() { - if (closing) return runtime.exited; + if (closing) { await runtime.exited; return; } if (activeSession) await activeSession.close(); 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; + 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; + }; + return { + cancel() { rawTurn.cancel(); }, + async *[Symbol.asyncIterator]() { + for await (const update of rawTurn) { + const event = eventFor(update); + if (event) yield event; + } + }, + 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; + } + 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; - }; 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 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(); @@ -1065,11 +1182,12 @@ export async function createFxAgent(options) { 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; + async checkpoint() { + assertOpen(); + if (activeTurn) throw new Error("cannot checkpoint while a prompt is active"); + const response = await request("libfx/checkpoint", { sessionId: result.sessionId }); + if (typeof response?.checkpoint !== "string") throw new Error("fx returned an invalid checkpoint"); + return base64ToBytes(response.checkpoint); }, prompt(input, promptOptions = {}) { assertOpen(); @@ -1079,14 +1197,17 @@ export async function createFxAgent(options) { 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: result.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)); } }; }, @@ -1096,7 +1217,7 @@ export async function createFxAgent(options) { const abort = () => turn.cancel(); signal?.addEventListener("abort", abort, { once: true }); turn.result = request("session/prompt", { sessionId: result.sessionId, prompt }) - .then((response) => ({ stopReason: response.stopReason })) + .then((response) => ({ stopReason: response.stopReason, usage: response.usage })) .catch((error) => { if (error.message === "Cancelled") return { stopReason: "cancelled" }; throw error; @@ -1106,24 +1227,13 @@ export async function createFxAgent(options) { signal?.removeEventListener("abort", abort); turns.delete(result.sessionId); if (activeTurn === turn) activeTurn = null; + toolControllers.clear(); 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; }, }; - 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; } } 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 @@