mirror of
https://github.com/vercel-labs/fx.git
synced 2026-10-28 10:15:17 +08:00
- `createFxAgent()` returns the agent at once instead of a promise, and does no I/O until a session runs a turn. - `agent.session(id)` opens a session, and `session.prompt()` returns a turn view with `accepted`, `result`, async iteration over its events, and an NDJSON `readable`. - Sessions are durable by default: `vercel()` on Vercel, `local()` files in Node.js elsewhere, and `memory()` in browsers. The `durability` option picks one. - `libfx/durable-local` and `libfx/durable-vercel` bundle their World, so an app installs no other package. - A turn whose process crashes, redeploys, or reaches its function deadline continues from its last record in the next process that gets work for the session. - On Vercel, a turn stops at its next model request 30 seconds before the function deadline and continues in a new invocation from the same queue message. - A model request or tool call still running 2 seconds before the deadline is cut off, and `onEvent` receives `session.deadline`. - One process runs a session's turns at a time, and a prompt for a busy session queues behind the running turn. - A process that froze and was replaced stops at its next write and never starts a tool with effects, and `onEvent` receives `session.fenced`. - After an interruption, a call to an `idempotent: true` tool runs again. Any other call never reruns, and the model gets an error saying it may have partly run. - `executionId` is the same each time a call runs, including after a crash. - A prompt sent again with the same `messageId` starts nothing new: its view follows the original turn, or resolves with `repeated: true`. - `session.stream(cursor)` replays a session's events from any cursor on any server, with `turn_start`, `turn_end`, `turn_resume`, and `turn_yield` lines. - Every reader of a session's stream shows the same lines, because lines a replaced worker lands late are hidden. - `session.steer()` and `session.cancel()` reach the session's running turn, and `session.resume()` continues a turn a stopped process left open. - Every turn end and yield saves a checkpoint, so continuing a session reads one checkpoint and the records after it. - Checkpoints record the libfx version, tool set, and model. A session or checkpoint saved under different ones still loads, and `onEvent` receives `checkpoint.mismatch`. - `session.checkpoint()` exports a session's history, and the `checkpoint` option starts a new agent's session from it. - `agent.wakeHandler()` is the queue delivery route an app mounts on Vercel and subscribes to `__libfx_wkf_workflow_session` in `vercel.json`. - On Vercel, libfx authenticates to the AI Gateway with the deployment's OIDC token when neither `apiKey` nor `AI_GATEWAY_API_KEY` is set. - `createFxEngine()` accepts a `persistence` store, and `createMemoryPersistence()` provides one in memory. A store that rejects a stale append with `FxFencedError` stops a second writer. - A turn whose store append fails stops at once with `FX_JOURNAL_APPEND_FAILED`, and the agent refuses later prompts.
394 lines
19 KiB
HTML
394 lines
19 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>fx core debugger</title>
|
|
<style>
|
|
@import url("https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-mono/style.css");
|
|
:root { color-scheme: dark; --bg: #000; --panel: #050505; --line: #242424; --muted: #777; --dim: #aaa; --text: #f2f2f2; }
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; }
|
|
body { margin: 0; background: var(--bg); color: var(--text); font: 13px/1.5 "Geist Mono", monospace; display: grid; grid-template-rows: auto auto 1fr auto; }
|
|
header { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
#controls { display: flex; align-items: end; gap: 10px; padding: 8px 14px; border-bottom: 1px solid var(--line); background: var(--panel); overflow-x: auto; }
|
|
#controls label { display: grid; gap: 3px; color: var(--muted); font-size: 11px; white-space: nowrap; }
|
|
#controls .model { min-width: min(340px, 45vw); }
|
|
h1 { font: inherit; font-weight: 600; margin: 0; }
|
|
#status { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
#status::before { content: "●"; margin-right: 7px; color: var(--muted); }
|
|
#status.ready::before { color: var(--text); }
|
|
#timeline { overflow: auto; scroll-behavior: smooth; }
|
|
.row { display: grid; grid-template-columns: 74px minmax(150px, 22vw) minmax(0, 1fr); gap: 12px; padding: 8px 14px; border-bottom: 1px solid var(--line); }
|
|
.row:hover { background: #080808; }
|
|
.time, .kind { color: var(--muted); }
|
|
.time { white-space: nowrap; }
|
|
.kind { min-width: 0; overflow-wrap: anywhere; text-transform: lowercase; }
|
|
.row.user .kind, .row.agent .kind, .row.error .kind, .row.lifecycle .kind { color: var(--dim); }
|
|
.payload, details { min-width: 0; }
|
|
.payload { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
.row.event .payload { color: var(--dim); font-size: 12px; }
|
|
details summary { cursor: pointer; color: var(--dim); overflow-wrap: anywhere; }
|
|
pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); }
|
|
form { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--line); background: var(--panel); }
|
|
input, select, button { font: inherit; color: var(--text); background: #080808; border: 1px solid #333; border-radius: 5px; padding: 8px 10px; }
|
|
input:focus, select:focus { outline: 1px solid var(--text); border-color: var(--text); }
|
|
.permission-card { display: grid; gap: 8px; }
|
|
.permission-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
.permission-actions button { padding: 5px 8px; }
|
|
button { cursor: pointer; }
|
|
button:hover { background: #151515; }
|
|
button:disabled, input:disabled { opacity: .4; cursor: default; }
|
|
@media (max-width: 650px) { .row { grid-template-columns: 62px minmax(100px, 30vw) minmax(0, 1fr); gap: 8px; padding-inline: 10px; } form { padding-inline: 10px; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header><h1>fx-core debugger</h1><span id="status">booting</span></header>
|
|
<section id="controls" aria-label="Session controls">
|
|
<label>Model<input id="model" class="model" list="models" disabled><datalist id="models"></datalist></label>
|
|
<label>Mode<select id="mode" disabled></select></label>
|
|
<label>Transport<select id="transport" disabled><option value="live">Live gateway</option><option value="mock">Mock stream</option><option value="stall">Stalled stream</option></select></label>
|
|
</section>
|
|
<main id="timeline" aria-live="polite"></main>
|
|
<form>
|
|
<input name="prompt" autocomplete="off" placeholder="Send a prompt…" disabled>
|
|
<button type="submit" disabled>Send</button>
|
|
<button type="button" id="cancel" disabled>Cancel</button>
|
|
</form>
|
|
<script type="module">
|
|
import { createFxEngine, supportsJspi } from "./browser.js";
|
|
import { createSkillsAdapter } from "./skills.js";
|
|
|
|
const configPrefix = "fx.core.config.";
|
|
const configStore = {
|
|
get(configId) { return localStorage.getItem(`${configPrefix}${configId}`); },
|
|
set(configId, value) { localStorage.setItem(`${configPrefix}${configId}`, value); },
|
|
};
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
let transport = params.get("transport") || "live";
|
|
const autorun = params.get("autorun");
|
|
const chunkDelay = Number(params.get("chunk-delay") || 25);
|
|
const cancelAfter = Number(params.get("cancel-after") || 0);
|
|
const initialModel = params.get("model");
|
|
const initialMode = params.get("mode");
|
|
const permission = params.get("permission") || "prompt";
|
|
const forceUnsupported = params.has("force-unsupported");
|
|
const hostToolEnabled = params.has("host-tool");
|
|
const hostSkillEnabled = params.has("host-skill");
|
|
const blobPrompt = params.has("blob-prompt");
|
|
const crossRealmBlob = params.has("blob-iframe");
|
|
const blobPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jP0cAAAAASUVORK5CYII=";
|
|
const status = document.querySelector("#status");
|
|
const timeline = document.querySelector("#timeline");
|
|
const form = document.querySelector("form");
|
|
const input = form.elements.prompt;
|
|
const sendButton = form.querySelector('[type="submit"]');
|
|
const cancelButton = document.querySelector("#cancel");
|
|
const modelSelect = document.querySelector("#model");
|
|
const modelList = document.querySelector("#models");
|
|
const modeSelect = document.querySelector("#mode");
|
|
const transportSelect = document.querySelector("#transport");
|
|
transportSelect.value = transport;
|
|
const startedAt = performance.now();
|
|
const test = window.__fxCoreTest = {
|
|
state: "booting",
|
|
transport,
|
|
events: [],
|
|
chunks: [],
|
|
fetchCalls: 0,
|
|
fetchAborted: false,
|
|
toolCalls: 0,
|
|
toolAdvertised: false,
|
|
toolResultSent: false,
|
|
skillSent: false,
|
|
stopReason: null,
|
|
error: null,
|
|
};
|
|
let agent;
|
|
let activeTurn = false;
|
|
let currentTurn = null;
|
|
let blobIframe = null;
|
|
|
|
const encoded = new TextEncoder();
|
|
function promptImage() {
|
|
const bytes = Uint8Array.from(atob(blobPng), (char) => char.charCodeAt(0));
|
|
if (!crossRealmBlob) return new Blob([bytes], { type: "image/png" });
|
|
blobIframe = document.createElement("iframe");
|
|
blobIframe.hidden = true;
|
|
document.body.append(blobIframe);
|
|
const file = new blobIframe.contentWindow.File([bytes], "image.png", { type: "image/png" });
|
|
test.crossRealm = !(file instanceof Blob);
|
|
return file;
|
|
}
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const elapsed = (timestamp = performance.now()) => `+${((timestamp - startedAt) / 1000).toFixed(3)}s`;
|
|
const json = (value) => JSON.stringify(value, null, 2);
|
|
function setState(state, error = null) {
|
|
test.state = state;
|
|
test.error = error ? String(error.stack || error.message || error) : null;
|
|
document.body.dataset.state = state;
|
|
}
|
|
function append(kind, payload, { tone = "event", timestamp, raw } = {}) {
|
|
const row = document.createElement("section");
|
|
row.className = `row ${tone}`;
|
|
const body = raw === undefined
|
|
? `<div class="payload"></div>`
|
|
: `<details><summary>${escapeHtml(payload)}</summary><pre>${escapeHtml(json(raw))}</pre></details>`;
|
|
row.innerHTML = `<time class="time">${elapsed(timestamp)}</time><div class="kind">${escapeHtml(kind)}</div>${body}`;
|
|
if (raw === undefined) row.querySelector(".payload").textContent = payload;
|
|
timeline.append(row);
|
|
timeline.scrollTop = timeline.scrollHeight;
|
|
}
|
|
function escapeHtml(value) {
|
|
return String(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]);
|
|
}
|
|
function setActive(value) {
|
|
activeTurn = value;
|
|
input.disabled = value;
|
|
sendButton.disabled = value;
|
|
cancelButton.disabled = !value;
|
|
if (!value && !autorun) input.focus();
|
|
}
|
|
function observe(event) {
|
|
const message = event.message;
|
|
test.events.push({ type: event.type, message });
|
|
if (event.type === "acp.send" && message?.method === "session/prompt") return;
|
|
if (event.type === "acp.receive" && message?.method === "session/update") {
|
|
const update = message.params.update;
|
|
append(`update.${update.sessionUpdate}`, update.sessionUpdate, { timestamp: event.timestamp, raw: update });
|
|
return;
|
|
}
|
|
const summary = message?.method || (message?.id !== undefined ? `response #${message.id}` : event.type);
|
|
append(event.type, summary, { tone: "lifecycle", timestamp: event.timestamp, raw: message || event });
|
|
}
|
|
async function dispatchFetch(url, init) {
|
|
if (transport === "live") return globalThis.fetch(url, init);
|
|
return mockFetch(url, init);
|
|
}
|
|
async function mockFetch(url, init) {
|
|
if (init.method === "GET" && String(url).endsWith("/v1/models")) {
|
|
return Response.json({ object: "list", data: [
|
|
{ id: "sdk/catalog-alpha", type: "language", released: 2, tags: ["tool-use"] },
|
|
{ id: "sdk/catalog-beta", type: "language", released: 1, tags: ["reasoning"] },
|
|
...(blobPrompt ? [{ id: "sdk/chrome-model", type: "language", tags: ["vision", "file-input"] }] : []),
|
|
] });
|
|
}
|
|
test.fetchCalls++;
|
|
if (blobPrompt) {
|
|
const payload = JSON.parse(new TextDecoder().decode(init.body));
|
|
test.blobSent = payload.prompt.some((message) =>
|
|
message.role === "user" && message.content?.some((part) =>
|
|
part.type === "file" && part.mediaType === "image/png" && part.data?.data === blobPng));
|
|
}
|
|
if (transport === "stall") {
|
|
let controller;
|
|
const body = new ReadableStream({ start(value) { controller = value; } });
|
|
init.signal.addEventListener("abort", () => {
|
|
test.fetchAborted = true;
|
|
controller.error(new DOMException("The operation was aborted", "AbortError"));
|
|
}, { once: true });
|
|
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
|
|
}
|
|
if (hostToolEnabled && test.fetchCalls === 1) {
|
|
const payload = JSON.parse(new TextDecoder().decode(init.body));
|
|
test.toolAdvertised = payload.tools?.some((tool) => tool.name === "browser_echo") === true;
|
|
test.skillSent = JSON.stringify(payload).includes("BROWSER_SKILL_SENTINEL");
|
|
return new Response([
|
|
'data: {"type":"tool-call","toolCallId":"browser_tool_1","toolName":"browser_echo","input":{"value":"hello"}}',
|
|
'data: {"type":"finish","finishReason":{"unified":"tool-calls","raw":"tool-calls"}}',
|
|
"data: [DONE]",
|
|
"",
|
|
].join("\n\n"), { status: 200, headers: { "content-type": "text/event-stream" } });
|
|
}
|
|
if (hostToolEnabled && test.fetchCalls === 2) {
|
|
test.toolResultSent = new TextDecoder().decode(init.body).includes("browser:hello");
|
|
return new Response('data: {"type":"text-delta","delta":"tool done"}\n\ndata: {"type":"finish","finishReason":{"unified":"stop","raw":"stop"}}\n\ndata: [DONE]\n\n', { status: 200, headers: { "content-type": "text/event-stream" } });
|
|
}
|
|
return new Response(new ReadableStream({
|
|
async start(controller) {
|
|
controller.enqueue(encoded.encode('data: {"type":"text-delta","delta":"hello"}\n\n'));
|
|
await sleep(chunkDelay);
|
|
controller.enqueue(encoded.encode('data: {"type":"text-delta","delta":" world"}\n\n'));
|
|
controller.enqueue(encoded.encode('data: {"type":"finish","finishReason":{"unified":"stop"},"usage":{"inputTokens":{"total":3},"outputTokens":{"total":2}}}\n\n'));
|
|
controller.enqueue(encoded.encode("data: [DONE]\n\n"));
|
|
controller.close();
|
|
},
|
|
}), { status: 200, headers: { "content-type": "text/event-stream" } });
|
|
}
|
|
async function resolvePermission(request) {
|
|
if (permission !== "prompt") return permission;
|
|
test.pendingPermission = request;
|
|
setState("permission");
|
|
status.textContent = "permission required";
|
|
return new Promise((resolve) => {
|
|
const row = document.createElement("section");
|
|
row.className = "row permission";
|
|
row.innerHTML = `<time class="time">${elapsed()}</time><div class="kind">permission</div><div class="permission-card"><strong></strong><pre></pre><div class="permission-actions"></div></div>`;
|
|
row.querySelector("strong").textContent = request.toolCall.title;
|
|
row.querySelector("pre").textContent = json(request.toolCall.rawInput);
|
|
const actions = row.querySelector(".permission-actions");
|
|
for (const option of request.options || []) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.textContent = option.name;
|
|
button.dataset.optionId = option.optionId;
|
|
button.addEventListener("click", () => {
|
|
actions.querySelectorAll("button").forEach((item) => { item.disabled = true; });
|
|
append("permission.answer", option.name, { tone: "user", raw: option });
|
|
test.permissionAnswer = option.optionId;
|
|
test.pendingPermission = null;
|
|
setState("running");
|
|
status.textContent = "running";
|
|
resolve(option.optionId);
|
|
}, { once: true });
|
|
actions.append(button);
|
|
}
|
|
timeline.append(row);
|
|
timeline.scrollTop = timeline.scrollHeight;
|
|
});
|
|
}
|
|
function renderConfigOptions(options) {
|
|
for (const config of options || []) {
|
|
const select = config.id === "model" ? modelSelect : config.id === "mode" ? modeSelect : null;
|
|
if (!select) continue;
|
|
const options = config.options.map((option) => {
|
|
const element = document.createElement("option");
|
|
element.value = option.value;
|
|
element.textContent = config.id === "mode" && option.permissionMode
|
|
? `${option.name} (${option.permissionMode})`
|
|
: option.name;
|
|
element.title = option.description || "";
|
|
return element;
|
|
});
|
|
if (config.id === "model") {
|
|
modelList.replaceChildren(...options);
|
|
test.models = config.options.map((option) => option.value);
|
|
} else select.replaceChildren(...options);
|
|
select.value = config.currentValue;
|
|
select.disabled = false;
|
|
}
|
|
transportSelect.disabled = false;
|
|
}
|
|
async function applyConfig(select, label, setter) {
|
|
const previous = select.dataset.current || select.value;
|
|
select.disabled = true;
|
|
try {
|
|
renderConfigOptions(await setter(select.value));
|
|
select.dataset.current = select.value;
|
|
test[label] = select.value;
|
|
append(`config.${label}`, select.value, { tone: "lifecycle" });
|
|
} catch (error) {
|
|
select.value = previous;
|
|
append("error", error.stack || error.message, { tone: "error" });
|
|
} finally {
|
|
select.disabled = false;
|
|
}
|
|
}
|
|
async function runPrompt(prompt) {
|
|
if (!prompt || activeTurn) return;
|
|
append("user", prompt, { tone: "user" });
|
|
setActive(true);
|
|
setState("running");
|
|
status.textContent = "running";
|
|
let cancelTimer;
|
|
try {
|
|
const turn = agent.prompt(blobPrompt ? [
|
|
{ type: "text", text: prompt },
|
|
{ type: "image", data: promptImage() },
|
|
] : prompt);
|
|
currentTurn = turn;
|
|
if (cancelAfter > 0) cancelTimer = setTimeout(() => turn.cancel(), cancelAfter);
|
|
for await (const event of turn) {
|
|
if (event.type !== "text_delta") continue;
|
|
test.chunks.push(event.delta);
|
|
append("agent", event.delta, { tone: "agent" });
|
|
}
|
|
const reason = (await turn.result).stopReason;
|
|
test.stopReason = reason;
|
|
append("turn.stop", reason, { tone: "lifecycle" });
|
|
status.textContent = `ready · last stop: ${reason}`;
|
|
setState("completed");
|
|
} catch (error) {
|
|
append("error", error.stack || error.message, { tone: "error" });
|
|
status.textContent = "turn failed";
|
|
setState("failed", error);
|
|
} finally {
|
|
clearTimeout(cancelTimer);
|
|
blobIframe?.remove();
|
|
blobIframe = null;
|
|
currentTurn = null;
|
|
setActive(false);
|
|
}
|
|
}
|
|
|
|
if (forceUnsupported || !supportsJspi()) {
|
|
status.textContent = "unsupported: Chrome or Edge 137+ with JSPI required";
|
|
append("error", status.textContent, { tone: "error" });
|
|
setState("unsupported");
|
|
} else {
|
|
try {
|
|
const skillAdapter = hostSkillEnabled ? createSkillsAdapter([{
|
|
name: "browser-skill",
|
|
instructions: "BROWSER_SKILL_SENTINEL",
|
|
}]) : { instructions: "", tools: [] };
|
|
agent = await createFxEngine({
|
|
wasm: location.hostname === "localhost" || location.hostname === "127.0.0.1"
|
|
? "../zig-out/bin/fx-core.wasm"
|
|
: "./fx-core.wasm",
|
|
apiKey: localStorage.getItem("FX_API_KEY") || "sdk-test-key",
|
|
...(initialModel ? { model: initialModel } : {}),
|
|
fetch: dispatchFetch,
|
|
onEvent: observe,
|
|
onPermission: resolvePermission,
|
|
instructions: skillAdapter.instructions,
|
|
tools: hostToolEnabled ? [{
|
|
name: "browser_echo",
|
|
description: "Echo in the browser host",
|
|
inputSchema: { type: "object", properties: { value: { type: "string" } }, required: ["value"] },
|
|
execute(input, { signal }) {
|
|
if (signal.aborted) throw new DOMException("aborted", "AbortError");
|
|
test.toolCalls += 1;
|
|
return `browser:${input.value}`;
|
|
},
|
|
}] : skillAdapter.tools,
|
|
});
|
|
test.model = initialModel || "default";
|
|
test.mode = initialMode || "default";
|
|
test.api = Object.keys(agent).sort();
|
|
status.textContent = "ready";
|
|
status.className = "ready";
|
|
append("agent.ready", "prompt · checkpoint · close", { tone: "lifecycle" });
|
|
setState("ready");
|
|
setActive(false);
|
|
if (autorun) await runPrompt(autorun);
|
|
} catch (error) {
|
|
status.textContent = "initialization failed";
|
|
append("error", error.stack || error.message, { tone: "error" });
|
|
setState("failed", error);
|
|
}
|
|
}
|
|
|
|
form.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const prompt = input.value.trim();
|
|
if (!prompt || activeTurn) return;
|
|
input.value = "";
|
|
await runPrompt(prompt);
|
|
});
|
|
|
|
transportSelect.addEventListener("change", () => {
|
|
transport = transportSelect.value;
|
|
test.transport = transport;
|
|
append("config.transport", transport, { tone: "lifecycle" });
|
|
});
|
|
|
|
cancelButton.addEventListener("click", () => {
|
|
append("user.cancel", "turn.cancel()", { tone: "user" });
|
|
currentTurn?.cancel();
|
|
cancelButton.disabled = true;
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|