libfx 0.0.15 → 0.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/browser-agent.js +21 -0
- package/browser-terminal.js +7 -0
- package/browser.js +12 -37
- package/bundled-asset.js +12 -0
- package/custom-provider.js +53 -21
- package/durable/local.mjs +3 -0
- package/durable/local.mjs.map +1 -0
- package/durable/vercel.mjs +3 -0
- package/durable/vercel.mjs.map +1 -0
- package/durable/world.mjs +3 -0
- package/durable/world.mjs.map +1 -0
- package/durable.js +274 -71
- package/engine.js +2498 -0
- package/fetch-cleanup.js +8 -0
- package/fx-core.wasm +0 -0
- package/fx-harness.js +1 -1
- package/fx-sdk.js +27 -3694
- package/fx-term.wasm +0 -0
- package/node-wasm.js +63 -0
- package/node.cjs +8 -8095
- package/node.js +100 -105
- package/package.json +10 -3
- package/types/libfx.d.cts +21 -8
- package/types/node.d.ts +1 -1
- package/wasm-runtime.js +1056 -0
- package/wasm-terminal.js +80 -0
- package/xterm.js +112 -0
- package/libfx.darwin-arm64.node +0 -0
- package/libfx.darwin-x64.node +0 -0
- package/libfx.linux-arm64.node +0 -0
- package/libfx.linux-x64.node +0 -0
package/fx-sdk.js
CHANGED
|
@@ -1,3695 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// The answer a resumed turn gives a call its crash left running; matches
|
|
29
|
-
// unfinished_tool_output in session_adapter.zig.
|
|
30
|
-
const unfinishedToolOutput = "fx stopped while this tool was running, so it may have partly run. Check its effects before running it again.";
|
|
31
|
-
const maxModelCatalogBytes = 4 * 1024 * 1024;
|
|
32
|
-
const maxModelCatalogEntries = 10_000;
|
|
33
|
-
const streamReadsPerTaskYield = 32;
|
|
34
|
-
const transportActivityIntervalMs = 250;
|
|
35
|
-
const maxUnreadEventBytes = 1024 * 1024;
|
|
36
|
-
const maxUnreadEvents = 256;
|
|
37
|
-
// Prompt images travel as raw bytes beside the ACP frame and are base64
|
|
38
|
-
// encoded only in the model request. An image may use 5 MiB of encoded
|
|
39
|
-
// request data, and a prompt's images 8 MiB, so the raw limits are 3/4 of that.
|
|
40
|
-
// The kernel still validates content and media type, and holds a prompt to
|
|
41
|
-
// these limits too: `max_libfx_prompt_images` and
|
|
42
|
-
// `max_libfx_prompt_images_bytes` in src/core/images/image_attachments.zig
|
|
43
|
-
// must match them.
|
|
44
|
-
const maxPromptImages = 8;
|
|
45
|
-
const maxPromptImageDataBytes = 5 * 1024 * 1024;
|
|
46
|
-
const maxPromptImagesDataBytes = 8 * 1024 * 1024;
|
|
47
|
-
const maxPromptImageBytes = (maxPromptImageDataBytes / 4) * 3;
|
|
48
|
-
const maxPromptImagesBytes = (maxPromptImagesDataBytes / 4) * 3;
|
|
49
|
-
// Matches the native attachment table: one prompt's images or one checkpoint.
|
|
50
|
-
const maxPendingAttachments = 8;
|
|
51
|
-
const maxPendingAttachmentBytes = 8 * 1024 * 1024;
|
|
52
|
-
const maxOutboundAttachments = 4;
|
|
53
|
-
// Matches the core's kernel checkpoint limit (max_checkpoint_bytes).
|
|
54
|
-
const maxCheckpointBytes = 4 * 1024 * 1024;
|
|
55
|
-
// Matches the core's journal load limit (journal.max_load_bytes).
|
|
56
|
-
const maxJournalBytes = 4 * 1024 * 1024;
|
|
57
|
-
// The core's ACP reader drops frames over 8 MiB without a request id to answer
|
|
58
|
-
// (jsonrpc frame_resource_byte_limit), so the SDK must never emit one. The
|
|
59
|
-
// envelope allowance covers the method key and request id.
|
|
60
|
-
const maxPromptFrameBytes = 8 * 1024 * 1024;
|
|
61
|
-
const promptFrameEnvelopeBytes = 128;
|
|
62
|
-
const maxSteeringMessageBytes = 64 * 1024;
|
|
63
|
-
const maxSteeringMessages = 64;
|
|
64
|
-
const maxSteeringQueueBytes = 1024 * 1024;
|
|
65
|
-
// tool_start events carry a bounded preview of the tool input; larger inputs
|
|
66
|
-
// are marked truncated instead of dropped or sent whole.
|
|
67
|
-
const maxToolStartInputBytes = 64 * 1024;
|
|
68
|
-
|
|
69
|
-
function boundedString(value, name, maxBytes, required) {
|
|
70
|
-
if (value === undefined && !required) return undefined;
|
|
71
|
-
if (typeof value !== "string" || value.length === 0) {
|
|
72
|
-
throw new TypeError(`${name} ${required ? "is required and " : ""}must be a non-empty string`);
|
|
73
|
-
}
|
|
74
|
-
if (encoder.encode(value).length > maxBytes) {
|
|
75
|
-
throw new RangeError(`${name} exceeds the ${maxBytes} byte libfx limit`);
|
|
76
|
-
}
|
|
77
|
-
return value;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function validateGatewayChatUrl(value) {
|
|
81
|
-
if (value === undefined) return;
|
|
82
|
-
boundedString(value, "gatewayChatUrl", maxUrlBytes, false);
|
|
83
|
-
let url;
|
|
84
|
-
try { url = new URL(value); } catch { throw new TypeError("gatewayChatUrl must be a valid URL"); }
|
|
85
|
-
if (url.username || url.password || url.hash) {
|
|
86
|
-
throw new TypeError("gatewayChatUrl must not contain credentials or a fragment");
|
|
87
|
-
}
|
|
88
|
-
if (url.href === "https://ai-gateway.vercel.sh/v4/ai/language-model") return;
|
|
89
|
-
if (url.href === "https://ai-gateway.vercel.sh/v3/ai/language-model") return;
|
|
90
|
-
const loopback = url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "localhost";
|
|
91
|
-
if (url.protocol !== "http:" || !loopback || !url.port) {
|
|
92
|
-
throw new TypeError("gatewayChatUrl must use the canonical Gateway or explicit loopback HTTP");
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Mirrors the kernel's ReasoningEffort.parse: "auto"/"adaptive"/"default" pick
|
|
97
|
-
// the model default; anything else must be a bounded effort name.
|
|
98
|
-
function normalizeEffort(value) {
|
|
99
|
-
if (value === undefined) return undefined;
|
|
100
|
-
boundedString(value, "effort", maxEffortBytes, false);
|
|
101
|
-
if (!/^[A-Za-z0-9._-]+$/.test(value)) {
|
|
102
|
-
throw new TypeError('effort must use only letters, digits, ".", "-", or "_"');
|
|
103
|
-
}
|
|
104
|
-
return value;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Mirrors the CLI's --fast/--no-fast toggle: a strict boolean, with undefined
|
|
108
|
-
// leaving the model default in place.
|
|
109
|
-
function normalizeFast(value) {
|
|
110
|
-
if (value === undefined) return undefined;
|
|
111
|
-
if (typeof value !== "boolean") {
|
|
112
|
-
throw new TypeError("fast must be a boolean");
|
|
113
|
-
}
|
|
114
|
-
return value;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function normalizeUltrafast(value) {
|
|
118
|
-
if (value === undefined) return undefined;
|
|
119
|
-
if (typeof value !== "boolean") {
|
|
120
|
-
throw new TypeError("ultrafast must be a boolean");
|
|
121
|
-
}
|
|
122
|
-
return value;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// The model choice in `options`, a model object or a model ID with the
|
|
126
|
-
// deprecated top-level options, as `{ model, effort, fast, ultrafast }`.
|
|
127
|
-
export function normalizeModelChoice(options) {
|
|
128
|
-
if (options.model !== null && typeof options.model === "object" && !Array.isArray(options.model)) {
|
|
129
|
-
if (Object.hasOwn(options, "effort") || Object.hasOwn(options, "fast") || Object.hasOwn(options, "ultrafast")) {
|
|
130
|
-
throw new TypeError("model options cannot be mixed with top-level effort, fast, or ultrafast");
|
|
131
|
-
}
|
|
132
|
-
const model = options.model;
|
|
133
|
-
for (const name of Object.keys(model)) {
|
|
134
|
-
if (name !== "id" && name !== "effort" && name !== "fast" && name !== "ultrafast") {
|
|
135
|
-
throw new TypeError(`unsupported model option: ${name}`);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return {
|
|
139
|
-
model: boundedString(model.id, "model.id", maxModelBytes, true),
|
|
140
|
-
effort: normalizeEffort(model.effort),
|
|
141
|
-
fast: normalizeFast(model.fast),
|
|
142
|
-
ultrafast: normalizeUltrafast(model.ultrafast),
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
return {
|
|
146
|
-
model: boundedString(options.model, "model", maxModelBytes, false),
|
|
147
|
-
effort: normalizeEffort(options.effort),
|
|
148
|
-
fast: normalizeFast(options.fast),
|
|
149
|
-
ultrafast: normalizeUltrafast(options.ultrafast),
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function normalizeAgentOptions(value) {
|
|
154
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
155
|
-
throw new TypeError("createFxEngine() options must be an object");
|
|
156
|
-
}
|
|
157
|
-
const options = { ...value };
|
|
158
|
-
if (options.hooks !== undefined) {
|
|
159
|
-
const hooks = options.hooks;
|
|
160
|
-
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks) || typeof hooks.onEvent !== "function") {
|
|
161
|
-
throw new TypeError("hooks must be an object with onEvent()");
|
|
162
|
-
}
|
|
163
|
-
if (hooks.onError !== undefined && typeof hooks.onError !== "function") throw new TypeError("hooks.onError must be a function");
|
|
164
|
-
if (hooks.captureContent !== undefined && typeof hooks.captureContent !== "boolean") throw new TypeError("hooks.captureContent must be a boolean");
|
|
165
|
-
options.hooks = {
|
|
166
|
-
captureContent: hooks.captureContent === true,
|
|
167
|
-
onEvent: hooks.onEvent.bind(hooks),
|
|
168
|
-
...(hooks.onError === undefined ? {} : { onError: hooks.onError.bind(hooks) }),
|
|
169
|
-
...(hooks.parent === undefined ? {} : { parent: { ...hooks.parent } }),
|
|
170
|
-
};
|
|
171
|
-
if (options.hooks.parent !== undefined) {
|
|
172
|
-
for (const name of ["rootSessionId", "sessionId", "turnId", "callId"]) {
|
|
173
|
-
if (typeof options.hooks.parent[name] !== "string" || options.hooks.parent[name].length === 0 || options.hooks.parent[name].length > 1024) throw new TypeError(`hooks.parent.${name} must be a bounded identity`);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
if (Object.hasOwn(options, "env")) {
|
|
178
|
-
throw new TypeError("createFxEngine() does not accept env; pass apiKey and model directly");
|
|
179
|
-
}
|
|
180
|
-
// Derived from the caller's provider every time, so normalizing again agrees.
|
|
181
|
-
const custom = options.provider !== null && typeof options.provider === "object"
|
|
182
|
-
? customProviderDefinition(options.provider)
|
|
183
|
-
: undefined;
|
|
184
|
-
if (!custom && options.provider !== undefined && options.provider !== "gateway" && !subscriptionProviders.has(options.provider)) {
|
|
185
|
-
throw new TypeError("provider must be \"gateway\", \"codex\", \"grok\" or a custom provider { name, baseUrl }");
|
|
186
|
-
}
|
|
187
|
-
options.customProvider = custom;
|
|
188
|
-
options.providerName = custom ? custom.name : options.provider;
|
|
189
|
-
const subscription = subscriptionProviders.has(options.provider);
|
|
190
|
-
// Gateway needs its key; every other provider runs on the host's credentials.
|
|
191
|
-
options.apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, !subscription && !custom);
|
|
192
|
-
if (subscription && typeof options.credentials !== "function") {
|
|
193
|
-
throw new TypeError(`provider "${options.provider}" needs a credentials() function`);
|
|
194
|
-
}
|
|
195
|
-
if (!subscription && !custom && options.credentials !== undefined) {
|
|
196
|
-
throw new TypeError("credentials needs provider \"codex\", \"grok\" or a custom provider");
|
|
197
|
-
}
|
|
198
|
-
// A custom provider's key is never refreshed, so it has no onCredentials or refreshLock.
|
|
199
|
-
for (const name of ["onCredentials", "refreshLock"]) {
|
|
200
|
-
if (!subscription && options[name] !== undefined) throw new TypeError(`${name} needs provider "codex" or "grok"`);
|
|
201
|
-
}
|
|
202
|
-
for (const name of ["credentials", "onCredentials", "refreshLock"]) {
|
|
203
|
-
if (options[name] !== undefined && typeof options[name] !== "function") throw new TypeError(`${name} must be a function`);
|
|
204
|
-
}
|
|
205
|
-
Object.assign(options, normalizeModelChoice(options));
|
|
206
|
-
// fx's default model is a Gateway id. Codex, Grok, and a custom provider
|
|
207
|
-
// name their own models, so each needs one, as the fx CLI asks for.
|
|
208
|
-
if (custom && options.model === undefined) throw new TypeError("a custom provider needs a model");
|
|
209
|
-
if (subscription && options.model === undefined) {
|
|
210
|
-
throw new TypeError(`provider "${options.provider}" needs a model; fx's default model is an AI Gateway id`);
|
|
211
|
-
}
|
|
212
|
-
validateGatewayChatUrl(options.gatewayChatUrl);
|
|
213
|
-
if (options.resizeImage !== undefined && typeof options.resizeImage !== "function") {
|
|
214
|
-
throw new TypeError("resizeImage must be a function");
|
|
215
|
-
}
|
|
216
|
-
if (options.sessionId !== undefined && options.sessionId !== null && !validSessionId(options.sessionId)) {
|
|
217
|
-
throw new TypeError(`sessionId must be ${sessionIdRule}`);
|
|
218
|
-
}
|
|
219
|
-
// A separate key, so normalizing these options again reads the caller's value.
|
|
220
|
-
if (options.modelCatalog !== undefined) options.modelCatalogBody = modelCatalogBody(options.modelCatalog);
|
|
221
|
-
if (options.persistence !== undefined) {
|
|
222
|
-
const persistence = options.persistence;
|
|
223
|
-
if (!persistence || typeof persistence !== "object" ||
|
|
224
|
-
typeof persistence.load !== "function" || typeof persistence.append !== "function" ||
|
|
225
|
-
(persistence.saveCheckpoint !== undefined && typeof persistence.saveCheckpoint !== "function")) {
|
|
226
|
-
throw new TypeError("persistence must be an object with load(), append() and, optionally, saveCheckpoint()");
|
|
227
|
-
}
|
|
228
|
-
if (options.checkpoint !== undefined) {
|
|
229
|
-
throw new TypeError("persistence cannot be combined with checkpoint");
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
if (options.checkpointAfterBytes !== undefined) {
|
|
233
|
-
if (options.persistence === undefined) throw new TypeError("checkpointAfterBytes needs persistence");
|
|
234
|
-
if (!Number.isSafeInteger(options.checkpointAfterBytes) || options.checkpointAfterBytes < 0) {
|
|
235
|
-
throw new RangeError("checkpointAfterBytes must be a non-negative integer");
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
return options;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Keys for libfx's own session worker; symbols keep them off the engine's
|
|
242
|
-
// public surface.
|
|
243
|
-
export const engineInternals = Symbol.for("libfx.engineInternals");
|
|
244
|
-
export const journalMarksWanted = Symbol.for("libfx.journalMarks");
|
|
245
|
-
// A persistence's image store, `{ put(name, bytes), get(name) }`, which keeps
|
|
246
|
-
// each image once, named by the SHA-256 of its bytes, so records and
|
|
247
|
-
// checkpoints only name it. Durable sessions give one to the engines they
|
|
248
|
-
// run; without it, images stay inline.
|
|
249
|
-
export const persistenceImages = Symbol.for("libfx.persistenceImages");
|
|
250
|
-
// `{ images, bytes }`: a smaller image window than the core's, the newest
|
|
251
|
-
// images a session with an image store keeps, so tests can reach its edge.
|
|
252
|
-
const imageWindowLimits = Symbol.for("libfx.imageWindow");
|
|
253
|
-
// Marks an error the core answered a request with, as opposed to one from a
|
|
254
|
-
// core that stopped answering.
|
|
255
|
-
export const coreAnswered = Symbol.for("libfx.coreAnswered");
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Another writer took over the session after this agent loaded it. A
|
|
259
|
-
* persistence store rejects an append whose `expected` cursor is not its head
|
|
260
|
-
* with this error; the agent stops its turn at once and makes no further
|
|
261
|
-
* effect or write. Check `code === "FX_FENCED"` when the error may come from
|
|
262
|
-
* another copy of libfx.
|
|
263
|
-
*/
|
|
264
|
-
export class FxFencedError extends Error {
|
|
265
|
-
constructor(message) {
|
|
266
|
-
super(message);
|
|
267
|
-
this.name = "FxFencedError";
|
|
268
|
-
this.code = "FX_FENCED";
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** The journal, or the `checkpoint` option, was written by a newer libfx. */
|
|
273
|
-
export class FxJournalVersionError extends Error {
|
|
274
|
-
constructor(message) {
|
|
275
|
-
super(message);
|
|
276
|
-
this.name = "FxJournalVersionError";
|
|
277
|
-
this.code = "FX_JOURNAL_VERSION";
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// A journal this libfx cannot open: more than one load holds
|
|
282
|
-
// (FX_JOURNAL_TOO_LARGE), or events that do not fold (FX_JOURNAL_INVALID).
|
|
283
|
-
function journalLoadError(message, code, cause) {
|
|
284
|
-
const error = new Error(message, cause === undefined ? undefined : { cause });
|
|
285
|
-
error.code = code;
|
|
286
|
-
return error;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// The core's libfx/journal_open errors, by their exact message. The journal
|
|
290
|
-
// tests open a journal for each, so rewording one fails them, except the size
|
|
291
|
-
// limit: libfx refuses a journal over it before the core sees one, so that
|
|
292
|
-
// entry is a backstop.
|
|
293
|
-
const journalOpenErrorCodes = new Map([
|
|
294
|
-
["Invalid libfx journal", "FX_JOURNAL_INVALID"],
|
|
295
|
-
["libfx journal events are out of order", "FX_JOURNAL_INVALID"],
|
|
296
|
-
["libfx journal is too large", "FX_JOURNAL_TOO_LARGE"],
|
|
297
|
-
["libfx journal holds more than 1024 turns", "FX_JOURNAL_TOO_LARGE"],
|
|
298
|
-
["libfx journal images are too large", "FX_JOURNAL_TOO_LARGE"],
|
|
299
|
-
["Invalid libfx journal images", "FX_JOURNAL_INVALID"],
|
|
300
|
-
["libfx image does not match its name", "FX_JOURNAL_IMAGE_MISSING"],
|
|
301
|
-
]);
|
|
302
|
-
|
|
303
|
-
// An image's name in a persistence's image store: the lowercase hex SHA-256
|
|
304
|
-
// of its bytes, as the core names it.
|
|
305
|
-
async function imageName(bytes) {
|
|
306
|
-
const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
|
|
307
|
-
let name = "";
|
|
308
|
-
for (const byte of digest) name += byte.toString(16).padStart(2, "0");
|
|
309
|
-
return name;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function imageMissingError(name, cause) {
|
|
313
|
-
const error = journalLoadError(`libfx journal names an image the store does not hold: ${name}`, "FX_JOURNAL_IMAGE_MISSING", cause);
|
|
314
|
-
error.image = name;
|
|
315
|
-
return error;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* A persistence store that keeps a session's records and its latest
|
|
320
|
-
* checkpoint in memory, for tests and for hosts that copy them elsewhere.
|
|
321
|
-
* `records` is the stored list, oldest first. An append whose `expected`
|
|
322
|
-
* cursor is not the last record's is refused, so a second agent cannot write
|
|
323
|
-
* the same session, and an append that repeats a stored write's
|
|
324
|
-
* `idempotencyKey` returns that write's cursor.
|
|
325
|
-
*/
|
|
326
|
-
export function createMemoryPersistence() {
|
|
327
|
-
const records = [];
|
|
328
|
-
let checkpoint = null;
|
|
329
|
-
const head = () => records.at(-1)?.cursor ?? null;
|
|
330
|
-
return {
|
|
331
|
-
records,
|
|
332
|
-
get checkpoint() {
|
|
333
|
-
return checkpoint;
|
|
334
|
-
},
|
|
335
|
-
async load() {
|
|
336
|
-
const after = checkpoint ? records.findIndex((record) => record.cursor === checkpoint.through) + 1 : 0;
|
|
337
|
-
return {
|
|
338
|
-
...(checkpoint ? { checkpoint: { data: checkpoint.data.slice(), through: checkpoint.through } } : {}),
|
|
339
|
-
journal: records.slice(after).map(({ cursor, data }) => ({ cursor, data: data.slice() })),
|
|
340
|
-
};
|
|
341
|
-
},
|
|
342
|
-
async append({ expected, idempotencyKey, data }) {
|
|
343
|
-
const stored = records.find((record) => record.idempotencyKey === idempotencyKey);
|
|
344
|
-
if (stored) return { cursor: stored.cursor };
|
|
345
|
-
if (expected !== head()) {
|
|
346
|
-
throw new FxFencedError(`another agent appended to this session: expected ${expected}, head ${head()}`);
|
|
347
|
-
}
|
|
348
|
-
const cursor = String(records.length + 1);
|
|
349
|
-
records.push({ cursor, idempotencyKey, data: data.slice() });
|
|
350
|
-
return { cursor };
|
|
351
|
-
},
|
|
352
|
-
async saveCheckpoint({ through, data }) {
|
|
353
|
-
checkpoint = { through, data: data.slice() };
|
|
354
|
-
},
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Marks the turn `agent.resume()` starts; never a caller-visible option.
|
|
359
|
-
const resumeTurn = Symbol("libfx.resume");
|
|
360
|
-
// Carries the follow-up a turn runs: `{ id, accepted }`.
|
|
361
|
-
const followUpInput = Symbol("libfx.followUp");
|
|
362
|
-
// Carries the ids of the calls a resumed turn runs again.
|
|
363
|
-
const rerunCallIds = Symbol("libfx.rerun");
|
|
364
|
-
// Marks a resume whose host already holds what the turn sent before it
|
|
365
|
-
// stopped, as a durable session's stream does, so the turn does not send it
|
|
366
|
-
// again. Any other resume runs in a host that saw none of it. Never a
|
|
367
|
-
// caller-visible option; `fx-harness.js` sets it.
|
|
368
|
-
export const resumeOutputShown = Symbol.for("libfx.resumeOutputShown");
|
|
369
|
-
|
|
370
|
-
function journalAppendError(cause) {
|
|
371
|
-
const error = new Error("libfx journal append failed", { cause });
|
|
372
|
-
error.code = "FX_JOURNAL_APPEND_FAILED";
|
|
373
|
-
return error;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Restoring an open turn needs only its newest progress, and the ones it
|
|
377
|
-
// extends when it carries only what is new back to one that carries all of
|
|
378
|
-
// it, and the results of calls no progress has answered yet. So an older
|
|
379
|
-
// progress's state, and an answered call's result, are left out of a load
|
|
380
|
-
// (data: null). A load then grows with the session's committed turns, not
|
|
381
|
-
// with every step of every turn.
|
|
382
|
-
function withoutSupersededProgress(events) {
|
|
383
|
-
const kept = new Array(events.length);
|
|
384
|
-
let superseded = false;
|
|
385
|
-
let answered = false;
|
|
386
|
-
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
387
|
-
const event = events[index];
|
|
388
|
-
const type = event?.type;
|
|
389
|
-
const dropped = event?.data != null && ((type === "turn_progress" && superseded) || (type === "tool_result" && answered));
|
|
390
|
-
kept[index] = dropped ? { ...event, data: null } : event;
|
|
391
|
-
if (type === "turn_committed" || type === "turn_progress_cleared") {
|
|
392
|
-
superseded = true;
|
|
393
|
-
answered = true;
|
|
394
|
-
} else if (type === "turn_progress") {
|
|
395
|
-
answered = true;
|
|
396
|
-
if (event.base === undefined) superseded = true;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
return kept;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// A session id reaches gateway headers; the core applies the same rule.
|
|
403
|
-
function validSessionId(id) {
|
|
404
|
-
return typeof id === "string" && /^[A-Za-z0-9._-]{1,255}$/.test(id) &&
|
|
405
|
-
id !== "." && id !== ".." && id.toLowerCase() !== "v2";
|
|
406
|
-
}
|
|
407
|
-
const sessionIdRule = "1 to 255 letters, digits, '.', '_' or '-'";
|
|
408
|
-
|
|
409
|
-
// A record is one append's events as UTF-8 JSON. Its shape is libfx's; a
|
|
410
|
-
// store keeps the bytes as they are.
|
|
411
|
-
const journalRecordFormat = "libfx-journal-v1";
|
|
412
|
-
// Checkpoints after the journal since the last one reaches this many bytes.
|
|
413
|
-
const defaultCheckpointAfterBytes = 1024 * 1024;
|
|
414
|
-
// A turn id reaches the journal and tool calls; the core applies the same rule.
|
|
415
|
-
const turnIdRule = "1 to 128 letters, digits, '.', '_' or '-'";
|
|
416
|
-
|
|
417
|
-
function validTurnId(id) {
|
|
418
|
-
return typeof id === "string" && /^[A-Za-z0-9._-]{1,128}$/.test(id);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function checkOnAmbiguous(onAmbiguous) {
|
|
422
|
-
if (onAmbiguous !== undefined && typeof onAmbiguous !== "function") throw new TypeError("onAmbiguous must be a function");
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// A call's input as the model wrote it: JSON when it parses, else the text.
|
|
426
|
-
function parsedToolInput(text) {
|
|
427
|
-
try { return JSON.parse(text); } catch { return text; }
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function validCursor(cursor) {
|
|
431
|
-
return typeof cursor === "string" && cursor.length > 0 && cursor.length <= 1024;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function encodeJournalRecord(events) {
|
|
435
|
-
return encoder.encode(JSON.stringify({ format: journalRecordFormat, events }));
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function persistenceBytes(value, what) {
|
|
439
|
-
if (value instanceof Uint8Array) return value;
|
|
440
|
-
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
441
|
-
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
442
|
-
throw new TypeError(`${what} must be bytes`);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function decodeJournalRecord(data) {
|
|
446
|
-
let value;
|
|
447
|
-
try { value = JSON.parse(decoder.decode(persistenceBytes(data, "a journal record's data"))); } catch (error) {
|
|
448
|
-
if (error instanceof TypeError && /must be bytes/.test(error.message)) throw error;
|
|
449
|
-
throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID", error);
|
|
450
|
-
}
|
|
451
|
-
if (value?.format !== journalRecordFormat || !Array.isArray(value.events)) {
|
|
452
|
-
if (typeof value?.format === "string" && value.format.startsWith("libfx-journal-v")) {
|
|
453
|
-
throw new FxJournalVersionError("libfx journal was written by a newer fx");
|
|
454
|
-
}
|
|
455
|
-
throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID");
|
|
456
|
-
}
|
|
457
|
-
return value.events;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// The last `seq` a checkpoint covers, from its header: `FXSN`, a version
|
|
461
|
-
// byte, then the seq as a little-endian u64. The core checks the rest.
|
|
462
|
-
function checkpointSeq(bytes) {
|
|
463
|
-
if (bytes.byteLength < 13 || decoder.decode(bytes.subarray(0, 4)) !== "FXSN") {
|
|
464
|
-
throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID");
|
|
465
|
-
}
|
|
466
|
-
return Number(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(5, true));
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// Reads what `persistence.load()` returns: the latest checkpoint, if any, and
|
|
470
|
-
// the records after it, as the events the core folds after that checkpoint.
|
|
471
|
-
// Modeled in `tests/tla/durable/SnapshotTail.tla`.
|
|
472
|
-
async function loadPersistence(persistence) {
|
|
473
|
-
const loaded = await persistence.load();
|
|
474
|
-
if (!loaded || typeof loaded !== "object") {
|
|
475
|
-
throw new TypeError("persistence.load() must resolve to { checkpoint?, journal? }");
|
|
476
|
-
}
|
|
477
|
-
let checkpoint = null;
|
|
478
|
-
let covered = 0;
|
|
479
|
-
let head = null;
|
|
480
|
-
if (loaded.checkpoint != null) {
|
|
481
|
-
const { data, through } = loaded.checkpoint;
|
|
482
|
-
if (!validCursor(through)) throw new TypeError("persistence.load() checkpoint.through must be a cursor string");
|
|
483
|
-
checkpoint = persistenceBytes(data, "checkpoint.data").slice();
|
|
484
|
-
if (checkpoint.byteLength > maxCheckpointBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
|
|
485
|
-
covered = checkpointSeq(checkpoint);
|
|
486
|
-
head = through;
|
|
487
|
-
}
|
|
488
|
-
const records = loaded.journal ?? [];
|
|
489
|
-
if (typeof records[Symbol.iterator] !== "function" && typeof records[Symbol.asyncIterator] !== "function") {
|
|
490
|
-
throw new TypeError("persistence.load() journal must be an iterable of { cursor, data }");
|
|
491
|
-
}
|
|
492
|
-
const events = [];
|
|
493
|
-
let bytes = 0;
|
|
494
|
-
for await (const record of records) {
|
|
495
|
-
if (!record || !validCursor(record.cursor)) throw new TypeError("each journal record needs a cursor string");
|
|
496
|
-
// The load limit applies once an open turn's older progress is dropped.
|
|
497
|
-
const data = persistenceBytes(record.data, "a journal record's data");
|
|
498
|
-
bytes += data.byteLength;
|
|
499
|
-
// A store may return records the checkpoint already covers.
|
|
500
|
-
for (const event of decodeJournalRecord(data)) if (!(Number.isSafeInteger(event?.seq) && event.seq <= covered)) events.push(event);
|
|
501
|
-
head = record.cursor;
|
|
502
|
-
}
|
|
503
|
-
// A store whose log holds entries of its own between records names the
|
|
504
|
-
// entry the next record follows.
|
|
505
|
-
if (loaded.head !== undefined) {
|
|
506
|
-
if (loaded.head !== null && !validCursor(loaded.head)) throw new TypeError("persistence.load() head must be a cursor string or null");
|
|
507
|
-
head = loaded.head;
|
|
508
|
-
}
|
|
509
|
-
return { checkpoint, events, head, tailBytes: bytes, lastSeq: events.at(-1)?.seq ?? covered };
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// Events someone waits on, so the record holding one is a barrier: a steer
|
|
513
|
-
// or withdrawal its caller awaits, and the end or yield a turn's result does.
|
|
514
|
-
const waitedForEvents = new Set(["input_accepted", "input_withdrawn", "turn_committed", "turn_progress_cleared", "turn_yielded"]);
|
|
515
|
-
|
|
516
|
-
// What a record's events say about turns, in order, for a store that tracks
|
|
517
|
-
// them without reading records: a turn started, ended, or yielded, an input
|
|
518
|
-
// was accepted or handed back unplaced, or the step the open turn is at, as
|
|
519
|
-
// its tool steps finished and the state of its calls.
|
|
520
|
-
function journalMarks(events) {
|
|
521
|
-
const marks = [];
|
|
522
|
-
for (const event of events) {
|
|
523
|
-
if (event?.type === "turn_progress") {
|
|
524
|
-
if (typeof event.turnId === "string") marks.push({ start: event.turnId });
|
|
525
|
-
// The inputs its model request carries for the first time.
|
|
526
|
-
for (const id of Array.isArray(event.inputs) ? event.inputs : []) if (typeof id === "string") marks.push({ placed: id });
|
|
527
|
-
// A progress that carries only what is new counts all the turn's steps.
|
|
528
|
-
const steps = Number.isSafeInteger(event.steps) ? event.steps : event.data?.execution?.tool_steps?.length;
|
|
529
|
-
if (Number.isSafeInteger(steps)) marks.push({ step: `${steps}:${String(event.data?.tool_state ?? "none")}` });
|
|
530
|
-
} else if (event?.type === "turn_committed" || event?.type === "turn_progress_cleared") marks.push({ end: true });
|
|
531
|
-
else if (event?.type === "turn_yielded") marks.push({ yield: true });
|
|
532
|
-
else if (event?.type === "input_accepted" && typeof event.data?.id === "string") marks.push({ accepted: event.data.id });
|
|
533
|
-
else if (event?.type === "input_withdrawn" && typeof event.data?.id === "string") marks.push({ withdrawn: event.data.id });
|
|
534
|
-
}
|
|
535
|
-
return marks;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function agentEnvironment(options) {
|
|
539
|
-
return {
|
|
540
|
-
...(options.apiKey === undefined ? {} : { AI_GATEWAY_API_KEY: options.apiKey }),
|
|
541
|
-
...(options.providerName === undefined ? {} : { FX_PROVIDER: options.providerName }),
|
|
542
|
-
...(options.customProvider === undefined ? {} : { FX_CONFIGURED_PROVIDERS: options.customProvider.json }),
|
|
543
|
-
...(options.model === undefined ? {} : { FX_MODEL: options.model }),
|
|
544
|
-
...(options.effort === undefined ? {} : { FX_EFFORT: options.effort }),
|
|
545
|
-
...(options.fast === undefined ? {} : { FX_FAST: options.fast ? "true" : "false" }),
|
|
546
|
-
...(options.ultrafast === undefined ? {} : { FX_ULTRAFAST: options.ultrafast ? "true" : "false" }),
|
|
547
|
-
...(options.gatewayChatUrl === undefined ? {} : { FX_GATEWAY_CHAT_URL: options.gatewayChatUrl }),
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
function agentRpcError(response) {
|
|
552
|
-
const error = new Error(response.message);
|
|
553
|
-
error[coreAnswered] = true;
|
|
554
|
-
const data = response.data;
|
|
555
|
-
if (data && ["LIBFX_MODEL_UNSUPPORTED_EFFORT", "LIBFX_MODEL_UNSUPPORTED_FAST", "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST"].includes(data.code) &&
|
|
556
|
-
typeof data.model === "string" &&
|
|
557
|
-
data.capability === (data.code === "LIBFX_MODEL_UNSUPPORTED_FAST" ? "fast" : data.code === "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST" ? "ultrafast" : "effort")) {
|
|
558
|
-
error.code = data.code;
|
|
559
|
-
error.model = data.model;
|
|
560
|
-
error.capability = data.capability;
|
|
561
|
-
}
|
|
562
|
-
return error;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
async function cancelResponseBody(response) {
|
|
566
|
-
try {
|
|
567
|
-
await response.body?.cancel();
|
|
568
|
-
} catch {}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
async function readBoundedResponseText(response, limit) {
|
|
572
|
-
const declared = Number(response.headers.get("content-length"));
|
|
573
|
-
if (Number.isFinite(declared) && declared > limit) {
|
|
574
|
-
await cancelResponseBody(response);
|
|
575
|
-
throw new RangeError(`model catalog exceeds the ${limit} byte libfx limit`);
|
|
576
|
-
}
|
|
577
|
-
if (!response.body) {
|
|
578
|
-
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
579
|
-
if (bytes.length > limit) throw new RangeError(`model catalog exceeds the ${limit} byte libfx limit`);
|
|
580
|
-
return strictDecoder.decode(bytes);
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
const reader = response.body.getReader();
|
|
584
|
-
const chunks = [];
|
|
585
|
-
let total = 0;
|
|
586
|
-
for (;;) {
|
|
587
|
-
const { done, value } = await reader.read();
|
|
588
|
-
if (done) break;
|
|
589
|
-
if (!value?.length) continue;
|
|
590
|
-
total += value.length;
|
|
591
|
-
if (total > limit) {
|
|
592
|
-
try {
|
|
593
|
-
await reader.cancel();
|
|
594
|
-
} catch {}
|
|
595
|
-
throw new RangeError(`model catalog exceeds the ${limit} byte libfx limit`);
|
|
596
|
-
}
|
|
597
|
-
chunks.push(value);
|
|
598
|
-
}
|
|
599
|
-
const bytes = new Uint8Array(total);
|
|
600
|
-
let offset = 0;
|
|
601
|
-
for (const chunk of chunks) {
|
|
602
|
-
bytes.set(chunk, offset);
|
|
603
|
-
offset += chunk.length;
|
|
604
|
-
}
|
|
605
|
-
return strictDecoder.decode(bytes);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
export async function listModels(options = {}) {
|
|
609
|
-
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
|
610
|
-
throw new TypeError("listModels() options must be an object");
|
|
611
|
-
}
|
|
612
|
-
const apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, true);
|
|
613
|
-
const fetchModels = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
614
|
-
if (typeof fetchModels !== "function") throw new TypeError("fetch is unavailable");
|
|
615
|
-
const response = await fetchModels("https://ai-gateway.vercel.sh/coding-agent/v1/models", {
|
|
616
|
-
method: "GET",
|
|
617
|
-
headers: { authorization: `Bearer ${apiKey}` },
|
|
618
|
-
});
|
|
619
|
-
if (!response.ok) {
|
|
620
|
-
await cancelResponseBody(response);
|
|
621
|
-
throw new Error(`model catalog request failed with HTTP ${response.status}`);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
let catalog;
|
|
625
|
-
try {
|
|
626
|
-
catalog = JSON.parse(await readBoundedResponseText(response, maxModelCatalogBytes));
|
|
627
|
-
} catch (error) {
|
|
628
|
-
if (error instanceof RangeError) throw error;
|
|
629
|
-
throw new TypeError("model catalog response is malformed");
|
|
630
|
-
}
|
|
631
|
-
if (!catalog || typeof catalog !== "object" || !Array.isArray(catalog.data)) {
|
|
632
|
-
throw new TypeError("model catalog response is malformed");
|
|
633
|
-
}
|
|
634
|
-
if (catalog.data.length > maxModelCatalogEntries) {
|
|
635
|
-
throw new RangeError(`model catalog exceeds the ${maxModelCatalogEntries} entry libfx limit`);
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
const ids = new Set();
|
|
639
|
-
for (const entry of catalog.data) {
|
|
640
|
-
if (!entry || typeof entry !== "object") continue;
|
|
641
|
-
if (typeof entry.type === "string" && entry.type.toLowerCase() !== "language") continue;
|
|
642
|
-
if (typeof entry.id !== "string" || entry.id.length === 0) continue;
|
|
643
|
-
if (encoder.encode(entry.id).length > maxModelBytes) continue;
|
|
644
|
-
ids.add(entry.id);
|
|
645
|
-
}
|
|
646
|
-
return [...ids].sort();
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
function validWorkspacePath(path) {
|
|
650
|
-
if (typeof path !== "string" || !path.startsWith("/") || path.includes("\0")) return false;
|
|
651
|
-
if (strictDecoder.decode(encoder.encode(path)) !== path) return false;
|
|
652
|
-
if (path === "/") return true;
|
|
653
|
-
if (path.endsWith("/")) return false;
|
|
654
|
-
return path.slice(1).split("/").every((part) => part && part !== "." && part !== "..");
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function workspacePathInside(root, path) {
|
|
658
|
-
return root === "/" ? path.startsWith("/") : path === root || path.startsWith(`${root}/`);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// fx only reads AGENTS.md files below the workspace root or the host home.
|
|
662
|
-
function workspaceInstructionPathAllowed(info, path) {
|
|
663
|
-
return validWorkspacePath(path) && path.endsWith("/AGENTS.md") &&
|
|
664
|
-
(workspacePathInside(info.root, path) || workspacePathInside(info.home, path));
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
function prepareWorkspaceAdapter(workspace) {
|
|
668
|
-
if (workspace == null) return { present: false, valid: false };
|
|
669
|
-
try {
|
|
670
|
-
const info = workspace.info;
|
|
671
|
-
const permission = workspace.permission;
|
|
672
|
-
const readFile = workspace.readFile;
|
|
673
|
-
if (!info || typeof workspace.exec !== "function" || info.version !== 1 ||
|
|
674
|
-
(readFile !== undefined && typeof readFile !== "function") ||
|
|
675
|
-
!validWorkspacePath(info.root) || !validWorkspacePath(info.cwd) ||
|
|
676
|
-
!validWorkspacePath(info.home) || info.cwd !== info.root ||
|
|
677
|
-
info.gitAvailable !== false || info.ephemeral !== true ||
|
|
678
|
-
(permission !== "allow-sandboxed" && permission !== "prompt")) {
|
|
679
|
-
return { present: true, valid: false };
|
|
680
|
-
}
|
|
681
|
-
const value = {
|
|
682
|
-
version: 1,
|
|
683
|
-
root: info.root,
|
|
684
|
-
cwd: info.cwd,
|
|
685
|
-
home: info.home,
|
|
686
|
-
git: false,
|
|
687
|
-
ephemeral: true,
|
|
688
|
-
permission,
|
|
689
|
-
instruction_files: typeof readFile === "function",
|
|
690
|
-
};
|
|
691
|
-
const encoded = encoder.encode(JSON.stringify(value));
|
|
692
|
-
if (encoded.length > workspaceInfoLimit) return { present: true, valid: false };
|
|
693
|
-
return { present: true, valid: true, adapter: workspace, info: value, encoded };
|
|
694
|
-
} catch {
|
|
695
|
-
return { present: true, valid: false };
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
function utf8Prefix(value, limit) {
|
|
700
|
-
if (value.length <= limit) return value;
|
|
701
|
-
let end = limit;
|
|
702
|
-
while (end > 0 && (value[end] & 0xc0) === 0x80) end -= 1;
|
|
703
|
-
return value.subarray(0, end);
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
export const fxSdkApiVersion = 2;
|
|
707
|
-
|
|
708
|
-
export function supportsJspi() {
|
|
709
|
-
return typeof WebAssembly.Suspending === "function" &&
|
|
710
|
-
typeof WebAssembly.promising === "function";
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
export function encodeXtermKeyEvent(event) {
|
|
714
|
-
if (event.type !== "keydown" || event.altKey || event.ctrlKey) return null;
|
|
715
|
-
if (event.key === "Enter" && event.shiftKey && !event.metaKey) return "\x1b[13;2u";
|
|
716
|
-
if (event.metaKey) {
|
|
717
|
-
const modifiers = 8 | (event.shiftKey ? 1 : 0);
|
|
718
|
-
if (event.key === "Backspace") return `\x1b[127;${modifiers + 1}u`;
|
|
719
|
-
const arrow = { ArrowUp: "A", ArrowDown: "B", ArrowRight: "C", ArrowLeft: "D" }[event.key];
|
|
720
|
-
if (arrow) return `\x1b[1;${modifiers + 1}${arrow}`;
|
|
721
|
-
const shortcut = { a: 97, c: 99, x: 120, z: 122 }[event.key.toLowerCase()];
|
|
722
|
-
if (shortcut) return `\x1b[${shortcut};${modifiers + 1}u`;
|
|
723
|
-
}
|
|
724
|
-
return null;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
function xtermPointerCell(term, event) {
|
|
728
|
-
const root = term.element;
|
|
729
|
-
const screen = root?.querySelector?.(".xterm-screen") || root;
|
|
730
|
-
const rect = screen?.getBoundingClientRect?.();
|
|
731
|
-
if (!rect || rect.width <= 0 || rect.height <= 0 || term.cols <= 0 || term.rows <= 0) return null;
|
|
732
|
-
if (event.clientX < rect.left || event.clientX >= rect.right ||
|
|
733
|
-
event.clientY < rect.top || event.clientY >= rect.bottom) return null;
|
|
734
|
-
return {
|
|
735
|
-
column: Math.min(term.cols, Math.floor((event.clientX - rect.left) * term.cols / rect.width) + 1),
|
|
736
|
-
row: Math.min(term.rows, Math.floor((event.clientY - rect.top) * term.rows / rect.height) + 1),
|
|
737
|
-
};
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
function installXtermClickHandler(term, callback) {
|
|
741
|
-
const element = term.element;
|
|
742
|
-
if (typeof element?.addEventListener !== "function") return () => {};
|
|
743
|
-
let pointerDown = null;
|
|
744
|
-
const down = (event) => {
|
|
745
|
-
if (event.button !== 0) return;
|
|
746
|
-
pointerDown = { id: event.pointerId, x: event.clientX, y: event.clientY };
|
|
747
|
-
};
|
|
748
|
-
const up = (event) => {
|
|
749
|
-
const start = pointerDown;
|
|
750
|
-
pointerDown = null;
|
|
751
|
-
if (!start || event.button !== 0 || event.pointerId !== start.id ||
|
|
752
|
-
event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
753
|
-
const dx = event.clientX - start.x;
|
|
754
|
-
const dy = event.clientY - start.y;
|
|
755
|
-
if (dx * dx + dy * dy > 16) return;
|
|
756
|
-
if (term.modes?.mouseTrackingMode && term.modes.mouseTrackingMode !== "none") return;
|
|
757
|
-
const cell = xtermPointerCell(term, event);
|
|
758
|
-
if (!cell) return;
|
|
759
|
-
callback(`\x1b[<0;${cell.column};${cell.row}M\x1b[<0;${cell.column};${cell.row}m`);
|
|
760
|
-
};
|
|
761
|
-
const cancel = () => { pointerDown = null; };
|
|
762
|
-
element.addEventListener("pointerdown", down);
|
|
763
|
-
element.addEventListener("pointerup", up);
|
|
764
|
-
element.addEventListener("pointercancel", cancel);
|
|
765
|
-
return () => {
|
|
766
|
-
element.removeEventListener("pointerdown", down);
|
|
767
|
-
element.removeEventListener("pointerup", up);
|
|
768
|
-
element.removeEventListener("pointercancel", cancel);
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
function installXtermShortcutHandler(term, callback) {
|
|
773
|
-
const element = term.element;
|
|
774
|
-
if (typeof element?.addEventListener !== "function") return () => {};
|
|
775
|
-
const keydown = (event) => {
|
|
776
|
-
if (xtermSelectionOwnsShortcut(term, event)) return;
|
|
777
|
-
const data = encodeXtermKeyEvent(event);
|
|
778
|
-
if (data === null) return;
|
|
779
|
-
event.preventDefault();
|
|
780
|
-
event.stopImmediatePropagation();
|
|
781
|
-
callback(data);
|
|
782
|
-
};
|
|
783
|
-
element.addEventListener("keydown", keydown, true);
|
|
784
|
-
return () => element.removeEventListener("keydown", keydown, true);
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
function xtermSelectionOwnsShortcut(term, event) {
|
|
788
|
-
return event.type === "keydown" && event.metaKey &&
|
|
789
|
-
(event.key.toLowerCase() === "c" || event.key.toLowerCase() === "x") &&
|
|
790
|
-
term.hasSelection?.();
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
export function xtermAdapter(term) {
|
|
794
|
-
let keyDataHandler = null;
|
|
795
|
-
if (typeof term.attachCustomKeyEventHandler === "function") {
|
|
796
|
-
term.attachCustomKeyEventHandler((event) => {
|
|
797
|
-
if (xtermSelectionOwnsShortcut(term, event)) return true;
|
|
798
|
-
const data = encodeXtermKeyEvent(event);
|
|
799
|
-
if (data === null || keyDataHandler === null) return true;
|
|
800
|
-
keyDataHandler(data);
|
|
801
|
-
return false;
|
|
802
|
-
});
|
|
803
|
-
}
|
|
804
|
-
return {
|
|
805
|
-
write(bytes) { term.write(typeof bytes === "string" ? bytes : decoder.decode(bytes)); },
|
|
806
|
-
onData(callback) { const disposable = term.onData(callback); return () => disposable.dispose(); },
|
|
807
|
-
onKeyData(callback) {
|
|
808
|
-
keyDataHandler = callback;
|
|
809
|
-
const removeShortcutHandler = installXtermShortcutHandler(term, callback);
|
|
810
|
-
const removeClickHandler = installXtermClickHandler(term, callback);
|
|
811
|
-
return () => {
|
|
812
|
-
removeShortcutHandler();
|
|
813
|
-
removeClickHandler();
|
|
814
|
-
if (keyDataHandler === callback) keyDataHandler = null;
|
|
815
|
-
};
|
|
816
|
-
},
|
|
817
|
-
get cols() { return term.cols; },
|
|
818
|
-
get rows() { return term.rows; },
|
|
819
|
-
onResize(callback) { const disposable = term.onResize(callback); return () => disposable.dispose(); },
|
|
820
|
-
};
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
class ByteQueue {
|
|
824
|
-
chunks = [];
|
|
825
|
-
waiters = [];
|
|
826
|
-
closed = false;
|
|
827
|
-
|
|
828
|
-
push(bytes) {
|
|
829
|
-
if (this.closed) throw new Error("fx runtime stdin is closed");
|
|
830
|
-
if (!bytes.length) return;
|
|
831
|
-
this.chunks.push(bytes);
|
|
832
|
-
this.wake();
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
read(max) {
|
|
836
|
-
if (!this.chunks.length) return null;
|
|
837
|
-
const chunk = this.chunks[0];
|
|
838
|
-
const value = chunk.subarray(0, max);
|
|
839
|
-
if (value.length === chunk.length) this.chunks.shift();
|
|
840
|
-
else this.chunks[0] = chunk.subarray(value.length);
|
|
841
|
-
return value;
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
wait(timeoutMs) {
|
|
845
|
-
if (this.closed) return Promise.resolve(true);
|
|
846
|
-
return new Promise((resolve) => {
|
|
847
|
-
let settled = false;
|
|
848
|
-
let timer;
|
|
849
|
-
const waiter = () => {
|
|
850
|
-
if (settled) return;
|
|
851
|
-
settled = true;
|
|
852
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
853
|
-
resolve(true);
|
|
854
|
-
};
|
|
855
|
-
this.waiters.push(waiter);
|
|
856
|
-
if (timeoutMs !== undefined) {
|
|
857
|
-
timer = setTimeout(() => {
|
|
858
|
-
if (settled) return;
|
|
859
|
-
settled = true;
|
|
860
|
-
const index = this.waiters.indexOf(waiter);
|
|
861
|
-
if (index >= 0) this.waiters.splice(index, 1);
|
|
862
|
-
resolve(false);
|
|
863
|
-
}, timeoutMs);
|
|
864
|
-
}
|
|
865
|
-
});
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
close() {
|
|
869
|
-
this.closed = true;
|
|
870
|
-
this.wake();
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
wake() {
|
|
874
|
-
this.waiters.splice(0).forEach((resolve) => resolve());
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
function raceWithTimeout(promise, timeoutMs, timeoutValue) {
|
|
879
|
-
let timer;
|
|
880
|
-
return new Promise((resolve, reject) => {
|
|
881
|
-
timer = setTimeout(() => resolve(timeoutValue), timeoutMs);
|
|
882
|
-
promise.then(
|
|
883
|
-
(value) => { clearTimeout(timer); resolve(value); },
|
|
884
|
-
(error) => { clearTimeout(timer); reject(error); },
|
|
885
|
-
);
|
|
886
|
-
});
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
function yieldToHostTask() {
|
|
890
|
-
if (typeof globalThis.setImmediate === "function") {
|
|
891
|
-
return new Promise((resolve) => globalThis.setImmediate(resolve));
|
|
892
|
-
}
|
|
893
|
-
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
function createRuntime(options) {
|
|
897
|
-
// Creating this inside a Wasm call would retain that instance through the error stack.
|
|
898
|
-
const abortReason = new DOMException("This operation was aborted", "AbortError");
|
|
899
|
-
const stdin = new ByteQueue();
|
|
900
|
-
const streams = new Map();
|
|
901
|
-
const httpRequests = new Set();
|
|
902
|
-
const steering = [];
|
|
903
|
-
let steeringBytes = 0;
|
|
904
|
-
let steeringOpen = false;
|
|
905
|
-
// A steer that cannot join the running turn waits to run next.
|
|
906
|
-
let steeringHandoff = false;
|
|
907
|
-
// Raw payloads beside ACP frames: the core copies inbound bytes into its
|
|
908
|
-
// own memory and publishes outbound bytes for the agent to take.
|
|
909
|
-
const inboundAttachments = new Map();
|
|
910
|
-
const outboundAttachments = new Map();
|
|
911
|
-
let nextOutboundAttachment = 1;
|
|
912
|
-
const workspaceOperations = new Set();
|
|
913
|
-
const workspace = prepareWorkspaceAdapter(options.workspace);
|
|
914
|
-
const args = ["fx", ...(options.args || [])];
|
|
915
|
-
const env = Object.entries(options.env || {}).map(([key, value]) => `${key}=${value}`);
|
|
916
|
-
let instance;
|
|
917
|
-
let nextHandle = 1;
|
|
918
|
-
let exitedResolve;
|
|
919
|
-
let exitCode = null;
|
|
920
|
-
let aborted = false;
|
|
921
|
-
let coreOutput;
|
|
922
|
-
let outputError;
|
|
923
|
-
const exited = new Promise((resolve) => { exitedResolve = resolve; });
|
|
924
|
-
const markExited = (code) => {
|
|
925
|
-
if (exitCode !== null) return;
|
|
926
|
-
exitCode = code;
|
|
927
|
-
exitedResolve(code);
|
|
928
|
-
};
|
|
929
|
-
const memory = () => instance.exports.memory;
|
|
930
|
-
const bytes = (ptr, len) => new Uint8Array(memory().buffer, ptr, len);
|
|
931
|
-
const text = (ptr, len) => decoder.decode(bytes(ptr, len));
|
|
932
|
-
const writeU32 = (ptr, value) => new DataView(memory().buffer).setUint32(ptr, value, true);
|
|
933
|
-
const writeU64 = (ptr, value) => new DataView(memory().buffer).setBigUint64(ptr, BigInt(value), true);
|
|
934
|
-
|
|
935
|
-
function checkedBytes(ptr, len) {
|
|
936
|
-
if (!Number.isInteger(ptr) || !Number.isInteger(len) || ptr < 0 || len < 0 ||
|
|
937
|
-
ptr > memory().buffer.byteLength || len > memory().buffer.byteLength - ptr) return null;
|
|
938
|
-
return bytes(ptr, len);
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
function writeVector(values, ptrs, data) {
|
|
942
|
-
let cursor = data;
|
|
943
|
-
values.forEach((value, index) => {
|
|
944
|
-
const encoded = encoder.encode(`${value}\0`);
|
|
945
|
-
writeU32(ptrs + index * 4, cursor);
|
|
946
|
-
bytes(cursor, encoded.length).set(encoded);
|
|
947
|
-
cursor += encoded.length;
|
|
948
|
-
});
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
function emitStdout(chunk) {
|
|
952
|
-
if (options.stdout) return options.stdout(chunk);
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
function fdWrite(fd, iovs, count, nwritten) {
|
|
956
|
-
if (options.traceWasi) console.error("wasi fd_write", { fd, count });
|
|
957
|
-
const view = new DataView(memory().buffer);
|
|
958
|
-
let total = 0;
|
|
959
|
-
for (let index = 0; index < count; index++) {
|
|
960
|
-
total += view.getUint32(iovs + index * 8 + 4, true);
|
|
961
|
-
}
|
|
962
|
-
if (coreOutput && total > maxCoreMessageBytes) throw new RangeError("core output message exceeds 64 MiB");
|
|
963
|
-
if (fd === 1 || fd === 2) {
|
|
964
|
-
const chunk = new Uint8Array(total);
|
|
965
|
-
let offset = 0;
|
|
966
|
-
for (let index = 0; index < count; index++) {
|
|
967
|
-
const ptr = view.getUint32(iovs + index * 8, true);
|
|
968
|
-
const len = view.getUint32(iovs + index * 8 + 4, true);
|
|
969
|
-
chunk.set(bytes(ptr, len), offset);
|
|
970
|
-
offset += len;
|
|
971
|
-
}
|
|
972
|
-
if (fd === 1) {
|
|
973
|
-
const pending = emitStdout(chunk);
|
|
974
|
-
if (coreOutput && pending) return Promise.resolve(pending).then(() => {
|
|
975
|
-
writeU32(nwritten, total);
|
|
976
|
-
return 0;
|
|
977
|
-
});
|
|
978
|
-
}
|
|
979
|
-
else if (typeof options.stderr === "function") options.stderr(chunk);
|
|
980
|
-
else console.warn(decoder.decode(chunk));
|
|
981
|
-
}
|
|
982
|
-
writeU32(nwritten, total);
|
|
983
|
-
return 0;
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
function fdRead(fd, iovs, count, nread) {
|
|
987
|
-
if (fd !== 0) return 8;
|
|
988
|
-
const attempt = () => {
|
|
989
|
-
const view = new DataView(memory().buffer);
|
|
990
|
-
let total = 0;
|
|
991
|
-
for (let index = 0; index < count; index++) {
|
|
992
|
-
const ptr = view.getUint32(iovs + index * 8, true);
|
|
993
|
-
const len = view.getUint32(iovs + index * 8 + 4, true);
|
|
994
|
-
const chunk = stdin.read(len);
|
|
995
|
-
if (!chunk) break;
|
|
996
|
-
bytes(ptr, chunk.length).set(chunk);
|
|
997
|
-
total += chunk.length;
|
|
998
|
-
if (chunk.length < len) break;
|
|
999
|
-
}
|
|
1000
|
-
if (total) { writeU32(nread, total); return 0; }
|
|
1001
|
-
return null;
|
|
1002
|
-
};
|
|
1003
|
-
const immediate = attempt();
|
|
1004
|
-
if (immediate !== null) return immediate;
|
|
1005
|
-
if (stdin.closed) { writeU32(nread, 0); return 0; }
|
|
1006
|
-
return stdin.wait().then(() => {
|
|
1007
|
-
const result = attempt();
|
|
1008
|
-
if (result !== null) return result;
|
|
1009
|
-
writeU32(nread, 0);
|
|
1010
|
-
return 0;
|
|
1011
|
-
});
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
function pollOneoff(subscriptions, events, count, nevents) {
|
|
1015
|
-
const view = new DataView(memory().buffer);
|
|
1016
|
-
for (let index = 0; index < count; index++) {
|
|
1017
|
-
const base = subscriptions + index * 48;
|
|
1018
|
-
const type = view.getUint8(base + 8);
|
|
1019
|
-
if (type === 1 && stdin.chunks.length) {
|
|
1020
|
-
bytes(events, 32).fill(0);
|
|
1021
|
-
bytes(events, 8).set(bytes(base, 8));
|
|
1022
|
-
view.setUint8(events + 10, 1);
|
|
1023
|
-
writeU32(nevents, 1);
|
|
1024
|
-
return 0;
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
let timeout = null;
|
|
1028
|
-
for (let index = 0; index < count; index++) {
|
|
1029
|
-
const base = subscriptions + index * 48;
|
|
1030
|
-
if (view.getUint8(base + 8) === 0) timeout = Number(view.getBigUint64(base + 24, true) / 1000000n);
|
|
1031
|
-
}
|
|
1032
|
-
return stdin.wait(timeout === null ? undefined : timeout).then(() => {
|
|
1033
|
-
bytes(events, 32).fill(0);
|
|
1034
|
-
bytes(events, 8).set(bytes(subscriptions, 8));
|
|
1035
|
-
writeU32(nevents, 1);
|
|
1036
|
-
return 0;
|
|
1037
|
-
});
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
function termPollInput(timeoutMs) {
|
|
1041
|
-
options.onTerminalPoll?.();
|
|
1042
|
-
if (stdin.chunks.length) return 1;
|
|
1043
|
-
if (stdin.closed) return -1;
|
|
1044
|
-
if (timeoutMs === 0) return 0;
|
|
1045
|
-
return stdin.wait(timeoutMs >= 0 ? timeoutMs : undefined).then(() =>
|
|
1046
|
-
stdin.chunks.length ? 1 : (stdin.closed ? -1 : 0));
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
function headersFromJson(ptr, len) {
|
|
1050
|
-
const headers = new Headers();
|
|
1051
|
-
for (const { name, value } of JSON.parse(text(ptr, len) || "[]")) headers.append(name, value);
|
|
1052
|
-
return headers;
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
function streamOpen(methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen) {
|
|
1056
|
-
const controller = new AbortController();
|
|
1057
|
-
const handle = nextHandle++;
|
|
1058
|
-
const state = {
|
|
1059
|
-
controller,
|
|
1060
|
-
reader: null,
|
|
1061
|
-
leftover: new Uint8Array(),
|
|
1062
|
-
response: null,
|
|
1063
|
-
responseError: null,
|
|
1064
|
-
responseSettled: null,
|
|
1065
|
-
pendingRead: null,
|
|
1066
|
-
readResult: null,
|
|
1067
|
-
readError: null,
|
|
1068
|
-
readsSinceTaskYield: 0,
|
|
1069
|
-
};
|
|
1070
|
-
streams.set(handle, state);
|
|
1071
|
-
state.responseSettled = Promise.resolve().then(() => options.fetch(text(urlPtr, urlLen), {
|
|
1072
|
-
method: text(methodPtr, methodLen),
|
|
1073
|
-
headers: headersFromJson(headersPtr, headersLen),
|
|
1074
|
-
body: bodyLen ? bytes(bodyPtr, bodyLen).slice() : undefined,
|
|
1075
|
-
signal: controller.signal,
|
|
1076
|
-
})).then((response) => {
|
|
1077
|
-
state.response = response;
|
|
1078
|
-
state.reader = response.body?.getReader() || null;
|
|
1079
|
-
}).catch((error) => {
|
|
1080
|
-
state.responseError = error;
|
|
1081
|
-
});
|
|
1082
|
-
return handle;
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
function streamStatus(handle, statusOut) {
|
|
1086
|
-
const state = streams.get(handle);
|
|
1087
|
-
if (!state) return -1;
|
|
1088
|
-
const settled = () => {
|
|
1089
|
-
if (state.responseError) return state.responseError?.name === "AbortError" ? -2 : -1;
|
|
1090
|
-
if (!state.response) return 0;
|
|
1091
|
-
new DataView(memory().buffer).setUint16(statusOut, state.response.status, true);
|
|
1092
|
-
return 1;
|
|
1093
|
-
};
|
|
1094
|
-
const immediate = settled();
|
|
1095
|
-
if (immediate !== 0) return immediate;
|
|
1096
|
-
return raceWithTimeout(state.responseSettled.then(() => true), 50, false).then((ready) =>
|
|
1097
|
-
ready ? settled() : 0);
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
function streamNext(handle, outPtr, outCap) {
|
|
1101
|
-
const state = streams.get(handle);
|
|
1102
|
-
if (!state) return -1;
|
|
1103
|
-
const yieldAfterReadyResult = (result) => {
|
|
1104
|
-
if (result <= 0) return result;
|
|
1105
|
-
state.readsSinceTaskYield += 1;
|
|
1106
|
-
if (state.readsSinceTaskYield < streamReadsPerTaskYield) return result;
|
|
1107
|
-
state.readsSinceTaskYield = 0;
|
|
1108
|
-
return yieldToHostTask().then(() => result);
|
|
1109
|
-
};
|
|
1110
|
-
const copy = (chunk) => {
|
|
1111
|
-
const written = chunk.subarray(0, outCap);
|
|
1112
|
-
bytes(outPtr, written.length).set(written);
|
|
1113
|
-
state.leftover = chunk.subarray(written.length);
|
|
1114
|
-
return written.length;
|
|
1115
|
-
};
|
|
1116
|
-
const consume = () => {
|
|
1117
|
-
if (state.leftover.length) return copy(state.leftover);
|
|
1118
|
-
if (state.readError) return state.readError?.name === "AbortError" ? -2 : -1;
|
|
1119
|
-
if (!state.readResult) return null;
|
|
1120
|
-
const { done, value } = state.readResult;
|
|
1121
|
-
state.readResult = null;
|
|
1122
|
-
if (done) return 0;
|
|
1123
|
-
if (!value?.length) return null;
|
|
1124
|
-
options.onTransportChunk?.(value.length);
|
|
1125
|
-
return copy(value);
|
|
1126
|
-
};
|
|
1127
|
-
const immediate = consume();
|
|
1128
|
-
if (immediate !== null) return yieldAfterReadyResult(immediate);
|
|
1129
|
-
if (!state.reader) return 0;
|
|
1130
|
-
if (!state.pendingRead) {
|
|
1131
|
-
state.pendingRead = state.reader.read().then((result) => {
|
|
1132
|
-
state.readResult = result;
|
|
1133
|
-
state.pendingRead = null;
|
|
1134
|
-
}).catch((error) => {
|
|
1135
|
-
state.readError = error;
|
|
1136
|
-
state.pendingRead = null;
|
|
1137
|
-
});
|
|
1138
|
-
}
|
|
1139
|
-
return raceWithTimeout(state.pendingRead.then(() => true), 50, false).then((ready) => {
|
|
1140
|
-
if (!ready) return -3;
|
|
1141
|
-
const result = consume();
|
|
1142
|
-
return result === null ? -3 : yieldAfterReadyResult(result);
|
|
1143
|
-
});
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
function httpRequest(methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen, statusOut, responsePtr, responseCap) {
|
|
1147
|
-
const controller = new AbortController();
|
|
1148
|
-
httpRequests.add(controller);
|
|
1149
|
-
let onAbort;
|
|
1150
|
-
const cancelled = new Promise((resolve) => {
|
|
1151
|
-
onAbort = () => resolve(-1);
|
|
1152
|
-
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
1153
|
-
});
|
|
1154
|
-
const request = (async () => {
|
|
1155
|
-
const response = await options.fetch(text(urlPtr, urlLen), {
|
|
1156
|
-
method: text(methodPtr, methodLen),
|
|
1157
|
-
headers: headersFromJson(headersPtr, headersLen),
|
|
1158
|
-
body: bodyLen ? bytes(bodyPtr, bodyLen).slice() : undefined,
|
|
1159
|
-
signal: controller.signal,
|
|
1160
|
-
});
|
|
1161
|
-
if (controller.signal.aborted) {
|
|
1162
|
-
void cancelResponseBody(response);
|
|
1163
|
-
return -1;
|
|
1164
|
-
}
|
|
1165
|
-
const body = new Uint8Array(await response.arrayBuffer());
|
|
1166
|
-
if (controller.signal.aborted) return -1;
|
|
1167
|
-
new DataView(memory().buffer).setUint16(statusOut, response.status, true);
|
|
1168
|
-
if (body.length > responseCap) return -2;
|
|
1169
|
-
bytes(responsePtr, body.length).set(body);
|
|
1170
|
-
return body.length;
|
|
1171
|
-
})().catch(() => -1);
|
|
1172
|
-
return Promise.race([request, cancelled]).finally(() => {
|
|
1173
|
-
controller.signal.removeEventListener("abort", onAbort);
|
|
1174
|
-
httpRequests.delete(controller);
|
|
1175
|
-
});
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
function clearSteering() {
|
|
1179
|
-
steering.length = 0;
|
|
1180
|
-
steeringBytes = 0;
|
|
1181
|
-
steeringHandoff = false;
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
function queueSteering(text, id = "") {
|
|
1185
|
-
if (!steeringOpen) throw new Error("no prompt is running");
|
|
1186
|
-
const value = encoder.encode(text);
|
|
1187
|
-
if (value.length === 0) throw new TypeError("steering text cannot be empty");
|
|
1188
|
-
if (value.length > maxSteeringMessageBytes) {
|
|
1189
|
-
throw new RangeError(`steering text exceeds the ${maxSteeringMessageBytes} byte libfx limit`);
|
|
1190
|
-
}
|
|
1191
|
-
if (steering.length >= maxSteeringMessages || value.length > maxSteeringQueueBytes - steeringBytes) {
|
|
1192
|
-
throw new Error("steering queue is full");
|
|
1193
|
-
}
|
|
1194
|
-
steering.push({ id, value });
|
|
1195
|
-
steeringBytes += value.length;
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
// The core takes queued steering at a boundary; until then it can be withdrawn.
|
|
1199
|
-
function withdrawSteering(id) {
|
|
1200
|
-
const index = steering.findIndex((entry) => entry.id === id);
|
|
1201
|
-
if (index < 0) return false;
|
|
1202
|
-
steeringBytes -= steering[index].value.length;
|
|
1203
|
-
steering.splice(index, 1);
|
|
1204
|
-
return true;
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
// Each message is its input id, a newline, then the text.
|
|
1208
|
-
function steeringTake(outputPtr, outputCap) {
|
|
1209
|
-
const entry = steering[0];
|
|
1210
|
-
if (!entry) return 0;
|
|
1211
|
-
const output = checkedBytes(outputPtr, outputCap);
|
|
1212
|
-
const id = encoder.encode(entry.id);
|
|
1213
|
-
const length = id.length + 1 + entry.value.length;
|
|
1214
|
-
if (!output || length > output.length) return -1;
|
|
1215
|
-
output.set(id, 0);
|
|
1216
|
-
output[id.length] = 10;
|
|
1217
|
-
output.set(entry.value, id.length + 1);
|
|
1218
|
-
steering.shift();
|
|
1219
|
-
steeringBytes -= entry.value.length;
|
|
1220
|
-
return length;
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
function writeAttachment(id, data) {
|
|
1224
|
-
if (inboundAttachments.size >= maxPendingAttachments) throw new Error("attachment table is full");
|
|
1225
|
-
inboundAttachments.set(id, data.slice());
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
function attachmentSize(id) {
|
|
1229
|
-
return inboundAttachments.get(id >>> 0)?.length ?? -1;
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
function attachmentTake(id, outputPtr, outputCap) {
|
|
1233
|
-
const key = id >>> 0;
|
|
1234
|
-
const value = inboundAttachments.get(key);
|
|
1235
|
-
if (!value) return -1;
|
|
1236
|
-
// An empty payload needs no output buffer, whose pointer may be arbitrary.
|
|
1237
|
-
if (value.length > 0) {
|
|
1238
|
-
const output = checkedBytes(outputPtr, outputCap);
|
|
1239
|
-
if (!output || value.length > output.length) return -1;
|
|
1240
|
-
output.set(value);
|
|
1241
|
-
}
|
|
1242
|
-
inboundAttachments.delete(key);
|
|
1243
|
-
return value.length;
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
function attachmentPut(inputPtr, inputLen) {
|
|
1247
|
-
const input = checkedBytes(inputPtr, inputLen);
|
|
1248
|
-
if (!input || outboundAttachments.size >= maxOutboundAttachments) return -1;
|
|
1249
|
-
const id = nextOutboundAttachment;
|
|
1250
|
-
nextOutboundAttachment = id === 0x7fffffff ? 1 : id + 1;
|
|
1251
|
-
outboundAttachments.set(id, input.slice());
|
|
1252
|
-
return id;
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
// Resolves once the agent's journal holds every event the core sent.
|
|
1256
|
-
function journalFlush() {
|
|
1257
|
-
if (typeof options.journalFlush !== "function") return -1;
|
|
1258
|
-
return Promise.resolve().then(() => options.journalFlush()).then(() => 0, () => -1);
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
let pendingHostToolResult = null;
|
|
1262
|
-
function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr, callIdPtr, callIdLen) {
|
|
1263
|
-
pendingHostToolResult = null;
|
|
1264
|
-
if (typeof options.hostToolExecutor !== "function") return -1;
|
|
1265
|
-
if (options.traceWasi) console.error("fx host tool call start");
|
|
1266
|
-
let input;
|
|
1267
|
-
try { input = JSON.parse(text(argumentsPtr, argumentsLen)); } catch { return -1; }
|
|
1268
|
-
return Promise.resolve(options.hostToolExecutor(text(namePtr, nameLen), input, undefined, text(callIdPtr, callIdLen))).then((result) => {
|
|
1269
|
-
if (options.traceWasi) console.error("fx host tool call settled", result.cancelled, result.isError);
|
|
1270
|
-
if (result.cancelled) return -2;
|
|
1271
|
-
const output = encoder.encode(result.content);
|
|
1272
|
-
bytes(statusPtr, 1)[0] = (result.isError ? 1 : 0) + (result.rich ? 2 : 0);
|
|
1273
|
-
if (output.length > outputCap) {
|
|
1274
|
-
if (!result.rich || output.length > 8 * 1024 * 1024) return -3;
|
|
1275
|
-
pendingHostToolResult = output;
|
|
1276
|
-
return output.length;
|
|
1277
|
-
}
|
|
1278
|
-
bytes(outputPtr, output.length).set(output);
|
|
1279
|
-
return output.length;
|
|
1280
|
-
}).catch(() => -1);
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
function openUrl(urlPtr, urlLen) {
|
|
1284
|
-
if (typeof options.openUrl !== "function") return 0;
|
|
1285
|
-
return Promise.resolve().then(() => options.openUrl(text(urlPtr, urlLen))).then((accepted) =>
|
|
1286
|
-
accepted === false ? 0 : 1).catch(() => 0);
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
function clipboardCopy(valuePtr, valueLen) {
|
|
1290
|
-
let clipboard = options.clipboard;
|
|
1291
|
-
if (clipboard === undefined) {
|
|
1292
|
-
try { clipboard = globalThis.navigator?.clipboard; } catch { clipboard = null; }
|
|
1293
|
-
}
|
|
1294
|
-
if (typeof clipboard?.writeText !== "function") return Promise.resolve(0);
|
|
1295
|
-
const value = text(valuePtr, valueLen);
|
|
1296
|
-
return Promise.resolve().then(() => clipboard.writeText(value)).then((accepted) => {
|
|
1297
|
-
if (accepted === false) return 0;
|
|
1298
|
-
options.emit?.("clipboard.copy", { length: value.length });
|
|
1299
|
-
return 1;
|
|
1300
|
-
}).catch((error) => {
|
|
1301
|
-
options.emit?.("clipboard.copy_error", { error });
|
|
1302
|
-
return 0;
|
|
1303
|
-
});
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
function oauthSessionLoad(outPtr, outCap, revisionPtr, revisionCap, revisionLenOut) {
|
|
1307
|
-
if (!options.oauthSessionStore?.load) return -1;
|
|
1308
|
-
return Promise.resolve().then(() => options.oauthSessionStore.load()).then((record) => {
|
|
1309
|
-
if (!record) return -2;
|
|
1310
|
-
const value = record.bytes instanceof Uint8Array ? record.bytes : new Uint8Array(record.bytes);
|
|
1311
|
-
if (typeof record.revision !== "string") return -1;
|
|
1312
|
-
const revision = encoder.encode(record.revision);
|
|
1313
|
-
if (value.length > outCap || revision.length > revisionCap) return -3;
|
|
1314
|
-
bytes(outPtr, value.length).set(value);
|
|
1315
|
-
bytes(revisionPtr, revision.length).set(revision);
|
|
1316
|
-
writeU32(revisionLenOut, revision.length);
|
|
1317
|
-
return value.length;
|
|
1318
|
-
}).catch(() => -1);
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
function oauthSessionCommit(valuePtr, valueLen, expectedPtr, expectedLen, revisionPtr, revisionCap, revisionLenOut) {
|
|
1322
|
-
if (!options.oauthSessionStore?.commit) return -1;
|
|
1323
|
-
const expectedRevision = expectedLen ? text(expectedPtr, expectedLen) : undefined;
|
|
1324
|
-
const value = bytes(valuePtr, valueLen).slice();
|
|
1325
|
-
return Promise.resolve().then(() => options.oauthSessionStore.commit(value, expectedRevision)).then((result) => {
|
|
1326
|
-
if (typeof result?.revision !== "string") return -1;
|
|
1327
|
-
const revision = encoder.encode(result.revision);
|
|
1328
|
-
if (revision.length > revisionCap) return -1;
|
|
1329
|
-
bytes(revisionPtr, revision.length).set(revision);
|
|
1330
|
-
writeU32(revisionLenOut, revision.length);
|
|
1331
|
-
return 0;
|
|
1332
|
-
}).catch((error) => error?.code === "FX_OAUTH_SESSION_REVISION_CONFLICT" ? -2 : -1);
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
function oauthSessionRemove(expectedPtr, expectedLen) {
|
|
1336
|
-
if (!options.oauthSessionStore?.remove) return -1;
|
|
1337
|
-
const expectedRevision = expectedLen ? text(expectedPtr, expectedLen) : undefined;
|
|
1338
|
-
return Promise.resolve().then(() => options.oauthSessionStore.remove(expectedRevision)).then((result) =>
|
|
1339
|
-
result === false || result === "missing" ? 1 : 0
|
|
1340
|
-
).catch((error) => error?.code === "FX_OAUTH_SESSION_REVISION_CONFLICT" ? -2 : -1);
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
function configGet(idPtr, idLen, outPtr, outCap) {
|
|
1344
|
-
if (!options.configStore?.get) return -2;
|
|
1345
|
-
const configId = text(idPtr, idLen);
|
|
1346
|
-
return Promise.resolve().then(() => options.configStore.get(configId)).then((value) => {
|
|
1347
|
-
if (value === null || value === undefined) return -2;
|
|
1348
|
-
if (typeof value !== "string") throw new TypeError("configStore.get() must return a string or null");
|
|
1349
|
-
const encoded = encoder.encode(value);
|
|
1350
|
-
if (encoded.length > outCap) return -3;
|
|
1351
|
-
bytes(outPtr, encoded.length).set(encoded);
|
|
1352
|
-
options.emit?.("config.restore", { configId, value });
|
|
1353
|
-
return encoded.length;
|
|
1354
|
-
}).catch((error) => {
|
|
1355
|
-
options.emit?.("config.restore_error", { configId, error });
|
|
1356
|
-
return -1;
|
|
1357
|
-
});
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
function configSet(idPtr, idLen, valuePtr, valueLen) {
|
|
1361
|
-
if (!options.configStore?.set) return 0;
|
|
1362
|
-
const configId = text(idPtr, idLen);
|
|
1363
|
-
const value = text(valuePtr, valueLen);
|
|
1364
|
-
return Promise.resolve().then(() => options.configStore.set(configId, value)).then(() => {
|
|
1365
|
-
options.emit?.("config.changed", { configId, value, source: "terminal" });
|
|
1366
|
-
return 0;
|
|
1367
|
-
}).catch((error) => {
|
|
1368
|
-
options.emit?.("config.persist_error", { configId, error });
|
|
1369
|
-
return -1;
|
|
1370
|
-
});
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
function promptHistoryLoad(workspacePtr, workspaceLen, limit, outPtr, outCap) {
|
|
1374
|
-
if (!options.promptHistoryStore?.load) return -1;
|
|
1375
|
-
const workspaceRoot = text(workspacePtr, workspaceLen);
|
|
1376
|
-
return Promise.resolve().then(() => options.promptHistoryStore.load(workspaceRoot, limit)).then((entries) => {
|
|
1377
|
-
if (!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string")) {
|
|
1378
|
-
throw new TypeError("promptHistoryStore.load() must return an array of strings");
|
|
1379
|
-
}
|
|
1380
|
-
const value = encoder.encode(JSON.stringify(entries));
|
|
1381
|
-
if (value.length > outCap) return -2;
|
|
1382
|
-
bytes(outPtr, value.length).set(value);
|
|
1383
|
-
options.emit?.("history.restore", { workspaceRoot, count: entries.length });
|
|
1384
|
-
return value.length;
|
|
1385
|
-
}).catch((error) => {
|
|
1386
|
-
options.emit?.("history.restore_error", { workspaceRoot, error });
|
|
1387
|
-
return -1;
|
|
1388
|
-
});
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
function promptHistoryAppend(timestampMs, workspacePtr, workspaceLen, valuePtr, valueLen) {
|
|
1392
|
-
if (!options.promptHistoryStore?.append) return -1;
|
|
1393
|
-
const workspaceRoot = text(workspacePtr, workspaceLen);
|
|
1394
|
-
const value = text(valuePtr, valueLen);
|
|
1395
|
-
return Promise.resolve().then(() => options.promptHistoryStore.append(workspaceRoot, value, Number(timestampMs))).then((result) => {
|
|
1396
|
-
options.emit?.("history.append", { workspaceRoot });
|
|
1397
|
-
if (result === "duplicate") return 1;
|
|
1398
|
-
if (result === "record_too_large") return 2;
|
|
1399
|
-
return 0;
|
|
1400
|
-
}).catch((error) => {
|
|
1401
|
-
options.emit?.("history.append_error", { workspaceRoot, error });
|
|
1402
|
-
return -1;
|
|
1403
|
-
});
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
function promptHistoryClear(workspacePtr, workspaceLen) {
|
|
1407
|
-
if (!options.promptHistoryStore?.clear) return -1;
|
|
1408
|
-
const workspaceRoot = text(workspacePtr, workspaceLen);
|
|
1409
|
-
return Promise.resolve().then(() => options.promptHistoryStore.clear(workspaceRoot)).then(() => {
|
|
1410
|
-
options.emit?.("history.clear", { workspaceRoot });
|
|
1411
|
-
return 0;
|
|
1412
|
-
}).catch((error) => {
|
|
1413
|
-
options.emit?.("history.clear_error", { workspaceRoot, error });
|
|
1414
|
-
return -1;
|
|
1415
|
-
});
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
function sessionLoad(idPtr, idLen, outPtr, outCap, revisionPtr, revisionCap, revisionLenOut) {
|
|
1419
|
-
if (!options.sessionStore) return -1;
|
|
1420
|
-
return Promise.resolve().then(() => options.sessionStore.load(text(idPtr, idLen))).then((record) => {
|
|
1421
|
-
if (!record) return -2;
|
|
1422
|
-
const value = record.bytes instanceof Uint8Array ? record.bytes : new Uint8Array(record.bytes);
|
|
1423
|
-
const revision = encoder.encode(record.revision);
|
|
1424
|
-
if (value.length > outCap || revision.length > revisionCap) return -3;
|
|
1425
|
-
bytes(outPtr, value.length).set(value);
|
|
1426
|
-
bytes(revisionPtr, revision.length).set(revision);
|
|
1427
|
-
writeU32(revisionLenOut, revision.length);
|
|
1428
|
-
return value.length;
|
|
1429
|
-
}).catch(() => -1);
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
function sessionCommit(idPtr, idLen, valuePtr, valueLen, expectedPtr, expectedLen, revisionPtr, revisionCap, revisionLenOut) {
|
|
1433
|
-
if (!options.sessionStore) return -1;
|
|
1434
|
-
const id = text(idPtr, idLen);
|
|
1435
|
-
const expectedRevision = expectedLen ? text(expectedPtr, expectedLen) : undefined;
|
|
1436
|
-
return Promise.resolve().then(() => options.sessionStore.commit(id, bytes(valuePtr, valueLen).slice(), expectedRevision)).then((result) => {
|
|
1437
|
-
const revision = encoder.encode(result.revision);
|
|
1438
|
-
if (revision.length > revisionCap) return -1;
|
|
1439
|
-
bytes(revisionPtr, revision.length).set(revision);
|
|
1440
|
-
writeU32(revisionLenOut, revision.length);
|
|
1441
|
-
return 0;
|
|
1442
|
-
}).catch((error) => error?.code === "FX_SESSION_REVISION_CONFLICT" ? -2 : -1);
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
function sessionList(outPtr, outCap) {
|
|
1446
|
-
if (!options.sessionStore) return -1;
|
|
1447
|
-
return Promise.resolve().then(() => options.sessionStore.list()).then((records) => {
|
|
1448
|
-
const value = encoder.encode(JSON.stringify(records));
|
|
1449
|
-
if (value.length > outCap) return -2;
|
|
1450
|
-
bytes(outPtr, value.length).set(value);
|
|
1451
|
-
return value.length;
|
|
1452
|
-
}).catch(() => -1);
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
function sessionRemove(idPtr, idLen) {
|
|
1456
|
-
if (!options.sessionStore) return -1;
|
|
1457
|
-
return Promise.resolve().then(() => options.sessionStore.remove(text(idPtr, idLen))).then(() => 0).catch(() => -1);
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
function workspaceInfo(outPtr, outCap) {
|
|
1461
|
-
if (!workspace.present) return -2;
|
|
1462
|
-
if (!workspace.valid) return -4;
|
|
1463
|
-
const output = checkedBytes(outPtr, outCap);
|
|
1464
|
-
if (!output) return -4;
|
|
1465
|
-
if (workspace.encoded.length > outCap) return -3;
|
|
1466
|
-
output.set(workspace.encoded);
|
|
1467
|
-
return workspace.encoded.length;
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
function workspaceExec(commandPtr, commandLen, timeoutMs, outputPtr, outputCap, resultPtr) {
|
|
1471
|
-
if (!workspace.present) return Promise.resolve(-2);
|
|
1472
|
-
if (!workspace.valid) return Promise.resolve(-4);
|
|
1473
|
-
const commandBytes = checkedBytes(commandPtr, commandLen);
|
|
1474
|
-
const output = checkedBytes(outputPtr, outputCap);
|
|
1475
|
-
const resultBytes = checkedBytes(resultPtr, 32);
|
|
1476
|
-
if (!commandBytes || !output || !resultBytes || commandLen > workspaceCommandLimit ||
|
|
1477
|
-
outputCap > workspaceOutputLimit || !Number.isInteger(timeoutMs) ||
|
|
1478
|
-
timeoutMs < 1 || timeoutMs > 30_000) return Promise.resolve(-4);
|
|
1479
|
-
let command;
|
|
1480
|
-
try {
|
|
1481
|
-
command = strictDecoder.decode(commandBytes);
|
|
1482
|
-
} catch {
|
|
1483
|
-
return Promise.resolve(-4);
|
|
1484
|
-
}
|
|
1485
|
-
if (command.includes("\0")) return Promise.resolve(-4);
|
|
1486
|
-
|
|
1487
|
-
return runWorkspaceOperation(timeoutMs, "workspace command", (signal) => workspace.adapter.exec({
|
|
1488
|
-
command,
|
|
1489
|
-
cwd: workspace.info.cwd,
|
|
1490
|
-
signal,
|
|
1491
|
-
timeoutMs,
|
|
1492
|
-
outputLimitBytes: workspaceOutputLimit,
|
|
1493
|
-
}), (value) => {
|
|
1494
|
-
if (!value || !Number.isInteger(value.exitCode) || value.exitCode < -0x80000000 ||
|
|
1495
|
-
value.exitCode > 0x7fffffff || typeof value.stdout !== "string" ||
|
|
1496
|
-
typeof value.stderr !== "string") return -1;
|
|
1497
|
-
const stdout = encoder.encode(value.stdout);
|
|
1498
|
-
const stderr = encoder.encode(value.stderr);
|
|
1499
|
-
if (stdout.length > 0xffffffff || stderr.length > 0xffffffff) return -1;
|
|
1500
|
-
|
|
1501
|
-
let stdoutCap = stdout.length;
|
|
1502
|
-
let stderrCap = stderr.length;
|
|
1503
|
-
if (stdout.length + stderr.length > outputCap) {
|
|
1504
|
-
stdoutCap = Math.min(stdout.length, Math.ceil(outputCap / 2));
|
|
1505
|
-
stderrCap = Math.min(stderr.length, Math.floor(outputCap / 2));
|
|
1506
|
-
let remaining = outputCap - stdoutCap - stderrCap;
|
|
1507
|
-
const stdoutExtra = Math.min(remaining, stdout.length - stdoutCap);
|
|
1508
|
-
stdoutCap += stdoutExtra;
|
|
1509
|
-
remaining -= stdoutExtra;
|
|
1510
|
-
stderrCap += Math.min(remaining, stderr.length - stderrCap);
|
|
1511
|
-
}
|
|
1512
|
-
const stdoutPreview = utf8Prefix(stdout, stdoutCap);
|
|
1513
|
-
const stderrPreview = utf8Prefix(stderr, stderrCap);
|
|
1514
|
-
output.set(stdoutPreview, 0);
|
|
1515
|
-
output.set(stderrPreview, stdoutPreview.length);
|
|
1516
|
-
const copied = stdoutPreview.length + stderrPreview.length;
|
|
1517
|
-
const view = new DataView(memory().buffer, resultPtr, 32);
|
|
1518
|
-
view.setInt32(0, value.exitCode, true);
|
|
1519
|
-
view.setUint32(4, 0, true);
|
|
1520
|
-
view.setUint32(8, stdoutPreview.length, true);
|
|
1521
|
-
view.setUint32(12, stdout.length, true);
|
|
1522
|
-
view.setUint32(16, stdoutPreview.length, true);
|
|
1523
|
-
view.setUint32(20, stderrPreview.length, true);
|
|
1524
|
-
view.setUint32(24, stderr.length, true);
|
|
1525
|
-
view.setUint32(28, copied < stdout.length + stderr.length ? 1 : 0, true);
|
|
1526
|
-
return 0;
|
|
1527
|
-
});
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
function workspaceReadFile(pathPtr, pathLen, outPtr, outCap, resultPtr) {
|
|
1531
|
-
if (!workspace.present || (workspace.valid && typeof workspace.adapter.readFile !== "function")) {
|
|
1532
|
-
return Promise.resolve(-2);
|
|
1533
|
-
}
|
|
1534
|
-
if (!workspace.valid) return Promise.resolve(-4);
|
|
1535
|
-
const pathBytes = checkedBytes(pathPtr, pathLen);
|
|
1536
|
-
if (!pathBytes || !checkedBytes(outPtr, outCap) || !checkedBytes(resultPtr, 8) ||
|
|
1537
|
-
pathLen > workspaceInstructionPathLimit || outCap > workspaceInstructionFileLimit) {
|
|
1538
|
-
return Promise.resolve(-4);
|
|
1539
|
-
}
|
|
1540
|
-
let path;
|
|
1541
|
-
try {
|
|
1542
|
-
path = strictDecoder.decode(pathBytes);
|
|
1543
|
-
} catch {
|
|
1544
|
-
return Promise.resolve(-4);
|
|
1545
|
-
}
|
|
1546
|
-
if (!workspaceInstructionPathAllowed(workspace.info, path)) return Promise.resolve(-4);
|
|
1547
|
-
|
|
1548
|
-
return runWorkspaceOperation(workspaceReadTimeoutMs, "workspace read", (signal) => workspace.adapter.readFile({
|
|
1549
|
-
path,
|
|
1550
|
-
signal,
|
|
1551
|
-
}), (value) => {
|
|
1552
|
-
if (value == null) return -6;
|
|
1553
|
-
let content;
|
|
1554
|
-
if (typeof value === "string") {
|
|
1555
|
-
content = encoder.encode(value);
|
|
1556
|
-
} else if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
|
1557
|
-
content = value instanceof ArrayBuffer
|
|
1558
|
-
? new Uint8Array(value)
|
|
1559
|
-
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
1560
|
-
try {
|
|
1561
|
-
strictDecoder.decode(content);
|
|
1562
|
-
} catch {
|
|
1563
|
-
return -7;
|
|
1564
|
-
}
|
|
1565
|
-
} else {
|
|
1566
|
-
return -1;
|
|
1567
|
-
}
|
|
1568
|
-
if (content.length > 0xffffffff) return -1;
|
|
1569
|
-
const copied = Math.min(content.length, outCap);
|
|
1570
|
-
// Reacquire views: memory may have grown while the host read was pending.
|
|
1571
|
-
bytes(outPtr, copied).set(content.subarray(0, copied));
|
|
1572
|
-
const view = new DataView(memory().buffer, resultPtr, 8);
|
|
1573
|
-
view.setUint32(0, content.length, true);
|
|
1574
|
-
view.setUint32(4, copied, true);
|
|
1575
|
-
return 0;
|
|
1576
|
-
});
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
// Runs one host workspace effect under a deadline and the shared Ctrl+C
|
|
1580
|
-
// abort path. `finish` maps the host value to an ABI status.
|
|
1581
|
-
function runWorkspaceOperation(timeoutMs, label, start, finish) {
|
|
1582
|
-
const controller = new AbortController();
|
|
1583
|
-
let resolveAbort;
|
|
1584
|
-
const aborted = new Promise((resolve) => { resolveAbort = resolve; });
|
|
1585
|
-
const state = {
|
|
1586
|
-
status: null,
|
|
1587
|
-
abort(status) {
|
|
1588
|
-
if (this.status !== null) return;
|
|
1589
|
-
this.status = status;
|
|
1590
|
-
resolveAbort(status);
|
|
1591
|
-
controller.abort(new DOMException(
|
|
1592
|
-
status === -5 ? `${label} timed out` : `${label} aborted`,
|
|
1593
|
-
status === -5 ? "TimeoutError" : "AbortError",
|
|
1594
|
-
));
|
|
1595
|
-
},
|
|
1596
|
-
};
|
|
1597
|
-
workspaceOperations.add(state);
|
|
1598
|
-
const timer = setTimeout(() => state.abort(-5), timeoutMs);
|
|
1599
|
-
const execution = Promise.resolve().then(() => start(controller.signal)).then((value) => {
|
|
1600
|
-
if (state.status !== null) return state.status;
|
|
1601
|
-
return finish(value);
|
|
1602
|
-
}).catch((error) => {
|
|
1603
|
-
if (state.status !== null) return state.status;
|
|
1604
|
-
if (error?.name === "TimeoutError") return -5;
|
|
1605
|
-
if (error?.name === "AbortError") return -3;
|
|
1606
|
-
return -1;
|
|
1607
|
-
});
|
|
1608
|
-
return Promise.race([execution, aborted]).finally(() => {
|
|
1609
|
-
clearTimeout(timer);
|
|
1610
|
-
workspaceOperations.delete(state);
|
|
1611
|
-
});
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
function abortHostEffects() {
|
|
1615
|
-
pendingHostToolResult = null;
|
|
1616
|
-
streams.forEach((state) => state.controller.abort(abortReason));
|
|
1617
|
-
httpRequests.forEach((controller) => controller.abort(abortReason));
|
|
1618
|
-
workspaceOperations.forEach((state) => state.abort(-3));
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
|
-
const unavailable = () => 52;
|
|
1622
|
-
const wasi = {
|
|
1623
|
-
args_sizes_get(count, size) { if (options.traceWasi) console.error("wasi args_sizes_get"); writeU32(count, args.length); writeU32(size, args.reduce((n, v) => n + encoder.encode(v).length + 1, 0)); return 0; },
|
|
1624
|
-
args_get(ptrs, data) { if (options.traceWasi) console.error("wasi args_get"); writeVector(args, ptrs, data); return 0; },
|
|
1625
|
-
environ_sizes_get(count, size) { if (options.traceWasi) console.error("wasi environ_sizes_get"); writeU32(count, env.length); writeU32(size, env.reduce((n, v) => n + encoder.encode(v).length + 1, 0)); return 0; },
|
|
1626
|
-
environ_get(ptrs, data) { if (options.traceWasi) console.error("wasi environ_get"); writeVector(env, ptrs, data); return 0; },
|
|
1627
|
-
fd_write: options.args?.[0] === "acp" ? new WebAssembly.Suspending(fdWrite) : fdWrite,
|
|
1628
|
-
fd_read: new WebAssembly.Suspending(fdRead),
|
|
1629
|
-
fd_close() { return 0; },
|
|
1630
|
-
fd_fdstat_get(fd, out) {
|
|
1631
|
-
if (options.traceWasi) console.error("wasi fd_fdstat_get", fd);
|
|
1632
|
-
bytes(out, 24).fill(0);
|
|
1633
|
-
const view = new DataView(memory().buffer);
|
|
1634
|
-
view.setUint8(out, fd <= 2 ? 2 : 0);
|
|
1635
|
-
view.setBigUint64(out + 8, 0xffffffffffffffffn, true);
|
|
1636
|
-
view.setBigUint64(out + 16, 0xffffffffffffffffn, true);
|
|
1637
|
-
return 0;
|
|
1638
|
-
},
|
|
1639
|
-
fd_filestat_get: unavailable,
|
|
1640
|
-
fd_filestat_set_size: unavailable,
|
|
1641
|
-
fd_filestat_set_times: unavailable,
|
|
1642
|
-
fd_pread: unavailable,
|
|
1643
|
-
fd_prestat_get() { return 8; },
|
|
1644
|
-
fd_prestat_dir_name: unavailable,
|
|
1645
|
-
fd_pwrite: unavailable,
|
|
1646
|
-
fd_readdir: unavailable,
|
|
1647
|
-
fd_seek() { return 29; },
|
|
1648
|
-
fd_sync() { return 0; },
|
|
1649
|
-
clock_res_get(_id, out) { if (options.traceWasi) console.error("wasi clock_res_get"); writeU64(out, 1000000n); return 0; },
|
|
1650
|
-
clock_time_get(_id, _precision, out) { if (options.traceWasi) console.error("wasi clock_time_get"); writeU64(out, BigInt(Date.now()) * 1000000n); return 0; },
|
|
1651
|
-
path_create_directory: unavailable,
|
|
1652
|
-
path_filestat_get: unavailable,
|
|
1653
|
-
path_filestat_set_times: unavailable,
|
|
1654
|
-
path_link: unavailable,
|
|
1655
|
-
path_open: unavailable,
|
|
1656
|
-
path_readlink: unavailable,
|
|
1657
|
-
path_remove_directory: unavailable,
|
|
1658
|
-
path_rename: unavailable,
|
|
1659
|
-
path_symlink: unavailable,
|
|
1660
|
-
path_unlink_file: unavailable,
|
|
1661
|
-
random_get(ptr, len) { crypto.getRandomValues(bytes(ptr, len)); return 0; },
|
|
1662
|
-
poll_oneoff: new WebAssembly.Suspending(pollOneoff),
|
|
1663
|
-
proc_exit(code) { if (options.traceWasi) console.error("wasi proc_exit", code); markExited(code); throw new WebAssembly.RuntimeError(`proc_exit(${code})`); },
|
|
1664
|
-
};
|
|
1665
|
-
|
|
1666
|
-
const fx = {
|
|
1667
|
-
fx_term_poll_input: new WebAssembly.Suspending(termPollInput),
|
|
1668
|
-
fx_clipboard_copy: new WebAssembly.Suspending(clipboardCopy),
|
|
1669
|
-
fx_prompt_history_available() { return options.promptHistoryStore ? 1 : 0; },
|
|
1670
|
-
fx_workspace_available() { return workspace.present ? 1 : 0; },
|
|
1671
|
-
fx_workspace_info: workspaceInfo,
|
|
1672
|
-
fx_workspace_exec: new WebAssembly.Suspending(workspaceExec),
|
|
1673
|
-
fx_workspace_read_file: new WebAssembly.Suspending(workspaceReadFile),
|
|
1674
|
-
fx_http_stream_open: streamOpen,
|
|
1675
|
-
fx_http_stream_status: new WebAssembly.Suspending(streamStatus),
|
|
1676
|
-
fx_http_stream_next: new WebAssembly.Suspending(streamNext),
|
|
1677
|
-
fx_http_stream_close(handle) { const state = streams.get(handle); state?.controller.abort(abortReason); streams.delete(handle); },
|
|
1678
|
-
fx_http_request: new WebAssembly.Suspending(httpRequest),
|
|
1679
|
-
fx_host_tool_call: new WebAssembly.Suspending(hostToolCall),
|
|
1680
|
-
fx_journal_flush: new WebAssembly.Suspending(journalFlush),
|
|
1681
|
-
fx_host_tool_result_read(offset, ptr, cap) {
|
|
1682
|
-
if (!pendingHostToolResult || offset < 0 || offset > pendingHostToolResult.length) return -1;
|
|
1683
|
-
const chunk = pendingHostToolResult.subarray(offset, offset + cap);
|
|
1684
|
-
bytes(ptr, chunk.length).set(chunk);
|
|
1685
|
-
return chunk.length;
|
|
1686
|
-
},
|
|
1687
|
-
fx_host_tool_result_release() { pendingHostToolResult = null; },
|
|
1688
|
-
fx_steering_take: steeringTake,
|
|
1689
|
-
fx_steering_close() { steeringOpen = false; clearSteering(); },
|
|
1690
|
-
// -1 once the turn takes no more steering, else twice how many steers
|
|
1691
|
-
// wait, plus one when a handoff does.
|
|
1692
|
-
fx_steering_state() { return steeringOpen ? steering.length * 2 + (steeringHandoff ? 1 : 0) : -1; },
|
|
1693
|
-
fx_attachment_size: attachmentSize,
|
|
1694
|
-
fx_attachment_take: attachmentTake,
|
|
1695
|
-
fx_attachment_put: attachmentPut,
|
|
1696
|
-
fx_open_url: new WebAssembly.Suspending(openUrl),
|
|
1697
|
-
fx_oauth_session_load: new WebAssembly.Suspending(oauthSessionLoad),
|
|
1698
|
-
fx_oauth_session_commit: new WebAssembly.Suspending(oauthSessionCommit),
|
|
1699
|
-
fx_oauth_session_remove: new WebAssembly.Suspending(oauthSessionRemove),
|
|
1700
|
-
fx_config_get: new WebAssembly.Suspending(configGet),
|
|
1701
|
-
fx_config_set: new WebAssembly.Suspending(configSet),
|
|
1702
|
-
fx_prompt_history_load: new WebAssembly.Suspending(promptHistoryLoad),
|
|
1703
|
-
fx_prompt_history_append: new WebAssembly.Suspending(promptHistoryAppend),
|
|
1704
|
-
fx_prompt_history_clear: new WebAssembly.Suspending(promptHistoryClear),
|
|
1705
|
-
fx_session_load: new WebAssembly.Suspending(sessionLoad),
|
|
1706
|
-
fx_session_commit: new WebAssembly.Suspending(sessionCommit),
|
|
1707
|
-
fx_session_list: new WebAssembly.Suspending(sessionList),
|
|
1708
|
-
fx_session_remove: new WebAssembly.Suspending(sessionRemove),
|
|
1709
|
-
fx_term_size(cols, rows) {
|
|
1710
|
-
const width = options.terminal?.cols || 80;
|
|
1711
|
-
const height = options.terminal?.rows || 24;
|
|
1712
|
-
new DataView(memory().buffer).setUint16(cols, width, true);
|
|
1713
|
-
new DataView(memory().buffer).setUint16(rows, height, true);
|
|
1714
|
-
options.emit?.("terminal.size", { cols: width, rows: height });
|
|
1715
|
-
},
|
|
1716
|
-
};
|
|
1717
|
-
|
|
1718
|
-
return {
|
|
1719
|
-
imports: { wasi_snapshot_preview1: wasi, fx }, exited,
|
|
1720
|
-
setInstance(value) { instance = value; },
|
|
1721
|
-
write(data) { stdin.push(typeof data === "string" ? encoder.encode(data) : data); },
|
|
1722
|
-
writeAttachment,
|
|
1723
|
-
takeAttachment(id) {
|
|
1724
|
-
const value = outboundAttachments.get(id) ?? null;
|
|
1725
|
-
outboundAttachments.delete(id);
|
|
1726
|
-
return value;
|
|
1727
|
-
},
|
|
1728
|
-
discardAttachments() { inboundAttachments.clear(); },
|
|
1729
|
-
wake() { stdin.wake(); },
|
|
1730
|
-
closeStdin() { steeringOpen = false; clearSteering(); stdin.close(); },
|
|
1731
|
-
openSteering() { clearSteering(); steeringOpen = true; },
|
|
1732
|
-
steer: queueSteering,
|
|
1733
|
-
withdrawSteering,
|
|
1734
|
-
// Ends the running turn at its next safe point; false when none runs.
|
|
1735
|
-
handOffSteering() {
|
|
1736
|
-
if (!steeringOpen) return false;
|
|
1737
|
-
steeringHandoff = true;
|
|
1738
|
-
return true;
|
|
1739
|
-
},
|
|
1740
|
-
closeSteering() { steeringOpen = false; clearSteering(); },
|
|
1741
|
-
abortHostEffects,
|
|
1742
|
-
abort(error) {
|
|
1743
|
-
aborted = true;
|
|
1744
|
-
outputError = error;
|
|
1745
|
-
coreOutput?.close();
|
|
1746
|
-
abortHostEffects();
|
|
1747
|
-
stdin.close();
|
|
1748
|
-
markExited(130);
|
|
1749
|
-
},
|
|
1750
|
-
markExited,
|
|
1751
|
-
get aborted() { return aborted; },
|
|
1752
|
-
get exitCode() { return exitCode; },
|
|
1753
|
-
get error() { return outputError; },
|
|
1754
|
-
setLineHandler(handler) {
|
|
1755
|
-
coreOutput = new CoreOutput(handler);
|
|
1756
|
-
options.stdout = (chunk) => coreOutput.write(chunk);
|
|
1757
|
-
},
|
|
1758
|
-
finishOutput() { coreOutput?.finish(); },
|
|
1759
|
-
};
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
async function instantiate(options) {
|
|
1763
|
-
if (!supportsJspi()) throw new Error("fx WebAssembly requires JSPI (Chrome or Edge 137+)");
|
|
1764
|
-
const runtime = createRuntime({ fetch: globalThis.fetch.bind(globalThis), ...options });
|
|
1765
|
-
const module = await loadModule(options.wasm);
|
|
1766
|
-
const instance = await WebAssembly.instantiate(module, runtime.imports);
|
|
1767
|
-
runtime.setInstance(instance);
|
|
1768
|
-
const start = WebAssembly.promising(instance.exports._start);
|
|
1769
|
-
start().then(
|
|
1770
|
-
() => {
|
|
1771
|
-
runtime.setInstance(null);
|
|
1772
|
-
try { runtime.finishOutput(); runtime.markExited(0); }
|
|
1773
|
-
catch (error) { runtime.abort(error); }
|
|
1774
|
-
},
|
|
1775
|
-
(error) => {
|
|
1776
|
-
runtime.setInstance(null);
|
|
1777
|
-
if (options.args?.[0] === "acp" && !String(error).includes("proc_exit")) runtime.abort(error);
|
|
1778
|
-
else {
|
|
1779
|
-
if (!String(error).includes("proc_exit")) {
|
|
1780
|
-
runtime.abortHostEffects();
|
|
1781
|
-
console.error(error);
|
|
1782
|
-
}
|
|
1783
|
-
runtime.markExited(runtime.aborted ? 130 : 1);
|
|
1784
|
-
}
|
|
1785
|
-
},
|
|
1786
|
-
);
|
|
1787
|
-
return runtime;
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
export async function createFxTerminal(options) {
|
|
1791
|
-
if (!options?.terminal) throw new TypeError("terminal is required");
|
|
1792
|
-
const emit = (type, detail = {}) => {
|
|
1793
|
-
try { options.onEvent?.({ type, timestamp: performance.now(), ...detail }); } catch {}
|
|
1794
|
-
};
|
|
1795
|
-
let resolveInteractive;
|
|
1796
|
-
let rejectInteractive;
|
|
1797
|
-
let interactiveScheduled = false;
|
|
1798
|
-
const interactive = new Promise((resolve, reject) => {
|
|
1799
|
-
resolveInteractive = resolve;
|
|
1800
|
-
rejectInteractive = reject;
|
|
1801
|
-
});
|
|
1802
|
-
const stdout = (bytes) => options.terminal.write(bytes);
|
|
1803
|
-
const onTerminalPoll = () => {
|
|
1804
|
-
if (interactiveScheduled) return;
|
|
1805
|
-
interactiveScheduled = true;
|
|
1806
|
-
queueMicrotask(async () => {
|
|
1807
|
-
try {
|
|
1808
|
-
await options.terminal.drain?.();
|
|
1809
|
-
resolveInteractive();
|
|
1810
|
-
} catch (error) {
|
|
1811
|
-
rejectInteractive(error);
|
|
1812
|
-
}
|
|
1813
|
-
});
|
|
1814
|
-
};
|
|
1815
|
-
emit("runtime.start", { surface: "terminal" });
|
|
1816
|
-
const runtime = await instantiate({ ...options, emit, stdout, onTerminalPoll });
|
|
1817
|
-
runtime.exited.then((code) => {
|
|
1818
|
-
if (!interactiveScheduled) rejectInteractive(new Error(`fx terminal exited with code ${code} before becoming interactive`));
|
|
1819
|
-
});
|
|
1820
|
-
emit("runtime.ready", { surface: "terminal" });
|
|
1821
|
-
const interruptKey = options.interruptKey ?? "\x03";
|
|
1822
|
-
const forwardData = (data) => {
|
|
1823
|
-
if (interruptKey && data.includes(interruptKey)) runtime.abortHostEffects();
|
|
1824
|
-
runtime.write(data);
|
|
1825
|
-
};
|
|
1826
|
-
const signalResize = () => {
|
|
1827
|
-
emit("terminal.resize", { cols: options.terminal.cols, rows: options.terminal.rows });
|
|
1828
|
-
runtime.wake();
|
|
1829
|
-
};
|
|
1830
|
-
let unsubscribeData;
|
|
1831
|
-
let unsubscribeKeyData;
|
|
1832
|
-
let unsubscribeResize;
|
|
1833
|
-
let subscriptionsReleased = false;
|
|
1834
|
-
const releaseSubscriptions = () => {
|
|
1835
|
-
if (subscriptionsReleased) return;
|
|
1836
|
-
subscriptionsReleased = true;
|
|
1837
|
-
try { unsubscribeData?.(); } catch (error) { emit("terminal.cleanup_error", { source: "data", error }); }
|
|
1838
|
-
try { unsubscribeKeyData?.(); } catch (error) { emit("terminal.cleanup_error", { source: "key_data", error }); }
|
|
1839
|
-
try { unsubscribeResize?.(); } catch (error) { emit("terminal.cleanup_error", { source: "resize", error }); }
|
|
1840
|
-
};
|
|
1841
|
-
runtime.exited.then((code) => {
|
|
1842
|
-
releaseSubscriptions();
|
|
1843
|
-
emit("runtime.exit", { surface: "terminal", code });
|
|
1844
|
-
});
|
|
1845
|
-
try {
|
|
1846
|
-
unsubscribeData = options.terminal.onData(forwardData);
|
|
1847
|
-
unsubscribeKeyData = options.terminal.onKeyData?.(forwardData);
|
|
1848
|
-
unsubscribeResize = options.terminal.onResize(signalResize);
|
|
1849
|
-
} catch (error) {
|
|
1850
|
-
// The rejected factory never transfers this promise to a caller.
|
|
1851
|
-
interactive.catch(() => {});
|
|
1852
|
-
releaseSubscriptions();
|
|
1853
|
-
runtime.abort();
|
|
1854
|
-
throw error;
|
|
1855
|
-
}
|
|
1856
|
-
return {
|
|
1857
|
-
interactive,
|
|
1858
|
-
exited: runtime.exited,
|
|
1859
|
-
write(data) {
|
|
1860
|
-
if (interruptKey && typeof data === "string" && data.includes(interruptKey)) runtime.abortHostEffects();
|
|
1861
|
-
runtime.write(data);
|
|
1862
|
-
},
|
|
1863
|
-
resize: signalResize,
|
|
1864
|
-
abort() { releaseSubscriptions(); runtime.abort(); },
|
|
1865
|
-
};
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
function blobByteLength(value) {
|
|
1869
|
-
if (typeof Blob === "undefined" || value == null) return null;
|
|
1870
|
-
try {
|
|
1871
|
-
return Object.getOwnPropertyDescriptor(Blob.prototype, "size").get.call(value);
|
|
1872
|
-
} catch {
|
|
1873
|
-
return null;
|
|
1874
|
-
}
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
function normalizeImageSourceRef(value, name) {
|
|
1878
|
-
boundedString(value, `${name} sourceRef`, 512, false);
|
|
1879
|
-
if (value !== undefined && /[\x00-\x1f\x7f\uD800-\uDFFF]/u.test(value)) {
|
|
1880
|
-
throw new TypeError(`${name} sourceRef must be valid UTF-8 without ASCII controls`);
|
|
1881
|
-
}
|
|
1882
|
-
return value;
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
function omitReferencedImageData(blocks) {
|
|
1886
|
-
return blocks.map((block) => block.type !== "image" || block.sourceRef === undefined ? block : {
|
|
1887
|
-
type: "image",
|
|
1888
|
-
mimeType: block.mimeType,
|
|
1889
|
-
sourceRef: block.sourceRef,
|
|
1890
|
-
});
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
function promptImageDataBytes(prompt) {
|
|
1894
|
-
return prompt.reduce((total, block) => {
|
|
1895
|
-
if (block.type !== "image") return total;
|
|
1896
|
-
if (typeof block.data === "string") return total + block.data.length;
|
|
1897
|
-
const byteLength = block.bytes?.byteLength ?? block.byteLength ?? blobByteLength(block.data) ?? 0;
|
|
1898
|
-
return total + Math.ceil(byteLength / 3) * 4;
|
|
1899
|
-
}, 0);
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
// Returns a Uint8Array view of an ArrayBuffer or typed array, or null.
|
|
1903
|
-
function byteView(value) {
|
|
1904
|
-
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
1905
|
-
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
1906
|
-
return null;
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
const base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
1910
|
-
|
|
1911
|
-
// Returns the decoded length of canonical, unwrapped base64, or -1.
|
|
1912
|
-
function canonicalBase64ByteLength(value) {
|
|
1913
|
-
if (value.length === 0 || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return -1;
|
|
1914
|
-
const groups = value.length / 4;
|
|
1915
|
-
if (value.endsWith("==")) {
|
|
1916
|
-
return (base64Alphabet.indexOf(value[value.length - 3]) & 0x0f) === 0 ? groups * 3 - 2 : -1;
|
|
1917
|
-
}
|
|
1918
|
-
if (value.endsWith("=")) {
|
|
1919
|
-
return (base64Alphabet.indexOf(value[value.length - 2]) & 0x03) === 0 ? groups * 3 - 1 : -1;
|
|
1920
|
-
}
|
|
1921
|
-
return groups * 3;
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
function requireImageMimeType(mimeType, index) {
|
|
1925
|
-
if (typeof mimeType !== "string" || mimeType.length === 0 || mimeType.length > 128) {
|
|
1926
|
-
throw new TypeError(`image prompt block ${index} requires a mimeType`);
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
function checkImageByteLength(byteLength, index) {
|
|
1931
|
-
if (byteLength > maxPromptImageBytes) {
|
|
1932
|
-
throw new RangeError(`image prompt block ${index} exceeds the ${maxPromptImageBytes} byte per-image libfx limit`);
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
|
-
function checkPromptImagesByteLength(byteLength) {
|
|
1937
|
-
if (byteLength > maxPromptImagesBytes) {
|
|
1938
|
-
throw new RangeError(`prompt images exceed the ${maxPromptImagesBytes} byte libfx limit`);
|
|
1939
|
-
}
|
|
1940
|
-
}
|
|
1941
|
-
|
|
1942
|
-
// Pixel inputs carry a private source and byteLength until preparation;
|
|
1943
|
-
// reference-only inputs remain public image descriptors without pixel data.
|
|
1944
|
-
function normalizePromptImage(block, index) {
|
|
1945
|
-
const sourceRef = normalizeImageSourceRef(block.sourceRef, `image prompt block ${index}`);
|
|
1946
|
-
const reference = sourceRef === undefined ? {} : { sourceRef };
|
|
1947
|
-
if (block.data === undefined && sourceRef !== undefined) {
|
|
1948
|
-
requireImageMimeType(block.mimeType, index);
|
|
1949
|
-
return { type: "image", mimeType: block.mimeType, ...reference };
|
|
1950
|
-
}
|
|
1951
|
-
const size = blobByteLength(block.data);
|
|
1952
|
-
if (size !== null) {
|
|
1953
|
-
const mimeType = block.data.type;
|
|
1954
|
-
if (block.mimeType !== undefined && block.mimeType !== mimeType) {
|
|
1955
|
-
throw new TypeError(`image prompt block ${index} mimeType disagrees with Blob.type`);
|
|
1956
|
-
}
|
|
1957
|
-
requireImageMimeType(mimeType, index);
|
|
1958
|
-
if (!Number.isSafeInteger(size) || size <= 0) {
|
|
1959
|
-
throw new TypeError(`image prompt block ${index} requires a non-empty Blob with a valid size`);
|
|
1960
|
-
}
|
|
1961
|
-
return { type: "image", source: "blob", data: block.data, mimeType, byteLength: size, ...reference };
|
|
1962
|
-
}
|
|
1963
|
-
if (typeof block.data === "string" && block.data.length > 0) {
|
|
1964
|
-
requireImageMimeType(block.mimeType, index);
|
|
1965
|
-
const byteLength = canonicalBase64ByteLength(block.data);
|
|
1966
|
-
if (byteLength <= 0) throw new TypeError(`image prompt block ${index} requires canonical base64 data`);
|
|
1967
|
-
return { type: "image", source: "base64", data: block.data, mimeType: block.mimeType, byteLength, ...reference };
|
|
1968
|
-
}
|
|
1969
|
-
const bytes = typeof block.data === "string" ? null : byteView(block.data);
|
|
1970
|
-
if (!bytes || bytes.byteLength === 0) {
|
|
1971
|
-
throw new TypeError(`image prompt block ${index} requires base64 data, bytes, or a Blob`);
|
|
1972
|
-
}
|
|
1973
|
-
requireImageMimeType(block.mimeType, index);
|
|
1974
|
-
return { type: "image", source: "bytes", data: bytes, mimeType: block.mimeType, byteLength: bytes.byteLength, ...reference };
|
|
1975
|
-
}
|
|
1976
|
-
|
|
1977
|
-
// With deferImageLimits, byte limits apply after resizeImage instead.
|
|
1978
|
-
function normalizePromptInput(input, { deferImageLimits = false } = {}) {
|
|
1979
|
-
if (typeof input === "string") return [{ type: "text", text: input }];
|
|
1980
|
-
if (!Array.isArray(input)) throw new TypeError("prompt input must be a string or an array of prompt blocks");
|
|
1981
|
-
let imageCount = 0;
|
|
1982
|
-
let imageBytes = 0;
|
|
1983
|
-
let prompt = input.map((block, index) => {
|
|
1984
|
-
if (!block || typeof block !== "object") throw new TypeError(`prompt block ${index} must be an object`);
|
|
1985
|
-
if (block.type === "image") {
|
|
1986
|
-
const image = normalizePromptImage(block, index);
|
|
1987
|
-
imageCount += 1;
|
|
1988
|
-
if (imageCount > maxPromptImages) {
|
|
1989
|
-
throw new RangeError(`prompt cannot contain more than ${maxPromptImages} images`);
|
|
1990
|
-
}
|
|
1991
|
-
if (!deferImageLimits) {
|
|
1992
|
-
if (image.byteLength > maxPromptImageBytes && image.sourceRef !== undefined) {
|
|
1993
|
-
return omitReferencedImageData([image])[0];
|
|
1994
|
-
}
|
|
1995
|
-
checkImageByteLength(image.byteLength ?? 0, index);
|
|
1996
|
-
imageBytes += image.byteLength ?? 0;
|
|
1997
|
-
}
|
|
1998
|
-
return image;
|
|
1999
|
-
}
|
|
2000
|
-
if (block.type === "text") {
|
|
2001
|
-
if (typeof block.text !== "string") throw new TypeError(`text prompt block ${index} requires text`);
|
|
2002
|
-
return { type: "text", text: block.text };
|
|
2003
|
-
}
|
|
2004
|
-
if (block.type === "resource") {
|
|
2005
|
-
const resource = block.resource || block;
|
|
2006
|
-
if (typeof resource.uri !== "string") throw new TypeError(`resource prompt block ${index} requires uri`);
|
|
2007
|
-
if (resource.text !== undefined && typeof resource.text !== "string") throw new TypeError(`resource prompt block ${index} text must be a string`);
|
|
2008
|
-
return { type: "resource", resource: { uri: resource.uri, ...(resource.text === undefined ? {} : { text: resource.text }) } };
|
|
2009
|
-
}
|
|
2010
|
-
throw new TypeError(`unsupported prompt block type: ${String(block.type)}`);
|
|
2011
|
-
});
|
|
2012
|
-
if (!deferImageLimits) {
|
|
2013
|
-
if (imageBytes > maxPromptImagesBytes) {
|
|
2014
|
-
prompt = omitReferencedImageData(prompt);
|
|
2015
|
-
imageBytes = prompt.reduce((total, block) => total + (block.type === "image" ? block.byteLength ?? 0 : 0), 0);
|
|
2016
|
-
}
|
|
2017
|
-
checkPromptImagesByteLength(imageBytes);
|
|
2018
|
-
if (promptFrameSize(prompt) > maxPromptFrameBytes) prompt = omitReferencedImageData(prompt);
|
|
2019
|
-
}
|
|
2020
|
-
return prompt;
|
|
2021
|
-
}
|
|
2022
|
-
|
|
2023
|
-
// The image types libfx's core takes, each known by how its bytes start.
|
|
2024
|
-
const imageSignatures = [
|
|
2025
|
-
["image/png", 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
|
|
2026
|
-
["image/jpeg", 0, [0xff, 0xd8, 0xff]],
|
|
2027
|
-
["image/gif", 0, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]],
|
|
2028
|
-
["image/gif", 0, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]],
|
|
2029
|
-
["image/webp", 0, [0x52, 0x49, 0x46, 0x46]],
|
|
2030
|
-
];
|
|
2031
|
-
const imageTypes = new Set(imageSignatures.map(([type]) => type));
|
|
2032
|
-
const webpMarker = [0x57, 0x45, 0x42, 0x50];
|
|
2033
|
-
|
|
2034
|
-
// The image type `bytes` start as, or null.
|
|
2035
|
-
function imageTypeOf(bytes) {
|
|
2036
|
-
const startsWith = (offset, signature) => bytes.length >= offset + signature.length && signature.every((byte, index) => bytes[offset + index] === byte);
|
|
2037
|
-
for (const [type, offset, signature] of imageSignatures) {
|
|
2038
|
-
if (!startsWith(offset, signature)) continue;
|
|
2039
|
-
if (type === "image/webp" && !startsWith(8, webpMarker)) continue;
|
|
2040
|
-
return type;
|
|
2041
|
-
}
|
|
2042
|
-
return null;
|
|
2043
|
-
}
|
|
2044
|
-
|
|
2045
|
-
// Refuses image bytes that are not the type they declare, as libfx's core does.
|
|
2046
|
-
function checkImageBytesType(bytes, mimeType, index) {
|
|
2047
|
-
const type = imageTypeOf(bytes);
|
|
2048
|
-
if (type === null) throw new TypeError(`image prompt block ${index} is not a PNG, JPEG, GIF or WebP image`);
|
|
2049
|
-
if (type !== mimeType) throw new TypeError(`image prompt block ${index} holds ${type} data, not ${mimeType}`);
|
|
2050
|
-
}
|
|
2051
|
-
|
|
2052
|
-
/**
|
|
2053
|
-
* Checks a prompt's input as libfx takes it and returns it normalized:
|
|
2054
|
-
* every check `prompt()` makes, and the ones libfx's core makes on images,
|
|
2055
|
-
* which would otherwise refuse the prompt only once it reached the core: a
|
|
2056
|
-
* supported image type, and bytes or base64 that are that type. A Blob is not
|
|
2057
|
-
* read, so its bytes are checked once read (`checkImageBytesType`). With
|
|
2058
|
-
* `resizeImage`, image bytes are checked after it runs instead. Throws a
|
|
2059
|
-
* TypeError or RangeError.
|
|
2060
|
-
*/
|
|
2061
|
-
export function checkPromptInput(input, { resizeImage } = {}) {
|
|
2062
|
-
const resized = resizeImage !== undefined;
|
|
2063
|
-
const normalized = normalizePromptInput(input, { deferImageLimits: resized });
|
|
2064
|
-
// The core takes no prompt without text or an image; a resource adds neither.
|
|
2065
|
-
if (!normalized.some((block) => block.type === "image" || (block.type === "text" && block.text.length > 0))) {
|
|
2066
|
-
throw new TypeError("prompt is empty");
|
|
2067
|
-
}
|
|
2068
|
-
// resizeImage decides the final image sizes, so they are counted after it runs.
|
|
2069
|
-
checkPromptFrameSize(normalized, !resized);
|
|
2070
|
-
normalized.forEach((block, index) => {
|
|
2071
|
-
if (block.type !== "image" || (resized && block.data !== undefined)) return;
|
|
2072
|
-
if (!imageTypes.has(block.mimeType)) throw new TypeError(`image prompt block ${index} mimeType must be image/png, image/jpeg, image/gif or image/webp`);
|
|
2073
|
-
if (block.source === "bytes") checkImageBytesType(block.data, block.mimeType, index);
|
|
2074
|
-
else if (block.source === "base64") checkImageBytesType(base64ToBytes(block.data.slice(0, 16)), block.mimeType, index);
|
|
2075
|
-
});
|
|
2076
|
-
return normalized;
|
|
2077
|
-
}
|
|
2078
|
-
|
|
2079
|
-
// Image bytes travel beside the frame as attachment references, but the model
|
|
2080
|
-
// request carries them base64 encoded and the native host caps that request at
|
|
2081
|
-
// 8 MiB. Images therefore count at their encoded size, the same budget they
|
|
2082
|
-
// had inside the frame. With countImages false, only the frame counts.
|
|
2083
|
-
function promptFrameSize(prompt, countImages = true) {
|
|
2084
|
-
const encodedImageBytes = countImages ? promptImageDataBytes(prompt) : 0;
|
|
2085
|
-
const projected = prompt.map((block) => block.type !== "image" ? block : {
|
|
2086
|
-
type: "image",
|
|
2087
|
-
mimeType: block.mimeType,
|
|
2088
|
-
...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
|
|
2089
|
-
...(block.data === undefined && block.bytes === undefined ? {} : { _meta: { fx: { attachment: 0xffffffff } } }),
|
|
2090
|
-
});
|
|
2091
|
-
return encoder.encode(JSON.stringify({ sessionId: "", prompt: projected })).length +
|
|
2092
|
-
encodedImageBytes + promptFrameEnvelopeBytes;
|
|
2093
|
-
}
|
|
2094
|
-
|
|
2095
|
-
function checkPromptFrameSize(prompt, countImages) {
|
|
2096
|
-
if (promptFrameSize(prompt, countImages) > maxPromptFrameBytes) {
|
|
2097
|
-
throw new RangeError(`prompt exceeds the ${maxPromptFrameBytes} byte libfx frame limit`);
|
|
2098
|
-
}
|
|
2099
|
-
}
|
|
2100
|
-
|
|
2101
|
-
// Returns prompt blocks whose images carry { mimeType, bytes }. Synchronous
|
|
2102
|
-
// sources only: base64 is decoded and caller bytes are used in place.
|
|
2103
|
-
function preparePromptImages(blocks) {
|
|
2104
|
-
return blocks.map((block) => block.type !== "image" || block.data === undefined ? block : {
|
|
2105
|
-
type: "image",
|
|
2106
|
-
mimeType: block.mimeType,
|
|
2107
|
-
bytes: block.source === "base64" ? base64ToBytes(block.data) : block.data,
|
|
2108
|
-
...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
|
|
2109
|
-
});
|
|
2110
|
-
}
|
|
2111
|
-
|
|
2112
|
-
function resizedPromptImage(value, index) {
|
|
2113
|
-
const bytes = byteView(value?.bytes);
|
|
2114
|
-
if (!bytes || bytes.byteLength === 0 || typeof value.mimeType !== "string" ||
|
|
2115
|
-
value.mimeType.length === 0 || value.mimeType.length > 128) {
|
|
2116
|
-
throw new TypeError(`resizeImage must return non-empty bytes and a mimeType for image prompt block ${index}`);
|
|
2117
|
-
}
|
|
2118
|
-
// Copied because a hook may reuse its output buffer for the next image.
|
|
2119
|
-
return { bytes: bytes.slice(), mimeType: value.mimeType };
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
// Reads Blob images and applies resizeImage, then checks the final byte and
|
|
2123
|
-
// frame limits. Returns null when the turn is cancelled first.
|
|
2124
|
-
async function materializePromptImages(blocks, isCancelled, resizeImage) {
|
|
2125
|
-
let prepared = [];
|
|
2126
|
-
let imageBytes = 0;
|
|
2127
|
-
for (let index = 0; index < blocks.length; index++) {
|
|
2128
|
-
if (isCancelled()) return null;
|
|
2129
|
-
const block = blocks[index];
|
|
2130
|
-
if (block.type !== "image" || block.data === undefined) {
|
|
2131
|
-
prepared.push(block);
|
|
2132
|
-
continue;
|
|
2133
|
-
}
|
|
2134
|
-
let image = block.source === "blob"
|
|
2135
|
-
? { mimeType: block.mimeType, bytes: new Uint8Array(await block.data.arrayBuffer()) }
|
|
2136
|
-
: preparePromptImages([block])[0];
|
|
2137
|
-
if (isCancelled()) return null;
|
|
2138
|
-
if (resizeImage) {
|
|
2139
|
-
image = resizedPromptImage(await resizeImage({ bytes: image.bytes, mimeType: image.mimeType }), index);
|
|
2140
|
-
if (isCancelled()) return null;
|
|
2141
|
-
}
|
|
2142
|
-
image = {
|
|
2143
|
-
type: "image",
|
|
2144
|
-
mimeType: image.mimeType,
|
|
2145
|
-
bytes: image.bytes,
|
|
2146
|
-
...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
|
|
2147
|
-
};
|
|
2148
|
-
if (image.bytes.byteLength > maxPromptImageBytes && image.sourceRef !== undefined) {
|
|
2149
|
-
image = omitReferencedImageData([image])[0];
|
|
2150
|
-
}
|
|
2151
|
-
checkImageByteLength(image.bytes?.byteLength ?? 0, index);
|
|
2152
|
-
imageBytes += image.bytes?.byteLength ?? 0;
|
|
2153
|
-
prepared.push(image);
|
|
2154
|
-
if (imageBytes > maxPromptImagesBytes) {
|
|
2155
|
-
prepared = omitReferencedImageData(prepared);
|
|
2156
|
-
// Do not read more referenced Blobs after the actual aggregate overflows.
|
|
2157
|
-
blocks = omitReferencedImageData(blocks);
|
|
2158
|
-
imageBytes = prepared.reduce((total, entry) => total + (entry.bytes?.byteLength ?? 0), 0);
|
|
2159
|
-
}
|
|
2160
|
-
checkPromptImagesByteLength(imageBytes);
|
|
2161
|
-
}
|
|
2162
|
-
if (promptFrameSize(prepared) > maxPromptFrameBytes) prepared = omitReferencedImageData(prepared);
|
|
2163
|
-
checkPromptFrameSize(prepared, true);
|
|
2164
|
-
// A file's bytes, or what resizeImage made, are checked once there are
|
|
2165
|
-
// some, after the size limits.
|
|
2166
|
-
prepared.forEach((image, index) => {
|
|
2167
|
-
if (image.type === "image" && image.bytes !== undefined) checkImageBytesType(image.bytes, image.mimeType, index);
|
|
2168
|
-
});
|
|
2169
|
-
return prepared;
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
|
-
function normalizeSteeringInput(input) {
|
|
2173
|
-
const blocks = normalizePromptInput(input);
|
|
2174
|
-
if (blocks.some((block) => block.type !== "text")) {
|
|
2175
|
-
throw new TypeError("steering accepts only text blocks");
|
|
2176
|
-
}
|
|
2177
|
-
const text = blocks.map((block) => block.text).join("\n");
|
|
2178
|
-
if (text.length === 0) throw new TypeError("steering text cannot be empty");
|
|
2179
|
-
if (encoder.encode(text).length > maxSteeringMessageBytes) {
|
|
2180
|
-
throw new RangeError(`steering text exceeds the ${maxSteeringMessageBytes} byte libfx limit`);
|
|
2181
|
-
}
|
|
2182
|
-
return text;
|
|
2183
|
-
}
|
|
2184
|
-
|
|
2185
|
-
// A tool set's shape, so a checkpoint can tell when the tools it continues
|
|
2186
|
-
// with changed since it was saved.
|
|
2187
|
-
function toolSchemaHash(descriptors) {
|
|
2188
|
-
const shape = JSON.stringify([...descriptors].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)));
|
|
2189
|
-
let hash = 0x811c9dc5;
|
|
2190
|
-
for (const byte of new TextEncoder().encode(shape)) hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
|
|
2191
|
-
return hash.toString(16).padStart(8, "0");
|
|
2192
|
-
}
|
|
2193
|
-
|
|
2194
|
-
// `tools` is an array of descriptors, or an object of descriptors keyed by
|
|
2195
|
-
// name. What `idempotent` and `writes` mean after a crash and for calls that
|
|
2196
|
-
// run together is modeled in `tests/tla/durable/ToolReplay.tla`, whose
|
|
2197
|
-
// `replay: "safe"` is an idempotent tool that does not write, and in
|
|
2198
|
-
// `tests/tla/durable/ParallelBatch.tla`.
|
|
2199
|
-
function normalizeHostTools(value) {
|
|
2200
|
-
if (value === undefined) return { descriptors: [], executors: new Map() };
|
|
2201
|
-
let entries;
|
|
2202
|
-
if (Array.isArray(value)) {
|
|
2203
|
-
entries = value;
|
|
2204
|
-
} else if (value && typeof value === "object") {
|
|
2205
|
-
entries = Object.entries(value).map(([key, tool]) => {
|
|
2206
|
-
if (!tool || typeof tool !== "object") throw new TypeError(`tool ${key} must be an object`);
|
|
2207
|
-
if (tool.name !== undefined && tool.name !== key) throw new TypeError(`tool ${key} has a different name: ${tool.name}`);
|
|
2208
|
-
return { ...tool, name: key };
|
|
2209
|
-
});
|
|
2210
|
-
} else {
|
|
2211
|
-
throw new TypeError("tools must be an array or an object");
|
|
2212
|
-
}
|
|
2213
|
-
const descriptors = [];
|
|
2214
|
-
const executors = new Map();
|
|
2215
|
-
const names = new Set();
|
|
2216
|
-
for (const [index, tool] of entries.entries()) {
|
|
2217
|
-
if (!tool || typeof tool !== "object") throw new TypeError(`tool ${index} must be an object`);
|
|
2218
|
-
const { name, description, inputSchema, execute, providerExecuted, writes, idempotent } = tool;
|
|
2219
|
-
if (typeof name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
|
|
2220
|
-
throw new TypeError(`tool ${index} has an invalid name`);
|
|
2221
|
-
}
|
|
2222
|
-
if (names.has(name)) throw new TypeError(`duplicate tool name: ${name}`);
|
|
2223
|
-
names.add(name);
|
|
2224
|
-
if (providerExecuted !== undefined && typeof providerExecuted !== "boolean") {
|
|
2225
|
-
throw new TypeError(`tool ${name} providerExecuted must be a boolean`);
|
|
2226
|
-
}
|
|
2227
|
-
if (providerExecuted === true) {
|
|
2228
|
-
if (execute !== undefined) throw new TypeError(`provider-executed tool ${name} must not define execute()`);
|
|
2229
|
-
descriptors.push({ name, providerExecuted: true });
|
|
2230
|
-
continue;
|
|
2231
|
-
}
|
|
2232
|
-
if (typeof description !== "string") throw new TypeError(`tool ${name} requires a description`);
|
|
2233
|
-
if (typeof execute !== "function") throw new TypeError(`tool ${name} requires execute()`);
|
|
2234
|
-
if (writes !== undefined && typeof writes !== "boolean") throw new TypeError(`tool ${name} writes must be a boolean`);
|
|
2235
|
-
if (idempotent !== undefined && typeof idempotent !== "boolean") throw new TypeError(`tool ${name} idempotent must be a boolean`);
|
|
2236
|
-
if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
|
|
2237
|
-
throw new TypeError(`tool ${name} requires an object inputSchema`);
|
|
2238
|
-
}
|
|
2239
|
-
let schema;
|
|
2240
|
-
try { schema = JSON.parse(JSON.stringify(inputSchema)); } catch {
|
|
2241
|
-
throw new TypeError(`tool ${name} inputSchema must be JSON-serializable`);
|
|
2242
|
-
}
|
|
2243
|
-
descriptors.push({
|
|
2244
|
-
name,
|
|
2245
|
-
description,
|
|
2246
|
-
inputSchema: schema,
|
|
2247
|
-
...(writes === undefined ? {} : { writes }),
|
|
2248
|
-
// The engine reads `idempotent`: such a tool runs beside others unless
|
|
2249
|
-
// it `writes`, and one that does not write starts before its intent is
|
|
2250
|
-
// stored. It is also in the shape a checkpoint records, so a change to
|
|
2251
|
-
// it counts as a changed tool set.
|
|
2252
|
-
...(idempotent === true ? { idempotent: true } : {}),
|
|
2253
|
-
});
|
|
2254
|
-
executors.set(name, execute);
|
|
2255
|
-
}
|
|
2256
|
-
return { descriptors, executors };
|
|
2257
|
-
}
|
|
2258
|
-
|
|
2259
|
-
// The model catalog a host supplies, as the response body the core would
|
|
2260
|
-
// otherwise fetch: `{ data }` as AI Gateway returns it, or the entries alone.
|
|
2261
|
-
function modelCatalogBody(value) {
|
|
2262
|
-
const entries = Array.isArray(value) ? value : value?.data;
|
|
2263
|
-
if (!Array.isArray(entries)) throw new TypeError("modelCatalog must be an array of catalog entries or { data: [...] }");
|
|
2264
|
-
if (entries.length > maxModelCatalogEntries) {
|
|
2265
|
-
throw new RangeError(`modelCatalog exceeds the ${maxModelCatalogEntries} entry libfx limit`);
|
|
2266
|
-
}
|
|
2267
|
-
for (const entry of entries) {
|
|
2268
|
-
if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || entry.id === "") {
|
|
2269
|
-
throw new TypeError("each modelCatalog entry needs a string id");
|
|
2270
|
-
}
|
|
2271
|
-
}
|
|
2272
|
-
let body;
|
|
2273
|
-
try { body = JSON.stringify({ object: "list", data: entries }); } catch {
|
|
2274
|
-
throw new TypeError("modelCatalog must be JSON-serializable");
|
|
2275
|
-
}
|
|
2276
|
-
if (encoder.encode(body).length > maxModelCatalogBytes) {
|
|
2277
|
-
throw new RangeError(`modelCatalog exceeds the ${maxModelCatalogBytes} byte libfx limit`);
|
|
2278
|
-
}
|
|
2279
|
-
return body;
|
|
2280
|
-
}
|
|
2281
|
-
|
|
2282
|
-
export function normalizeInstructions(value) {
|
|
2283
|
-
let instructions;
|
|
2284
|
-
if (value === undefined) instructions = "";
|
|
2285
|
-
else if (typeof value === "string") instructions = value;
|
|
2286
|
-
if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
|
|
2287
|
-
instructions = value.filter(Boolean).join("\n\n");
|
|
2288
|
-
}
|
|
2289
|
-
if (instructions === undefined) {
|
|
2290
|
-
throw new TypeError("instructions must be a string or an array of strings");
|
|
2291
|
-
}
|
|
2292
|
-
if (encoder.encode(instructions).length > maxInstructionsBytes) {
|
|
2293
|
-
throw new RangeError(`instructions exceed the ${maxInstructionsBytes} byte libfx limit`);
|
|
2294
|
-
}
|
|
2295
|
-
return instructions;
|
|
2296
|
-
}
|
|
2297
|
-
|
|
2298
|
-
// `stored` maps an image's base64 data to its name in the image store, for
|
|
2299
|
-
// the images stored before the result goes to the core: their blocks carry
|
|
2300
|
-
// the name, so the core names them too.
|
|
2301
|
-
function hostToolContent(value, stored = null) {
|
|
2302
|
-
if (value?.type === "libfx.tool-result") {
|
|
2303
|
-
if (typeof value.text !== "string" || !Array.isArray(value.images) || value.images.length > 8) {
|
|
2304
|
-
throw new TypeError("invalid typed tool result");
|
|
2305
|
-
}
|
|
2306
|
-
let images = value.images.map((image) => {
|
|
2307
|
-
if (image?.type !== "image") throw new TypeError("invalid tool image");
|
|
2308
|
-
const sourceRef = normalizeImageSourceRef(image.sourceRef, "tool image");
|
|
2309
|
-
const referenceOnly = image.data === undefined && sourceRef !== undefined;
|
|
2310
|
-
if ((!referenceOnly && typeof image.data !== "string") || typeof image.mimeType !== "string" || image.mimeType.length === 0 || image.mimeType.length > 128 || (image.data?.length > maxPromptImageDataBytes && sourceRef === undefined)) {
|
|
2311
|
-
throw new TypeError("invalid tool image");
|
|
2312
|
-
}
|
|
2313
|
-
const data = !referenceOnly && image.data.length <= maxPromptImageDataBytes ? image.data : undefined;
|
|
2314
|
-
return {
|
|
2315
|
-
type: "image",
|
|
2316
|
-
...(data === undefined ? {} : { data }),
|
|
2317
|
-
mimeType: image.mimeType,
|
|
2318
|
-
...(sourceRef === undefined ? {} : { sourceRef }),
|
|
2319
|
-
...(data !== undefined && stored?.has(data) ? { stored: stored.get(data) } : {}),
|
|
2320
|
-
};
|
|
2321
|
-
});
|
|
2322
|
-
if (promptImageDataBytes(images) > maxPromptImagesDataBytes) images = omitReferencedImageData(images);
|
|
2323
|
-
if (promptImageDataBytes(images) > maxPromptImagesDataBytes) throw new RangeError("tool images exceed the result limit");
|
|
2324
|
-
let content = JSON.stringify({ text: value.text, images });
|
|
2325
|
-
if (encoder.encode(content).length > maxPromptImagesDataBytes) {
|
|
2326
|
-
images = omitReferencedImageData(images);
|
|
2327
|
-
content = JSON.stringify({ text: value.text, images });
|
|
2328
|
-
}
|
|
2329
|
-
if (encoder.encode(content).length > maxPromptImagesDataBytes) throw new RangeError("typed tool result exceeds the result limit");
|
|
2330
|
-
return { content, rich: true, isError: value.isError === true, images };
|
|
2331
|
-
}
|
|
2332
|
-
if (typeof value === "string") return { content: value, rich: false };
|
|
2333
|
-
if (value === undefined) return { content: "null", rich: false };
|
|
2334
|
-
const encoded = JSON.stringify(value);
|
|
2335
|
-
return { content: encoded === undefined ? "null" : encoded, rich: false };
|
|
2336
|
-
}
|
|
2337
|
-
|
|
2338
|
-
function checkpointBytes(value) {
|
|
2339
|
-
if (value === undefined) return null;
|
|
2340
|
-
if (value instanceof Uint8Array) return value.slice();
|
|
2341
|
-
if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0));
|
|
2342
|
-
if (ArrayBuffer.isView(value)) {
|
|
2343
|
-
return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
|
|
2344
|
-
}
|
|
2345
|
-
throw new TypeError("checkpoint must be an ArrayBuffer or typed array");
|
|
2346
|
-
}
|
|
2347
|
-
|
|
2348
|
-
function base64ToBytes(value) {
|
|
2349
|
-
const binary = atob(value);
|
|
2350
|
-
const bytes = new Uint8Array(binary.length);
|
|
2351
|
-
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
2352
|
-
return bytes;
|
|
2353
|
-
}
|
|
2354
|
-
|
|
2355
|
-
/**
|
|
2356
|
-
* One session on one fx core, with no durability of its own: `createFxAgent`
|
|
2357
|
-
* runs each durable session's turns on one of these, and a host with its own
|
|
2358
|
-
* storage may pass `persistence`.
|
|
2359
|
-
*/
|
|
2360
|
-
export async function createFxEngine(options = {}) {
|
|
2361
|
-
options = normalizeAgentOptions(options);
|
|
2362
|
-
const hostTools = normalizeHostTools(options.tools);
|
|
2363
|
-
const instructions = normalizeInstructions(options.instructions);
|
|
2364
|
-
// Recorded in every checkpoint this agent saves, so a later one can tell
|
|
2365
|
-
// it continues with another libfx, other tools, or another model.
|
|
2366
|
-
const checkpointMeta = {
|
|
2367
|
-
libfxVersion: String(fxSdkApiVersion),
|
|
2368
|
-
toolSchemaHash: toolSchemaHash(hostTools.descriptors),
|
|
2369
|
-
model: options.model ?? "",
|
|
2370
|
-
};
|
|
2371
|
-
const initialCheckpoint = checkpointBytes(options.checkpoint);
|
|
2372
|
-
// Checked before the core starts, and with the core's message, so both
|
|
2373
|
-
// backends report it the same way.
|
|
2374
|
-
if (initialCheckpoint && initialCheckpoint.byteLength > maxCheckpointBytes) {
|
|
2375
|
-
throw new Error("libfx checkpoint is too large");
|
|
2376
|
-
}
|
|
2377
|
-
const pending = new Map();
|
|
2378
|
-
let nextId = 1;
|
|
2379
|
-
let sessionId = null;
|
|
2380
|
-
const hookScopes = new Map();
|
|
2381
|
-
// Observation never participates in effects, journal barriers, or retries.
|
|
2382
|
-
// Contain both throws and rejected promises, including an error handler's.
|
|
2383
|
-
const hookError = (error) => {
|
|
2384
|
-
try { Promise.resolve(options.hooks?.onError?.(error)).catch(() => {}); } catch {}
|
|
2385
|
-
};
|
|
2386
|
-
const hook = (event) => {
|
|
2387
|
-
if (!options.hooks) return;
|
|
2388
|
-
if (options.hooks.parent) event = {
|
|
2389
|
-
...event, rootSessionId: options.hooks.parent.rootSessionId,
|
|
2390
|
-
...(event.sessionId === sessionId && event.parentSessionId == null ? {
|
|
2391
|
-
parentSessionId: options.hooks.parent.sessionId, parentTurnId: options.hooks.parent.turnId, parentCallId: options.hooks.parent.callId,
|
|
2392
|
-
} : {}),
|
|
2393
|
-
};
|
|
2394
|
-
if (event.type === "tool.start") {
|
|
2395
|
-
const { data, type, ...scope } = event;
|
|
2396
|
-
hookScopes.set(`${event.sessionId}:${data.call.callId}`, scope);
|
|
2397
|
-
} else if (event.type === "tool.end") hookScopes.delete(`${event.sessionId}:${event.data.call.callId}`);
|
|
2398
|
-
else if (event.type === "turn.end") {
|
|
2399
|
-
for (const [key, scope] of hookScopes) if (scope.executionId === event.executionId) hookScopes.delete(key);
|
|
2400
|
-
} else if (event.type === "journal.error" && event.data.fenced) hookScopes.clear();
|
|
2401
|
-
try { Promise.resolve(options.hooks.onEvent(event)).catch(hookError); } catch (error) { hookError(error); }
|
|
2402
|
-
};
|
|
2403
|
-
const journalHook = (type, data) => hook({
|
|
2404
|
-
version: 1, type, sessionId, rootSessionId: sessionId, parentSessionId: null, sourceSessionId: null, workId: null, turnId: null, executionId: null, stepIndex: 0,
|
|
2405
|
-
timestamp: Date.now(), contentCaptured: false, contentTruncated: false, data,
|
|
2406
|
-
});
|
|
2407
|
-
let activeTurn = null;
|
|
2408
|
-
let closing = false;
|
|
2409
|
-
let coreExitError = null;
|
|
2410
|
-
const isCurrentTurn = (turn) => turn && activeTurn === turn && !turn.cancelled && !closing;
|
|
2411
|
-
// Events go to the store in the order the core sends them, one append at
|
|
2412
|
-
// a time: events that arrive together become one record, and so do all
|
|
2413
|
-
// the events that arrive while an append is in flight. Each record's append
|
|
2414
|
-
// names the cursor of the one before it, so a store refuses a write that
|
|
2415
|
-
// does not continue what it holds, and says with `barrier` whether anyone
|
|
2416
|
-
// waits for it: the core before an action others can see, such as a tool
|
|
2417
|
-
// call with effects, or a caller for its input.
|
|
2418
|
-
const persistence = options.persistence ?? null;
|
|
2419
|
-
// A persistence with an image store keeps each prompt image once, by
|
|
2420
|
-
// name, and the core names it in records and checkpoints instead of
|
|
2421
|
-
// holding its bytes.
|
|
2422
|
-
const imageStorage = persistence?.[persistenceImages] ?? null;
|
|
2423
|
-
const imageStore = imageStorage !== null;
|
|
2424
|
-
const wantsMarks = persistence?.marks === true || persistence?.[journalMarksWanted] === true;
|
|
2425
|
-
const checkpointAfterBytes = options.checkpointAfterBytes ?? defaultCheckpointAfterBytes;
|
|
2426
|
-
// The store's last cursor, and the last seq its records hold.
|
|
2427
|
-
let journalHead = null;
|
|
2428
|
-
let appendedSeq = 0;
|
|
2429
|
-
// Record bytes stored since the last checkpoint.
|
|
2430
|
-
let bytesSinceCheckpoint = 0;
|
|
2431
|
-
let appendTail = Promise.resolve();
|
|
2432
|
-
// One per agent, so a store can tell a repeated write from another writer's.
|
|
2433
|
-
const writerId = crypto.randomUUID();
|
|
2434
|
-
// Pipelined appends and barriers are modeled in `tests/tla/durable/Journal.tla`,
|
|
2435
|
-
// and two processes appending to one log in `tests/tla/durable/Fencing.tla`.
|
|
2436
|
-
const journal = persistence && {
|
|
2437
|
-
append(events, barrier) {
|
|
2438
|
-
const data = encodeJournalRecord(events);
|
|
2439
|
-
const lastSeq = events.at(-1)?.seq;
|
|
2440
|
-
// A store that tracks turns without reading records asks for marks.
|
|
2441
|
-
const marks = wantsMarks ? { marks: journalMarks(events) } : {};
|
|
2442
|
-
const appended = appendTail.then(async () => {
|
|
2443
|
-
const result = await persistence.append({ expected: journalHead, idempotencyKey: `${writerId}:${events[0]?.seq}`, data, barrier, ...marks });
|
|
2444
|
-
if (!validCursor(result?.cursor)) throw new TypeError("persistence.append() must resolve to { cursor }");
|
|
2445
|
-
journalHead = result.cursor;
|
|
2446
|
-
if (Number.isSafeInteger(lastSeq)) appendedSeq = lastSeq;
|
|
2447
|
-
bytesSinceCheckpoint += data.byteLength;
|
|
2448
|
-
journalHook("journal.commit", { cursor: result.cursor, firstSeq: Number.isSafeInteger(events[0]?.seq) ? events[0].seq : null, lastSeq: Number.isSafeInteger(lastSeq) ? lastSeq : null, eventCount: events.length });
|
|
2449
|
-
});
|
|
2450
|
-
// A failed append fails every later one: they would not continue it.
|
|
2451
|
-
appendTail = appended;
|
|
2452
|
-
return appended;
|
|
2453
|
-
},
|
|
2454
|
-
};
|
|
2455
|
-
let resumable = false;
|
|
2456
|
-
// The host's ids for the turn a crash left open and the last turn that
|
|
2457
|
-
// ended, so a retried prompt continues or skips its turn.
|
|
2458
|
-
let openTurnId = null;
|
|
2459
|
-
let lastTurnId = null;
|
|
2460
|
-
// The calls the open turn left running: `{ id, name, arguments }`, and
|
|
2461
|
-
// what the resume tells the model about each one it does not run again.
|
|
2462
|
-
let ambiguousCalls = [];
|
|
2463
|
-
// Asks `onAmbiguous` about each of them, once. Returns the ids of the
|
|
2464
|
-
// calls to run again, and the rest, which the resume answers as possibly
|
|
2465
|
-
// run.
|
|
2466
|
-
const settleAmbiguous = (onAmbiguous) => {
|
|
2467
|
-
const calls = ambiguousCalls;
|
|
2468
|
-
ambiguousCalls = [];
|
|
2469
|
-
const rerun = onAmbiguous === undefined
|
|
2470
|
-
? []
|
|
2471
|
-
: calls.filter((call) => onAmbiguous({ executionId: call.id, name: call.name, input: parsedToolInput(call.arguments) }) === "rerun").map((call) => call.id);
|
|
2472
|
-
return { rerun, answered: calls.filter((call) => !rerun.includes(call.id)) };
|
|
2473
|
-
};
|
|
2474
|
-
// Continues the open turn: the calls it left running run again or are
|
|
2475
|
-
// answered as possibly run, and each answered one ends for the turn's
|
|
2476
|
-
// viewers as it does for the model. Modeled in
|
|
2477
|
-
// `tests/tla/durable/Resumption.tla`.
|
|
2478
|
-
const resumeOpenTurn = (promptOptions) => {
|
|
2479
|
-
const { rerun, answered } = settleAmbiguous(promptOptions.onAmbiguous);
|
|
2480
|
-
return normalizeTurn(startTurn(null, { ...promptOptions, [resumeTurn]: true, [rerunCallIds]: rerun }), answered);
|
|
2481
|
-
};
|
|
2482
|
-
// Follow-ups waiting for the turn ahead of them, oldest first.
|
|
2483
|
-
const followUps = [];
|
|
2484
|
-
// A follow-up this agent queued starts when the turn ahead of it ends, ahead
|
|
2485
|
-
// of any the journal held: those have no caller, so each waits for
|
|
2486
|
-
// `resume()` to start it, oldest first.
|
|
2487
|
-
const runNextFollowUp = (resuming = false) => {
|
|
2488
|
-
if (activeTurn || closing || journalFailure) return;
|
|
2489
|
-
const index = followUps.findIndex((entry) => (entry.held === true) === resuming);
|
|
2490
|
-
if (index < 0) return;
|
|
2491
|
-
const [next] = followUps.splice(index, 1);
|
|
2492
|
-
let turn;
|
|
2493
|
-
try {
|
|
2494
|
-
turn = normalizeTurn(startTurn(next.text, { turnId: next.id, [followUpInput]: { id: next.id, accepted: next.accepted } }));
|
|
2495
|
-
} catch (error) {
|
|
2496
|
-
next.rejectTurn?.(error);
|
|
2497
|
-
return;
|
|
2498
|
-
}
|
|
2499
|
-
next.resolveTurn?.(turn);
|
|
2500
|
-
};
|
|
2501
|
-
let journalQueue = [];
|
|
2502
|
-
let journalFlushScheduled = false;
|
|
2503
|
-
const journalPending = new Set();
|
|
2504
|
-
// The append in flight, and whether anyone waits for the events queued
|
|
2505
|
-
// behind it.
|
|
2506
|
-
let journalInFlight = null;
|
|
2507
|
-
let journalBarrier = false;
|
|
2508
|
-
let journalFailure = null;
|
|
2509
|
-
// Set by `turn.cancel({ reason: "handoff" })`: the journal keeps the open
|
|
2510
|
-
// turn for another agent, so this one stores nothing more.
|
|
2511
|
-
let handedOff = false;
|
|
2512
|
-
const handedOffError = () => new Error("this agent handed its session off; open it again to continue");
|
|
2513
|
-
// Input events (`accepted:<id>`, `withdrawn:<id>`) once stored, and the
|
|
2514
|
-
// steer and withdraw calls waiting for them, which resolve only then
|
|
2515
|
-
// (`tests/tla/durable/Inbox.tla`).
|
|
2516
|
-
const durableInputs = new Set();
|
|
2517
|
-
const inputWaiters = new Map();
|
|
2518
|
-
const waitForInput = (key) => {
|
|
2519
|
-
if (durableInputs.delete(key)) return Promise.resolve();
|
|
2520
|
-
if (journalFailure) return Promise.reject(journalFailure);
|
|
2521
|
-
return new Promise((resolve, reject) => {
|
|
2522
|
-
const waiters = inputWaiters.get(key) ?? [];
|
|
2523
|
-
waiters.push({ resolve, reject });
|
|
2524
|
-
inputWaiters.set(key, waiters);
|
|
2525
|
-
});
|
|
2526
|
-
};
|
|
2527
|
-
const settleInputs = (batch) => {
|
|
2528
|
-
for (const event of batch) {
|
|
2529
|
-
if (event.type !== "input_accepted" && event.type !== "input_withdrawn") continue;
|
|
2530
|
-
const key = `${event.type === "input_accepted" ? "accepted" : "withdrawn"}:${event.data?.id}`;
|
|
2531
|
-
durableInputs.add(key);
|
|
2532
|
-
for (const waiter of inputWaiters.get(key) ?? []) waiter.resolve();
|
|
2533
|
-
inputWaiters.delete(key);
|
|
2534
|
-
}
|
|
2535
|
-
};
|
|
2536
|
-
const rejectInputWaiters = (keys, error) => {
|
|
2537
|
-
for (const key of keys) {
|
|
2538
|
-
for (const waiter of inputWaiters.get(key) ?? []) waiter.reject(error);
|
|
2539
|
-
inputWaiters.delete(key);
|
|
2540
|
-
}
|
|
2541
|
-
};
|
|
2542
|
-
// close() reports a failure no turn or prompt call has reported yet.
|
|
2543
|
-
let journalFailureReported = false;
|
|
2544
|
-
const reportJournalFailure = () => {
|
|
2545
|
-
journalFailureReported = true;
|
|
2546
|
-
return journalFailure;
|
|
2547
|
-
};
|
|
2548
|
-
// `barrier`: the core waits for these events before it goes on.
|
|
2549
|
-
const queueJournalEvents = (events, barrier = false) => {
|
|
2550
|
-
if (journalFailure || !Array.isArray(events) || events.length === 0) return;
|
|
2551
|
-
if (handedOff) {
|
|
2552
|
-
// The agent that opens the session next decides about these inputs.
|
|
2553
|
-
const dropped = events
|
|
2554
|
-
.filter((event) => event?.type === "input_accepted" || event?.type === "input_withdrawn")
|
|
2555
|
-
.map((event) => `${event.type === "input_accepted" ? "accepted" : "withdrawn"}:${event.data?.id}`);
|
|
2556
|
-
rejectInputWaiters(dropped, handedOffError());
|
|
2557
|
-
return;
|
|
2558
|
-
}
|
|
2559
|
-
journalQueue.push(...events);
|
|
2560
|
-
// A caller waits for its steer or withdrawal to be stored, and a turn's
|
|
2561
|
-
// result for the turn's end or yield.
|
|
2562
|
-
if (barrier || events.some((event) => waitedForEvents.has(event?.type))) journalBarrier = true;
|
|
2563
|
-
// A turn notes a new config right before its first progress, a barrier:
|
|
2564
|
-
// the two go out in one write instead of two in a row.
|
|
2565
|
-
if (events.every((event) => event?.type === "session_config")) return;
|
|
2566
|
-
if (journalFlushScheduled) return;
|
|
2567
|
-
journalFlushScheduled = true;
|
|
2568
|
-
queueMicrotask(flushJournal);
|
|
2569
|
-
};
|
|
2570
|
-
// Sends the queued events as one record, unless an append is in flight:
|
|
2571
|
-
// then they wait for it, with any that arrive meanwhile, and go out
|
|
2572
|
-
// together once it lands.
|
|
2573
|
-
function flushJournal() {
|
|
2574
|
-
journalFlushScheduled = false;
|
|
2575
|
-
if (journalFailure || journalInFlight || journalQueue.length === 0) return;
|
|
2576
|
-
const batch = journalQueue;
|
|
2577
|
-
journalQueue = [];
|
|
2578
|
-
const barrier = journalBarrier;
|
|
2579
|
-
journalBarrier = false;
|
|
2580
|
-
emit("journal.append", { events: batch.length, barrier });
|
|
2581
|
-
let appended;
|
|
2582
|
-
try {
|
|
2583
|
-
appended = Promise.resolve(journal.append(batch, barrier));
|
|
2584
|
-
} catch (error) {
|
|
2585
|
-
appended = Promise.reject(error);
|
|
2586
|
-
}
|
|
2587
|
-
const settled = appended.then(() => {
|
|
2588
|
-
settleInputs(batch);
|
|
2589
|
-
}, (error) => {
|
|
2590
|
-
// Later events would leave a gap after the failed batch.
|
|
2591
|
-
journalQueue = [];
|
|
2592
|
-
if (journalFailure) return;
|
|
2593
|
-
journalFailure = journalAppendError(error);
|
|
2594
|
-
emit("journal.error", { error: error instanceof Error ? error.name : "Error" });
|
|
2595
|
-
journalHook("journal.error", { error: error instanceof Error ? error.name : "Error", fenced: error?.code === "FX_FENCED" || error?.cause?.code === "FX_FENCED" });
|
|
2596
|
-
rejectInputWaiters([...inputWaiters.keys()], journalFailure);
|
|
2597
|
-
// Nothing the turn does from here can be saved, and after a fence the
|
|
2598
|
-
// session has another owner: stop the turn and its effects now.
|
|
2599
|
-
activeTurn?.cancel();
|
|
2600
|
-
}).finally(() => {
|
|
2601
|
-
journalPending.delete(settled);
|
|
2602
|
-
journalInFlight = null;
|
|
2603
|
-
flushJournal();
|
|
2604
|
-
});
|
|
2605
|
-
journalInFlight = settled;
|
|
2606
|
-
journalPending.add(settled);
|
|
2607
|
-
}
|
|
2608
|
-
// The journal's own work, such as a heartbeat, ends with the agent: once,
|
|
2609
|
-
// after the last append settles.
|
|
2610
|
-
let journalClosed = null;
|
|
2611
|
-
function closeJournal() {
|
|
2612
|
-
journalClosed ??= journalSettled().catch(() => {}).then(() => checkpointing);
|
|
2613
|
-
return journalClosed;
|
|
2614
|
-
}
|
|
2615
|
-
async function journalSettled() {
|
|
2616
|
-
for (;;) {
|
|
2617
|
-
// This caller waits for every event queued so far.
|
|
2618
|
-
if (journalQueue.length > 0) journalBarrier = true;
|
|
2619
|
-
if (journalFlushScheduled || journalQueue.length > 0) flushJournal();
|
|
2620
|
-
if (journalPending.size === 0) break;
|
|
2621
|
-
await Promise.all([...journalPending]);
|
|
2622
|
-
}
|
|
2623
|
-
if (journalFailure) throw journalFailure;
|
|
2624
|
-
}
|
|
2625
|
-
const emit = (type, detail = {}) => {
|
|
2626
|
-
try { options.onEvent?.({ type, timestamp: performance.now(), ...detail }); } catch {}
|
|
2627
|
-
};
|
|
2628
|
-
// A checkpoint saved by another libfx, with other tools, or for another
|
|
2629
|
-
// model still restores; the host hears what changed.
|
|
2630
|
-
const compareCheckpointMeta = (saved) => {
|
|
2631
|
-
if (!saved || typeof saved !== "object") return;
|
|
2632
|
-
const changed = Object.keys(checkpointMeta).filter((key) => typeof saved[key] === "string" && saved[key] !== "" && saved[key] !== checkpointMeta[key]);
|
|
2633
|
-
if (changed.length) emit("checkpoint.mismatch", { sessionId, changed, saved, current: checkpointMeta });
|
|
2634
|
-
};
|
|
2635
|
-
// Saves a checkpoint once the records since the last one reach
|
|
2636
|
-
// `checkpointAfterBytes`, or with `force` whatever their size, at a point
|
|
2637
|
-
// no turn is running: a turn's end, or where it yielded. A failed save
|
|
2638
|
-
// leaves the journal as it was; the next turn's end tries again. Returns
|
|
2639
|
-
// the save in flight, which never rejects.
|
|
2640
|
-
let checkpointing = null;
|
|
2641
|
-
const maybeCheckpoint = (force = false) => {
|
|
2642
|
-
if (checkpointing) return checkpointing;
|
|
2643
|
-
if (!persistence?.saveCheckpoint || closing || handedOff || journalFailure) return Promise.resolve();
|
|
2644
|
-
checkpointing = (async () => {
|
|
2645
|
-
// The turn's last records may still be in flight; count them first.
|
|
2646
|
-
await journalSettled();
|
|
2647
|
-
// A save begun before close() finishes; close() waits for it.
|
|
2648
|
-
if (activeTurn) return;
|
|
2649
|
-
if (bytesSinceCheckpoint === 0 || (!force && bytesSinceCheckpoint < checkpointAfterBytes)) return;
|
|
2650
|
-
const response = await request("libfx/snapshot", { sessionId, checkpointMeta });
|
|
2651
|
-
const id = response?.snapshotAttachment;
|
|
2652
|
-
if (!Number.isSafeInteger(id) || id <= 0) return;
|
|
2653
|
-
const bytes = runtime.takeAttachment?.(id);
|
|
2654
|
-
// The core took it after every event this agent stored, and no later.
|
|
2655
|
-
if (!(bytes instanceof Uint8Array) || response.atSeq !== appendedSeq) return;
|
|
2656
|
-
const through = journalHead;
|
|
2657
|
-
const covered = bytesSinceCheckpoint;
|
|
2658
|
-
await persistence.saveCheckpoint({ through, data: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) });
|
|
2659
|
-
bytesSinceCheckpoint -= covered;
|
|
2660
|
-
emit("checkpoint.save", { through, bytes: bytes.byteLength });
|
|
2661
|
-
})().catch((error) => {
|
|
2662
|
-
emit("checkpoint.error", { error: error instanceof Error ? error.name : "Error", message: error?.message ?? "" });
|
|
2663
|
-
}).finally(() => { checkpointing = null; });
|
|
2664
|
-
return checkpointing;
|
|
2665
|
-
};
|
|
2666
|
-
const hostFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
2667
|
-
// Adds the host's credentials to the provider's own requests.
|
|
2668
|
-
const providerAuth = subscriptionProviders.has(options.provider)
|
|
2669
|
-
? createSubscriptionAuthorizer([codexSubscription, grokSubscription], { credentials: options.credentials, onCredentials: options.onCredentials, refreshLock: options.refreshLock, fetch: hostFetch, memory: options[credentialMemory] })
|
|
2670
|
-
: options.customProvider && options.credentials
|
|
2671
|
-
? createSubscriptionAuthorizer([customProviderSpec(options.customProvider, () => sessionId)], { credentials: options.credentials, fetch: hostFetch })
|
|
2672
|
-
: null;
|
|
2673
|
-
const transportFetch = async (input, init = {}) => {
|
|
2674
|
-
const method = String(init.method ?? input?.method ?? "GET").toUpperCase();
|
|
2675
|
-
let endpoint = String(input?.url ?? input);
|
|
2676
|
-
let path = null;
|
|
2677
|
-
try {
|
|
2678
|
-
const url = new URL(endpoint);
|
|
2679
|
-
endpoint = `${url.origin}${url.pathname}`;
|
|
2680
|
-
path = url.pathname;
|
|
2681
|
-
} catch {}
|
|
2682
|
-
// A supplied catalog answers the core's catalog request without the network.
|
|
2683
|
-
if (options.modelCatalogBody && method === "GET" && path === "/coding-agent/v1/models") {
|
|
2684
|
-
return new Response(options.modelCatalogBody, { status: 200, headers: { "content-type": "application/json" } });
|
|
2685
|
-
}
|
|
2686
|
-
for (let attemptIndex = 0; attemptIndex < 2; attemptIndex++) {
|
|
2687
|
-
const startedAt = performance.now();
|
|
2688
|
-
const attempt = activeTurn ? ++activeTurn.transportAttempts : attemptIndex + 1;
|
|
2689
|
-
if (activeTurn) {
|
|
2690
|
-
activeTurn.transportBytes = 0;
|
|
2691
|
-
activeTurn.lastTransportActivityAt = null;
|
|
2692
|
-
}
|
|
2693
|
-
emit("transport.start", { attempt, method, endpoint, model: options.model });
|
|
2694
|
-
try {
|
|
2695
|
-
if (activeTurn?.cancelled) {
|
|
2696
|
-
runtime.abortHostEffects();
|
|
2697
|
-
throw new DOMException("Aborted", "AbortError");
|
|
2698
|
-
}
|
|
2699
|
-
if (!hostFetch) throw new TypeError("fetch is unavailable");
|
|
2700
|
-
const spec = providerAuth?.specFor(endpoint);
|
|
2701
|
-
const response = spec
|
|
2702
|
-
? await providerAuth.fetch(spec, endpoint, hostFetch, input, init)
|
|
2703
|
-
: await hostFetch(input, init);
|
|
2704
|
-
const headers = response.headers;
|
|
2705
|
-
emit("transport.response", {
|
|
2706
|
-
attempt,
|
|
2707
|
-
status: response.status,
|
|
2708
|
-
elapsedMs: performance.now() - startedAt,
|
|
2709
|
-
requestId: headers.get("x-vercel-id"),
|
|
2710
|
-
generationId: headers.get("x-generation-id"),
|
|
2711
|
-
model: headers.get("x-model-id") ?? options.model,
|
|
2712
|
-
provider: headers.get("x-vercel-ai-gateway-provider") ?? headers.get("x-ai-gateway-provider"),
|
|
2713
|
-
});
|
|
2714
|
-
return response;
|
|
2715
|
-
} catch (error) {
|
|
2716
|
-
const errorName = error instanceof Error ? error.name : "Error";
|
|
2717
|
-
const elapsedMs = performance.now() - startedAt;
|
|
2718
|
-
emit("transport.error", { attempt, elapsedMs, error: errorName });
|
|
2719
|
-
if (init.signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
2720
|
-
if (attemptIndex === 1) throw error;
|
|
2721
|
-
emit("transport.retry", {
|
|
2722
|
-
attempt,
|
|
2723
|
-
nextAttempt: attempt + 1,
|
|
2724
|
-
elapsedMs,
|
|
2725
|
-
error: errorName,
|
|
2726
|
-
});
|
|
2727
|
-
if (init.signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
2728
|
-
}
|
|
2729
|
-
}
|
|
2730
|
-
throw new Error("transport retry exhausted");
|
|
2731
|
-
};
|
|
2732
|
-
// A typed tool result's images go in the image store by name before the
|
|
2733
|
-
// result reaches the core, as a prompt's do, so the session's records name
|
|
2734
|
-
// them. The tool has already run, so an image the store refuses is not
|
|
2735
|
-
// lost: it stays in the result, and the records hold it inline.
|
|
2736
|
-
const storedToolContent = async (value) => {
|
|
2737
|
-
const normalized = hostToolContent(value);
|
|
2738
|
-
if (!imageStore || !normalized.images?.some((image) => image.data !== undefined)) return normalized;
|
|
2739
|
-
const stored = new Map();
|
|
2740
|
-
for (const image of normalized.images) {
|
|
2741
|
-
if (image.data === undefined || stored.has(image.data)) continue;
|
|
2742
|
-
let bytes;
|
|
2743
|
-
// Data that is not base64 is the core's to refuse.
|
|
2744
|
-
try { bytes = base64ToBytes(image.data); } catch { continue; }
|
|
2745
|
-
const name = await imageName(bytes);
|
|
2746
|
-
try {
|
|
2747
|
-
await imageStorage.put(name, bytes);
|
|
2748
|
-
stored.set(image.data, name);
|
|
2749
|
-
} catch {
|
|
2750
|
-
// Not stored: the result keeps the image, and the records hold it
|
|
2751
|
-
// inline.
|
|
2752
|
-
}
|
|
2753
|
-
}
|
|
2754
|
-
return stored.size ? hostToolContent(value, stored) : normalized;
|
|
2755
|
-
};
|
|
2756
|
-
const executeHostTool = async (name, input, requestedSessionId, toolCallId) => {
|
|
2757
|
-
const execute = hostTools.executors.get(name);
|
|
2758
|
-
const issuerSessionId = requestedSessionId ?? sessionId;
|
|
2759
|
-
const scope = hookScopes.get(`${issuerSessionId}:${toolCallId}`);
|
|
2760
|
-
// Hook metadata is not authority to execute a foreign session's tool.
|
|
2761
|
-
const turn = issuerSessionId === sessionId
|
|
2762
|
-
? activeTurn
|
|
2763
|
-
: null;
|
|
2764
|
-
if (!isCurrentTurn(turn)) return { content: "", isError: true, cancelled: true };
|
|
2765
|
-
const controller = new AbortController();
|
|
2766
|
-
turn.toolControllers.add(controller);
|
|
2767
|
-
let onAbort;
|
|
2768
|
-
const aborted = new Promise((resolve) => { onAbort = () => resolve(); });
|
|
2769
|
-
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
2770
|
-
let content = "";
|
|
2771
|
-
let rich = false;
|
|
2772
|
-
let isError = false;
|
|
2773
|
-
try {
|
|
2774
|
-
if (!execute) throw new Error(`unknown host tool: ${String(name)}`);
|
|
2775
|
-
const execution = Promise.resolve().then(() => {
|
|
2776
|
-
if (controller.signal.aborted || !isCurrentTurn(turn)) return;
|
|
2777
|
-
// The model's call id is stable across restores of a persisted
|
|
2778
|
-
// session, so a tool can key its own retries on it.
|
|
2779
|
-
return execute(input, {
|
|
2780
|
-
signal: controller.signal,
|
|
2781
|
-
executionId: String(toolCallId ?? ""),
|
|
2782
|
-
sessionId: issuerSessionId,
|
|
2783
|
-
...(options.hooks && scope ? { observation: toolObservation(scope, String(toolCallId), options.hooks, hook, controller.signal) } : {}),
|
|
2784
|
-
...(options.toolContext === undefined ? {} : { context: options.toolContext }),
|
|
2785
|
-
});
|
|
2786
|
-
});
|
|
2787
|
-
const value = await Promise.race([execution, aborted]);
|
|
2788
|
-
if (!controller.signal.aborted) {
|
|
2789
|
-
const normalized = await storedToolContent(value);
|
|
2790
|
-
content = normalized.content;
|
|
2791
|
-
rich = normalized.rich;
|
|
2792
|
-
isError = normalized.isError === true;
|
|
2793
|
-
}
|
|
2794
|
-
} catch (error) {
|
|
2795
|
-
isError = true;
|
|
2796
|
-
if (error?.toolResult?.type === "libfx.tool-result") {
|
|
2797
|
-
try {
|
|
2798
|
-
const normalized = await storedToolContent(error.toolResult);
|
|
2799
|
-
content = normalized.content;
|
|
2800
|
-
rich = normalized.rich;
|
|
2801
|
-
} catch {
|
|
2802
|
-
content = error instanceof Error ? error.message : String(error);
|
|
2803
|
-
}
|
|
2804
|
-
} else {
|
|
2805
|
-
content = error instanceof Error ? error.message : String(error);
|
|
2806
|
-
}
|
|
2807
|
-
} finally {
|
|
2808
|
-
controller.signal.removeEventListener("abort", onAbort);
|
|
2809
|
-
turn.toolControllers.delete(controller);
|
|
2810
|
-
}
|
|
2811
|
-
// Providers reject an empty error result, which would end the session.
|
|
2812
|
-
if (isError && content === "") content = `Tool ${String(name)} failed without a message`;
|
|
2813
|
-
return { content, isError, rich, cancelled: controller.signal.aborted || !isCurrentTurn(turn) };
|
|
2814
|
-
};
|
|
2815
|
-
emit("runtime.start");
|
|
2816
|
-
const runtimeOptions = {
|
|
2817
|
-
...options,
|
|
2818
|
-
fetch: transportFetch,
|
|
2819
|
-
args: ["acp"],
|
|
2820
|
-
env: agentEnvironment(options),
|
|
2821
|
-
hostToolExecutor: executeHostTool,
|
|
2822
|
-
journalFlush: () => journalSettled(),
|
|
2823
|
-
onTransportChunk(byteLength) {
|
|
2824
|
-
const turn = activeTurn;
|
|
2825
|
-
if (!isCurrentTurn(turn) || !Number.isSafeInteger(byteLength) || byteLength <= 0) return;
|
|
2826
|
-
turn.transportBytes += byteLength;
|
|
2827
|
-
const now = performance.now();
|
|
2828
|
-
if (turn.lastTransportActivityAt !== null && now - turn.lastTransportActivityAt < transportActivityIntervalMs) return;
|
|
2829
|
-
turn.lastTransportActivityAt = now;
|
|
2830
|
-
emit("transport.activity", {
|
|
2831
|
-
attempt: turn.transportAttempts,
|
|
2832
|
-
chunkBytes: byteLength,
|
|
2833
|
-
totalBytes: turn.transportBytes,
|
|
2834
|
-
});
|
|
2835
|
-
},
|
|
2836
|
-
};
|
|
2837
|
-
const runtime = options.runtimeFactory
|
|
2838
|
-
? await options.runtimeFactory(runtimeOptions)
|
|
2839
|
-
: await instantiate(runtimeOptions);
|
|
2840
|
-
emit("runtime.ready");
|
|
2841
|
-
const send = (message) => {
|
|
2842
|
-
if (closing) throw new Error("fx agent is closing");
|
|
2843
|
-
emit("acp.send", { message });
|
|
2844
|
-
if (message.method === "session/prompt" && activeTurn?.cancelled) throw new Error("Cancelled");
|
|
2845
|
-
runtime.write(`${JSON.stringify(message)}\n`);
|
|
2846
|
-
if (message.method === "session/prompt") activeTurn?.promptWritten();
|
|
2847
|
-
};
|
|
2848
|
-
const request = (method, params = {}) => new Promise((resolve, reject) => {
|
|
2849
|
-
const id = nextId++;
|
|
2850
|
-
pending.set(id, { resolve, reject });
|
|
2851
|
-
try { send({ jsonrpc: "2.0", id, method, params }); } catch (error) { pending.delete(id); reject(error); }
|
|
2852
|
-
});
|
|
2853
|
-
// Raw payloads ride beside the next frame instead of inside it. Payloads
|
|
2854
|
-
// left by an earlier frame that never reached the core are dropped first.
|
|
2855
|
-
let nextAttachmentId = 1;
|
|
2856
|
-
const attachBytes = (payloads) => {
|
|
2857
|
-
if (typeof runtime.writeAttachment !== "function") throw new Error("fx runtime does not accept binary attachments");
|
|
2858
|
-
runtime.discardAttachments?.();
|
|
2859
|
-
return payloads.map((bytes) => {
|
|
2860
|
-
const id = nextAttachmentId;
|
|
2861
|
-
nextAttachmentId = id === 0x7fffffff ? 1 : id + 1;
|
|
2862
|
-
runtime.writeAttachment(id, bytes);
|
|
2863
|
-
return id;
|
|
2864
|
-
});
|
|
2865
|
-
};
|
|
2866
|
-
let checkpointTail = null;
|
|
2867
|
-
let pendingCheckpoints = 0;
|
|
2868
|
-
async function takeCheckpoint() {
|
|
2869
|
-
if (closing) throw new Error("fx agent is closed");
|
|
2870
|
-
if (activeTurn) throw new Error("cannot checkpoint while a prompt is active");
|
|
2871
|
-
const response = await request("libfx/checkpoint", { sessionId, checkpointMeta });
|
|
2872
|
-
const id = response?.checkpointAttachment;
|
|
2873
|
-
const bytes = Number.isSafeInteger(id) && id > 0 ? runtime.takeAttachment?.(id) : null;
|
|
2874
|
-
if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) throw new Error("fx returned an invalid checkpoint");
|
|
2875
|
-
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
2876
|
-
}
|
|
2877
|
-
// Stores each prompt image in the persistence's image store, by name,
|
|
2878
|
-
// before the prompt that carries it is sent: nothing the core writes may
|
|
2879
|
-
// name an image the store does not hold. Returns the blocks with their
|
|
2880
|
-
// names, or null when the prompt stopped meanwhile.
|
|
2881
|
-
async function storePromptImages(blocks, stopped) {
|
|
2882
|
-
const named = [];
|
|
2883
|
-
for (const block of blocks) {
|
|
2884
|
-
if (block.type !== "image" || block.bytes === undefined) {
|
|
2885
|
-
named.push(block);
|
|
2886
|
-
continue;
|
|
2887
|
-
}
|
|
2888
|
-
const name = await imageName(block.bytes);
|
|
2889
|
-
if (stopped()) return null;
|
|
2890
|
-
await imageStorage.put(name, block.bytes);
|
|
2891
|
-
if (stopped()) return null;
|
|
2892
|
-
named.push({ ...block, stored: name });
|
|
2893
|
-
}
|
|
2894
|
-
return named;
|
|
2895
|
-
}
|
|
2896
|
-
const sendPrompt = (blocks, resuming = false, followUp = null, turnId = null, rerun = [], yieldAt = undefined, outputShown = false) => {
|
|
2897
|
-
// A journaled turn given a time to stop at yields before its first model
|
|
2898
|
-
// request after it, keeping the turn open.
|
|
2899
|
-
const stopAt = journal && yieldAt !== undefined ? { yieldAt } : {};
|
|
2900
|
-
if (resuming) {
|
|
2901
|
-
return request("session/prompt", { sessionId, prompt: [], _meta: { fx: { continueRecovery: true, ...(outputShown ? {} : { freshOutput: true }), ...(rerun.length ? { rerun } : {}), ...stopAt } } });
|
|
2902
|
-
}
|
|
2903
|
-
const images = blocks.filter((block) => block.type === "image" && block.bytes !== undefined);
|
|
2904
|
-
const ids = images.length ? attachBytes(images.map((block) => block.bytes)) : [];
|
|
2905
|
-
let next = 0;
|
|
2906
|
-
const prompt = blocks.map((block) => block.type !== "image" ? block : {
|
|
2907
|
-
type: "image",
|
|
2908
|
-
mimeType: block.mimeType,
|
|
2909
|
-
...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
|
|
2910
|
-
// An image the store holds carries its name, so the core names it too.
|
|
2911
|
-
...(block.bytes === undefined ? {} : { _meta: { fx: { attachment: ids[next++], ...(block.stored === undefined ? {} : { stored: block.stored }) } } }),
|
|
2912
|
-
});
|
|
2913
|
-
// A follow-up's turn places it; the core records it as accepted first
|
|
2914
|
-
// when the host could not. A journaled turn's first progress records its id.
|
|
2915
|
-
const fx = {
|
|
2916
|
-
...(followUp ? { inputId: followUp.id, inputAccepted: followUp.accepted } : {}),
|
|
2917
|
-
...((journal || options.hooks) && turnId ? { turnId } : {}),
|
|
2918
|
-
...stopAt,
|
|
2919
|
-
};
|
|
2920
|
-
return request("session/prompt", { sessionId, prompt, ...(Object.keys(fx).length ? { _meta: { fx } } : {}) });
|
|
2921
|
-
};
|
|
2922
|
-
runtime.exited.then((code) => {
|
|
2923
|
-
hookScopes.clear();
|
|
2924
|
-
closing = true;
|
|
2925
|
-
// An agent whose core exited writes nothing more.
|
|
2926
|
-
if (journal) void closeJournal().catch(() => {});
|
|
2927
|
-
const error = runtime.error ?? new Error(`fx-core exited with code ${code} before completing the ACP request`);
|
|
2928
|
-
coreExitError = error;
|
|
2929
|
-
activeTurn?.failImagePrep(error);
|
|
2930
|
-
for (const waiter of pending.values()) waiter.reject(error);
|
|
2931
|
-
pending.clear();
|
|
2932
|
-
emit("runtime.exit", { code });
|
|
2933
|
-
});
|
|
2934
|
-
runtime.setLineHandler((message, size) => {
|
|
2935
|
-
if (message.method === "libfx/hook") {
|
|
2936
|
-
if (message.params?.rootSessionId === sessionId && message.params?.version === 1) {
|
|
2937
|
-
if (message.params.type === "turn.end" && handedOff) message.params.data.handoff = true;
|
|
2938
|
-
hook(message.params);
|
|
2939
|
-
}
|
|
2940
|
-
return;
|
|
2941
|
-
}
|
|
2942
|
-
emit("acp.receive", { message });
|
|
2943
|
-
if (message.method === "libfx/journal_append") {
|
|
2944
|
-
if (journal && message.params?.sessionId === sessionId) {
|
|
2945
|
-
queueJournalEvents(message.params.events, message.params.barrier === true);
|
|
2946
|
-
}
|
|
2947
|
-
return;
|
|
2948
|
-
}
|
|
2949
|
-
if (message.method === "session/update") {
|
|
2950
|
-
if (message.params.sessionId === sessionId) return activeTurn?.push(message.params.update, size);
|
|
2951
|
-
return;
|
|
2952
|
-
}
|
|
2953
|
-
void handleControlMessage(message).catch((error) => runtime.abort(error));
|
|
2954
|
-
});
|
|
2955
|
-
async function handleControlMessage(message) {
|
|
2956
|
-
if (message.method === "session/request_permission") {
|
|
2957
|
-
const turn = activeTurn;
|
|
2958
|
-
if (!isCurrentTurn(turn)) return;
|
|
2959
|
-
emit("permission.request", { request: message.params });
|
|
2960
|
-
if (!isCurrentTurn(turn)) return;
|
|
2961
|
-
let optionId = null;
|
|
2962
|
-
try { optionId = await options.onPermission?.(message.params); } catch {}
|
|
2963
|
-
if (!isCurrentTurn(turn)) return;
|
|
2964
|
-
emit("permission.resolve", { optionId });
|
|
2965
|
-
if (!isCurrentTurn(turn)) return;
|
|
2966
|
-
send({ jsonrpc: "2.0", id: message.id, result: optionId ? { outcome: { outcome: "selected", optionId } } : { outcome: { outcome: "cancelled" } } });
|
|
2967
|
-
return;
|
|
2968
|
-
}
|
|
2969
|
-
if (message.method === "libfx/journal_flush") {
|
|
2970
|
-
// Every append the core sent before this request is already queued.
|
|
2971
|
-
let failure = null;
|
|
2972
|
-
try { await journalSettled(); } catch (error) { failure = error; }
|
|
2973
|
-
if (closing) return;
|
|
2974
|
-
send(failure
|
|
2975
|
-
? { jsonrpc: "2.0", id: message.id, error: { code: -32000, message: failure.message } }
|
|
2976
|
-
: { jsonrpc: "2.0", id: message.id, result: {} });
|
|
2977
|
-
return;
|
|
2978
|
-
}
|
|
2979
|
-
if (message.method === "libfx/tool_call") {
|
|
2980
|
-
const { content, isError, rich, cancelled } = await executeHostTool(
|
|
2981
|
-
message.params?.name,
|
|
2982
|
-
message.params?.input,
|
|
2983
|
-
message.params?.sessionId,
|
|
2984
|
-
message.params?.toolCallId,
|
|
2985
|
-
);
|
|
2986
|
-
if (cancelled || closing) return;
|
|
2987
|
-
const response = { jsonrpc: "2.0", id: message.id, result: { content, isError, ...(rich ? { contentType: "rich" } : {}) } };
|
|
2988
|
-
if (rich && encoder.encode(JSON.stringify(response)).length + 1 > maxPromptFrameBytes) {
|
|
2989
|
-
const result = JSON.parse(content);
|
|
2990
|
-
response.result.content = JSON.stringify({ ...result, images: omitReferencedImageData(result.images) });
|
|
2991
|
-
}
|
|
2992
|
-
if (encoder.encode(JSON.stringify(response)).length + 1 > 8 * 1024 * 1024) {
|
|
2993
|
-
response.result = { content: "Host tool result exceeded the response frame limit", isError: true };
|
|
2994
|
-
}
|
|
2995
|
-
send(response);
|
|
2996
|
-
return;
|
|
2997
|
-
}
|
|
2998
|
-
const waiter = pending.get(message.id); if (!waiter) return; pending.delete(message.id);
|
|
2999
|
-
if (message.error) waiter.reject(agentRpcError(message.error)); else waiter.resolve(message.result);
|
|
3000
|
-
}
|
|
3001
|
-
try {
|
|
3002
|
-
await request("initialize", {
|
|
3003
|
-
protocolVersion: 1,
|
|
3004
|
-
clientCapabilities: {
|
|
3005
|
-
...(hostTools.descriptors.length || instructions
|
|
3006
|
-
? { libfx: { tools: hostTools.descriptors, instructions } }
|
|
3007
|
-
: {}),
|
|
3008
|
-
},
|
|
3009
|
-
});
|
|
3010
|
-
|
|
3011
|
-
const loaded = persistence ? await loadPersistence(persistence) : null;
|
|
3012
|
-
const requestedSessionId = options.sessionId ?? null;
|
|
3013
|
-
const sessionResult = await request("libfx/new", {
|
|
3014
|
-
...(requestedSessionId === null ? {} : { sessionId: requestedSessionId }),
|
|
3015
|
-
...(options.hooks ? { hooks: { captureContent: options.hooks.captureContent === true } } : {}),
|
|
3016
|
-
});
|
|
3017
|
-
sessionId = sessionResult.sessionId;
|
|
3018
|
-
if (options.hooks && sessionResult?._meta?.fx?.hooksVersion !== 1) throw new Error("Semantic hooks require a matching libfx core with hooks version 1");
|
|
3019
|
-
if (initialCheckpoint) {
|
|
3020
|
-
const [checkpointAttachment] = attachBytes([initialCheckpoint]);
|
|
3021
|
-
const restored = await request("libfx/restore", { sessionId, checkpointAttachment }).catch((error) => {
|
|
3022
|
-
if (/newer fx/.test(error?.message ?? "")) throw new FxJournalVersionError(error.message);
|
|
3023
|
-
throw error;
|
|
3024
|
-
});
|
|
3025
|
-
compareCheckpointMeta(restored?.checkpointMeta);
|
|
3026
|
-
}
|
|
3027
|
-
if (journal) {
|
|
3028
|
-
const { events, checkpoint } = loaded;
|
|
3029
|
-
const journalBytes = events.length > 0 ? encoder.encode(JSON.stringify(withoutSupersededProgress(events))) : null;
|
|
3030
|
-
if (journalBytes && journalBytes.byteLength > maxJournalBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
|
|
3031
|
-
const openFailed = (error) => {
|
|
3032
|
-
const message = error?.message ?? "";
|
|
3033
|
-
if (/newer fx/.test(message)) throw new FxJournalVersionError(message);
|
|
3034
|
-
const code = journalOpenErrorCodes.get(message);
|
|
3035
|
-
if (code) throw journalLoadError(message, code, error);
|
|
3036
|
-
throw error;
|
|
3037
|
-
};
|
|
3038
|
-
const openOnce = () => {
|
|
3039
|
-
const params = {
|
|
3040
|
-
sessionId,
|
|
3041
|
-
...(options.inputsDurable === true ? { inputsDurable: true } : {}),
|
|
3042
|
-
...(imageStore ? { imageStore: true } : {}),
|
|
3043
|
-
...(imageStore && options[imageWindowLimits] ? { imageWindow: options[imageWindowLimits] } : {}),
|
|
3044
|
-
};
|
|
3045
|
-
// One call: attaching again would drop the first attachment.
|
|
3046
|
-
const attachments = [];
|
|
3047
|
-
if (journalBytes) attachments.push(["journalAttachment", journalBytes]);
|
|
3048
|
-
if (checkpoint) attachments.push(["snapshotAttachment", checkpoint]);
|
|
3049
|
-
const ids = attachments.length ? attachBytes(attachments.map(([, bytes]) => bytes)) : [];
|
|
3050
|
-
attachments.forEach(([name], index) => { params[name] = ids[index]; });
|
|
3051
|
-
return request("libfx/journal_open", params).catch(openFailed);
|
|
3052
|
-
};
|
|
3053
|
-
// Stages the images a history names, read from the store, in batches
|
|
3054
|
-
// the attachment table holds. A store that lacks one fails the open:
|
|
3055
|
-
// the history without it is not the one saved.
|
|
3056
|
-
const stageImages = async (names) => {
|
|
3057
|
-
let batch = [];
|
|
3058
|
-
let batchBytes = 0;
|
|
3059
|
-
const flush = async () => {
|
|
3060
|
-
if (batch.length === 0) return;
|
|
3061
|
-
const ids = attachBytes(batch.map(({ bytes }) => bytes));
|
|
3062
|
-
const images = batch.map(({ name }, index) => ({ name, attachment: ids[index] }));
|
|
3063
|
-
batch = [];
|
|
3064
|
-
batchBytes = 0;
|
|
3065
|
-
await request("libfx/journal_images", { sessionId, images }).catch(openFailed);
|
|
3066
|
-
};
|
|
3067
|
-
for (const name of new Set(names.map(String))) {
|
|
3068
|
-
const stored = imageStore ? await imageStorage.get(name) : undefined;
|
|
3069
|
-
if (stored === undefined || stored === null) throw imageMissingError(name);
|
|
3070
|
-
const bytes = persistenceBytes(stored, "an image the image store returns");
|
|
3071
|
-
if (batch.length === maxPendingAttachments || batchBytes + bytes.byteLength > maxPendingAttachmentBytes) await flush();
|
|
3072
|
-
batch.push({ name, bytes });
|
|
3073
|
-
batchBytes += bytes.byteLength;
|
|
3074
|
-
}
|
|
3075
|
-
await flush();
|
|
3076
|
-
};
|
|
3077
|
-
// A history that names images opens once they are staged from the
|
|
3078
|
-
// store: the core answers with the names it needs, all at once.
|
|
3079
|
-
let opened = await openOnce();
|
|
3080
|
-
if (Array.isArray(opened?.imagesNeeded)) {
|
|
3081
|
-
await stageImages(opened.imagesNeeded);
|
|
3082
|
-
opened = await openOnce();
|
|
3083
|
-
if (Array.isArray(opened?.imagesNeeded)) throw imageMissingError(String(opened.imagesNeeded[0]));
|
|
3084
|
-
}
|
|
3085
|
-
compareCheckpointMeta(opened?.checkpointMeta);
|
|
3086
|
-
resumable = opened?.resumable === true;
|
|
3087
|
-
openTurnId = resumable && validTurnId(opened?.openTurnId) ? opened.openTurnId : null;
|
|
3088
|
-
lastTurnId = validTurnId(opened?.lastTurnId) ? opened.lastTurnId : null;
|
|
3089
|
-
ambiguousCalls = resumable && Array.isArray(opened?.runningCalls)
|
|
3090
|
-
? opened.runningCalls.filter((call) => typeof call?.id === "string" && typeof call.name === "string")
|
|
3091
|
-
: [];
|
|
3092
|
-
journalHead = loaded.head;
|
|
3093
|
-
appendedSeq = loaded.lastSeq;
|
|
3094
|
-
bytesSinceCheckpoint = loaded.tailBytes;
|
|
3095
|
-
// Follow-ups the journal holds, as accepted; each waits for `resume()`.
|
|
3096
|
-
for (const held of Array.isArray(opened?.followUps) ? opened.followUps : []) {
|
|
3097
|
-
followUps.push({ id: held.id, text: held.text, accepted: true, held: true });
|
|
3098
|
-
}
|
|
3099
|
-
emit("journal.open", { events: events.length, checkpoint: checkpoint !== null, turns: opened?.turns, resumable });
|
|
3100
|
-
journalHook("journal.restore", { cursor: loaded.head, lastSeq: loaded.lastSeq, resumable, openTurnId, lastTurnId });
|
|
3101
|
-
}
|
|
3102
|
-
} catch (error) {
|
|
3103
|
-
closing = true;
|
|
3104
|
-
try { runtime.abortHostEffects(); } catch {}
|
|
3105
|
-
try { runtime.closeStdin(); } catch {}
|
|
3106
|
-
try { await runtime.exited; } catch {}
|
|
3107
|
-
throw error;
|
|
3108
|
-
}
|
|
3109
|
-
|
|
3110
|
-
const agent = {
|
|
3111
|
-
get sessionId() {
|
|
3112
|
-
return sessionId;
|
|
3113
|
-
},
|
|
3114
|
-
// What libfx's session worker needs beyond the public surface.
|
|
3115
|
-
[engineInternals]: {
|
|
3116
|
-
// Resolves once every record the session started has landed: a
|
|
3117
|
-
// turn's result does not wait for its last ones.
|
|
3118
|
-
settled() {
|
|
3119
|
-
return journal ? journalSettled() : Promise.resolve();
|
|
3120
|
-
},
|
|
3121
|
-
// Saves a checkpoint now, at a turn's end or where it yielded, whatever
|
|
3122
|
-
// has accumulated since the last; resolves once it is stored or skipped.
|
|
3123
|
-
checkpoint() {
|
|
3124
|
-
return maybeCheckpoint(true);
|
|
3125
|
-
},
|
|
3126
|
-
// Resolves when the core this agent runs on exits.
|
|
3127
|
-
exited: runtime.exited,
|
|
3128
|
-
// The turn the journal left open, if any, and the calls it left
|
|
3129
|
-
// running, as `{ executionId, name, input }`.
|
|
3130
|
-
get openTurn() {
|
|
3131
|
-
if (!resumable) return null;
|
|
3132
|
-
return {
|
|
3133
|
-
id: openTurnId,
|
|
3134
|
-
calls: ambiguousCalls.map((call) => ({ executionId: call.id, name: call.name, input: parsedToolInput(call.arguments) })),
|
|
3135
|
-
};
|
|
3136
|
-
},
|
|
3137
|
-
},
|
|
3138
|
-
/**
|
|
3139
|
-
* Starts a turn. With `turnId`, a retried call is the same turn: when the
|
|
3140
|
-
* journal left that turn open, this continues it as `resume()` does, and
|
|
3141
|
-
* when that turn already ended, this returns a turn that has ended too,
|
|
3142
|
-
* without running it again.
|
|
3143
|
-
*/
|
|
3144
|
-
prompt(input, promptOptions = {}) {
|
|
3145
|
-
if (closing) throw new Error("fx agent is closed");
|
|
3146
|
-
if (journalFailure) throw reportJournalFailure();
|
|
3147
|
-
if (handedOff) throw handedOffError();
|
|
3148
|
-
if (activeTurn) throw new Error("a prompt is already in progress for this session");
|
|
3149
|
-
const turnId = promptOptions.turnId;
|
|
3150
|
-
if (turnId !== undefined && !validTurnId(turnId)) throw new TypeError(`turnId must be ${turnIdRule}`);
|
|
3151
|
-
checkOnAmbiguous(promptOptions.onAmbiguous);
|
|
3152
|
-
if (turnId !== undefined && journal) {
|
|
3153
|
-
if (resumable && turnId === openTurnId) {
|
|
3154
|
-
resumable = false;
|
|
3155
|
-
return resumeOpenTurn(promptOptions);
|
|
3156
|
-
}
|
|
3157
|
-
if (turnId === lastTurnId) return endedTurn(turnId);
|
|
3158
|
-
}
|
|
3159
|
-
// A new prompt ends a turn the last process left open as interrupted.
|
|
3160
|
-
resumable = false;
|
|
3161
|
-
ambiguousCalls = [];
|
|
3162
|
-
return normalizeTurn(startTurn(input, { ...promptOptions, turnId: turnId ?? `turn_${crypto.randomUUID()}` }));
|
|
3163
|
-
},
|
|
3164
|
-
/**
|
|
3165
|
-
* Continues the turn the last process left open, with no new input, and
|
|
3166
|
-
* returns it; returns null when the journal holds no open turn. The
|
|
3167
|
-
* model is told the session was interrupted; calls that were running
|
|
3168
|
-
* come back answered as possibly run, for the model to decide about,
|
|
3169
|
-
* unless `onAmbiguous(call)` returns "rerun" for them.
|
|
3170
|
-
*/
|
|
3171
|
-
resume(promptOptions = {}) {
|
|
3172
|
-
if (closing) throw new Error("fx agent is closed");
|
|
3173
|
-
if (journalFailure) throw reportJournalFailure();
|
|
3174
|
-
if (handedOff) throw handedOffError();
|
|
3175
|
-
if (activeTurn) throw new Error("a prompt is already in progress for this session");
|
|
3176
|
-
checkOnAmbiguous(promptOptions.onAmbiguous);
|
|
3177
|
-
if (resumable) {
|
|
3178
|
-
resumable = false;
|
|
3179
|
-
return resumeOpenTurn(promptOptions);
|
|
3180
|
-
}
|
|
3181
|
-
// With no open turn, the follow-ups the journal held are the work left.
|
|
3182
|
-
const next = followUps.find((entry) => entry.held);
|
|
3183
|
-
if (!next) return null;
|
|
3184
|
-
let started = null;
|
|
3185
|
-
const resolveTurn = next.resolveTurn;
|
|
3186
|
-
next.resolveTurn = (turn) => {
|
|
3187
|
-
started = turn;
|
|
3188
|
-
resolveTurn?.(turn);
|
|
3189
|
-
};
|
|
3190
|
-
runNextFollowUp(true);
|
|
3191
|
-
return started;
|
|
3192
|
-
},
|
|
3193
|
-
/**
|
|
3194
|
-
* Queues `input` to run as its own turn after the current one, or at once
|
|
3195
|
-
* when no turn is running. Returns a promise for that turn, carrying the
|
|
3196
|
-
* follow-up's `id` and `accepted`, which resolves `{ id }` once a
|
|
3197
|
-
* journal holds it.
|
|
3198
|
-
*/
|
|
3199
|
-
followUp(input) {
|
|
3200
|
-
if (closing) return Promise.reject(new Error("fx agent is closed"));
|
|
3201
|
-
if (journalFailure) return Promise.reject(reportJournalFailure());
|
|
3202
|
-
if (handedOff) return Promise.reject(handedOffError());
|
|
3203
|
-
let text;
|
|
3204
|
-
try { text = normalizeSteeringInput(input); } catch (error) { return Promise.reject(error); }
|
|
3205
|
-
const id = `in_${crypto.randomUUID()}`;
|
|
3206
|
-
const entry = { id, text, accepted: false };
|
|
3207
|
-
const started = new Promise((resolve, reject) => { entry.resolveTurn = resolve; entry.rejectTurn = reject; });
|
|
3208
|
-
let accepted;
|
|
3209
|
-
if (journal && activeTurn && typeof runtime.steer !== "function") {
|
|
3210
|
-
// The native core records it now, ahead of the turn that will run it.
|
|
3211
|
-
entry.accepted = true;
|
|
3212
|
-
accepted = request("libfx/follow_up", { sessionId, id, text })
|
|
3213
|
-
.then(() => waitForInput(`accepted:${id}`))
|
|
3214
|
-
.then(() => ({ id }), (error) => {
|
|
3215
|
-
const index = followUps.indexOf(entry);
|
|
3216
|
-
if (index >= 0) followUps.splice(index, 1);
|
|
3217
|
-
entry.rejectTurn(error);
|
|
3218
|
-
throw error;
|
|
3219
|
-
});
|
|
3220
|
-
} else {
|
|
3221
|
-
// Its own turn records it with the first progress, a barrier; it is
|
|
3222
|
-
// accepted once that is stored.
|
|
3223
|
-
accepted = started.then(() => (journal ? waitForInput(`accepted:${id}`) : undefined)).then(() => ({ id }));
|
|
3224
|
-
}
|
|
3225
|
-
followUps.push(entry);
|
|
3226
|
-
void accepted.catch(() => {});
|
|
3227
|
-
queueMicrotask(runNextFollowUp);
|
|
3228
|
-
started.id = id;
|
|
3229
|
-
started.accepted = accepted;
|
|
3230
|
-
return started;
|
|
3231
|
-
},
|
|
3232
|
-
/**
|
|
3233
|
-
* The session's state as opaque bytes, when no prompt is running; pass
|
|
3234
|
-
* them as `checkpoint` to restore it in a new agent.
|
|
3235
|
-
*/
|
|
3236
|
-
checkpoint() {
|
|
3237
|
-
// One checkpoint runs at a time: each holds an outbound attachment until
|
|
3238
|
-
// it is taken, and the native table holds only a few. An idle call still
|
|
3239
|
-
// sends its request before returning, ahead of a later prompt(). The slot
|
|
3240
|
-
// is claimed before that send, so a call from an event handler during it
|
|
3241
|
-
// still waits. The count drops before the caller's own reaction to `run`,
|
|
3242
|
-
// so a call made right after awaiting the previous one is idle.
|
|
3243
|
-
const previous = pendingCheckpoints > 0 ? checkpointTail : null;
|
|
3244
|
-
pendingCheckpoints++;
|
|
3245
|
-
let release;
|
|
3246
|
-
checkpointTail = new Promise((resolve) => { release = resolve; });
|
|
3247
|
-
const run = previous ? previous.then(takeCheckpoint) : takeCheckpoint();
|
|
3248
|
-
const settle = () => { pendingCheckpoints--; release(); };
|
|
3249
|
-
run.then(settle, settle);
|
|
3250
|
-
return run;
|
|
3251
|
-
},
|
|
3252
|
-
async close() {
|
|
3253
|
-
hookScopes.clear();
|
|
3254
|
-
if (closing) {
|
|
3255
|
-
await runtime.exited;
|
|
3256
|
-
if (journal) await closeJournal();
|
|
3257
|
-
return;
|
|
3258
|
-
}
|
|
3259
|
-
const turn = activeTurn;
|
|
3260
|
-
turn?.cancel();
|
|
3261
|
-
if (turn) await turn.result.catch(() => {});
|
|
3262
|
-
// A checkpoint being taken needs the core; it finishes first.
|
|
3263
|
-
await checkpointing;
|
|
3264
|
-
closing = true;
|
|
3265
|
-
// A journal keeps these; the next `resume()` runs them.
|
|
3266
|
-
for (const entry of followUps.splice(0)) entry.rejectTurn?.(new Error("fx agent closed before the follow-up ran"));
|
|
3267
|
-
runtime.closeStdin();
|
|
3268
|
-
await runtime.exited;
|
|
3269
|
-
// Every append the session started has landed, or close reports why.
|
|
3270
|
-
if (journal) {
|
|
3271
|
-
let failure = null;
|
|
3272
|
-
try {
|
|
3273
|
-
await journalSettled();
|
|
3274
|
-
} catch {
|
|
3275
|
-
if (!journalFailureReported) failure = reportJournalFailure();
|
|
3276
|
-
}
|
|
3277
|
-
await closeJournal();
|
|
3278
|
-
if (failure) throw failure;
|
|
3279
|
-
}
|
|
3280
|
-
},
|
|
3281
|
-
};
|
|
3282
|
-
return agent;
|
|
3283
|
-
|
|
3284
|
-
// `answered`: calls a resumed turn answers as possibly run before it goes
|
|
3285
|
-
// on, whose `tool_end` comes first.
|
|
3286
|
-
function normalizeTurn(rawTurn, answered = []) {
|
|
3287
|
-
const toolNames = new Map();
|
|
3288
|
-
const started = new Set();
|
|
3289
|
-
const eventFor = (update) => {
|
|
3290
|
-
if (update.sessionUpdate === "agent_message_chunk") {
|
|
3291
|
-
const delta = update.content?.text;
|
|
3292
|
-
if (!delta || delta.startsWith("[context]")) return null;
|
|
3293
|
-
return { type: "text_delta", delta };
|
|
3294
|
-
}
|
|
3295
|
-
if (update.sessionUpdate === "agent_thought_chunk") {
|
|
3296
|
-
const delta = update.content?.text;
|
|
3297
|
-
return delta ? { type: "reasoning_delta", delta } : null;
|
|
3298
|
-
}
|
|
3299
|
-
if (update.sessionUpdate === "user_message_chunk" && update.content?.type === "text") {
|
|
3300
|
-
// A steer shows where it joined the turn, with the id it was sent with.
|
|
3301
|
-
const id = update._meta?.fx?.steering?.inputId;
|
|
3302
|
-
return { type: "user_message", text: update.content.text, ...(typeof id === "string" ? { id } : {}) };
|
|
3303
|
-
}
|
|
3304
|
-
if (update.sessionUpdate === "tool_call") {
|
|
3305
|
-
toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool");
|
|
3306
|
-
if (started.has(update.toolCallId)) return null;
|
|
3307
|
-
started.add(update.toolCallId);
|
|
3308
|
-
return {
|
|
3309
|
-
type: "tool_start",
|
|
3310
|
-
id: update.toolCallId,
|
|
3311
|
-
name: toolNames.get(update.toolCallId),
|
|
3312
|
-
...toolStartInput(update.rawInput),
|
|
3313
|
-
};
|
|
3314
|
-
}
|
|
3315
|
-
if (update.sessionUpdate === "tool_call_update" &&
|
|
3316
|
-
(update.status === "completed" || update.status === "failed")) {
|
|
3317
|
-
const content = update.content?.find((entry) => entry.content?.type === "text")?.content?.text;
|
|
3318
|
-
return {
|
|
3319
|
-
type: "tool_end",
|
|
3320
|
-
id: update.toolCallId,
|
|
3321
|
-
name: toolNames.get(update.toolCallId) || "tool",
|
|
3322
|
-
...(content === undefined ? {} : { content }),
|
|
3323
|
-
isError: update.status === "failed",
|
|
3324
|
-
};
|
|
3325
|
-
}
|
|
3326
|
-
return null;
|
|
3327
|
-
};
|
|
3328
|
-
const result = rawTurn.result.then((value) => ({
|
|
3329
|
-
stopReason: value.stopReason,
|
|
3330
|
-
usage: normalizeTurnUsage(value.usage),
|
|
3331
|
-
}));
|
|
3332
|
-
void result.catch(() => {});
|
|
3333
|
-
return {
|
|
3334
|
-
id: rawTurn.id ?? null,
|
|
3335
|
-
cancel(cancelOptions) { rawTurn.cancel(cancelOptions); },
|
|
3336
|
-
steer(input, id) {
|
|
3337
|
-
if (id !== undefined && !validTurnId(id)) throw new TypeError(`steer id must be ${turnIdRule}`);
|
|
3338
|
-
return rawTurn.steer(normalizeSteeringInput(input), id);
|
|
3339
|
-
},
|
|
3340
|
-
withdraw(id) { return rawTurn.withdraw(id); },
|
|
3341
|
-
[engineInternals]: rawTurn[engineInternals],
|
|
3342
|
-
[Symbol.asyncIterator]() {
|
|
3343
|
-
const iterator = (async function* () {
|
|
3344
|
-
for (const call of answered) {
|
|
3345
|
-
yield { type: "tool_end", id: call.id, name: call.name, content: unfinishedToolOutput, isError: true };
|
|
3346
|
-
}
|
|
3347
|
-
for await (const update of rawTurn) {
|
|
3348
|
-
const event = eventFor(update);
|
|
3349
|
-
if (event) yield event;
|
|
3350
|
-
}
|
|
3351
|
-
})();
|
|
3352
|
-
return {
|
|
3353
|
-
next(value) { return iterator.next(value); },
|
|
3354
|
-
return(value) { rawTurn.cancel(); return iterator.return(value); },
|
|
3355
|
-
throw(error) { rawTurn.cancel(); return iterator.throw(error); },
|
|
3356
|
-
[Symbol.asyncIterator]() { return this; },
|
|
3357
|
-
};
|
|
3358
|
-
},
|
|
3359
|
-
result,
|
|
3360
|
-
};
|
|
3361
|
-
}
|
|
3362
|
-
|
|
3363
|
-
// A turn the journal shows already ended, returned for a retried prompt.
|
|
3364
|
-
function endedTurn(id) {
|
|
3365
|
-
return {
|
|
3366
|
-
id,
|
|
3367
|
-
result: Promise.resolve({ stopReason: "end_turn", usage: {} }),
|
|
3368
|
-
cancel() {},
|
|
3369
|
-
steer() { return Promise.reject(new Error("no prompt is running")); },
|
|
3370
|
-
withdraw() { return Promise.resolve("already_placed"); },
|
|
3371
|
-
async *[Symbol.asyncIterator]() {},
|
|
3372
|
-
};
|
|
3373
|
-
}
|
|
3374
|
-
|
|
3375
|
-
function normalizeTurnUsage(usage) {
|
|
3376
|
-
const result = {};
|
|
3377
|
-
if (Number.isSafeInteger(usage?.inputTokens)) result.inputTokens = usage.inputTokens;
|
|
3378
|
-
if (Number.isSafeInteger(usage?.outputTokens)) result.outputTokens = usage.outputTokens;
|
|
3379
|
-
if (Number.isSafeInteger(usage?.cacheReadTokens)) result.cacheReadTokens = usage.cacheReadTokens;
|
|
3380
|
-
if (Number.isSafeInteger(usage?.cacheWriteTokens)) result.cacheWriteTokens = usage.cacheWriteTokens;
|
|
3381
|
-
if (Number.isSafeInteger(usage?.reasoningTokens)) result.reasoningTokens = usage.reasoningTokens;
|
|
3382
|
-
return result;
|
|
3383
|
-
}
|
|
3384
|
-
|
|
3385
|
-
// The input object rides the event when it fits the preview budget; larger
|
|
3386
|
-
// inputs become a bounded JSON prefix plus an explicit marker.
|
|
3387
|
-
function toolStartInput(rawInput) {
|
|
3388
|
-
if (rawInput === undefined || rawInput === null) return {};
|
|
3389
|
-
const serialized = JSON.stringify(rawInput);
|
|
3390
|
-
if (serialized === undefined) return {};
|
|
3391
|
-
const bytes = encoder.encode(serialized);
|
|
3392
|
-
if (bytes.length <= maxToolStartInputBytes) return { input: rawInput };
|
|
3393
|
-
return { inputTruncated: true, inputPreview: decoder.decode(utf8Prefix(bytes, maxToolStartInputBytes)) };
|
|
3394
|
-
}
|
|
3395
|
-
|
|
3396
|
-
function startTurn(input, promptOptions) {
|
|
3397
|
-
const resizeImage = options.resizeImage;
|
|
3398
|
-
const resuming = promptOptions[resumeTurn] === true;
|
|
3399
|
-
const followUp = promptOptions[followUpInput] ?? null;
|
|
3400
|
-
const normalized = resuming ? [] : checkPromptInput(input, { resizeImage });
|
|
3401
|
-
const hasPixels = normalized.some((block) => block.type === "image" && block.data !== undefined);
|
|
3402
|
-
// Blob reads, resizeImage and storing images run before the prompt
|
|
3403
|
-
// frame is sent.
|
|
3404
|
-
const asyncImages = hasPixels && (resizeImage !== undefined || imageStore ||
|
|
3405
|
-
normalized.some((block) => block.type === "image" && block.source === "blob"));
|
|
3406
|
-
// Snapshot caller-owned bytes, which could change before an async send.
|
|
3407
|
-
const prompt = asyncImages
|
|
3408
|
-
? normalized.map((block) => block.type === "image" && block.source === "bytes" ? { ...block, data: block.data.slice() } : block)
|
|
3409
|
-
: preparePromptImages(normalized);
|
|
3410
|
-
const signal = promptOptions.signal;
|
|
3411
|
-
if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal");
|
|
3412
|
-
const yieldAt = promptOptions.yieldAt;
|
|
3413
|
-
if (yieldAt !== undefined && !(Number.isSafeInteger(yieldAt) && yieldAt > 0)) throw new TypeError("yieldAt must be a time in milliseconds since the epoch");
|
|
3414
|
-
const queue = [];
|
|
3415
|
-
const waiters = [];
|
|
3416
|
-
let queuedBytes = 0;
|
|
3417
|
-
let resumeOutput;
|
|
3418
|
-
let iteratorTaken = false;
|
|
3419
|
-
let terminalError;
|
|
3420
|
-
let reportedPressure = false;
|
|
3421
|
-
let discardedBytes = 0;
|
|
3422
|
-
let cancelImagePrep = null;
|
|
3423
|
-
let rejectImagePrep = null;
|
|
3424
|
-
let resolvePromptStart = null;
|
|
3425
|
-
const promptStarted = asyncImages ? new Promise((resolve) => { resolvePromptStart = resolve; }) : null;
|
|
3426
|
-
let pendingSteeringCount = 0;
|
|
3427
|
-
let pendingSteeringBytes = 0;
|
|
3428
|
-
// This turn's steers: whether each reached the core, or was withdrawn first.
|
|
3429
|
-
const steers = new Map();
|
|
3430
|
-
const withdrawnSteer = (id) => Object.assign(new Error("steer was withdrawn"), { code: "FX_STEER_WITHDRAWN", id });
|
|
3431
|
-
const startSteer = (id, text) => {
|
|
3432
|
-
if (finished || cancelled || activeTurn !== turn) {
|
|
3433
|
-
return Promise.reject(new Error("no prompt is running"));
|
|
3434
|
-
}
|
|
3435
|
-
if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
|
|
3436
|
-
const local = { sent: false, withdrawn: false };
|
|
3437
|
-
steers.set(id, local);
|
|
3438
|
-
const apply = () => {
|
|
3439
|
-
if (local.withdrawn) return Promise.resolve({ id, withdrawn: true });
|
|
3440
|
-
if (finished || cancelled || activeTurn !== turn) {
|
|
3441
|
-
return Promise.reject(new Error("no prompt is running"));
|
|
3442
|
-
}
|
|
3443
|
-
if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
|
|
3444
|
-
try {
|
|
3445
|
-
local.sent = true;
|
|
3446
|
-
const accepted = () => (journal ? waitForInput(`accepted:${id}`) : Promise.resolve());
|
|
3447
|
-
if (typeof runtime.steer === "function") {
|
|
3448
|
-
runtime.steer(text, id);
|
|
3449
|
-
// The web core accepts it when it takes it at the next boundary,
|
|
3450
|
-
// and shows where it joined the turn then.
|
|
3451
|
-
return accepted().then(() => ({ id }), (error) => (error?.code === "FX_STEER_WITHDRAWN" ? { id, withdrawn: true } : Promise.reject(error)));
|
|
3452
|
-
}
|
|
3453
|
-
return request("libfx/steer", { sessionId, text, id }).then(accepted).then(() => ({ id }));
|
|
3454
|
-
} catch (error) {
|
|
3455
|
-
return Promise.reject(error);
|
|
3456
|
-
}
|
|
3457
|
-
};
|
|
3458
|
-
if (!resolvePromptStart) return apply();
|
|
3459
|
-
const bytes = encoder.encode(text).length;
|
|
3460
|
-
if (pendingSteeringCount >= maxSteeringMessages || bytes > maxSteeringQueueBytes - pendingSteeringBytes) {
|
|
3461
|
-
return Promise.reject(new Error("steering queue is full"));
|
|
3462
|
-
}
|
|
3463
|
-
pendingSteeringCount++;
|
|
3464
|
-
pendingSteeringBytes += bytes;
|
|
3465
|
-
return promptStarted.then((started) => {
|
|
3466
|
-
if (coreExitError && !cancelled) throw coreExitError;
|
|
3467
|
-
return started === true ? apply() : Promise.reject(started instanceof Error ? started : new Error("no prompt is running"));
|
|
3468
|
-
}).finally(() => { pendingSteeringCount--; pendingSteeringBytes -= bytes; });
|
|
3469
|
-
};
|
|
3470
|
-
const toolControllers = new Set();
|
|
3471
|
-
let finished = false;
|
|
3472
|
-
let cancelled = false;
|
|
3473
|
-
const turn = {
|
|
3474
|
-
// The host's id: the one it gave, or for a resumed turn, the one the
|
|
3475
|
-
// turn started with.
|
|
3476
|
-
id: resuming ? openTurnId : promptOptions.turnId ?? null,
|
|
3477
|
-
push(update, size = encoder.encode(JSON.stringify(update)).length) {
|
|
3478
|
-
if (cancelled || finished) { discardedBytes += size; return; }
|
|
3479
|
-
if (size > maxCoreMessageBytes) throw new RangeError("core output message exceeds 64 MiB");
|
|
3480
|
-
if (queue.length && (queue.length >= maxUnreadEvents || size > maxUnreadEventBytes - queuedBytes)) {
|
|
3481
|
-
const capacity = new Promise((resolveCapacity) => { resumeOutput = resolveCapacity; });
|
|
3482
|
-
if (!reportedPressure) {
|
|
3483
|
-
reportedPressure = true;
|
|
3484
|
-
emit("output.backpressure", { bufferedBytes: queuedBytes, bufferedEvents: queue.length });
|
|
3485
|
-
}
|
|
3486
|
-
return capacity.then(() => turn.push(update, size));
|
|
3487
|
-
}
|
|
3488
|
-
const waiter = waiters.shift();
|
|
3489
|
-
if (waiter) waiter.resolve({ value: update, done: false });
|
|
3490
|
-
else { queue.push({ update, size }); queuedBytes += size; }
|
|
3491
|
-
},
|
|
3492
|
-
toolControllers,
|
|
3493
|
-
transportAttempts: 0,
|
|
3494
|
-
transportBytes: 0,
|
|
3495
|
-
lastTransportActivityAt: null,
|
|
3496
|
-
get cancelled() { return cancelled; },
|
|
3497
|
-
failImagePrep(error) {
|
|
3498
|
-
rejectImagePrep?.(error);
|
|
3499
|
-
rejectImagePrep = null;
|
|
3500
|
-
cancelImagePrep = null;
|
|
3501
|
-
},
|
|
3502
|
-
promptWritten() {
|
|
3503
|
-
resolvePromptStart?.(true);
|
|
3504
|
-
resolvePromptStart = null;
|
|
3505
|
-
},
|
|
3506
|
-
// Resolves `{ id }` once the steer is accepted: in a journaled session,
|
|
3507
|
-
// once its acceptance is stored. The promise carries `id` at once.
|
|
3508
|
-
steer(text, id = `in_${crypto.randomUUID()}`) {
|
|
3509
|
-
const steered = startSteer(id, text).then((value) => value ?? { id });
|
|
3510
|
-
steered.id = id;
|
|
3511
|
-
return steered;
|
|
3512
|
-
},
|
|
3513
|
-
// 'withdrawn' when the model never saw the steer, else 'already_placed'.
|
|
3514
|
-
withdraw(id) {
|
|
3515
|
-
const local = steers.get(id);
|
|
3516
|
-
if (!local) return Promise.resolve("already_placed");
|
|
3517
|
-
if (!local.sent) {
|
|
3518
|
-
local.withdrawn = true;
|
|
3519
|
-
return Promise.resolve("withdrawn");
|
|
3520
|
-
}
|
|
3521
|
-
if (typeof runtime.withdrawSteering === "function") {
|
|
3522
|
-
if (!runtime.withdrawSteering(id)) return Promise.resolve("already_placed");
|
|
3523
|
-
local.withdrawn = true;
|
|
3524
|
-
rejectInputWaiters([`accepted:${id}`], withdrawnSteer(id));
|
|
3525
|
-
return Promise.resolve("withdrawn");
|
|
3526
|
-
}
|
|
3527
|
-
return request("libfx/withdraw", { sessionId, id }).then(async (response) => {
|
|
3528
|
-
if (response?.result !== "withdrawn") return "already_placed";
|
|
3529
|
-
if (journal) await waitForInput(`withdrawn:${id}`);
|
|
3530
|
-
return "withdrawn";
|
|
3531
|
-
});
|
|
3532
|
-
},
|
|
3533
|
-
// For libfx's session worker: a steer that cannot join the turn, such
|
|
3534
|
-
// as one with images, ends the turn at its next safe point, at once
|
|
3535
|
-
// while the model streams a reply, so that steer can run next.
|
|
3536
|
-
[engineInternals]: {
|
|
3537
|
-
endForSteer() {
|
|
3538
|
-
const send = () => {
|
|
3539
|
-
if (finished || cancelled || activeTurn !== turn || closing) return undefined;
|
|
3540
|
-
if (typeof runtime.handOffSteering === "function") {
|
|
3541
|
-
runtime.handOffSteering();
|
|
3542
|
-
return undefined;
|
|
3543
|
-
}
|
|
3544
|
-
return request("libfx/handoff", { sessionId }).then(() => undefined);
|
|
3545
|
-
};
|
|
3546
|
-
const sent = promptStarted && resolvePromptStart ? promptStarted.then((started) => (started === true ? send() : undefined)) : Promise.resolve().then(send);
|
|
3547
|
-
return sent.catch(() => {});
|
|
3548
|
-
},
|
|
3549
|
-
},
|
|
3550
|
-
cancel(cancelOptions = {}) {
|
|
3551
|
-
const reason = cancelOptions?.reason;
|
|
3552
|
-
if (reason !== undefined && reason !== "handoff") throw new TypeError('cancel() reason must be "handoff" when given');
|
|
3553
|
-
if (reason === "handoff" && !journal) throw new TypeError("a handoff needs persistence to keep the turn in");
|
|
3554
|
-
if (finished || cancelled) return;
|
|
3555
|
-
if (reason === "handoff") {
|
|
3556
|
-
handedOff = true;
|
|
3557
|
-
// The journal keeps queued follow-ups for the next agent.
|
|
3558
|
-
for (const entry of followUps.splice(0)) entry.rejectTurn?.(handedOffError());
|
|
3559
|
-
}
|
|
3560
|
-
cancelled = true;
|
|
3561
|
-
cancelImagePrep?.();
|
|
3562
|
-
cancelImagePrep = null;
|
|
3563
|
-
rejectImagePrep = null;
|
|
3564
|
-
resolvePromptStart?.(false);
|
|
3565
|
-
resolvePromptStart = null;
|
|
3566
|
-
runtime.closeSteering?.();
|
|
3567
|
-
resumeOutput?.();
|
|
3568
|
-
resumeOutput = null;
|
|
3569
|
-
if (!closing) send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
|
|
3570
|
-
for (const controller of toolControllers) controller.abort();
|
|
3571
|
-
runtime.abortHostEffects();
|
|
3572
|
-
},
|
|
3573
|
-
[Symbol.asyncIterator]() {
|
|
3574
|
-
if (iteratorTaken) throw new Error("a turn has only one event consumer");
|
|
3575
|
-
iteratorTaken = true;
|
|
3576
|
-
return {
|
|
3577
|
-
next() {
|
|
3578
|
-
if (queue.length) {
|
|
3579
|
-
const { update, size } = queue.shift();
|
|
3580
|
-
queuedBytes -= size;
|
|
3581
|
-
resumeOutput?.();
|
|
3582
|
-
resumeOutput = null;
|
|
3583
|
-
return Promise.resolve({ value: update, done: false });
|
|
3584
|
-
}
|
|
3585
|
-
if (terminalError) return Promise.reject(terminalError);
|
|
3586
|
-
if (finished) return Promise.resolve({ done: true });
|
|
3587
|
-
return new Promise((resolve, reject) => waiters.push({ resolve, reject }));
|
|
3588
|
-
},
|
|
3589
|
-
return() { turn.cancel(); return Promise.resolve({ done: true }); },
|
|
3590
|
-
};
|
|
3591
|
-
},
|
|
3592
|
-
};
|
|
3593
|
-
if (signal?.aborted) {
|
|
3594
|
-
resolvePromptStart?.(false);
|
|
3595
|
-
resolvePromptStart = null;
|
|
3596
|
-
finished = true;
|
|
3597
|
-
turn.result = Promise.resolve({ stopReason: "cancelled" });
|
|
3598
|
-
return turn;
|
|
3599
|
-
}
|
|
3600
|
-
activeTurn = turn;
|
|
3601
|
-
runtime.openSteering?.();
|
|
3602
|
-
const abort = () => turn.cancel();
|
|
3603
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
3604
|
-
const imagePrepCancelled = asyncImages ? new Promise((resolve, reject) => {
|
|
3605
|
-
cancelImagePrep = () => resolve(null);
|
|
3606
|
-
rejectImagePrep = reject;
|
|
3607
|
-
}) : null;
|
|
3608
|
-
let response;
|
|
3609
|
-
if (asyncImages) {
|
|
3610
|
-
const stopped = () => cancelled || closing;
|
|
3611
|
-
response = Promise.race([
|
|
3612
|
-
Promise.resolve()
|
|
3613
|
-
.then(() => materializePromptImages(prompt, stopped, resizeImage))
|
|
3614
|
-
.then((prepared) => prepared && imageStore ? storePromptImages(prepared, stopped) : prepared),
|
|
3615
|
-
imagePrepCancelled,
|
|
3616
|
-
]).then((prepared) => {
|
|
3617
|
-
cancelImagePrep = null;
|
|
3618
|
-
rejectImagePrep = null;
|
|
3619
|
-
if (coreExitError && !cancelled) throw coreExitError;
|
|
3620
|
-
if (prepared === null || cancelled || closing) {
|
|
3621
|
-
resolvePromptStart?.(false);
|
|
3622
|
-
resolvePromptStart = null;
|
|
3623
|
-
return { stopReason: "cancelled" };
|
|
3624
|
-
}
|
|
3625
|
-
return sendPrompt(prepared, false, followUp, turn.id, [], yieldAt);
|
|
3626
|
-
});
|
|
3627
|
-
} else {
|
|
3628
|
-
try { response = sendPrompt(prompt, resuming, followUp, turn.id, promptOptions[rerunCallIds] ?? [], yieldAt, promptOptions[resumeOutputShown] === true); } catch (error) { response = Promise.reject(error); }
|
|
3629
|
-
}
|
|
3630
|
-
// Appends after a turn's first progress are lazy, so the turn runs ahead
|
|
3631
|
-
// of them, but its result waits until every record it wrote is stored:
|
|
3632
|
-
// a turn counts as done only once its end is durable, and a failure
|
|
3633
|
-
// among those records fails the turn.
|
|
3634
|
-
turn.result = response
|
|
3635
|
-
.then(async (value) => {
|
|
3636
|
-
if (journal) await journalSettled().catch(() => {});
|
|
3637
|
-
if (journalFailure) throw reportJournalFailure();
|
|
3638
|
-
if (value.stopReason === "yielded" && !cancelled) {
|
|
3639
|
-
// The core keeps the turn open: `resume()` continues it here, or
|
|
3640
|
-
// a later agent continues it from the journal.
|
|
3641
|
-
resumable = true;
|
|
3642
|
-
openTurnId = turn.id;
|
|
3643
|
-
ambiguousCalls = [];
|
|
3644
|
-
} else if (!handedOff) {
|
|
3645
|
-
// A handed-off turn stays open for the next agent.
|
|
3646
|
-
if (turn.id) lastTurnId = turn.id;
|
|
3647
|
-
openTurnId = null;
|
|
3648
|
-
}
|
|
3649
|
-
return { stopReason: cancelled ? "cancelled" : value.stopReason, usage: value.usage };
|
|
3650
|
-
})
|
|
3651
|
-
.catch(async (error) => {
|
|
3652
|
-
cancelImagePrep = null;
|
|
3653
|
-
rejectImagePrep = null;
|
|
3654
|
-
resolvePromptStart?.(error);
|
|
3655
|
-
resolvePromptStart = null;
|
|
3656
|
-
// A failed append is why the core stopped, including when the turn
|
|
3657
|
-
// was cancelled because of it; report the host's error.
|
|
3658
|
-
if (journalFailure) {
|
|
3659
|
-
terminalError = reportJournalFailure();
|
|
3660
|
-
throw terminalError;
|
|
3661
|
-
}
|
|
3662
|
-
if (cancelled && error.message === "Cancelled") return { stopReason: "cancelled" };
|
|
3663
|
-
terminalError = error;
|
|
3664
|
-
throw terminalError;
|
|
3665
|
-
})
|
|
3666
|
-
.finally(() => {
|
|
3667
|
-
finished = true;
|
|
3668
|
-
resumeOutput?.();
|
|
3669
|
-
resumeOutput = null;
|
|
3670
|
-
signal?.removeEventListener("abort", abort);
|
|
3671
|
-
if (activeTurn === turn) activeTurn = null;
|
|
3672
|
-
// A steer still queued when the turn ends never reached the model.
|
|
3673
|
-
if (typeof runtime.withdrawSteering === "function") {
|
|
3674
|
-
const untaken = [...steers.keys()].filter((id) => runtime.withdrawSteering(id));
|
|
3675
|
-
rejectInputWaiters(untaken.map((id) => `accepted:${id}`), new Error("the turn ended before it took the steer"));
|
|
3676
|
-
}
|
|
3677
|
-
for (const id of steers.keys()) {
|
|
3678
|
-
durableInputs.delete(`accepted:${id}`);
|
|
3679
|
-
durableInputs.delete(`withdrawn:${id}`);
|
|
3680
|
-
}
|
|
3681
|
-
queueMicrotask(runNextFollowUp);
|
|
3682
|
-
queueMicrotask(() => { void maybeCheckpoint(); });
|
|
3683
|
-
runtime.closeSteering?.();
|
|
3684
|
-
toolControllers.clear();
|
|
3685
|
-
if (discardedBytes) emit("output.discarded", { reason: "cancelled", bytes: discardedBytes });
|
|
3686
|
-
for (const waiter of waiters.splice(0)) {
|
|
3687
|
-
if (terminalError) waiter.reject(terminalError);
|
|
3688
|
-
else waiter.resolve({ done: true });
|
|
3689
|
-
}
|
|
3690
|
-
});
|
|
3691
|
-
if (signal?.aborted) turn.cancel();
|
|
3692
|
-
void turn.result.catch(() => {});
|
|
3693
|
-
return turn;
|
|
3694
|
-
}
|
|
1
|
+
// `libfx/wasm`: the fx engine and terminal on WebAssembly. It names no
|
|
2
|
+
// packaged asset, so every factory takes its `wasm` explicitly.
|
|
3
|
+
import { createFxEngine as createEngine } from "./engine.js";
|
|
4
|
+
import { wasmRuntimeFactory } from "./wasm-runtime.js";
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
checkPromptInput,
|
|
8
|
+
coreAnswered,
|
|
9
|
+
createMemoryPersistence,
|
|
10
|
+
engineInternals,
|
|
11
|
+
FxFencedError,
|
|
12
|
+
FxJournalVersionError,
|
|
13
|
+
fxSdkApiVersion,
|
|
14
|
+
journalMarksWanted,
|
|
15
|
+
listModels,
|
|
16
|
+
normalizeAgentOptions,
|
|
17
|
+
normalizeInstructions,
|
|
18
|
+
normalizeModelChoice,
|
|
19
|
+
persistenceImages,
|
|
20
|
+
resumeOutputShown,
|
|
21
|
+
supportsJspi,
|
|
22
|
+
} from "./engine.js";
|
|
23
|
+
export { encodeXtermKeyEvent, xtermAdapter } from "./xterm.js";
|
|
24
|
+
export { createFxTerminal } from "./wasm-terminal.js";
|
|
25
|
+
|
|
26
|
+
export function createFxEngine(options = {}) {
|
|
27
|
+
return createEngine(options, wasmRuntimeFactory());
|
|
3695
28
|
}
|