diff --git a/.github/scripts/linux-canvas-smoke.sh b/.github/scripts/linux-canvas-smoke.sh index ade751534..29912eba6 100755 --- a/.github/scripts/linux-canvas-smoke.sh +++ b/.github/scripts/linux-canvas-smoke.sh @@ -42,6 +42,14 @@ set -u # Accessibility is not what this smoke tests. export GTK_A11Y="${GTK_A11Y:-none}" +# Allocate the display with xvfb-run when the runner does not provide one. +# Re-exec the whole smoke script (rather than only the app) so the app, +# xdotool, and xwininfo all use the same DISPLAY. This also avoids depending +# on Xvfb's -displayfd support, which is not consistent across runner images. +if [ -z "${DISPLAY:-}" ] && command -v xvfb-run >/dev/null 2>&1; then + exec xvfb-run -a --server-args="-screen 0 1280x800x24" "$0" "$@" +fi + repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" app_dir="$repo_root/examples/ui-inbox" snap="$app_dir/.zig-cache/native-sdk-automation/snapshot.txt" @@ -128,18 +136,25 @@ assert_no_webkit() { echo "== native-only ELF audit ok" # ---- launch --------------------------------------------------------------- -# The script owns its Xvfb (instead of wrapping the app in xvfb-run) so -# the xdotool step below shares the app's display. -displayfd picks a -# free display number, the modern equivalent of xvfb-run -a's probing. -display_file="$(mktemp)" -Xvfb -displayfd 4 -screen 0 1280x800x24 4>"$display_file" & -xvfb_pid=$! -for _ in $(seq 1 100); do - [ -s "$display_file" ] && break - sleep 0.1 -done -[ -s "$display_file" ] || fail "Xvfb never reported a display number" -export DISPLAY=":$(cat "$display_file")" +# The normal CI path reaches this point with DISPLAY set by xvfb-run. Keep a +# direct-Xvfb fallback for local environments that have Xvfb but not the +# wrapper, while retaining the same display-sharing behavior. +if [ -z "${DISPLAY:-}" ]; then + display_file="$(mktemp)" + xvfb_log="$(mktemp)" + Xvfb -displayfd 4 -screen 0 1280x800x24 4>"$display_file" 2>"$xvfb_log" & + xvfb_pid=$! + for _ in $(seq 1 100); do + [ -s "$display_file" ] && break + sleep 0.1 + done + if [ ! -s "$display_file" ]; then + echo "-- Xvfb stderr:" + sed 's/^/ /' "$xvfb_log" 2>/dev/null + fail "Xvfb never reported a display number" + fi + export DISPLAY=":$(cat "$display_file")" +fi echo "== Xvfb on $DISPLAY" cd "$app_dir" || fail "missing $app_dir" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4556d6901..aa43ebd7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,7 @@ jobs: - run: zig build test-gpu-components-smoke env: NATIVE_SDK_SMOKE_BUDGET_MS: "1500" + NATIVE_SDK_INPUT_LATENCY_BUDGET_MS: "500" macos-gpu-perf: name: macOS GPU Perf diff --git a/.gitignore b/.gitignore index 85a837aa1..52a2b6daf 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ packages/core/node_modules/ # TS scaffold writes into a new app's .gitignore) examples/soundboard-ts/node_modules/ examples/system-monitor-ts/node_modules/ +examples/agent-wars/node_modules/ diff --git a/README.md b/README.md index 258d3038c..801df9a56 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ The apps pictured above live in [examples/](./examples), most as zero-config pro | Example | What it shows | | --- | --- | | [`chatbot`](./examples/chatbot) | TypeScript + Native markup end to end: modules, a text editor, streaming fetch effects, and replay-safe configuration. | +| [`agent-wars`](./examples/agent-wars) | A two-model Pi harness comparison app: native controls and progress around side-by-side WebView results. | | [`soundboard-ts`](./examples/soundboard-ts) | The full music-player showcase in TypeScript + Native markup: audio, search, assets, timers, and context menus. | | [`system-monitor-ts`](./examples/system-monitor-ts) | A live process monitor in TypeScript + Native markup: subprocess effects, tables, charts, and timers. | | [`calculator`](./examples/calculator) | A complete small app: markup keypad, keyboard input, chrome shortcuts, theming. | diff --git a/build.zig b/build.zig index 49785b4d8..5161fc257 100644 --- a/build.zig +++ b/build.zig @@ -1647,6 +1647,7 @@ pub fn build(b: *std.Build) void { addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-terminal", "Run terminal example tests", "examples/terminal", .owned), addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-workbench", "Run workbench example tests", "examples/workbench", .owned), addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-system-monitor-ts", "Run system-monitor-ts example tests", "examples/system-monitor-ts", .managed), + addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-agent-wars", "Run agent-wars example tests", "examples/agent-wars", .managed), addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-effects-probe", "Run effects probe example tests", "examples/effects-probe", .managed), addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-channel-monitor", "Run channel monitor example tests", "examples/channel-monitor", .managed), addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-menu-bar", "Run menu-bar lifecycle example tests", "examples/menu-bar", .managed), diff --git a/examples/README.md b/examples/README.md index 4f4b3cc9d..7e07c635c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,6 +17,7 @@ TypeScript is the primary app-authoring language. A new `native init my_app` pro | Example | Shows | | --- | --- | | `chatbot` | Multi-module TypeScript core, text editing, streaming `Cmd.fetch`, environment messages, and deterministic replay. | +| `agent-wars` | Two editable Pi harness models, a shared task, spawn-streamed status, and side-by-side WebView previews. | | `service-feed-reader` | The complete services loop: `Cmd.fetch`, a parsing service reached through the generated `@native-sdk/services` client, shared record shapes, and recorded replay without the service. | | `relational-notes` | Append-only SQLite migrations, build-time checked SQL, generated typed transactions and page decoders, FTS5, and live queries. | | `gpu-components` | Isolated interactive Native UI specimens, disclosure trees, anchored menus, and controlled component state. | @@ -62,4 +63,4 @@ The `-ts` suffix is historical: `soundboard-ts` and `system-monitor-ts` distingu `mobile-shell`, `ios`, and `android` are mobile host projects (Xcode/Gradle shells plus shared `app.zon` metadata) rather than desktop app directories. -Start with `native init` for a small TypeScript + Native markup app, then use `chatbot`, `gpu-components`, `soundboard-ts`, or `system-monitor-ts` according to the feature you need. Use `habits` when you specifically want the smallest Zig-core equivalent, `hello` for the lower-level WebView path, `webview` for native commands or WebView policy, `capabilities` for guarded OS services, and `gpu-surface` or `gpu-dashboard` for custom-rendered or retained-canvas panes. +Start with `native init` for a small TypeScript + Native markup app, then use `chatbot`, `agent-wars`, `gpu-components`, `soundboard-ts`, or `system-monitor-ts` according to the feature you need. Use `habits` when you specifically want the smallest Zig-core equivalent, `hello` for the lower-level WebView path, `webview` for native commands or WebView policy, `capabilities` for guarded OS services, and `gpu-surface` or `gpu-dashboard` for custom-rendered or retained-canvas panes. diff --git a/examples/agent-wars/README.md b/examples/agent-wars/README.md new file mode 100644 index 000000000..c757a4c65 --- /dev/null +++ b/examples/agent-wars/README.md @@ -0,0 +1,84 @@ +# Native SDK Agent Wars example + +A deliberately small, native-rendered comparison bench for exactly two coding +models. The app shell is TypeScript + Native markup; a single long-lived Node +sidecar uses the [AI SDK Pi harness](https://ai-sdk.dev/providers/ai-sdk-harnesses/pi) +with `@ai-sdk/sandbox-just-bash` to run both agents concurrently. + +The example keeps the architecture visible: + +- `src/core.ts` owns the Native state machine, editable comboboxes, shared task, + compare/stop HTTP effects, and the coarse line-streamed status protocol. +- `src/app.native` is the complete native UI. Progress appears only once per + model, immediately above its preview. +- `sidecar/coordinator.ts` owns one local server, two isolated Pi sessions, + the versioned preview results, and the viewer bootstrap served to both child + WebViews. +- `Cmd.navigateWebView` navigates those two declared child WebViews after the + coordinator announces that it is ready; the Native core never navigates the + reserved `main` WebView. + +## Requirements + +- macOS +- Node.js 22 or newer +- an `AI_GATEWAY_API_KEY` available to the app process + +Every comparison is routed through Vercel AI Gateway; provider-specific keys +are neither read nor classified. The eight built-in choices are current model +ids from Pi's [Vercel AI Gateway model catalog](https://pi.dev/models?provider=vercel-ai-gateway) +that appear among recent [Terminal-Bench v2.1](https://artificialanalysis.ai/evaluations/terminalbench-v2-1) +results: DeepSeek V4 Flash, GPT-5.6 Luna, GPT-5.6 Sol, Claude Opus 5, +Claude Fable 5, Claude Opus 4.8, Kimi K3, and Grok 4.5. DeepSeek V4 Flash +and GPT-5.6 Luna remain the defaults. + +The compact menus arrange those choices in two horizontal rows below each +combobox. The native layout reserves that 64px surface before the platform +WebViews begin, so every option receives real pointer clicks. +Add another built-in choice to one of the two `MODEL_OPTIONS_*` arrays in +`src/core.ts`; keep each row compact. The comboboxes remain editable, so any +other model id from Pi's Vercel AI Gateway section can be entered directly. + +## Run + +```sh +cd examples/agent-wars +npm install +AI_GATEWAY_API_KEY=... npm run dev +``` + +The key must be exported into the app process. The example never reads dotenv +files. If the key is stored in one, export it in the shell before running the +app (for example, `set -a; source ~/.env; set +a`). + +The Native core starts exactly one sidecar with `Cmd.spawn`. The sidecar listens +on `127.0.0.1:43110` for `POST /compare`, `POST /stop`, and each slot's +viewer/version/result routes. A compare sends the task as its plain-text body and the +small run/model metadata as query parameters, so the Native core needs no JSON +encoder and the sidecar needs no JSON request parser. +Sidecar stdout is reserved for bounded tab-separated status records, which the +core receives through the spawn's line message. Each slot reports only +Starting, Working, and a detailed Ready/Failed terminal state. There is no SSE +endpoint and no browser-to-native coordinator channel. + +Each Pi agent receives a separate in-memory just-bash filesystem. It must write +one `index.html`; CSS, application code, and visual assets stay inline or +procedural. A task may use a requested browser library such as Three.js through +a version-pinned jsDelivr or unpkg ESM URL. To keep this example focused on the +Native shell and sidecar boundary, the coordinator publishes that file verbatim: +it does not validate or rewrite the document and it does not attach a content +security policy. A missing `index.html` still fails the slot explicitly. +Both declared child WebViews start with `zero://inline`, then the Native core +uses `Cmd.navigateWebView` to load `http://127.0.0.1:43110/preview/A/viewer` +and `/preview/B/viewer` once the coordinator is ready. The viewer page polls +its slot's version route once per second, then places the completed page in an +opaque iframe sandbox. It keeps the previous completed result visible while the +next comparison runs. The poll only swaps preview documents; agent status and +progress remain on the Native sidecar channel. + +## Check + +```sh +npm run check +npm test +``` diff --git a/examples/agent-wars/app.zon b/examples/agent-wars/app.zon new file mode 100644 index 000000000..778261d1f --- /dev/null +++ b/examples/agent-wars/app.zon @@ -0,0 +1,40 @@ +.{ + .id = "dev.native_sdk.agent_wars", + .name = "agent-wars", + .display_name = "Agent Wars", + .description = "Compare two Pi harness coding agents in a native-rendered visual evaluation bench.", + .version = "0.1.0", + .platforms = .{"macos"}, + .permissions = .{ "view", "command", "network" }, + .capabilities = .{ "native_views", "gpu_surfaces", "webview" }, + .shell = .{ + .windows = .{ + .{ + .label = "main", + .title = "Agent Wars", + .width = 1380, + .height = 820, + .resizable = false, + .restore_state = false, + .restore_policy = "center_on_primary", + .views = .{ + .{ .label = "agent-wars-canvas", .kind = "gpu_surface", .fill = true, .role = "Agent Wars controls", .accessibility_label = "Agent Wars", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true }, + // app.native's fixed geometry: 16px outer padding, a + // 668px column, and a 1px inset that leaves its border. + // Both panes are child WebViews. They start with the + // inline blank page and the TypeScript core navigates + // them to the sidecar viewer after it is ready. + .{ .label = "preview-a", .kind = "webview", .parent = "agent-wars-canvas", .url = "zero://inline", .x = 17, .y = 205, .width = 666, .height = 598, .layer = 20 }, + .{ .label = "preview-b", .kind = "webview", .parent = "agent-wars-canvas", .url = "zero://inline", .x = 697, .y = 205, .width = 666, .height = 598, .layer = 20 }, + }, + }, + }, + }, + .security = .{ + .navigation = .{ + .allowed_origins = .{ "zero://inline", "http://127.0.0.1:43110" }, + .external_links = .{ .action = "deny" }, + }, + }, + .web_engine = "system", +} diff --git a/examples/agent-wars/package.json b/examples/agent-wars/package.json new file mode 100644 index 000000000..863887fd9 --- /dev/null +++ b/examples/agent-wars/package.json @@ -0,0 +1,33 @@ +{ + "name": "agent-wars", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "A two-model Pi harness comparison example for Native SDK.", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "native dev", + "build": "native build", + "check": "native check && npm run typecheck:sidecar", + "typecheck:sidecar": "tsc -p tsconfig.sidecar.json", + "test": "native test -Dplatform=null && npm run test:sidecar", + "test:sidecar": "node --import tsx --test sidecar/coordinator.test.ts", + "sidecar": "node --import tsx sidecar/coordinator.ts" + }, + "dependencies": { + "@ai-sdk/harness": "1.0.62", + "@ai-sdk/harness-pi": "1.0.62", + "@ai-sdk/sandbox-just-bash": "1.0.62", + "@native-sdk/core": "0.9.1", + "ai": "7.0.56", + "ws": "8.21.2", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "20.19.43", + "tsx": "4.23.8", + "typescript": "7.0.2" + } +} diff --git a/examples/agent-wars/preview/index.html b/examples/agent-wars/preview/index.html new file mode 100644 index 000000000..5e8c7d0b6 --- /dev/null +++ b/examples/agent-wars/preview/index.html @@ -0,0 +1,74 @@ + + + + + + Agent preview + + + +
The completed preview will appear here.
+ + + diff --git a/examples/agent-wars/sidecar/coordinator.test.ts b/examples/agent-wars/sidecar/coordinator.test.ts new file mode 100644 index 000000000..83982ce5a --- /dev/null +++ b/examples/agent-wars/sidecar/coordinator.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { HarnessAgent } from "@ai-sdk/harness/agent"; +import { createPi } from "@ai-sdk/harness-pi"; +import { createJustBashSandbox } from "@ai-sdk/sandbox-just-bash"; +import { + agentInstructions, + ensureSessionWorkDir, + parseCompareRequest, + readViewerHtml, + requireGatewayApiKey, + sanitizeProtocolField, + validateModelId, +} from "./coordinator.ts"; + +test("agent contract honors requested browser libraries through pinned CDNs", () => { + const instructions = agentInstructions(); + assert.match(instructions, /Three\.js task must use Three\.js/); + assert.match(instructions, /version-pinned https:\/\/cdn\.jsdelivr\.net or https:\/\/unpkg\.com/); + assert.match(instructions, /Keep visual assets procedural or inline/); + assert.doesNotMatch(instructions, /Do not use packages, external assets, network requests/); +}); + +test("protocol fields stay on one bounded line", () => { + assert.equal(sanitizeProtocolField(" one\ttwo\nthree "), "one two three"); + assert.equal(sanitizeProtocolField("abcdef", 4), "abcd"); +}); + +test("the coordinator requires one Vercel AI Gateway key", () => { + assert.equal(requireGatewayApiKey({ AI_GATEWAY_API_KEY: " gateway-key " }), "gateway-key"); + assert.throws(() => requireGatewayApiKey({}), /AI_GATEWAY_API_KEY is required/); + assert.throws(() => requireGatewayApiKey({ AI_GATEWAY_API_KEY: " " }), /AI_GATEWAY_API_KEY is required/); +}); + +test("model ids accept Pi provider/model ids", () => { + assert.equal(validateModelId(" anthropic/claude-opus-5 "), "anthropic/claude-opus-5"); + assert.equal(validateModelId("openai/gpt-5.6-sol"), "openai/gpt-5.6-sol"); + assert.equal(validateModelId("xai/grok-4.5"), "xai/grok-4.5"); + assert.throws(() => validateModelId("bad model"), /unsupported/); +}); + +test("compare control uses query metadata and a raw task body", () => { + const request = parseCompareRequest( + new URL( + "http://127.0.0.1:43110/compare?runId=7&modelA=deepseek/deepseek-v4-flash-0731&modelB=openai/gpt-5.6-luna", + ), + " Build a hamburger ", + ); + assert.deepEqual(request, { + runId: 7, + task: "Build a hamburger", + modelA: "deepseek/deepseek-v4-flash-0731", + modelB: "openai/gpt-5.6-luna", + }); + assert.throws( + () => parseCompareRequest(new URL("http://127.0.0.1:43110/compare?runId=0"), "task"), + /runId must be a positive integer/, + ); +}); + +test("the bundled viewer retries and loads either completed slot", () => { + const viewer = readViewerHtml(); + assert.equal(viewer, readFileSync(new URL("../preview/index.html", import.meta.url), "utf8")); + assert.match(viewer, /get\("slot"\) === "B" \? "B" : "A"/); + assert.match(viewer, /fetch\(`\$\{baseUrl\}\/version`/); + assert.match(viewer, /frame\.src = `\$\{baseUrl\}\/site\?run=\$\{version\}`/); + assert.match(viewer, /root\.append\(frame\)/); + assert.doesNotMatch(viewer, /Content-Security-Policy/i); + assert.match(viewer, /setInterval\(checkVersion, 1000\)/); +}); + +test("Pi can start inside the just-bash session directory", async () => { + const agent = new HarnessAgent({ + id: "agent-wars-workdir-test", + harness: createPi({ model: "openai/gpt-5.6-sol" }), + sandbox: createJustBashSandbox({ cwd: "/work" }), + sandboxConfig: { + onSession: async ({ session, sessionWorkDir, abortSignal }) => { + await ensureSessionWorkDir(session, sessionWorkDir, abortSignal); + }, + }, + }); + + const session = await agent.createSession(); + await session.destroy(); +}); diff --git a/examples/agent-wars/sidecar/coordinator.ts b/examples/agent-wars/sidecar/coordinator.ts new file mode 100644 index 000000000..bbd522352 --- /dev/null +++ b/examples/agent-wars/sidecar/coordinator.ts @@ -0,0 +1,405 @@ +import { HarnessAgent } from "@ai-sdk/harness/agent"; +import { createPi } from "@ai-sdk/harness-pi"; +import { createJustBashSandbox } from "@ai-sdk/sandbox-just-bash"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const CONTROL_PORT = 43110; +const HOST = "127.0.0.1"; +const MAX_BODY_BYTES = 4096; +const RUN_TIMEOUT_MS = 5 * 60 * 1000; +const VIEWER_PATH = new URL("../preview/index.html", import.meta.url); + +export type Slot = "A" | "B"; +type ProgressPhase = "starting" | "working" | "ready" | "failed" | "stopped"; + +export interface CompareRequest { + readonly runId: number; + readonly task: string; + readonly modelA: string; + readonly modelB: string; +} + +interface PublishedPreview { + version: number; + html: string | null; +} + +interface ActiveRun { + readonly runId: number; + readonly controller: AbortController; + timedOut: boolean; + readonly timeout: NodeJS.Timeout; +} + +interface SandboxReader { + run(options: { + command: string; + abortSignal?: AbortSignal; + }): PromiseLike<{ exitCode: number; stdout: string; stderr: string }>; + readTextFile(options: { + path: string; + encoding?: string; + abortSignal?: AbortSignal; + }): PromiseLike; +} + +export async function ensureSessionWorkDir( + session: SandboxReader, + sessionWorkDir: string, + abortSignal?: AbortSignal, +): Promise { + // The adapter's bootstrap currently expands its session directory through + // a just-bash environment variable that is not preserved. Create the + // already-validated path literally before Pi mirrors the workspace. + const result = await session.run({ + command: `mkdir -p ${JSON.stringify(sessionWorkDir)}`, + ...(abortSignal ? { abortSignal } : {}), + }); + if (result.exitCode !== 0) { + throw new Error( + `Failed to create Pi session directory ${sessionWorkDir}: ${result.stderr || result.stdout}`, + ); + } +} + +interface CapturedSandbox { + readonly session: SandboxReader; + readonly workDir: string; +} + +const previews: Record = { + A: { version: 0, html: null }, + B: { version: 0, html: null }, +}; + +let activeRun: ActiveRun | null = null; + +export function sanitizeProtocolField(value: unknown, maxLength = 512): string { + const text = value instanceof Error ? value.message : String(value ?? ""); + return text.replace(/[\t\r\n]+/g, " ").replace(/\s{2,}/g, " ").trim().slice(0, maxLength); +} + +function emitStatus(runId: number, slot: Slot | "server", phase: ProgressPhase, message: unknown): void { + const safeMessage = sanitizeProtocolField(message) || "Unknown status"; + process.stdout.write(`status\t${runId}\t${slot}\t${phase}\t${safeMessage}\n`); +} + +export function validateModelId(value: unknown): string { + if (typeof value !== "string") throw new Error("Model id must be a string"); + const model = value.trim(); + if (model.length === 0 || model.length > 128) throw new Error("Model id must be 1–128 characters"); + if (!/^[A-Za-z0-9._:/-]+$/.test(model)) throw new Error("Model id contains unsupported characters"); + return model; +} + +function parseRunId(value: string | null): number { + if (value === null || !/^\d+$/.test(value)) throw new Error("runId must be a positive integer"); + const runId = Number(value); + if (!Number.isSafeInteger(runId) || runId <= 0) throw new Error("runId must be a positive integer"); + return runId; +} + +export function parseCompareRequest(url: URL, taskValue: string): CompareRequest { + const runId = parseRunId(url.searchParams.get("runId")); + const task = taskValue.trim(); + if (task.length === 0) throw new Error("Shared task is required"); + const modelA = validateModelId(url.searchParams.get("modelA")); + const modelB = validateModelId(url.searchParams.get("modelB")); + if (modelA === modelB) throw new Error("Choose two different models"); + return { runId, task, modelA, modelB }; +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" ? (value as Record) : null; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + if (typeof error === "string") return error; + const record = asRecord(error); + if (typeof record?.message === "string") return record.message; + if (record?.error !== undefined) return errorMessage(record.error); + if (record?.cause !== undefined) return errorMessage(record.cause); + return ""; +} + +const GATEWAY_FAILURE_MESSAGE = + "AI Gateway request failed. Check the selected model, account access, credits, and rate limits."; + +export function requireGatewayApiKey(env: NodeJS.ProcessEnv = process.env): string { + const apiKey = env.AI_GATEWAY_API_KEY?.trim(); + if (!apiKey) { + throw new Error( + "AI_GATEWAY_API_KEY is required. Create a Vercel AI Gateway key and restart Agent Wars.", + ); + } + return apiKey; +} + +export function agentInstructions(): string { + return [ + "Build a single, polished, interactive web page for the requested task.", + "Honor technologies explicitly requested by the task; for example, a Three.js task must use Three.js rather than a 2D canvas substitute.", + "Work only in the provided sandbox and create one index.html in the current working directory.", + "Put all CSS and application JavaScript inline.", + "When a requested browser library is too large to inline, load it from a version-pinned https://cdn.jsdelivr.net or https://unpkg.com URL; prefer an ESM import and use no other network hosts.", + "Keep visual assets procedural or inline as data/blob URLs. Do not use remote images, fonts, APIs, fetch, XHR, WebSockets, package installation, build tools, or a development server.", + "Use the write/edit tools to produce the file; do not merely describe code in your answer.", + "The page must work at desktop preview size and remain usable when narrower.", + "Finish only after index.html contains the complete result.", + ].join(" "); +} + +export function readViewerHtml(): string { + return readFileSync(VIEWER_PATH, "utf8"); +} + +async function runSlot( + run: ActiveRun, + slot: Slot, + model: string, + task: string, + gatewayApiKey: string, +): Promise { + let captured: CapturedSandbox | null = null; + let streamFailed = false; + let streamFailure: unknown; + + try { + const agent = new HarnessAgent({ + id: `agent-wars-${slot.toLowerCase()}-${run.runId}`, + harness: createPi({ + model, + auth: { gateway: { apiKey: gatewayApiKey } }, + }), + instructions: agentInstructions(), + sandbox: createJustBashSandbox({ cwd: "/work" }), + sandboxConfig: { + onSession: async ({ session: sandboxSession, sessionWorkDir, abortSignal }) => { + await ensureSessionWorkDir(sandboxSession, sessionWorkDir, abortSignal); + captured = { session: sandboxSession, workDir: sessionWorkDir }; + }, + }, + }); + + const session = await agent.createSession({ abortSignal: run.controller.signal }); + try { + emitStatus(run.runId, slot, "working", "Working…"); + const streamResult = await agent.stream({ + session, + prompt: `Create the visual result for this shared task:\n\n${task}`, + abortSignal: run.controller.signal, + }); + + for await (const part of streamResult.stream as AsyncIterable) { + const event = asRecord(part); + if (event?.type === "error") { + if (!streamFailed) streamFailure = event.error; + streamFailed = true; + continue; + } + } + + if (run.controller.signal.aborted) throw run.controller.signal.reason; + if (streamFailed) { + const detail = sanitizeProtocolField(errorMessage(streamFailure), 384); + console.error( + detail + ? `AI Gateway request failed for model ${model}: ${detail}` + : `AI Gateway request failed for model ${model}`, + ); + throw new Error(GATEWAY_FAILURE_MESSAGE); + } + const sandbox = captured as CapturedSandbox | null; + if (sandbox === null) throw new Error("Sandbox session was not initialized"); + const source = await sandbox.session.readTextFile({ + path: `${sandbox.workDir}/index.html`, + abortSignal: run.controller.signal, + }); + if (source === null) throw new Error("The agent did not create index.html"); + previews[slot] = { version: run.runId, html: source }; + emitStatus(run.runId, slot, "ready", "Ready"); + } finally { + await session.destroy().catch(() => {}); + } + } catch (error) { + if (run.controller.signal.aborted) { + emitStatus( + run.runId, + slot, + run.timedOut ? "failed" : "stopped", + run.timedOut ? "Timed out after 5 minutes" : "Stopped", + ); + } else { + emitStatus(run.runId, slot, "failed", sanitizeProtocolField(error)); + } + } +} + +function startRun(request: CompareRequest, gatewayApiKey: string): void { + const controller = new AbortController(); + let run: ActiveRun; + const timeout = setTimeout(() => { + run.timedOut = true; + controller.abort(new Error("Agent Wars run timed out")); + }, RUN_TIMEOUT_MS); + run = { + runId: request.runId, + controller, + timedOut: false, + timeout, + }; + activeRun = run; + + emitStatus(run.runId, "A", "starting", "Starting…"); + emitStatus(run.runId, "B", "starting", "Starting…"); + + void Promise.allSettled([ + runSlot(run, "A", request.modelA, request.task, gatewayApiKey), + runSlot(run, "B", request.modelB, request.task, gatewayApiKey), + ]).finally(() => { + clearTimeout(run.timeout); + if (activeRun === run) activeRun = null; + }); +} + +async function readText(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.length; + if (total > MAX_BODY_BYTES) throw new Error("Request body is too large"); + chunks.push(buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function sendJson(response: ServerResponse, status: number, body: Record): void { + response.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "access-control-allow-origin": "*", + }); + response.end(JSON.stringify(body)); +} + +function sendHtml(response: ServerResponse, html: string, headOnly: boolean): void { + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + }); + response.end(headOnly ? undefined : html); +} + +async function handleRequest( + request: IncomingMessage, + response: ServerResponse, + gatewayApiKey: string, +): Promise { + const url = new URL(request.url ?? "/", `http://${HOST}:${CONTROL_PORT}`); + + if (request.method === "POST" && url.pathname === "/compare") { + const compare = parseCompareRequest(url, await readText(request)); + if (activeRun !== null) { + sendJson(response, 409, { error: "A comparison is already running" }); + return; + } + startRun(compare, gatewayApiKey); + sendJson(response, 202, { accepted: true, runId: compare.runId }); + return; + } + + if (request.method === "POST" && url.pathname === "/stop") { + const runId = parseRunId(url.searchParams.get("runId")); + if (activeRun === null) { + sendJson(response, 200, { stopped: false, reason: "No active comparison" }); + return; + } + if (runId !== activeRun.runId) { + sendJson(response, 409, { error: "runId does not match the active comparison" }); + return; + } + activeRun.controller.abort(new Error("Stopped by user")); + sendJson(response, 202, { stopped: true, runId }); + return; + } + + const previewMatch = url.pathname.match(/^\/preview\/([AB])\/(viewer|version|site)$/); + if ((request.method === "GET" || request.method === "HEAD") && previewMatch !== null) { + const slot = previewMatch[1] as Slot; + const resource = previewMatch[2]; + if (resource === "viewer") { + sendHtml(response, readViewerHtml(), request.method === "HEAD"); + return; + } + if (resource === "version") { + sendJson(response, 200, { version: previews[slot].version }); + return; + } + + const preview = previews[slot]; + const requested = Number(url.searchParams.get("run")); + if (preview.html === null || !Number.isSafeInteger(requested) || requested !== preview.version) { + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("Preview not found"); + return; + } + sendHtml(response, preview.html, request.method === "HEAD"); + return; + } + + sendJson(response, 404, { error: "Not found" }); +} + +function listen(server: Server, port: number): Promise { + return new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(port, HOST, () => { + server.off("error", reject); + resolveListen(); + }); + }); +} + +async function closeServer(server: Server): Promise { + server.closeAllConnections?.(); + await new Promise((resolveClose) => server.close(() => resolveClose())); +} + +export async function main(): Promise { + const gatewayApiKey = requireGatewayApiKey(); + + // Keep stdout exclusively line-framed for the Native Cmd.spawn channel. + console.log = (...args: unknown[]) => console.error(...args); + console.debug = (...args: unknown[]) => console.error(...args); + + const coordinator = createServer((request, response) => { + void handleRequest(request, response, gatewayApiKey).catch((error: unknown) => { + sendJson(response, 400, { error: sanitizeProtocolField(error) }); + }); + }); + await listen(coordinator, CONTROL_PORT); + emitStatus(0, "server", "ready", "Coordinator ready"); + + const shutdown = (): void => { + activeRun?.controller.abort(new Error("Coordinator shutting down")); + void closeServer(coordinator).finally(() => { process.exitCode = 0; }); + }; + process.once("SIGTERM", shutdown); + process.once("SIGINT", shutdown); +} + +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ""; +if (invokedPath === import.meta.url) { + void main().catch((error: unknown) => { + emitStatus(0, "server", "failed", sanitizeProtocolField(error)); + console.error(error); + process.exitCode = 1; + }); +} diff --git a/examples/agent-wars/src/app.native b/examples/agent-wars/src/app.native new file mode 100644 index 000000000..0b2a77ea8 --- /dev/null +++ b/examples/agent-wars/src/app.native @@ -0,0 +1,78 @@ + + + +