mirror of
https://github.com/vercel-labs/fx.git
synced 2026-10-28 18:25:19 +08:00
- In the browser, agents created without a name or a durability each get their own memory() store, so two of them with different options are allowed again instead of throwing `FX_AGENT_NAME_IN_USE`. - In Node and on Vercel, where every such agent shares one default store, a second one with different options is still refused.
728 lines
28 KiB
JavaScript
728 lines
28 KiB
JavaScript
import { access, readFile } from "node:fs/promises";
|
|
import { closeSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { Socket } from "node:net";
|
|
import { homedir, tmpdir } from "node:os";
|
|
import { isAbsolute, resolve } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { CoreOutput } from "./core-output.js";
|
|
import { loadModule, withModuleFailure } from "./wasm-module.js";
|
|
import { fetchCleanupAction, cleanupTimeoutMs, cleanupByteLimit } from "./fetch-cleanup.js";
|
|
import { createDurableAgentFactory, memory, wakeHandler } from "./durable.js";
|
|
import { fxHarness } from "./fx-harness.js";
|
|
import {
|
|
createFxEngine as createWasmAgent,
|
|
createFxTerminal as createWasmTerminal,
|
|
createMemoryPersistence,
|
|
FxFencedError,
|
|
FxJournalVersionError,
|
|
encodeXtermKeyEvent,
|
|
fxSdkApiVersion,
|
|
listModels,
|
|
normalizeAgentOptions,
|
|
supportsJspi,
|
|
xtermAdapter,
|
|
} from "./fx-sdk.js";
|
|
|
|
export { createMemoryPersistence, encodeXtermKeyEvent, FxFencedError, FxJournalVersionError, fxSdkApiVersion, listModels, memory, supportsJspi, wakeHandler, xtermAdapter };
|
|
export const libfxApiVersion = 3;
|
|
const nativeCoreApiVersion = 4;
|
|
|
|
const fetchOperationStale = 0;
|
|
const fetchOperationApplied = 1;
|
|
const fetchOperationBackpressure = 2;
|
|
|
|
const nodeRequire = createRequire(import.meta.url);
|
|
const defaultCoreWasm = new URL("./fx-core.wasm", import.meta.url);
|
|
const defaultTermWasm = new URL("./fx-term.wasm", import.meta.url);
|
|
let nativeBackendPromise;
|
|
const wasmFilePromises = new Map();
|
|
|
|
const backendReasonCodes = {
|
|
unsupportedPlatform: "LIBFX_UNSUPPORTED_PLATFORM",
|
|
missingArtifact: "LIBFX_NATIVE_ARTIFACT_MISSING",
|
|
nativeLoad: "LIBFX_NATIVE_LOAD_FAILED",
|
|
nativeApi: "LIBFX_NATIVE_API_MISMATCH",
|
|
missingSurface: "LIBFX_NATIVE_SURFACE_MISSING",
|
|
disabledNative: "LIBFX_NATIVE_DISABLED",
|
|
jspiUnavailable: "LIBFX_JSPI_UNAVAILABLE",
|
|
wasmLoad: "LIBFX_WASM_LOAD_FAILED",
|
|
};
|
|
|
|
function bundledAssetUrl(asset) {
|
|
if (asset.protocol !== "" || typeof asset.href !== "string" ||
|
|
typeof __webpack_base_uri__ === "undefined" || typeof __webpack_public_path__ !== "string" ||
|
|
!asset.href.startsWith(__webpack_public_path__)) {
|
|
throw new TypeError("Bundled asset URL has no filesystem mapping");
|
|
}
|
|
// Webpack's relative URL is a public asset address, not a Node URL instance.
|
|
return new URL(asset.href.slice(__webpack_public_path__.length), __webpack_base_uri__);
|
|
}
|
|
|
|
function jspiFallbackError(surface, nativeError) {
|
|
const nativeDetail = nativeError ? ` Native loading failed: ${nativeError.message}.` : " No compatible native addon was found.";
|
|
const error = new Error(
|
|
`libfx could not start the ${surface} backend.${nativeDetail} ` +
|
|
"The WebAssembly fallback requires JavaScript Promise Integration (JSPI). " +
|
|
"Run Node with --experimental-wasm-jspi or install a libfx package containing a compatible native addon.",
|
|
);
|
|
error.code = "LIBFX_JSPI_REQUIRED";
|
|
error.cause = nativeError;
|
|
return error;
|
|
}
|
|
|
|
async function loadNativeCandidate(candidate) {
|
|
if (candidate == null) return null;
|
|
if (candidate instanceof URL) {
|
|
if (candidate.protocol === "") candidate = bundledAssetUrl(candidate);
|
|
if (candidate.protocol === "file:" && candidate.pathname.endsWith(".node")) {
|
|
// Bundlers trace the asset URL; Node must load the native file at runtime.
|
|
return Reflect.apply(nodeRequire, undefined, [fileURLToPath(candidate)]);
|
|
}
|
|
const imported = await import(candidate.href);
|
|
return imported.default ?? imported;
|
|
}
|
|
if (typeof candidate === "object") return candidate.default ?? candidate;
|
|
if (typeof candidate !== "string") {
|
|
throw new TypeError("nativeAddon must be a module, path, URL, false, or undefined");
|
|
}
|
|
if (candidate.endsWith(".node")) {
|
|
return Reflect.apply(nodeRequire, undefined, [isAbsolute(candidate) ? candidate : resolve(candidate)]);
|
|
}
|
|
const imported = await import(candidate.startsWith("file:") ? candidate : pathToFileURL(candidate).href);
|
|
return imported.default ?? imported;
|
|
}
|
|
|
|
function defaultNativeCandidate() {
|
|
// Local path bindings let deployment tracers retain these assets in the generated CommonJS entry.
|
|
if (process.platform === "linux" && process.arch === "x64") {
|
|
const asset = new URL("./libfx.linux-x64.node", import.meta.url);
|
|
if (asset.protocol === "") return fileURLToPath(bundledAssetUrl(asset));
|
|
const path = fileURLToPath(asset);
|
|
return path;
|
|
}
|
|
if (process.platform === "linux" && process.arch === "arm64") {
|
|
const asset = new URL("./libfx.linux-arm64.node", import.meta.url);
|
|
if (asset.protocol === "") return fileURLToPath(bundledAssetUrl(asset));
|
|
const path = fileURLToPath(asset);
|
|
return path;
|
|
}
|
|
if (process.platform === "darwin" && process.arch === "x64") {
|
|
const asset = new URL("./libfx.darwin-x64.node", import.meta.url);
|
|
if (asset.protocol === "") return fileURLToPath(bundledAssetUrl(asset));
|
|
const path = fileURLToPath(asset);
|
|
return path;
|
|
}
|
|
if (process.platform === "darwin" && process.arch === "arm64") {
|
|
const asset = new URL("./libfx.darwin-arm64.node", import.meta.url);
|
|
if (asset.protocol === "") return fileURLToPath(bundledAssetUrl(asset));
|
|
const path = fileURLToPath(asset);
|
|
return path;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function validateNativeBackend(backend) {
|
|
if (!backend) return null;
|
|
const hasLowLevelCore = typeof backend.createCore === "function";
|
|
const expectedVersion = hasLowLevelCore ? nativeCoreApiVersion : libfxApiVersion;
|
|
if ((hasLowLevelCore || backend.libfxApiVersion !== undefined) && backend.libfxApiVersion !== expectedVersion) {
|
|
const actualVersion = backend.libfxApiVersion ?? "missing";
|
|
throw new Error(`native addon API version ${actualVersion} is incompatible with expected API version ${expectedVersion}`);
|
|
}
|
|
if (typeof backend.createCore !== "function" && typeof backend.createFxTerminal !== "function") {
|
|
throw new Error("native addon must export createCore() or createFxTerminal()");
|
|
}
|
|
return backend;
|
|
}
|
|
|
|
function missingArtifact(error) {
|
|
return error?.code === "ENOENT" || error?.code === "MODULE_NOT_FOUND" || error?.code === "ERR_MODULE_NOT_FOUND";
|
|
}
|
|
|
|
function nativeCandidateFilePath(candidate) {
|
|
if (candidate instanceof URL) {
|
|
if (candidate.protocol === "") candidate = bundledAssetUrl(candidate);
|
|
return candidate.protocol === "file:" ? fileURLToPath(candidate) : null;
|
|
}
|
|
if (typeof candidate !== "string") return null;
|
|
if (candidate.startsWith("file:")) return fileURLToPath(new URL(candidate));
|
|
return URL.canParse(candidate) ? null : resolve(candidate);
|
|
}
|
|
|
|
async function nativeArtifactMissing(candidate) {
|
|
try {
|
|
const path = nativeCandidateFilePath(candidate);
|
|
if (path === null) return false;
|
|
await access(path);
|
|
return false;
|
|
} catch (error) {
|
|
return missingArtifact(error);
|
|
}
|
|
}
|
|
|
|
function validationFailure(error) {
|
|
return error?.message?.startsWith("native addon API version ") ? "api" : "surface";
|
|
}
|
|
|
|
async function loadAndValidateNativeCandidate(candidate, artifactMissing = false) {
|
|
let backend;
|
|
try {
|
|
backend = await loadNativeCandidate(candidate);
|
|
} catch (error) {
|
|
return { backend: null, error, failure: artifactMissing ? "missing" : "load" };
|
|
}
|
|
try {
|
|
return { backend: validateNativeBackend(backend), error: null, failure: null };
|
|
} catch (error) {
|
|
return { backend: null, error, failure: validationFailure(error) };
|
|
}
|
|
}
|
|
|
|
async function discoverNativeBackend() {
|
|
const candidate = defaultNativeCandidate();
|
|
if (!candidate) {
|
|
return { backend: null, error: null, failure: "unsupported" };
|
|
}
|
|
try {
|
|
await access(candidate);
|
|
} catch (error) {
|
|
if (missingArtifact(error)) {
|
|
return { backend: null, error: null, probeError: error, failure: "missing" };
|
|
}
|
|
return { backend: null, error, failure: "load" };
|
|
}
|
|
return loadAndValidateNativeCandidate(candidate);
|
|
}
|
|
|
|
async function resolveNativeBackend(nativeAddon) {
|
|
if (nativeAddon === false) return { backend: null, error: null, failure: "disabled" };
|
|
if (nativeAddon !== undefined) {
|
|
return loadAndValidateNativeCandidate(nativeAddon, await nativeArtifactMissing(nativeAddon));
|
|
}
|
|
nativeBackendPromise ??= discoverNativeBackend();
|
|
return nativeBackendPromise;
|
|
}
|
|
|
|
function wasmInput(input) {
|
|
const path = wasmFilePath(input);
|
|
if (path === null) {
|
|
if (input instanceof URL) return input.href;
|
|
return input;
|
|
}
|
|
const cached = wasmFilePromises.get(path);
|
|
if (cached) return cached;
|
|
const pendingRead = readFile(path);
|
|
let pending;
|
|
pending = withModuleFailure(pendingRead, () => {
|
|
if (wasmFilePromises.get(path) === pending) wasmFilePromises.delete(path);
|
|
});
|
|
wasmFilePromises.set(path, pending);
|
|
pendingRead.catch(() => {
|
|
if (wasmFilePromises.get(path) === pending) wasmFilePromises.delete(path);
|
|
});
|
|
return pending;
|
|
}
|
|
|
|
function wasmFilePath(input) {
|
|
if (input instanceof URL) {
|
|
if (input.protocol === "") input = bundledAssetUrl(input);
|
|
if (input.protocol === "file:") return fileURLToPath(input);
|
|
}
|
|
if (typeof input === "string" && !URL.canParse(input)) return resolve(input);
|
|
return null;
|
|
}
|
|
|
|
function reason(code, message, error) {
|
|
const causeCode = error?.code;
|
|
return {
|
|
code,
|
|
message,
|
|
...(typeof causeCode === "string" || typeof causeCode === "number" ? { causeCode } : {}),
|
|
};
|
|
}
|
|
|
|
function nativeFailureReason(result) {
|
|
const detailError = result.probeError ?? result.error;
|
|
switch (result.failure) {
|
|
case "unsupported":
|
|
return reason(
|
|
backendReasonCodes.unsupportedPlatform,
|
|
`native addon is not available for ${process.platform}-${process.arch}`,
|
|
);
|
|
case "missing":
|
|
return reason(
|
|
backendReasonCodes.missingArtifact,
|
|
`native addon artifact was not found${detailError?.message ? `: ${detailError.message}` : ""}`,
|
|
detailError,
|
|
);
|
|
case "load":
|
|
return reason(
|
|
backendReasonCodes.nativeLoad,
|
|
`native addon failed to load${result.error?.message ? `: ${result.error.message}` : ""}`,
|
|
result.error,
|
|
);
|
|
case "api":
|
|
return reason(backendReasonCodes.nativeApi, result.error.message, result.error);
|
|
case "surface":
|
|
return reason(backendReasonCodes.missingSurface, result.error.message, result.error);
|
|
case "disabled":
|
|
return reason(backendReasonCodes.disabledNative, "native addon loading is disabled");
|
|
default:
|
|
return reason(backendReasonCodes.nativeLoad, "native addon is unavailable", result.error);
|
|
}
|
|
}
|
|
|
|
function validateBackendInfoOptions(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new TypeError("getBackendInfo() options must be an object");
|
|
}
|
|
const { nativeAddon, backend = "auto", surface = "agent", ...options } = value;
|
|
for (const key of Object.keys(options)) {
|
|
if (key !== "wasm") {
|
|
throw new TypeError(`getBackendInfo() does not accept ${key}`);
|
|
}
|
|
}
|
|
if (!new Set(["agent", "terminal"]).has(surface)) {
|
|
throw new TypeError('surface must be "agent" or "terminal"');
|
|
}
|
|
if (!new Set(["auto", "native", "wasm"]).has(backend)) {
|
|
throw new TypeError('backend must be "auto", "native", or "wasm"');
|
|
}
|
|
if (nativeAddon !== undefined && nativeAddon !== false &&
|
|
typeof nativeAddon !== "string" && !(nativeAddon instanceof URL) &&
|
|
(typeof nativeAddon !== "object" || nativeAddon === null)) {
|
|
throw new TypeError("nativeAddon must be a module, path, URL, false, or undefined");
|
|
}
|
|
const validWasm = options.wasm === undefined || typeof options.wasm === "string" || options.wasm instanceof URL ||
|
|
options.wasm instanceof Promise || options.wasm instanceof WebAssembly.Module || options.wasm instanceof Response ||
|
|
options.wasm instanceof ArrayBuffer || ArrayBuffer.isView(options.wasm);
|
|
if (Object.hasOwn(options, "wasm") && !validWasm) {
|
|
throw new TypeError("wasm must be a URL, Response, ArrayBuffer, typed array, or WebAssembly.Module");
|
|
}
|
|
return { ...options, surface, backend, nativeAddon };
|
|
}
|
|
|
|
export async function getBackendInfo(value = {}) {
|
|
const { surface, backend, nativeAddon, wasm } = validateBackendInfoOptions(value);
|
|
const attempts = [];
|
|
if (backend !== "wasm") {
|
|
const native = await resolveNativeBackend(nativeAddon);
|
|
const nativeMethod = surface === "agent" ? "createCore" : "createFxTerminal";
|
|
if (typeof native.backend?.[nativeMethod] === "function") {
|
|
attempts.push({ backend: "native", available: true, reason: null });
|
|
return { surface, backend: "native", attempts };
|
|
}
|
|
const failureReason = native.backend
|
|
? reason(backendReasonCodes.missingSurface, `native addon does not provide ${nativeMethod}()`)
|
|
: nativeFailureReason(native);
|
|
attempts.push({ backend: "native", available: false, reason: failureReason });
|
|
if (backend === "native") return { surface, backend: "unavailable", attempts };
|
|
}
|
|
|
|
if (!supportsJspi()) {
|
|
attempts.push({
|
|
backend: "wasm-jspi",
|
|
available: false,
|
|
reason: reason(
|
|
backendReasonCodes.jspiUnavailable,
|
|
"WebAssembly backend requires JavaScript Promise Integration (JSPI)",
|
|
),
|
|
});
|
|
return { surface, backend: "unavailable", attempts };
|
|
}
|
|
const defaultWasm = surface === "agent" ? defaultCoreWasm : defaultTermWasm;
|
|
const wasmSource = wasm ?? defaultWasm;
|
|
try {
|
|
await loadModule(await wasmInput(wasmSource));
|
|
attempts.push({ backend: "wasm-jspi", available: true, reason: null });
|
|
return { surface, backend: "wasm-jspi", attempts };
|
|
} catch (error) {
|
|
attempts.push({
|
|
backend: "wasm-jspi",
|
|
available: false,
|
|
reason: reason(
|
|
backendReasonCodes.wasmLoad,
|
|
`WebAssembly asset failed to load or compile: ${error?.message ?? String(error)}`,
|
|
error,
|
|
),
|
|
});
|
|
return { surface, backend: "unavailable", attempts };
|
|
}
|
|
}
|
|
|
|
function createNativeCoreRuntime(addon, options) {
|
|
const { apiKey, providerName: provider, customProvider, model, effort, fast, ultrafast, gatewayChatUrl } = options;
|
|
if (ultrafast !== undefined && addon.supportsUltrafast !== true) {
|
|
const error = new Error("native addon does not support the ultrafast option");
|
|
error.code = "LIBFX_NATIVE_CAPABILITY_UNAVAILABLE";
|
|
throw error;
|
|
}
|
|
const capability = customProvider ? "supportsConfiguredProvider" : { codex: "supportsCodex", grok: "supportsGrok" }[provider];
|
|
if (capability && addon[capability] !== true) {
|
|
const error = new Error(`native addon does not support the ${provider} provider`);
|
|
error.code = "LIBFX_NATIVE_CAPABILITY_UNAVAILABLE";
|
|
throw error;
|
|
}
|
|
const core = addon.createCore({
|
|
...(apiKey === undefined ? {} : { apiKey }),
|
|
...(provider === undefined ? {} : { provider }),
|
|
...(customProvider === undefined ? {} : { configuredProviders: customProvider.json }),
|
|
home: options.home ?? homedir(),
|
|
workspaceRoot: options.workspaceRoot ?? process.cwd(),
|
|
...(model === undefined ? {} : { model }),
|
|
...(effort === undefined ? {} : { effort }),
|
|
...(fast === undefined ? {} : { fast }),
|
|
...(ultrafast === undefined ? {} : { ultrafast }),
|
|
...(gatewayChatUrl === undefined ? {} : { gatewayChatUrl }),
|
|
});
|
|
let readyFd;
|
|
let readySocket;
|
|
try {
|
|
readyFd = addon.takeCoreReadyFd(core);
|
|
readySocket = new Socket({ fd: readyFd, readable: true, writable: false });
|
|
} catch (error) {
|
|
if (readyFd !== undefined) {
|
|
try { closeSync(readyFd); } catch {}
|
|
}
|
|
addon.destroyCore(core);
|
|
throw error;
|
|
}
|
|
const readyClosed = new Promise((resolve) => readySocket.once("close", resolve));
|
|
let exitedResolve;
|
|
let lineHandler = null;
|
|
const output = new CoreOutput((message, size) => lineHandler(message, size));
|
|
let draining = false;
|
|
let outputError;
|
|
let settled = false;
|
|
let fetchState = null;
|
|
const exited = new Promise((resolve) => { exitedResolve = resolve; });
|
|
function refreshFetch(state) {
|
|
if (settled) return;
|
|
if (typeof addon.coreFetchDisposition !== "function") {
|
|
state.active = addon.coreFetchActive(core, state.handle);
|
|
return;
|
|
}
|
|
// One native lock observes consumption and retirement together.
|
|
const disposition = addon.coreFetchDisposition(core, state.handle);
|
|
if (disposition !== 0 && disposition !== 1 && disposition !== 2) {
|
|
throw new Error(`invalid native fetch disposition ${disposition}`);
|
|
}
|
|
state.consumed ||= disposition === 2;
|
|
state.active = disposition === 1;
|
|
}
|
|
function cleanupAction(state) {
|
|
return fetchCleanupAction({ ...state, alive: !settled,
|
|
age: state.drainStarted === null ? 0 : performance.now() - state.drainStarted });
|
|
}
|
|
function beginDrain(state) {
|
|
if (state.drainStarted !== null) return;
|
|
state.drainStarted = performance.now();
|
|
state.timer = setTimeout(() => state.controller.abort(), cleanupTimeoutMs);
|
|
}
|
|
const abortHostEffects = () => {
|
|
if (fetchState) {
|
|
fetchState.canceled = true;
|
|
fetchState.controller.abort();
|
|
}
|
|
if (!settled) { try { addon.abortCoreFetch(core); } catch {} }
|
|
};
|
|
const finish = (code, error) => {
|
|
if (settled) return;
|
|
const state = fetchState;
|
|
if (state) {
|
|
refreshFetch(state);
|
|
state.closing = true;
|
|
state.failed ||= code !== 0 || error !== undefined;
|
|
}
|
|
settled = true;
|
|
outputError = error;
|
|
output.close();
|
|
const destroy = () => {
|
|
try { addon.destroyCore(core); } catch {}
|
|
readySocket.destroy();
|
|
void readyClosed.then(() => exitedResolve(code));
|
|
};
|
|
if (state && cleanupAction(state) === "drain") {
|
|
beginDrain(state);
|
|
void state.done.then(destroy);
|
|
} else {
|
|
if (state && cleanupAction(state) !== "done") state.controller.abort();
|
|
destroy();
|
|
}
|
|
};
|
|
const pumpFetch = async (request, body) => {
|
|
const controller = new AbortController();
|
|
let complete, reader;
|
|
const state = { handle: request.handle, controller, consumed: false, active: true,
|
|
canceled: false, failed: false, closing: false, eof: false, discarded: 0,
|
|
drainStarted: null, timer: null, done: new Promise(resolve => { complete = resolve; }) };
|
|
fetchState = state;
|
|
let onAbort;
|
|
const aborted = new Promise((_, reject) => {
|
|
onAbort = () => reject(new DOMException("Aborted", "AbortError"));
|
|
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
void aborted.catch(() => {});
|
|
const wait = task => Promise.race([task, aborted]);
|
|
try {
|
|
const responseTask = Promise.resolve().then(() => (options.fetch ?? globalThis.fetch)(request.url, {
|
|
method: request.method,
|
|
headers: new Headers(JSON.parse(request.headers).map(({ name, value }) => [name, value])),
|
|
body: body.length ? body : undefined,
|
|
signal: controller.signal,
|
|
})).then(response => {
|
|
if (controller.signal.aborted) {
|
|
void response.body?.cancel().catch(() => {});
|
|
throw new DOMException("Aborted", "AbortError");
|
|
}
|
|
return response;
|
|
});
|
|
const response = await wait(responseTask);
|
|
if (settled && !state.consumed) throw new DOMException("Aborted", "AbortError");
|
|
const started = settled ? fetchOperationStale : addon.startCoreFetchResponse(core, state.handle, response.status);
|
|
if (started !== fetchOperationApplied && started !== fetchOperationStale) throw new Error(`invalid native fetch start result ${started}`);
|
|
state.active = started === fetchOperationApplied;
|
|
reader = response.body?.getReader();
|
|
while (reader) {
|
|
refreshFetch(state);
|
|
const action = cleanupAction(state);
|
|
if (action === "abort") { controller.abort(); throw new DOMException("Aborted", "AbortError"); }
|
|
if (action === "drain") beginDrain(state);
|
|
const { done, value } = await wait(reader.read());
|
|
if (done) break;
|
|
const buffer = Buffer.from(value);
|
|
options.onTransportChunk?.(buffer.length);
|
|
let offset = 0;
|
|
while (offset < buffer.length) {
|
|
refreshFetch(state);
|
|
const next = cleanupAction(state);
|
|
if (next === "drain") {
|
|
beginDrain(state);
|
|
state.discarded += buffer.length - offset;
|
|
if (state.discarded >= cleanupByteLimit) controller.abort();
|
|
break;
|
|
}
|
|
if (next !== "forward") { controller.abort(); throw new DOMException("Aborted", "AbortError"); }
|
|
const end = Math.min(offset + 64 * 1024, buffer.length);
|
|
const pushed = addon.pushCoreFetchResponse(core, state.handle, buffer.subarray(offset, end));
|
|
if (pushed === fetchOperationApplied) { offset = end; continue; }
|
|
if (pushed === fetchOperationStale) {
|
|
refreshFetch(state);
|
|
if (cleanupAction(state) === "drain") continue;
|
|
controller.abort(); throw new DOMException("Aborted", "AbortError");
|
|
}
|
|
if (pushed !== fetchOperationBackpressure) throw new Error(`invalid native fetch push result ${pushed}`);
|
|
await wait(new Promise(resolve => setTimeout(resolve, 2)));
|
|
}
|
|
}
|
|
state.eof = true;
|
|
if (!settled && state.active) {
|
|
const finished = addon.finishCoreFetch(core, state.handle);
|
|
if (finished !== fetchOperationApplied && finished !== fetchOperationStale) throw new Error(`invalid native fetch finish result ${finished}`);
|
|
}
|
|
} catch (error) {
|
|
if (!settled && !controller.signal.aborted) {
|
|
state.failed = true;
|
|
try { if (addon.coreFetchActive(core, state.handle)) addon.failCoreFetch(core, state.handle); } catch {}
|
|
}
|
|
} finally {
|
|
clearTimeout(state.timer);
|
|
controller.signal.removeEventListener("abort", onAbort);
|
|
if (reader) {
|
|
if (!state.eof) { try { void reader.cancel().catch(() => {}); } catch {} }
|
|
try { reader.releaseLock(); } catch {}
|
|
}
|
|
complete();
|
|
if (fetchState === state) { fetchState = null; queueMicrotask(drainReady); }
|
|
}
|
|
};
|
|
function drainReady() {
|
|
if (settled) return;
|
|
try {
|
|
if (fetchState) {
|
|
refreshFetch(fetchState);
|
|
const action = cleanupAction(fetchState);
|
|
if (action === "drain") beginDrain(fetchState);
|
|
if (action === "abort") fetchState.controller.abort();
|
|
} else {
|
|
// The core hands over JSON metadata and the raw request body separately.
|
|
const fetchRequest = addon.takeCoreFetch(core);
|
|
if (fetchRequest) void pumpFetch(JSON.parse(fetchRequest.request.toString("utf8")), fetchRequest.body);
|
|
}
|
|
if (addon.coreExitCode(core) !== 0) {
|
|
finish(1, new Error("native output delivery failed"));
|
|
return;
|
|
}
|
|
void drainOutput();
|
|
} catch (error) {
|
|
finish(1, error);
|
|
}
|
|
}
|
|
async function drainOutput() {
|
|
if (draining || settled) return;
|
|
draining = true;
|
|
try {
|
|
while (!settled) {
|
|
const chunk = addon.drainCore(core);
|
|
if (!chunk.length) break;
|
|
const pending = output.write(chunk);
|
|
if (pending) await pending;
|
|
}
|
|
if (!settled && addon.coreExited(core)) {
|
|
output.finish();
|
|
finish(addon.coreExitCode(core));
|
|
}
|
|
} catch (error) {
|
|
finish(1, error);
|
|
} finally {
|
|
draining = false;
|
|
}
|
|
}
|
|
|
|
readySocket.on("data", drainReady);
|
|
readySocket.on("end", () => { drainReady(); if (!settled) finish(1); });
|
|
readySocket.on("error", () => finish(1));
|
|
readySocket.on("close", () => { if (!settled) finish(1); });
|
|
// Some runtimes defer descriptor adoption until connect().
|
|
if (readySocket.pending) {
|
|
try { readySocket.connect({ fd: readyFd }); } catch (error) { finish(1); throw error; }
|
|
}
|
|
|
|
return {
|
|
exited,
|
|
get error() { return outputError; },
|
|
write(data) { addon.writeCore(core, Buffer.from(data)); },
|
|
// The addon copies attachment bytes before returning.
|
|
writeAttachment(id, bytes) {
|
|
addon.writeCoreAttachment(core, id, Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
|
|
},
|
|
takeAttachment(id) { return addon.takeCoreAttachment(core, id); },
|
|
discardAttachments() { addon.discardCoreAttachments(core); },
|
|
closeStdin() { addon.closeCore(core); },
|
|
abortHostEffects,
|
|
abort(error) {
|
|
if (error) finish(1, error);
|
|
else { abortHostEffects(); addon.closeCore(core); }
|
|
},
|
|
setLineHandler(handler) { lineHandler = handler; },
|
|
};
|
|
}
|
|
|
|
function createNativeAgent(addon, options) {
|
|
return createWasmAgent({
|
|
...options,
|
|
runtimeFactory(runtimeOptions) {
|
|
return createNativeCoreRuntime(addon, runtimeOptions);
|
|
},
|
|
});
|
|
}
|
|
|
|
async function createWithFallback(surface, nativeMethod, wasmFactory, defaultWasm, options) {
|
|
const { nativeAddon, backend = "auto", ...unvalidatedOptions } = options ?? {};
|
|
if (!new Set(["auto", "native", "wasm"]).has(backend)) {
|
|
throw new TypeError('backend must be "auto", "native", or "wasm"');
|
|
}
|
|
const runtimeOptions = surface === "agent" ? normalizeAgentOptions(unvalidatedOptions) : unvalidatedOptions;
|
|
|
|
let nativeError;
|
|
let nativeAttempted = false;
|
|
if (backend !== "wasm") {
|
|
const native = await resolveNativeBackend(nativeAddon);
|
|
nativeError = native.error;
|
|
if (typeof native.backend?.[nativeMethod] === "function") {
|
|
nativeAttempted = true;
|
|
try {
|
|
if (surface === "agent") return await createNativeAgent(native.backend, runtimeOptions);
|
|
return await native.backend[nativeMethod](runtimeOptions);
|
|
} catch (error) {
|
|
nativeError = error;
|
|
if (backend === "native" || error?.code === "LIBFX_MODEL_UNSUPPORTED_FAST" ||
|
|
error?.code === "LIBFX_MODEL_UNSUPPORTED_EFFORT" ||
|
|
typeof error?.code === "string" && error.code.startsWith("LIBFX_MODEL_METADATA_")) throw error;
|
|
}
|
|
}
|
|
if (backend === "native") {
|
|
const error = nativeError ?? new Error(`native addon does not provide ${nativeMethod}()`);
|
|
error.code ??= "LIBFX_NATIVE_UNAVAILABLE";
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (!supportsJspi()) {
|
|
if (nativeAttempted) throw nativeError;
|
|
throw jspiFallbackError(surface, nativeError);
|
|
}
|
|
const wasmSource = runtimeOptions.wasm ?? defaultWasm;
|
|
return wasmFactory({ ...runtimeOptions, wasm: await wasmInput(wasmSource) });
|
|
}
|
|
|
|
/**
|
|
* One fx session on the native core, or on WebAssembly when the native
|
|
* addon is unavailable, with no durability of its own. `createFxAgent`
|
|
* runs durable sessions on these.
|
|
*/
|
|
export async function createFxEngine(options = {}) {
|
|
return createWithFallback(
|
|
"agent",
|
|
"createCore",
|
|
createWasmAgent,
|
|
defaultCoreWasm,
|
|
options,
|
|
);
|
|
}
|
|
|
|
// The durability a session gets when the caller names none: Vercel's World
|
|
// on Vercel, otherwise files in the temporary directory. Each is its own
|
|
// module, loaded only here: a bundler copies the file the URL names and
|
|
// leaves the import itself to Node.
|
|
// Agents created without a durability share one for each place sessions
|
|
// live, as agents passed one durability do, so they can send each other's
|
|
// prompts on.
|
|
const environments = new Map();
|
|
function environmentDurability() {
|
|
const dir = process.env.VERCEL ? null : process.env.FX_SESSIONS_DIR || resolve(tmpdir(), "libfx", "sessions");
|
|
const key = dir ?? "vercel";
|
|
if (!environments.has(key)) {
|
|
environments.set(key, chooseEnvironmentDurability(dir).catch((error) => {
|
|
environments.delete(key);
|
|
throw error;
|
|
}));
|
|
}
|
|
return environments.get(key);
|
|
}
|
|
|
|
async function chooseEnvironmentDurability(dir) {
|
|
const load = (asset) => import(/* webpackIgnore: true */ /* turbopackIgnore: true */ (asset.protocol === "" ? bundledAssetUrl(asset) : asset).href);
|
|
if (dir === null) {
|
|
const { vercel } = await load(new URL("./durable/vercel.mjs", import.meta.url));
|
|
return vercel();
|
|
}
|
|
const { local } = await load(new URL("./durable/local.mjs", import.meta.url));
|
|
return local({ dir });
|
|
}
|
|
|
|
/**
|
|
* An agent whose sessions survive crashes, timeouts and redeployments. It
|
|
* does no I/O until a session is used.
|
|
*/
|
|
export const createFxAgent = createDurableAgentFactory({
|
|
name: "createFxAgent",
|
|
label: "fx agent",
|
|
defaultDurability: environmentDurability,
|
|
// One durability per environment, so agents created without one share it.
|
|
defaultShared: true,
|
|
harness: fxHarness({
|
|
createEngine: createFxEngine,
|
|
// An explicit key, then the durability's own credential, such as a
|
|
// Vercel deployment's OIDC token, then the one in the environment.
|
|
defaultApiKey: async (durability) => process.env.AI_GATEWAY_API_KEY ||
|
|
(await Promise.resolve(durability?.gatewayKey?.()).catch(() => undefined)) ||
|
|
process.env.VERCEL_OIDC_TOKEN || undefined,
|
|
}),
|
|
});
|
|
|
|
export function createFxTerminal(options = {}) {
|
|
return createWithFallback("terminal", "createFxTerminal", createWasmTerminal, defaultTermWasm, options);
|
|
}
|