libfx 0.0.13 → 0.0.14-dev.1534.g6afa7c33dac5
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 +409 -486
- package/browser.js +18 -4
- package/chatgpt-sign-in.js +141 -0
- package/chatgpt.js +68 -0
- package/custom-provider.js +123 -0
- package/durable/local.mjs +190 -0
- package/durable/vercel.mjs +268 -0
- package/durable/world.mjs +1 -0
- package/durable.js +2591 -0
- package/fetch-cleanup.js +12 -0
- package/fx-core.wasm +0 -0
- package/fx-harness.js +220 -0
- package/fx-sdk.js +1226 -115
- package/fx-term.wasm +0 -0
- package/grok-sign-in.js +145 -0
- package/grok.js +108 -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/mcp.js +3 -0
- package/node.cjs +4159 -212
- package/node.js +193 -47
- package/oauth.js +64 -0
- package/package.json +66 -13
- package/subscription.js +251 -0
- package/types/browser.d.ts +139 -0
- package/types/chatgpt.d.ts +58 -0
- package/types/durable-local.d.ts +18 -0
- package/types/durable-vercel.d.ts +22 -0
- package/types/durable-world.d.ts +89 -0
- package/types/grok.d.ts +61 -0
- package/types/libfx.d.cts +935 -0
- package/types/mcp.d.ts +41 -0
- package/types/node.d.cts +158 -0
- package/types/node.d.ts +3 -0
- package/types/skills-node.d.ts +25 -0
- package/types/skills.d.ts +28 -0
- package/types/wasm.d.ts +99 -0
package/fx-sdk.js
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
1
|
import { CoreOutput, maxCoreMessageBytes } from "./core-output.js";
|
|
2
|
+
import { codexSubscription } from "./chatgpt.js";
|
|
3
|
+
import { customProviderDefinition, customProviderSpec } from "./custom-provider.js";
|
|
4
|
+
import { grokSubscription } from "./grok.js";
|
|
5
|
+
import { createSubscriptionAuthorizer, credentialMemory } from "./subscription.js";
|
|
2
6
|
import { loadModule } from "./wasm-module.js";
|
|
3
7
|
|
|
8
|
+
// Providers that run on a user's plan, authorized by the host's credentials.
|
|
9
|
+
const subscriptionProviders = new Set(["codex", "grok"]);
|
|
10
|
+
|
|
4
11
|
const encoder = new TextEncoder();
|
|
5
12
|
const decoder = new TextDecoder();
|
|
6
13
|
const strictDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
7
14
|
const workspaceInfoLimit = 4 * 1024;
|
|
8
15
|
const workspaceCommandLimit = 64 * 1024;
|
|
9
16
|
const workspaceOutputLimit = 64 * 1024;
|
|
17
|
+
// Matches max_instruction_file_bytes and max_instruction_path_bytes in js_host_workspace.zig.
|
|
18
|
+
const workspaceInstructionFileLimit = 1024 * 1024;
|
|
19
|
+
const workspaceInstructionPathLimit = 4 * 1024;
|
|
20
|
+
const workspaceReadTimeoutMs = 10_000;
|
|
10
21
|
const maxInstructionsBytes = 64 * 1024;
|
|
11
22
|
const maxApiKeyBytes = 64 * 1024;
|
|
12
23
|
const maxModelBytes = 1024;
|
|
13
24
|
// Matches the kernel's ReasoningEffort.max_name_bytes.
|
|
14
25
|
const maxEffortBytes = 64;
|
|
15
26
|
const maxUrlBytes = 16 * 1024;
|
|
27
|
+
// The answer a resumed turn gives a call its crash left running; matches
|
|
28
|
+
// unfinished_tool_output in session_adapter.zig.
|
|
29
|
+
const unfinishedToolOutput = "fx stopped while this tool was running, so it may have partly run. Check its effects before running it again.";
|
|
16
30
|
const maxModelCatalogBytes = 4 * 1024 * 1024;
|
|
17
31
|
const maxModelCatalogEntries = 10_000;
|
|
18
32
|
const streamReadsPerTaskYield = 32;
|
|
@@ -33,6 +47,8 @@ const maxPendingAttachments = 8;
|
|
|
33
47
|
const maxOutboundAttachments = 4;
|
|
34
48
|
// Matches the core's kernel checkpoint limit (max_checkpoint_bytes).
|
|
35
49
|
const maxCheckpointBytes = 4 * 1024 * 1024;
|
|
50
|
+
// Matches the core's journal load limit (journal.max_load_bytes).
|
|
51
|
+
const maxJournalBytes = 4 * 1024 * 1024;
|
|
36
52
|
// The core's ACP reader drops frames over 8 MiB without a request id to answer
|
|
37
53
|
// (jsonrpc frame_resource_byte_limit), so the SDK must never emit one. The
|
|
38
54
|
// envelope allowance covers the method key and request id.
|
|
@@ -101,15 +117,9 @@ function normalizeUltrafast(value) {
|
|
|
101
117
|
return value;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
const options = { ...value };
|
|
109
|
-
if (Object.hasOwn(options, "env")) {
|
|
110
|
-
throw new TypeError("createFxAgent() does not accept env; pass apiKey and model directly");
|
|
111
|
-
}
|
|
112
|
-
options.apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, true);
|
|
120
|
+
// The model choice in `options`, a model object or a model ID with the
|
|
121
|
+
// deprecated top-level options, as `{ model, effort, fast, ultrafast }`.
|
|
122
|
+
export function normalizeModelChoice(options) {
|
|
113
123
|
if (options.model !== null && typeof options.model === "object" && !Array.isArray(options.model)) {
|
|
114
124
|
if (Object.hasOwn(options, "effort") || Object.hasOwn(options, "fast") || Object.hasOwn(options, "ultrafast")) {
|
|
115
125
|
throw new TypeError("model options cannot be mixed with top-level effort, fast, or ultrafast");
|
|
@@ -120,26 +130,352 @@ export function normalizeAgentOptions(value) {
|
|
|
120
130
|
throw new TypeError(`unsupported model option: ${name}`);
|
|
121
131
|
}
|
|
122
132
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
options.
|
|
133
|
+
return {
|
|
134
|
+
model: boundedString(model.id, "model.id", maxModelBytes, true),
|
|
135
|
+
effort: normalizeEffort(model.effort),
|
|
136
|
+
fast: normalizeFast(model.fast),
|
|
137
|
+
ultrafast: normalizeUltrafast(model.ultrafast),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
model: boundedString(options.model, "model", maxModelBytes, false),
|
|
142
|
+
effort: normalizeEffort(options.effort),
|
|
143
|
+
fast: normalizeFast(options.fast),
|
|
144
|
+
ultrafast: normalizeUltrafast(options.ultrafast),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function normalizeAgentOptions(value) {
|
|
149
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
150
|
+
throw new TypeError("createFxEngine() options must be an object");
|
|
132
151
|
}
|
|
152
|
+
const options = { ...value };
|
|
153
|
+
if (Object.hasOwn(options, "env")) {
|
|
154
|
+
throw new TypeError("createFxEngine() does not accept env; pass apiKey and model directly");
|
|
155
|
+
}
|
|
156
|
+
// Derived from the caller's provider every time, so normalizing again agrees.
|
|
157
|
+
const custom = options.provider !== null && typeof options.provider === "object"
|
|
158
|
+
? customProviderDefinition(options.provider)
|
|
159
|
+
: undefined;
|
|
160
|
+
if (!custom && options.provider !== undefined && options.provider !== "gateway" && !subscriptionProviders.has(options.provider)) {
|
|
161
|
+
throw new TypeError("provider must be \"gateway\", \"codex\", \"grok\" or a custom provider { name, baseUrl }");
|
|
162
|
+
}
|
|
163
|
+
options.customProvider = custom;
|
|
164
|
+
options.providerName = custom ? custom.name : options.provider;
|
|
165
|
+
const subscription = subscriptionProviders.has(options.provider);
|
|
166
|
+
// Gateway needs its key; every other provider runs on the host's credentials.
|
|
167
|
+
options.apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, !subscription && !custom);
|
|
168
|
+
if (subscription && typeof options.credentials !== "function") {
|
|
169
|
+
throw new TypeError(`provider "${options.provider}" needs a credentials() function`);
|
|
170
|
+
}
|
|
171
|
+
if (!subscription && !custom && options.credentials !== undefined) {
|
|
172
|
+
throw new TypeError("credentials needs provider \"codex\", \"grok\" or a custom provider");
|
|
173
|
+
}
|
|
174
|
+
// A custom provider's key is never refreshed, so it has no onCredentials or refreshLock.
|
|
175
|
+
for (const name of ["onCredentials", "refreshLock"]) {
|
|
176
|
+
if (!subscription && options[name] !== undefined) throw new TypeError(`${name} needs provider "codex" or "grok"`);
|
|
177
|
+
}
|
|
178
|
+
for (const name of ["credentials", "onCredentials", "refreshLock"]) {
|
|
179
|
+
if (options[name] !== undefined && typeof options[name] !== "function") throw new TypeError(`${name} must be a function`);
|
|
180
|
+
}
|
|
181
|
+
Object.assign(options, normalizeModelChoice(options));
|
|
182
|
+
// fx's default model is a Gateway id; a custom provider names its own.
|
|
183
|
+
if (custom && options.model === undefined) throw new TypeError("a custom provider needs a model");
|
|
133
184
|
validateGatewayChatUrl(options.gatewayChatUrl);
|
|
134
185
|
if (options.resizeImage !== undefined && typeof options.resizeImage !== "function") {
|
|
135
186
|
throw new TypeError("resizeImage must be a function");
|
|
136
187
|
}
|
|
188
|
+
if (options.sessionId !== undefined && options.sessionId !== null && !validSessionId(options.sessionId)) {
|
|
189
|
+
throw new TypeError(`sessionId must be ${sessionIdRule}`);
|
|
190
|
+
}
|
|
191
|
+
// A separate key, so normalizing these options again reads the caller's value.
|
|
192
|
+
if (options.modelCatalog !== undefined) options.modelCatalogBody = modelCatalogBody(options.modelCatalog);
|
|
193
|
+
if (options.persistence !== undefined) {
|
|
194
|
+
const persistence = options.persistence;
|
|
195
|
+
if (!persistence || typeof persistence !== "object" ||
|
|
196
|
+
typeof persistence.load !== "function" || typeof persistence.append !== "function" ||
|
|
197
|
+
(persistence.saveCheckpoint !== undefined && typeof persistence.saveCheckpoint !== "function")) {
|
|
198
|
+
throw new TypeError("persistence must be an object with load(), append() and, optionally, saveCheckpoint()");
|
|
199
|
+
}
|
|
200
|
+
if (options.checkpoint !== undefined) {
|
|
201
|
+
throw new TypeError("persistence cannot be combined with checkpoint");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (options.checkpointAfterBytes !== undefined) {
|
|
205
|
+
if (options.persistence === undefined) throw new TypeError("checkpointAfterBytes needs persistence");
|
|
206
|
+
if (!Number.isSafeInteger(options.checkpointAfterBytes) || options.checkpointAfterBytes < 0) {
|
|
207
|
+
throw new RangeError("checkpointAfterBytes must be a non-negative integer");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
137
210
|
return options;
|
|
138
211
|
}
|
|
139
212
|
|
|
213
|
+
// Keys for libfx's own session worker; symbols keep them off the engine's
|
|
214
|
+
// public surface.
|
|
215
|
+
export const engineInternals = Symbol.for("libfx.engineInternals");
|
|
216
|
+
export const journalMarksWanted = Symbol.for("libfx.journalMarks");
|
|
217
|
+
// Marks an error the core answered a request with, as opposed to one from a
|
|
218
|
+
// core that stopped answering.
|
|
219
|
+
export const coreAnswered = Symbol.for("libfx.coreAnswered");
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Another writer took over the session after this agent loaded it. A
|
|
223
|
+
* persistence store rejects an append whose `expected` cursor is not its head
|
|
224
|
+
* with this error; the agent stops its turn at once and makes no further
|
|
225
|
+
* effect or write. Check `code === "FX_FENCED"` when the error may come from
|
|
226
|
+
* another copy of libfx.
|
|
227
|
+
*/
|
|
228
|
+
export class FxFencedError extends Error {
|
|
229
|
+
constructor(message) {
|
|
230
|
+
super(message);
|
|
231
|
+
this.name = "FxFencedError";
|
|
232
|
+
this.code = "FX_FENCED";
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The journal, or the `checkpoint` option, was written by a newer libfx. */
|
|
237
|
+
export class FxJournalVersionError extends Error {
|
|
238
|
+
constructor(message) {
|
|
239
|
+
super(message);
|
|
240
|
+
this.name = "FxJournalVersionError";
|
|
241
|
+
this.code = "FX_JOURNAL_VERSION";
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// A journal this libfx cannot open: more than one load holds
|
|
246
|
+
// (FX_JOURNAL_TOO_LARGE), or events that do not fold (FX_JOURNAL_INVALID).
|
|
247
|
+
function journalLoadError(message, code, cause) {
|
|
248
|
+
const error = new Error(message, cause === undefined ? undefined : { cause });
|
|
249
|
+
error.code = code;
|
|
250
|
+
return error;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The core's libfx/journal_open errors, by their exact message. The journal
|
|
254
|
+
// tests open a journal for each, so rewording one fails them, except the size
|
|
255
|
+
// limit: libfx refuses a journal over it before the core sees one, so that
|
|
256
|
+
// entry is a backstop.
|
|
257
|
+
const journalOpenErrorCodes = new Map([
|
|
258
|
+
["Invalid libfx journal", "FX_JOURNAL_INVALID"],
|
|
259
|
+
["libfx journal events are out of order", "FX_JOURNAL_INVALID"],
|
|
260
|
+
["libfx journal is too large", "FX_JOURNAL_TOO_LARGE"],
|
|
261
|
+
["libfx journal holds more than 1024 turns", "FX_JOURNAL_TOO_LARGE"],
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A persistence store that keeps a session's records and its latest
|
|
266
|
+
* checkpoint in memory, for tests and for hosts that copy them elsewhere.
|
|
267
|
+
* `records` is the stored list, oldest first. An append whose `expected`
|
|
268
|
+
* cursor is not the last record's is refused, so a second agent cannot write
|
|
269
|
+
* the same session, and an append that repeats a stored write's
|
|
270
|
+
* `idempotencyKey` returns that write's cursor.
|
|
271
|
+
*/
|
|
272
|
+
export function createMemoryPersistence() {
|
|
273
|
+
const records = [];
|
|
274
|
+
let checkpoint = null;
|
|
275
|
+
const head = () => records.at(-1)?.cursor ?? null;
|
|
276
|
+
return {
|
|
277
|
+
records,
|
|
278
|
+
get checkpoint() {
|
|
279
|
+
return checkpoint;
|
|
280
|
+
},
|
|
281
|
+
async load() {
|
|
282
|
+
const after = checkpoint ? records.findIndex((record) => record.cursor === checkpoint.through) + 1 : 0;
|
|
283
|
+
return {
|
|
284
|
+
...(checkpoint ? { checkpoint: { data: checkpoint.data.slice(), through: checkpoint.through } } : {}),
|
|
285
|
+
journal: records.slice(after).map(({ cursor, data }) => ({ cursor, data: data.slice() })),
|
|
286
|
+
};
|
|
287
|
+
},
|
|
288
|
+
async append({ expected, idempotencyKey, data }) {
|
|
289
|
+
const stored = records.find((record) => record.idempotencyKey === idempotencyKey);
|
|
290
|
+
if (stored) return { cursor: stored.cursor };
|
|
291
|
+
if (expected !== head()) {
|
|
292
|
+
throw new FxFencedError(`another agent appended to this session: expected ${expected}, head ${head()}`);
|
|
293
|
+
}
|
|
294
|
+
const cursor = String(records.length + 1);
|
|
295
|
+
records.push({ cursor, idempotencyKey, data: data.slice() });
|
|
296
|
+
return { cursor };
|
|
297
|
+
},
|
|
298
|
+
async saveCheckpoint({ through, data }) {
|
|
299
|
+
checkpoint = { through, data: data.slice() };
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Marks the turn `agent.resume()` starts; never a caller-visible option.
|
|
305
|
+
const resumeTurn = Symbol("libfx.resume");
|
|
306
|
+
// Carries the follow-up a turn runs: `{ id, accepted }`.
|
|
307
|
+
const followUpInput = Symbol("libfx.followUp");
|
|
308
|
+
// Carries the ids of the calls a resumed turn runs again.
|
|
309
|
+
const rerunCallIds = Symbol("libfx.rerun");
|
|
310
|
+
|
|
311
|
+
function journalAppendError(cause) {
|
|
312
|
+
const error = new Error("libfx journal append failed", { cause });
|
|
313
|
+
error.code = "FX_JOURNAL_APPEND_FAILED";
|
|
314
|
+
return error;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Restoring an open turn needs only its newest progress, and the ones it
|
|
318
|
+
// extends when it carries only what is new back to one that carries all of
|
|
319
|
+
// it, and the results of calls no progress has answered yet. So an older
|
|
320
|
+
// progress's state, and an answered call's result, are left out of a load
|
|
321
|
+
// (data: null). A load then grows with the session's committed turns, not
|
|
322
|
+
// with every step of every turn.
|
|
323
|
+
function withoutSupersededProgress(events) {
|
|
324
|
+
const kept = new Array(events.length);
|
|
325
|
+
let superseded = false;
|
|
326
|
+
let answered = false;
|
|
327
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
328
|
+
const event = events[index];
|
|
329
|
+
const type = event?.type;
|
|
330
|
+
const dropped = event?.data != null && ((type === "turn_progress" && superseded) || (type === "tool_result" && answered));
|
|
331
|
+
kept[index] = dropped ? { ...event, data: null } : event;
|
|
332
|
+
if (type === "turn_committed" || type === "turn_progress_cleared") {
|
|
333
|
+
superseded = true;
|
|
334
|
+
answered = true;
|
|
335
|
+
} else if (type === "turn_progress") {
|
|
336
|
+
answered = true;
|
|
337
|
+
if (event.base === undefined) superseded = true;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return kept;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// A session id reaches gateway headers; the core applies the same rule.
|
|
344
|
+
function validSessionId(id) {
|
|
345
|
+
return typeof id === "string" && /^[A-Za-z0-9._-]{1,255}$/.test(id) &&
|
|
346
|
+
id !== "." && id !== ".." && id.toLowerCase() !== "v2";
|
|
347
|
+
}
|
|
348
|
+
const sessionIdRule = "1 to 255 letters, digits, '.', '_' or '-'";
|
|
349
|
+
|
|
350
|
+
// A record is one append's events as UTF-8 JSON. Its shape is libfx's; a
|
|
351
|
+
// store keeps the bytes as they are.
|
|
352
|
+
const journalRecordFormat = "libfx-journal-v1";
|
|
353
|
+
// Checkpoints after the journal since the last one reaches this many bytes.
|
|
354
|
+
const defaultCheckpointAfterBytes = 1024 * 1024;
|
|
355
|
+
// A turn id reaches the journal and tool calls; the core applies the same rule.
|
|
356
|
+
const turnIdRule = "1 to 128 letters, digits, '.', '_' or '-'";
|
|
357
|
+
|
|
358
|
+
function validTurnId(id) {
|
|
359
|
+
return typeof id === "string" && /^[A-Za-z0-9._-]{1,128}$/.test(id);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function checkOnAmbiguous(onAmbiguous) {
|
|
363
|
+
if (onAmbiguous !== undefined && typeof onAmbiguous !== "function") throw new TypeError("onAmbiguous must be a function");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// A call's input as the model wrote it: JSON when it parses, else the text.
|
|
367
|
+
function parsedToolInput(text) {
|
|
368
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function validCursor(cursor) {
|
|
372
|
+
return typeof cursor === "string" && cursor.length > 0 && cursor.length <= 1024;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function encodeJournalRecord(events) {
|
|
376
|
+
return encoder.encode(JSON.stringify({ format: journalRecordFormat, events }));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function persistenceBytes(value, what) {
|
|
380
|
+
if (value instanceof Uint8Array) return value;
|
|
381
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
382
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
383
|
+
throw new TypeError(`${what} must be bytes`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function decodeJournalRecord(data) {
|
|
387
|
+
let value;
|
|
388
|
+
try { value = JSON.parse(decoder.decode(persistenceBytes(data, "a journal record's data"))); } catch (error) {
|
|
389
|
+
if (error instanceof TypeError && /must be bytes/.test(error.message)) throw error;
|
|
390
|
+
throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID", error);
|
|
391
|
+
}
|
|
392
|
+
if (value?.format !== journalRecordFormat || !Array.isArray(value.events)) {
|
|
393
|
+
if (typeof value?.format === "string" && value.format.startsWith("libfx-journal-v")) {
|
|
394
|
+
throw new FxJournalVersionError("libfx journal was written by a newer fx");
|
|
395
|
+
}
|
|
396
|
+
throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID");
|
|
397
|
+
}
|
|
398
|
+
return value.events;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// The last `seq` a checkpoint covers, from its header: `FXSN`, a version
|
|
402
|
+
// byte, then the seq as a little-endian u64. The core checks the rest.
|
|
403
|
+
function checkpointSeq(bytes) {
|
|
404
|
+
if (bytes.byteLength < 13 || decoder.decode(bytes.subarray(0, 4)) !== "FXSN") {
|
|
405
|
+
throw journalLoadError("Invalid libfx journal", "FX_JOURNAL_INVALID");
|
|
406
|
+
}
|
|
407
|
+
return Number(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(5, true));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Reads what `persistence.load()` returns: the latest checkpoint, if any, and
|
|
411
|
+
// the records after it, as the events the core folds after that checkpoint.
|
|
412
|
+
async function loadPersistence(persistence) {
|
|
413
|
+
const loaded = await persistence.load();
|
|
414
|
+
if (!loaded || typeof loaded !== "object") {
|
|
415
|
+
throw new TypeError("persistence.load() must resolve to { checkpoint?, journal? }");
|
|
416
|
+
}
|
|
417
|
+
let checkpoint = null;
|
|
418
|
+
let covered = 0;
|
|
419
|
+
let head = null;
|
|
420
|
+
if (loaded.checkpoint != null) {
|
|
421
|
+
const { data, through } = loaded.checkpoint;
|
|
422
|
+
if (!validCursor(through)) throw new TypeError("persistence.load() checkpoint.through must be a cursor string");
|
|
423
|
+
checkpoint = persistenceBytes(data, "checkpoint.data").slice();
|
|
424
|
+
if (checkpoint.byteLength > maxCheckpointBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
|
|
425
|
+
covered = checkpointSeq(checkpoint);
|
|
426
|
+
head = through;
|
|
427
|
+
}
|
|
428
|
+
const records = loaded.journal ?? [];
|
|
429
|
+
if (typeof records[Symbol.iterator] !== "function" && typeof records[Symbol.asyncIterator] !== "function") {
|
|
430
|
+
throw new TypeError("persistence.load() journal must be an iterable of { cursor, data }");
|
|
431
|
+
}
|
|
432
|
+
const events = [];
|
|
433
|
+
let bytes = 0;
|
|
434
|
+
for await (const record of records) {
|
|
435
|
+
if (!record || !validCursor(record.cursor)) throw new TypeError("each journal record needs a cursor string");
|
|
436
|
+
// The load limit applies once an open turn's older progress is dropped.
|
|
437
|
+
const data = persistenceBytes(record.data, "a journal record's data");
|
|
438
|
+
bytes += data.byteLength;
|
|
439
|
+
// A store may return records the checkpoint already covers.
|
|
440
|
+
for (const event of decodeJournalRecord(data)) if (!(Number.isSafeInteger(event?.seq) && event.seq <= covered)) events.push(event);
|
|
441
|
+
head = record.cursor;
|
|
442
|
+
}
|
|
443
|
+
// A store whose log holds entries of its own between records names the
|
|
444
|
+
// entry the next record follows.
|
|
445
|
+
if (loaded.head !== undefined) {
|
|
446
|
+
if (loaded.head !== null && !validCursor(loaded.head)) throw new TypeError("persistence.load() head must be a cursor string or null");
|
|
447
|
+
head = loaded.head;
|
|
448
|
+
}
|
|
449
|
+
return { checkpoint, events, head, tailBytes: bytes, lastSeq: events.at(-1)?.seq ?? covered };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// What a record's events say about turns, in order, for a store that tracks
|
|
453
|
+
// them without reading records: a turn started, ended, or yielded, an input
|
|
454
|
+
// was accepted or handed back unplaced, or the step the open turn is at, as
|
|
455
|
+
// its tool steps finished and the state of its calls.
|
|
456
|
+
function journalMarks(events) {
|
|
457
|
+
const marks = [];
|
|
458
|
+
for (const event of events) {
|
|
459
|
+
if (event?.type === "turn_progress") {
|
|
460
|
+
if (typeof event.turnId === "string") marks.push({ start: event.turnId });
|
|
461
|
+
// The inputs its model request carries for the first time.
|
|
462
|
+
for (const id of Array.isArray(event.inputs) ? event.inputs : []) if (typeof id === "string") marks.push({ placed: id });
|
|
463
|
+
// A progress that carries only what is new counts all the turn's steps.
|
|
464
|
+
const steps = Number.isSafeInteger(event.steps) ? event.steps : event.data?.execution?.tool_steps?.length;
|
|
465
|
+
if (Number.isSafeInteger(steps)) marks.push({ step: `${steps}:${String(event.data?.tool_state ?? "none")}` });
|
|
466
|
+
} else if (event?.type === "turn_committed" || event?.type === "turn_progress_cleared") marks.push({ end: true });
|
|
467
|
+
else if (event?.type === "turn_yielded") marks.push({ yield: true });
|
|
468
|
+
else if (event?.type === "input_accepted" && typeof event.data?.id === "string") marks.push({ accepted: event.data.id });
|
|
469
|
+
else if (event?.type === "input_withdrawn" && typeof event.data?.id === "string") marks.push({ withdrawn: event.data.id });
|
|
470
|
+
}
|
|
471
|
+
return marks;
|
|
472
|
+
}
|
|
473
|
+
|
|
140
474
|
function agentEnvironment(options) {
|
|
141
475
|
return {
|
|
142
|
-
AI_GATEWAY_API_KEY: options.apiKey,
|
|
476
|
+
...(options.apiKey === undefined ? {} : { AI_GATEWAY_API_KEY: options.apiKey }),
|
|
477
|
+
...(options.providerName === undefined ? {} : { FX_PROVIDER: options.providerName }),
|
|
478
|
+
...(options.customProvider === undefined ? {} : { FX_CONFIGURED_PROVIDERS: options.customProvider.json }),
|
|
143
479
|
...(options.model === undefined ? {} : { FX_MODEL: options.model }),
|
|
144
480
|
...(options.effort === undefined ? {} : { FX_EFFORT: options.effort }),
|
|
145
481
|
...(options.fast === undefined ? {} : { FX_FAST: options.fast ? "true" : "false" }),
|
|
@@ -150,6 +486,7 @@ function agentEnvironment(options) {
|
|
|
150
486
|
|
|
151
487
|
function agentRpcError(response) {
|
|
152
488
|
const error = new Error(response.message);
|
|
489
|
+
error[coreAnswered] = true;
|
|
153
490
|
const data = response.data;
|
|
154
491
|
if (data && ["LIBFX_MODEL_UNSUPPORTED_EFFORT", "LIBFX_MODEL_UNSUPPORTED_FAST", "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST"].includes(data.code) &&
|
|
155
492
|
typeof data.model === "string" &&
|
|
@@ -253,12 +590,24 @@ function validWorkspacePath(path) {
|
|
|
253
590
|
return path.slice(1).split("/").every((part) => part && part !== "." && part !== "..");
|
|
254
591
|
}
|
|
255
592
|
|
|
593
|
+
function workspacePathInside(root, path) {
|
|
594
|
+
return root === "/" ? path.startsWith("/") : path === root || path.startsWith(`${root}/`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// fx only reads AGENTS.md files below the workspace root or the host home.
|
|
598
|
+
function workspaceInstructionPathAllowed(info, path) {
|
|
599
|
+
return validWorkspacePath(path) && path.endsWith("/AGENTS.md") &&
|
|
600
|
+
(workspacePathInside(info.root, path) || workspacePathInside(info.home, path));
|
|
601
|
+
}
|
|
602
|
+
|
|
256
603
|
function prepareWorkspaceAdapter(workspace) {
|
|
257
604
|
if (workspace == null) return { present: false, valid: false };
|
|
258
605
|
try {
|
|
259
606
|
const info = workspace.info;
|
|
260
607
|
const permission = workspace.permission;
|
|
608
|
+
const readFile = workspace.readFile;
|
|
261
609
|
if (!info || typeof workspace.exec !== "function" || info.version !== 1 ||
|
|
610
|
+
(readFile !== undefined && typeof readFile !== "function") ||
|
|
262
611
|
!validWorkspacePath(info.root) || !validWorkspacePath(info.cwd) ||
|
|
263
612
|
!validWorkspacePath(info.home) || info.cwd !== info.root ||
|
|
264
613
|
info.gitAvailable !== false || info.ephemeral !== true ||
|
|
@@ -273,6 +622,7 @@ function prepareWorkspaceAdapter(workspace) {
|
|
|
273
622
|
git: false,
|
|
274
623
|
ephemeral: true,
|
|
275
624
|
permission,
|
|
625
|
+
instruction_files: typeof readFile === "function",
|
|
276
626
|
};
|
|
277
627
|
const encoded = encoder.encode(JSON.stringify(value));
|
|
278
628
|
if (encoded.length > workspaceInfoLimit) return { present: true, valid: false };
|
|
@@ -488,12 +838,14 @@ function createRuntime(options) {
|
|
|
488
838
|
const steering = [];
|
|
489
839
|
let steeringBytes = 0;
|
|
490
840
|
let steeringOpen = false;
|
|
841
|
+
// A steer that cannot join the running turn waits to run next.
|
|
842
|
+
let steeringHandoff = false;
|
|
491
843
|
// Raw payloads beside ACP frames: the core copies inbound bytes into its
|
|
492
844
|
// own memory and publishes outbound bytes for the agent to take.
|
|
493
845
|
const inboundAttachments = new Map();
|
|
494
846
|
const outboundAttachments = new Map();
|
|
495
847
|
let nextOutboundAttachment = 1;
|
|
496
|
-
const
|
|
848
|
+
const workspaceOperations = new Set();
|
|
497
849
|
const workspace = prepareWorkspaceAdapter(options.workspace);
|
|
498
850
|
const args = ["fx", ...(options.args || [])];
|
|
499
851
|
const env = Object.entries(options.env || {}).map(([key, value]) => `${key}=${value}`);
|
|
@@ -762,9 +1114,10 @@ function createRuntime(options) {
|
|
|
762
1114
|
function clearSteering() {
|
|
763
1115
|
steering.length = 0;
|
|
764
1116
|
steeringBytes = 0;
|
|
1117
|
+
steeringHandoff = false;
|
|
765
1118
|
}
|
|
766
1119
|
|
|
767
|
-
function queueSteering(text) {
|
|
1120
|
+
function queueSteering(text, id = "") {
|
|
768
1121
|
if (!steeringOpen) throw new Error("no prompt is running");
|
|
769
1122
|
const value = encoder.encode(text);
|
|
770
1123
|
if (value.length === 0) throw new TypeError("steering text cannot be empty");
|
|
@@ -774,19 +1127,33 @@ function createRuntime(options) {
|
|
|
774
1127
|
if (steering.length >= maxSteeringMessages || value.length > maxSteeringQueueBytes - steeringBytes) {
|
|
775
1128
|
throw new Error("steering queue is full");
|
|
776
1129
|
}
|
|
777
|
-
steering.push(value);
|
|
1130
|
+
steering.push({ id, value });
|
|
778
1131
|
steeringBytes += value.length;
|
|
779
1132
|
}
|
|
780
1133
|
|
|
1134
|
+
// The core takes queued steering at a boundary; until then it can be withdrawn.
|
|
1135
|
+
function withdrawSteering(id) {
|
|
1136
|
+
const index = steering.findIndex((entry) => entry.id === id);
|
|
1137
|
+
if (index < 0) return false;
|
|
1138
|
+
steeringBytes -= steering[index].value.length;
|
|
1139
|
+
steering.splice(index, 1);
|
|
1140
|
+
return true;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Each message is its input id, a newline, then the text.
|
|
781
1144
|
function steeringTake(outputPtr, outputCap) {
|
|
782
|
-
const
|
|
783
|
-
if (!
|
|
1145
|
+
const entry = steering[0];
|
|
1146
|
+
if (!entry) return 0;
|
|
784
1147
|
const output = checkedBytes(outputPtr, outputCap);
|
|
785
|
-
|
|
786
|
-
|
|
1148
|
+
const id = encoder.encode(entry.id);
|
|
1149
|
+
const length = id.length + 1 + entry.value.length;
|
|
1150
|
+
if (!output || length > output.length) return -1;
|
|
1151
|
+
output.set(id, 0);
|
|
1152
|
+
output[id.length] = 10;
|
|
1153
|
+
output.set(entry.value, id.length + 1);
|
|
787
1154
|
steering.shift();
|
|
788
|
-
steeringBytes -= value.length;
|
|
789
|
-
return
|
|
1155
|
+
steeringBytes -= entry.value.length;
|
|
1156
|
+
return length;
|
|
790
1157
|
}
|
|
791
1158
|
|
|
792
1159
|
function writeAttachment(id, data) {
|
|
@@ -821,14 +1188,20 @@ function createRuntime(options) {
|
|
|
821
1188
|
return id;
|
|
822
1189
|
}
|
|
823
1190
|
|
|
1191
|
+
// Resolves once the agent's journal holds every event the core sent.
|
|
1192
|
+
function journalFlush() {
|
|
1193
|
+
if (typeof options.journalFlush !== "function") return -1;
|
|
1194
|
+
return Promise.resolve().then(() => options.journalFlush()).then(() => 0, () => -1);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
824
1197
|
let pendingHostToolResult = null;
|
|
825
|
-
function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr) {
|
|
1198
|
+
function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr, callIdPtr, callIdLen) {
|
|
826
1199
|
pendingHostToolResult = null;
|
|
827
1200
|
if (typeof options.hostToolExecutor !== "function") return -1;
|
|
828
1201
|
if (options.traceWasi) console.error("fx host tool call start");
|
|
829
1202
|
let input;
|
|
830
1203
|
try { input = JSON.parse(text(argumentsPtr, argumentsLen)); } catch { return -1; }
|
|
831
|
-
return Promise.resolve(options.hostToolExecutor(text(namePtr, nameLen), input)).then((result) => {
|
|
1204
|
+
return Promise.resolve(options.hostToolExecutor(text(namePtr, nameLen), input, undefined, text(callIdPtr, callIdLen))).then((result) => {
|
|
832
1205
|
if (options.traceWasi) console.error("fx host tool call settled", result.cancelled, result.isError);
|
|
833
1206
|
if (result.cancelled) return -2;
|
|
834
1207
|
const output = encoder.encode(result.content);
|
|
@@ -1047,32 +1420,13 @@ function createRuntime(options) {
|
|
|
1047
1420
|
}
|
|
1048
1421
|
if (command.includes("\0")) return Promise.resolve(-4);
|
|
1049
1422
|
|
|
1050
|
-
|
|
1051
|
-
let resolveAbort;
|
|
1052
|
-
const aborted = new Promise((resolve) => { resolveAbort = resolve; });
|
|
1053
|
-
const state = {
|
|
1054
|
-
controller,
|
|
1055
|
-
status: null,
|
|
1056
|
-
abort(status) {
|
|
1057
|
-
if (this.status !== null) return;
|
|
1058
|
-
this.status = status;
|
|
1059
|
-
resolveAbort(status);
|
|
1060
|
-
controller.abort(new DOMException(
|
|
1061
|
-
status === -5 ? "workspace command timed out" : "workspace command aborted",
|
|
1062
|
-
status === -5 ? "TimeoutError" : "AbortError",
|
|
1063
|
-
));
|
|
1064
|
-
},
|
|
1065
|
-
};
|
|
1066
|
-
workspaceExecs.add(state);
|
|
1067
|
-
const timer = setTimeout(() => state.abort(-5), timeoutMs);
|
|
1068
|
-
const execution = Promise.resolve().then(() => workspace.adapter.exec({
|
|
1423
|
+
return runWorkspaceOperation(timeoutMs, "workspace command", (signal) => workspace.adapter.exec({
|
|
1069
1424
|
command,
|
|
1070
1425
|
cwd: workspace.info.cwd,
|
|
1071
|
-
signal
|
|
1426
|
+
signal,
|
|
1072
1427
|
timeoutMs,
|
|
1073
1428
|
outputLimitBytes: workspaceOutputLimit,
|
|
1074
|
-
})
|
|
1075
|
-
if (state.status !== null) return state.status;
|
|
1429
|
+
}), (value) => {
|
|
1076
1430
|
if (!value || !Number.isInteger(value.exitCode) || value.exitCode < -0x80000000 ||
|
|
1077
1431
|
value.exitCode > 0x7fffffff || typeof value.stdout !== "string" ||
|
|
1078
1432
|
typeof value.stderr !== "string") return -1;
|
|
@@ -1106,6 +1460,81 @@ function createRuntime(options) {
|
|
|
1106
1460
|
view.setUint32(24, stderr.length, true);
|
|
1107
1461
|
view.setUint32(28, copied < stdout.length + stderr.length ? 1 : 0, true);
|
|
1108
1462
|
return 0;
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function workspaceReadFile(pathPtr, pathLen, outPtr, outCap, resultPtr) {
|
|
1467
|
+
if (!workspace.present || (workspace.valid && typeof workspace.adapter.readFile !== "function")) {
|
|
1468
|
+
return Promise.resolve(-2);
|
|
1469
|
+
}
|
|
1470
|
+
if (!workspace.valid) return Promise.resolve(-4);
|
|
1471
|
+
const pathBytes = checkedBytes(pathPtr, pathLen);
|
|
1472
|
+
if (!pathBytes || !checkedBytes(outPtr, outCap) || !checkedBytes(resultPtr, 8) ||
|
|
1473
|
+
pathLen > workspaceInstructionPathLimit || outCap > workspaceInstructionFileLimit) {
|
|
1474
|
+
return Promise.resolve(-4);
|
|
1475
|
+
}
|
|
1476
|
+
let path;
|
|
1477
|
+
try {
|
|
1478
|
+
path = strictDecoder.decode(pathBytes);
|
|
1479
|
+
} catch {
|
|
1480
|
+
return Promise.resolve(-4);
|
|
1481
|
+
}
|
|
1482
|
+
if (!workspaceInstructionPathAllowed(workspace.info, path)) return Promise.resolve(-4);
|
|
1483
|
+
|
|
1484
|
+
return runWorkspaceOperation(workspaceReadTimeoutMs, "workspace read", (signal) => workspace.adapter.readFile({
|
|
1485
|
+
path,
|
|
1486
|
+
signal,
|
|
1487
|
+
}), (value) => {
|
|
1488
|
+
if (value == null) return -6;
|
|
1489
|
+
let content;
|
|
1490
|
+
if (typeof value === "string") {
|
|
1491
|
+
content = encoder.encode(value);
|
|
1492
|
+
} else if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
|
1493
|
+
content = value instanceof ArrayBuffer
|
|
1494
|
+
? new Uint8Array(value)
|
|
1495
|
+
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
1496
|
+
try {
|
|
1497
|
+
strictDecoder.decode(content);
|
|
1498
|
+
} catch {
|
|
1499
|
+
return -7;
|
|
1500
|
+
}
|
|
1501
|
+
} else {
|
|
1502
|
+
return -1;
|
|
1503
|
+
}
|
|
1504
|
+
if (content.length > 0xffffffff) return -1;
|
|
1505
|
+
const copied = Math.min(content.length, outCap);
|
|
1506
|
+
// Reacquire views: memory may have grown while the host read was pending.
|
|
1507
|
+
bytes(outPtr, copied).set(content.subarray(0, copied));
|
|
1508
|
+
const view = new DataView(memory().buffer, resultPtr, 8);
|
|
1509
|
+
view.setUint32(0, content.length, true);
|
|
1510
|
+
view.setUint32(4, copied, true);
|
|
1511
|
+
return 0;
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// Runs one host workspace effect under a deadline and the shared Ctrl+C
|
|
1516
|
+
// abort path. `finish` maps the host value to an ABI status.
|
|
1517
|
+
function runWorkspaceOperation(timeoutMs, label, start, finish) {
|
|
1518
|
+
const controller = new AbortController();
|
|
1519
|
+
let resolveAbort;
|
|
1520
|
+
const aborted = new Promise((resolve) => { resolveAbort = resolve; });
|
|
1521
|
+
const state = {
|
|
1522
|
+
status: null,
|
|
1523
|
+
abort(status) {
|
|
1524
|
+
if (this.status !== null) return;
|
|
1525
|
+
this.status = status;
|
|
1526
|
+
resolveAbort(status);
|
|
1527
|
+
controller.abort(new DOMException(
|
|
1528
|
+
status === -5 ? `${label} timed out` : `${label} aborted`,
|
|
1529
|
+
status === -5 ? "TimeoutError" : "AbortError",
|
|
1530
|
+
));
|
|
1531
|
+
},
|
|
1532
|
+
};
|
|
1533
|
+
workspaceOperations.add(state);
|
|
1534
|
+
const timer = setTimeout(() => state.abort(-5), timeoutMs);
|
|
1535
|
+
const execution = Promise.resolve().then(() => start(controller.signal)).then((value) => {
|
|
1536
|
+
if (state.status !== null) return state.status;
|
|
1537
|
+
return finish(value);
|
|
1109
1538
|
}).catch((error) => {
|
|
1110
1539
|
if (state.status !== null) return state.status;
|
|
1111
1540
|
if (error?.name === "TimeoutError") return -5;
|
|
@@ -1114,7 +1543,7 @@ function createRuntime(options) {
|
|
|
1114
1543
|
});
|
|
1115
1544
|
return Promise.race([execution, aborted]).finally(() => {
|
|
1116
1545
|
clearTimeout(timer);
|
|
1117
|
-
|
|
1546
|
+
workspaceOperations.delete(state);
|
|
1118
1547
|
});
|
|
1119
1548
|
}
|
|
1120
1549
|
|
|
@@ -1122,7 +1551,7 @@ function createRuntime(options) {
|
|
|
1122
1551
|
pendingHostToolResult = null;
|
|
1123
1552
|
streams.forEach((state) => state.controller.abort(abortReason));
|
|
1124
1553
|
httpRequests.forEach((controller) => controller.abort(abortReason));
|
|
1125
|
-
|
|
1554
|
+
workspaceOperations.forEach((state) => state.abort(-3));
|
|
1126
1555
|
}
|
|
1127
1556
|
|
|
1128
1557
|
const unavailable = () => 52;
|
|
@@ -1177,12 +1606,14 @@ function createRuntime(options) {
|
|
|
1177
1606
|
fx_workspace_available() { return workspace.present ? 1 : 0; },
|
|
1178
1607
|
fx_workspace_info: workspaceInfo,
|
|
1179
1608
|
fx_workspace_exec: new WebAssembly.Suspending(workspaceExec),
|
|
1609
|
+
fx_workspace_read_file: new WebAssembly.Suspending(workspaceReadFile),
|
|
1180
1610
|
fx_http_stream_open: streamOpen,
|
|
1181
1611
|
fx_http_stream_status: new WebAssembly.Suspending(streamStatus),
|
|
1182
1612
|
fx_http_stream_next: new WebAssembly.Suspending(streamNext),
|
|
1183
1613
|
fx_http_stream_close(handle) { const state = streams.get(handle); state?.controller.abort(abortReason); streams.delete(handle); },
|
|
1184
1614
|
fx_http_request: new WebAssembly.Suspending(httpRequest),
|
|
1185
1615
|
fx_host_tool_call: new WebAssembly.Suspending(hostToolCall),
|
|
1616
|
+
fx_journal_flush: new WebAssembly.Suspending(journalFlush),
|
|
1186
1617
|
fx_host_tool_result_read(offset, ptr, cap) {
|
|
1187
1618
|
if (!pendingHostToolResult || offset < 0 || offset > pendingHostToolResult.length) return -1;
|
|
1188
1619
|
const chunk = pendingHostToolResult.subarray(offset, offset + cap);
|
|
@@ -1192,6 +1623,9 @@ function createRuntime(options) {
|
|
|
1192
1623
|
fx_host_tool_result_release() { pendingHostToolResult = null; },
|
|
1193
1624
|
fx_steering_take: steeringTake,
|
|
1194
1625
|
fx_steering_close() { steeringOpen = false; clearSteering(); },
|
|
1626
|
+
// -1 once the turn takes no more steering, else twice how many steers
|
|
1627
|
+
// wait, plus one when a handoff does.
|
|
1628
|
+
fx_steering_state() { return steeringOpen ? steering.length * 2 + (steeringHandoff ? 1 : 0) : -1; },
|
|
1195
1629
|
fx_attachment_size: attachmentSize,
|
|
1196
1630
|
fx_attachment_take: attachmentTake,
|
|
1197
1631
|
fx_attachment_put: attachmentPut,
|
|
@@ -1232,6 +1666,13 @@ function createRuntime(options) {
|
|
|
1232
1666
|
closeStdin() { steeringOpen = false; clearSteering(); stdin.close(); },
|
|
1233
1667
|
openSteering() { clearSteering(); steeringOpen = true; },
|
|
1234
1668
|
steer: queueSteering,
|
|
1669
|
+
withdrawSteering,
|
|
1670
|
+
// Ends the running turn at its next safe point; false when none runs.
|
|
1671
|
+
handOffSteering() {
|
|
1672
|
+
if (!steeringOpen) return false;
|
|
1673
|
+
steeringHandoff = true;
|
|
1674
|
+
return true;
|
|
1675
|
+
},
|
|
1235
1676
|
closeSteering() { steeringOpen = false; clearSteering(); },
|
|
1236
1677
|
abortHostEffects,
|
|
1237
1678
|
abort(error) {
|
|
@@ -1616,16 +2057,38 @@ function normalizeSteeringInput(input) {
|
|
|
1616
2057
|
return text;
|
|
1617
2058
|
}
|
|
1618
2059
|
|
|
2060
|
+
// A tool set's shape, so a checkpoint can tell when the tools it continues
|
|
2061
|
+
// with changed since it was saved.
|
|
2062
|
+
function toolSchemaHash(descriptors) {
|
|
2063
|
+
const shape = JSON.stringify([...descriptors].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)));
|
|
2064
|
+
let hash = 0x811c9dc5;
|
|
2065
|
+
for (const byte of new TextEncoder().encode(shape)) hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
|
|
2066
|
+
return hash.toString(16).padStart(8, "0");
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// `tools` is an array of descriptors, or an object of descriptors keyed by
|
|
2070
|
+
// name.
|
|
1619
2071
|
function normalizeHostTools(value) {
|
|
1620
2072
|
if (value === undefined) return { descriptors: [], executors: new Map() };
|
|
1621
|
-
|
|
1622
|
-
if (value
|
|
2073
|
+
let entries;
|
|
2074
|
+
if (Array.isArray(value)) {
|
|
2075
|
+
entries = value;
|
|
2076
|
+
} else if (value && typeof value === "object") {
|
|
2077
|
+
entries = Object.entries(value).map(([key, tool]) => {
|
|
2078
|
+
if (!tool || typeof tool !== "object") throw new TypeError(`tool ${key} must be an object`);
|
|
2079
|
+
if (tool.name !== undefined && tool.name !== key) throw new TypeError(`tool ${key} has a different name: ${tool.name}`);
|
|
2080
|
+
return { ...tool, name: key };
|
|
2081
|
+
});
|
|
2082
|
+
} else {
|
|
2083
|
+
throw new TypeError("tools must be an array or an object");
|
|
2084
|
+
}
|
|
2085
|
+
if (entries.length > 64) throw new RangeError("tools cannot contain more than 64 entries");
|
|
1623
2086
|
const descriptors = [];
|
|
1624
2087
|
const executors = new Map();
|
|
1625
2088
|
const names = new Set();
|
|
1626
|
-
for (const [index, tool] of
|
|
2089
|
+
for (const [index, tool] of entries.entries()) {
|
|
1627
2090
|
if (!tool || typeof tool !== "object") throw new TypeError(`tool ${index} must be an object`);
|
|
1628
|
-
const { name, description, inputSchema, execute, providerExecuted } = tool;
|
|
2091
|
+
const { name, description, inputSchema, execute, providerExecuted, writes, idempotent } = tool;
|
|
1629
2092
|
if (typeof name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
|
|
1630
2093
|
throw new TypeError(`tool ${index} has an invalid name`);
|
|
1631
2094
|
}
|
|
@@ -1641,6 +2104,8 @@ function normalizeHostTools(value) {
|
|
|
1641
2104
|
}
|
|
1642
2105
|
if (typeof description !== "string") throw new TypeError(`tool ${name} requires a description`);
|
|
1643
2106
|
if (typeof execute !== "function") throw new TypeError(`tool ${name} requires execute()`);
|
|
2107
|
+
if (writes !== undefined && typeof writes !== "boolean") throw new TypeError(`tool ${name} writes must be a boolean`);
|
|
2108
|
+
if (idempotent !== undefined && typeof idempotent !== "boolean") throw new TypeError(`tool ${name} idempotent must be a boolean`);
|
|
1644
2109
|
if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
|
|
1645
2110
|
throw new TypeError(`tool ${name} requires an object inputSchema`);
|
|
1646
2111
|
}
|
|
@@ -1648,13 +2113,46 @@ function normalizeHostTools(value) {
|
|
|
1648
2113
|
try { schema = JSON.parse(JSON.stringify(inputSchema)); } catch {
|
|
1649
2114
|
throw new TypeError(`tool ${name} inputSchema must be JSON-serializable`);
|
|
1650
2115
|
}
|
|
1651
|
-
descriptors.push({
|
|
2116
|
+
descriptors.push({
|
|
2117
|
+
name,
|
|
2118
|
+
description,
|
|
2119
|
+
inputSchema: schema,
|
|
2120
|
+
...(writes === undefined ? {} : { writes }),
|
|
2121
|
+
// The engine reads `idempotent`: such a tool runs beside others unless
|
|
2122
|
+
// it `writes`, and one that does not write starts before its intent is
|
|
2123
|
+
// stored. It is also in the shape a checkpoint records, so a change to
|
|
2124
|
+
// it counts as a changed tool set.
|
|
2125
|
+
...(idempotent === true ? { idempotent: true } : {}),
|
|
2126
|
+
});
|
|
1652
2127
|
executors.set(name, execute);
|
|
1653
2128
|
}
|
|
1654
2129
|
return { descriptors, executors };
|
|
1655
2130
|
}
|
|
1656
2131
|
|
|
1657
|
-
|
|
2132
|
+
// The model catalog a host supplies, as the response body the core would
|
|
2133
|
+
// otherwise fetch: `{ data }` as AI Gateway returns it, or the entries alone.
|
|
2134
|
+
function modelCatalogBody(value) {
|
|
2135
|
+
const entries = Array.isArray(value) ? value : value?.data;
|
|
2136
|
+
if (!Array.isArray(entries)) throw new TypeError("modelCatalog must be an array of catalog entries or { data: [...] }");
|
|
2137
|
+
if (entries.length > maxModelCatalogEntries) {
|
|
2138
|
+
throw new RangeError(`modelCatalog exceeds the ${maxModelCatalogEntries} entry libfx limit`);
|
|
2139
|
+
}
|
|
2140
|
+
for (const entry of entries) {
|
|
2141
|
+
if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || entry.id === "") {
|
|
2142
|
+
throw new TypeError("each modelCatalog entry needs a string id");
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
let body;
|
|
2146
|
+
try { body = JSON.stringify({ object: "list", data: entries }); } catch {
|
|
2147
|
+
throw new TypeError("modelCatalog must be JSON-serializable");
|
|
2148
|
+
}
|
|
2149
|
+
if (encoder.encode(body).length > maxModelCatalogBytes) {
|
|
2150
|
+
throw new RangeError(`modelCatalog exceeds the ${maxModelCatalogBytes} byte libfx limit`);
|
|
2151
|
+
}
|
|
2152
|
+
return body;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
export function normalizeInstructions(value) {
|
|
1658
2156
|
let instructions;
|
|
1659
2157
|
if (value === undefined) instructions = "";
|
|
1660
2158
|
else if (typeof value === "string") instructions = value;
|
|
@@ -1722,10 +2220,22 @@ function base64ToBytes(value) {
|
|
|
1722
2220
|
return bytes;
|
|
1723
2221
|
}
|
|
1724
2222
|
|
|
1725
|
-
|
|
2223
|
+
/**
|
|
2224
|
+
* One session on one fx core, with no durability of its own: `createFxAgent`
|
|
2225
|
+
* runs each durable session's turns on one of these, and a host with its own
|
|
2226
|
+
* storage may pass `persistence`.
|
|
2227
|
+
*/
|
|
2228
|
+
export async function createFxEngine(options = {}) {
|
|
1726
2229
|
options = normalizeAgentOptions(options);
|
|
1727
2230
|
const hostTools = normalizeHostTools(options.tools);
|
|
1728
2231
|
const instructions = normalizeInstructions(options.instructions);
|
|
2232
|
+
// Recorded in every checkpoint this agent saves, so a later one can tell
|
|
2233
|
+
// it continues with another libfx, other tools, or another model.
|
|
2234
|
+
const checkpointMeta = {
|
|
2235
|
+
libfxVersion: String(fxSdkApiVersion),
|
|
2236
|
+
toolSchemaHash: toolSchemaHash(hostTools.descriptors),
|
|
2237
|
+
model: options.model ?? "",
|
|
2238
|
+
};
|
|
1729
2239
|
const initialCheckpoint = checkpointBytes(options.checkpoint);
|
|
1730
2240
|
// Checked before the core starts, and with the core's message, so both
|
|
1731
2241
|
// backends report it the same way.
|
|
@@ -1739,17 +2249,269 @@ export async function createFxAgent(options = {}) {
|
|
|
1739
2249
|
let closing = false;
|
|
1740
2250
|
let coreExitError = null;
|
|
1741
2251
|
const isCurrentTurn = (turn) => turn && activeTurn === turn && !turn.cancelled && !closing;
|
|
2252
|
+
// Events go to the store in the order the core sends them, one append at
|
|
2253
|
+
// a time: events that arrive together become one record, and so do all
|
|
2254
|
+
// the events that arrive while an append is in flight. Each record's append
|
|
2255
|
+
// names the cursor of the one before it, so a store refuses a write that
|
|
2256
|
+
// does not continue what it holds, and says with `barrier` whether anyone
|
|
2257
|
+
// waits for it: the core before an action others can see, such as a tool
|
|
2258
|
+
// call with effects, or a caller for its input.
|
|
2259
|
+
const persistence = options.persistence ?? null;
|
|
2260
|
+
const wantsMarks = persistence?.marks === true || persistence?.[journalMarksWanted] === true;
|
|
2261
|
+
const checkpointAfterBytes = options.checkpointAfterBytes ?? defaultCheckpointAfterBytes;
|
|
2262
|
+
// The store's last cursor, and the last seq its records hold.
|
|
2263
|
+
let journalHead = null;
|
|
2264
|
+
let appendedSeq = 0;
|
|
2265
|
+
// Record bytes stored since the last checkpoint.
|
|
2266
|
+
let bytesSinceCheckpoint = 0;
|
|
2267
|
+
let appendTail = Promise.resolve();
|
|
2268
|
+
// One per agent, so a store can tell a repeated write from another writer's.
|
|
2269
|
+
const writerId = crypto.randomUUID();
|
|
2270
|
+
const journal = persistence && {
|
|
2271
|
+
append(events, barrier) {
|
|
2272
|
+
const data = encodeJournalRecord(events);
|
|
2273
|
+
const lastSeq = events.at(-1)?.seq;
|
|
2274
|
+
// A store that tracks turns without reading records asks for marks.
|
|
2275
|
+
const marks = wantsMarks ? { marks: journalMarks(events) } : {};
|
|
2276
|
+
const appended = appendTail.then(async () => {
|
|
2277
|
+
const result = await persistence.append({ expected: journalHead, idempotencyKey: `${writerId}:${events[0]?.seq}`, data, barrier, ...marks });
|
|
2278
|
+
if (!validCursor(result?.cursor)) throw new TypeError("persistence.append() must resolve to { cursor }");
|
|
2279
|
+
journalHead = result.cursor;
|
|
2280
|
+
if (Number.isSafeInteger(lastSeq)) appendedSeq = lastSeq;
|
|
2281
|
+
bytesSinceCheckpoint += data.byteLength;
|
|
2282
|
+
});
|
|
2283
|
+
// A failed append fails every later one: they would not continue it.
|
|
2284
|
+
appendTail = appended;
|
|
2285
|
+
return appended;
|
|
2286
|
+
},
|
|
2287
|
+
};
|
|
2288
|
+
let resumable = false;
|
|
2289
|
+
// The host's ids for the turn a crash left open and the last turn that
|
|
2290
|
+
// ended, so a retried prompt continues or skips its turn.
|
|
2291
|
+
let openTurnId = null;
|
|
2292
|
+
let lastTurnId = null;
|
|
2293
|
+
// The calls the open turn left running: `{ id, name, arguments }`, and
|
|
2294
|
+
// what the resume tells the model about each one it does not run again.
|
|
2295
|
+
let ambiguousCalls = [];
|
|
2296
|
+
// Asks `onAmbiguous` about each of them, once. Returns the ids of the
|
|
2297
|
+
// calls to run again, and the rest, which the resume answers as possibly
|
|
2298
|
+
// run.
|
|
2299
|
+
const settleAmbiguous = (onAmbiguous) => {
|
|
2300
|
+
const calls = ambiguousCalls;
|
|
2301
|
+
ambiguousCalls = [];
|
|
2302
|
+
const rerun = onAmbiguous === undefined
|
|
2303
|
+
? []
|
|
2304
|
+
: calls.filter((call) => onAmbiguous({ executionId: call.id, name: call.name, input: parsedToolInput(call.arguments) }) === "rerun").map((call) => call.id);
|
|
2305
|
+
return { rerun, answered: calls.filter((call) => !rerun.includes(call.id)) };
|
|
2306
|
+
};
|
|
2307
|
+
// Continues the open turn: the calls it left running run again or are
|
|
2308
|
+
// answered as possibly run, and each answered one ends for the turn's
|
|
2309
|
+
// viewers as it does for the model.
|
|
2310
|
+
const resumeOpenTurn = (promptOptions) => {
|
|
2311
|
+
const { rerun, answered } = settleAmbiguous(promptOptions.onAmbiguous);
|
|
2312
|
+
return normalizeTurn(startTurn(null, { ...promptOptions, [resumeTurn]: true, [rerunCallIds]: rerun }), answered);
|
|
2313
|
+
};
|
|
2314
|
+
// Follow-ups waiting for the turn ahead of them, oldest first.
|
|
2315
|
+
const followUps = [];
|
|
2316
|
+
// A follow-up this agent queued starts when the turn ahead of it ends, ahead
|
|
2317
|
+
// of any the journal held: those have no caller, so each waits for
|
|
2318
|
+
// `resume()` to start it, oldest first.
|
|
2319
|
+
const runNextFollowUp = (resuming = false) => {
|
|
2320
|
+
if (activeTurn || closing || journalFailure) return;
|
|
2321
|
+
const index = followUps.findIndex((entry) => (entry.held === true) === resuming);
|
|
2322
|
+
if (index < 0) return;
|
|
2323
|
+
const [next] = followUps.splice(index, 1);
|
|
2324
|
+
let turn;
|
|
2325
|
+
try {
|
|
2326
|
+
turn = normalizeTurn(startTurn(next.text, { turnId: next.id, [followUpInput]: { id: next.id, accepted: next.accepted } }));
|
|
2327
|
+
} catch (error) {
|
|
2328
|
+
next.rejectTurn?.(error);
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
next.resolveTurn?.(turn);
|
|
2332
|
+
};
|
|
2333
|
+
let journalQueue = [];
|
|
2334
|
+
let journalFlushScheduled = false;
|
|
2335
|
+
const journalPending = new Set();
|
|
2336
|
+
// The append in flight, and whether anyone waits for the events queued
|
|
2337
|
+
// behind it.
|
|
2338
|
+
let journalInFlight = null;
|
|
2339
|
+
let journalBarrier = false;
|
|
2340
|
+
let journalFailure = null;
|
|
2341
|
+
// Set by `turn.cancel({ reason: "handoff" })`: the journal keeps the open
|
|
2342
|
+
// turn for another agent, so this one stores nothing more.
|
|
2343
|
+
let handedOff = false;
|
|
2344
|
+
const handedOffError = () => new Error("this agent handed its session off; open it again to continue");
|
|
2345
|
+
// Input events (`accepted:<id>`, `withdrawn:<id>`) once stored, and the
|
|
2346
|
+
// steer and withdraw calls waiting for them.
|
|
2347
|
+
const durableInputs = new Set();
|
|
2348
|
+
const inputWaiters = new Map();
|
|
2349
|
+
const waitForInput = (key) => {
|
|
2350
|
+
if (durableInputs.delete(key)) return Promise.resolve();
|
|
2351
|
+
if (journalFailure) return Promise.reject(journalFailure);
|
|
2352
|
+
return new Promise((resolve, reject) => {
|
|
2353
|
+
const waiters = inputWaiters.get(key) ?? [];
|
|
2354
|
+
waiters.push({ resolve, reject });
|
|
2355
|
+
inputWaiters.set(key, waiters);
|
|
2356
|
+
});
|
|
2357
|
+
};
|
|
2358
|
+
const settleInputs = (batch) => {
|
|
2359
|
+
for (const event of batch) {
|
|
2360
|
+
if (event.type !== "input_accepted" && event.type !== "input_withdrawn") continue;
|
|
2361
|
+
const key = `${event.type === "input_accepted" ? "accepted" : "withdrawn"}:${event.data?.id}`;
|
|
2362
|
+
durableInputs.add(key);
|
|
2363
|
+
for (const waiter of inputWaiters.get(key) ?? []) waiter.resolve();
|
|
2364
|
+
inputWaiters.delete(key);
|
|
2365
|
+
}
|
|
2366
|
+
};
|
|
2367
|
+
const rejectInputWaiters = (keys, error) => {
|
|
2368
|
+
for (const key of keys) {
|
|
2369
|
+
for (const waiter of inputWaiters.get(key) ?? []) waiter.reject(error);
|
|
2370
|
+
inputWaiters.delete(key);
|
|
2371
|
+
}
|
|
2372
|
+
};
|
|
2373
|
+
// close() reports a failure no turn or prompt call has reported yet.
|
|
2374
|
+
let journalFailureReported = false;
|
|
2375
|
+
const reportJournalFailure = () => {
|
|
2376
|
+
journalFailureReported = true;
|
|
2377
|
+
return journalFailure;
|
|
2378
|
+
};
|
|
2379
|
+
// `barrier`: the core waits for these events before it goes on.
|
|
2380
|
+
const queueJournalEvents = (events, barrier = false) => {
|
|
2381
|
+
if (journalFailure || !Array.isArray(events) || events.length === 0) return;
|
|
2382
|
+
if (handedOff) {
|
|
2383
|
+
// The agent that opens the session next decides about these inputs.
|
|
2384
|
+
const dropped = events
|
|
2385
|
+
.filter((event) => event?.type === "input_accepted" || event?.type === "input_withdrawn")
|
|
2386
|
+
.map((event) => `${event.type === "input_accepted" ? "accepted" : "withdrawn"}:${event.data?.id}`);
|
|
2387
|
+
rejectInputWaiters(dropped, handedOffError());
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
journalQueue.push(...events);
|
|
2391
|
+
// A caller waits for its steer or withdrawal to be stored.
|
|
2392
|
+
if (barrier || events.some((event) => event?.type === "input_accepted" || event?.type === "input_withdrawn")) journalBarrier = true;
|
|
2393
|
+
// A turn notes a new config right before its first progress, a barrier:
|
|
2394
|
+
// the two go out in one write instead of two in a row.
|
|
2395
|
+
if (events.every((event) => event?.type === "session_config")) return;
|
|
2396
|
+
if (journalFlushScheduled) return;
|
|
2397
|
+
journalFlushScheduled = true;
|
|
2398
|
+
queueMicrotask(flushJournal);
|
|
2399
|
+
};
|
|
2400
|
+
// Sends the queued events as one record, unless an append is in flight:
|
|
2401
|
+
// then they wait for it, with any that arrive meanwhile, and go out
|
|
2402
|
+
// together once it lands.
|
|
2403
|
+
function flushJournal() {
|
|
2404
|
+
journalFlushScheduled = false;
|
|
2405
|
+
if (journalFailure || journalInFlight || journalQueue.length === 0) return;
|
|
2406
|
+
const batch = journalQueue;
|
|
2407
|
+
journalQueue = [];
|
|
2408
|
+
const barrier = journalBarrier;
|
|
2409
|
+
journalBarrier = false;
|
|
2410
|
+
emit("journal.append", { events: batch.length, barrier });
|
|
2411
|
+
let appended;
|
|
2412
|
+
try {
|
|
2413
|
+
appended = Promise.resolve(journal.append(batch, barrier));
|
|
2414
|
+
} catch (error) {
|
|
2415
|
+
appended = Promise.reject(error);
|
|
2416
|
+
}
|
|
2417
|
+
const settled = appended.then(() => {
|
|
2418
|
+
settleInputs(batch);
|
|
2419
|
+
}, (error) => {
|
|
2420
|
+
// Later events would leave a gap after the failed batch.
|
|
2421
|
+
journalQueue = [];
|
|
2422
|
+
if (journalFailure) return;
|
|
2423
|
+
journalFailure = journalAppendError(error);
|
|
2424
|
+
emit("journal.error", { error: error instanceof Error ? error.name : "Error" });
|
|
2425
|
+
rejectInputWaiters([...inputWaiters.keys()], journalFailure);
|
|
2426
|
+
// Nothing the turn does from here can be saved, and after a fence the
|
|
2427
|
+
// session has another owner: stop the turn and its effects now.
|
|
2428
|
+
activeTurn?.cancel();
|
|
2429
|
+
}).finally(() => {
|
|
2430
|
+
journalPending.delete(settled);
|
|
2431
|
+
journalInFlight = null;
|
|
2432
|
+
flushJournal();
|
|
2433
|
+
});
|
|
2434
|
+
journalInFlight = settled;
|
|
2435
|
+
journalPending.add(settled);
|
|
2436
|
+
}
|
|
2437
|
+
// The journal's own work, such as a heartbeat, ends with the agent: once,
|
|
2438
|
+
// after the last append settles.
|
|
2439
|
+
let journalClosed = null;
|
|
2440
|
+
function closeJournal() {
|
|
2441
|
+
journalClosed ??= journalSettled().catch(() => {}).then(() => checkpointing);
|
|
2442
|
+
return journalClosed;
|
|
2443
|
+
}
|
|
2444
|
+
async function journalSettled() {
|
|
2445
|
+
for (;;) {
|
|
2446
|
+
// This caller waits for every event queued so far.
|
|
2447
|
+
if (journalQueue.length > 0) journalBarrier = true;
|
|
2448
|
+
if (journalFlushScheduled || journalQueue.length > 0) flushJournal();
|
|
2449
|
+
if (journalPending.size === 0) break;
|
|
2450
|
+
await Promise.all([...journalPending]);
|
|
2451
|
+
}
|
|
2452
|
+
if (journalFailure) throw journalFailure;
|
|
2453
|
+
}
|
|
1742
2454
|
const emit = (type, detail = {}) => {
|
|
1743
2455
|
try { options.onEvent?.({ type, timestamp: performance.now(), ...detail }); } catch {}
|
|
1744
2456
|
};
|
|
2457
|
+
// A checkpoint saved by another libfx, with other tools, or for another
|
|
2458
|
+
// model still restores; the host hears what changed.
|
|
2459
|
+
const compareCheckpointMeta = (saved) => {
|
|
2460
|
+
if (!saved || typeof saved !== "object") return;
|
|
2461
|
+
const changed = Object.keys(checkpointMeta).filter((key) => typeof saved[key] === "string" && saved[key] !== "" && saved[key] !== checkpointMeta[key]);
|
|
2462
|
+
if (changed.length) emit("checkpoint.mismatch", { sessionId, changed, saved, current: checkpointMeta });
|
|
2463
|
+
};
|
|
2464
|
+
// Saves a checkpoint once the records since the last one reach
|
|
2465
|
+
// `checkpointAfterBytes`, or with `force` whatever their size, at a point
|
|
2466
|
+
// no turn is running: a turn's end, or where it yielded. A failed save
|
|
2467
|
+
// leaves the journal as it was; the next turn's end tries again. Returns
|
|
2468
|
+
// the save in flight, which never rejects.
|
|
2469
|
+
let checkpointing = null;
|
|
2470
|
+
const maybeCheckpoint = (force = false) => {
|
|
2471
|
+
if (checkpointing) return checkpointing;
|
|
2472
|
+
if (!persistence?.saveCheckpoint || closing || handedOff || journalFailure) return Promise.resolve();
|
|
2473
|
+
checkpointing = (async () => {
|
|
2474
|
+
// The turn's last records may still be in flight; count them first.
|
|
2475
|
+
await journalSettled();
|
|
2476
|
+
// A save begun before close() finishes; close() waits for it.
|
|
2477
|
+
if (activeTurn) return;
|
|
2478
|
+
if (bytesSinceCheckpoint === 0 || (!force && bytesSinceCheckpoint < checkpointAfterBytes)) return;
|
|
2479
|
+
const response = await request("libfx/snapshot", { sessionId, checkpointMeta });
|
|
2480
|
+
const id = response?.snapshotAttachment;
|
|
2481
|
+
if (!Number.isSafeInteger(id) || id <= 0) return;
|
|
2482
|
+
const bytes = runtime.takeAttachment?.(id);
|
|
2483
|
+
// The core took it after every event this agent stored, and no later.
|
|
2484
|
+
if (!(bytes instanceof Uint8Array) || response.atSeq !== appendedSeq) return;
|
|
2485
|
+
const through = journalHead;
|
|
2486
|
+
const covered = bytesSinceCheckpoint;
|
|
2487
|
+
await persistence.saveCheckpoint({ through, data: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) });
|
|
2488
|
+
bytesSinceCheckpoint -= covered;
|
|
2489
|
+
emit("checkpoint.save", { through, bytes: bytes.byteLength });
|
|
2490
|
+
})().catch((error) => {
|
|
2491
|
+
emit("checkpoint.error", { error: error instanceof Error ? error.name : "Error", message: error?.message ?? "" });
|
|
2492
|
+
}).finally(() => { checkpointing = null; });
|
|
2493
|
+
return checkpointing;
|
|
2494
|
+
};
|
|
1745
2495
|
const hostFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
2496
|
+
// Adds the host's credentials to the provider's own requests.
|
|
2497
|
+
const providerAuth = subscriptionProviders.has(options.provider)
|
|
2498
|
+
? createSubscriptionAuthorizer([codexSubscription, grokSubscription], { credentials: options.credentials, onCredentials: options.onCredentials, refreshLock: options.refreshLock, fetch: hostFetch, memory: options[credentialMemory] })
|
|
2499
|
+
: options.customProvider && options.credentials
|
|
2500
|
+
? createSubscriptionAuthorizer([customProviderSpec(options.customProvider, () => sessionId)], { credentials: options.credentials, fetch: hostFetch })
|
|
2501
|
+
: null;
|
|
1746
2502
|
const transportFetch = async (input, init = {}) => {
|
|
1747
2503
|
const method = String(init.method ?? input?.method ?? "GET").toUpperCase();
|
|
1748
2504
|
let endpoint = String(input?.url ?? input);
|
|
2505
|
+
let path = null;
|
|
1749
2506
|
try {
|
|
1750
2507
|
const url = new URL(endpoint);
|
|
1751
2508
|
endpoint = `${url.origin}${url.pathname}`;
|
|
2509
|
+
path = url.pathname;
|
|
1752
2510
|
} catch {}
|
|
2511
|
+
// A supplied catalog answers the core's catalog request without the network.
|
|
2512
|
+
if (options.modelCatalogBody && method === "GET" && path === "/coding-agent/v1/models") {
|
|
2513
|
+
return new Response(options.modelCatalogBody, { status: 200, headers: { "content-type": "application/json" } });
|
|
2514
|
+
}
|
|
1753
2515
|
for (let attemptIndex = 0; attemptIndex < 2; attemptIndex++) {
|
|
1754
2516
|
const startedAt = performance.now();
|
|
1755
2517
|
const attempt = activeTurn ? ++activeTurn.transportAttempts : attemptIndex + 1;
|
|
@@ -1764,7 +2526,10 @@ export async function createFxAgent(options = {}) {
|
|
|
1764
2526
|
throw new DOMException("Aborted", "AbortError");
|
|
1765
2527
|
}
|
|
1766
2528
|
if (!hostFetch) throw new TypeError("fetch is unavailable");
|
|
1767
|
-
const
|
|
2529
|
+
const spec = providerAuth?.specFor(endpoint);
|
|
2530
|
+
const response = spec
|
|
2531
|
+
? await providerAuth.fetch(spec, endpoint, hostFetch, input, init)
|
|
2532
|
+
: await hostFetch(input, init);
|
|
1768
2533
|
const headers = response.headers;
|
|
1769
2534
|
emit("transport.response", {
|
|
1770
2535
|
attempt,
|
|
@@ -1793,7 +2558,7 @@ export async function createFxAgent(options = {}) {
|
|
|
1793
2558
|
}
|
|
1794
2559
|
throw new Error("transport retry exhausted");
|
|
1795
2560
|
};
|
|
1796
|
-
const executeHostTool = async (name, input, requestedSessionId) => {
|
|
2561
|
+
const executeHostTool = async (name, input, requestedSessionId, toolCallId) => {
|
|
1797
2562
|
const execute = hostTools.executors.get(name);
|
|
1798
2563
|
const turn = requestedSessionId === undefined || requestedSessionId === sessionId
|
|
1799
2564
|
? activeTurn
|
|
@@ -1811,7 +2576,14 @@ export async function createFxAgent(options = {}) {
|
|
|
1811
2576
|
if (!execute) throw new Error(`unknown host tool: ${String(name)}`);
|
|
1812
2577
|
const execution = Promise.resolve().then(() => {
|
|
1813
2578
|
if (controller.signal.aborted || !isCurrentTurn(turn)) return;
|
|
1814
|
-
|
|
2579
|
+
// The model's call id is stable across restores of a persisted
|
|
2580
|
+
// session, so a tool can key its own retries on it.
|
|
2581
|
+
return execute(input, {
|
|
2582
|
+
signal: controller.signal,
|
|
2583
|
+
executionId: String(toolCallId ?? ""),
|
|
2584
|
+
sessionId,
|
|
2585
|
+
...(options.toolContext === undefined ? {} : { context: options.toolContext }),
|
|
2586
|
+
});
|
|
1815
2587
|
});
|
|
1816
2588
|
const value = await Promise.race([execution, aborted]);
|
|
1817
2589
|
if (!controller.signal.aborted) {
|
|
@@ -1837,6 +2609,8 @@ export async function createFxAgent(options = {}) {
|
|
|
1837
2609
|
controller.signal.removeEventListener("abort", onAbort);
|
|
1838
2610
|
turn.toolControllers.delete(controller);
|
|
1839
2611
|
}
|
|
2612
|
+
// Providers reject an empty error result, which would end the session.
|
|
2613
|
+
if (isError && content === "") content = `Tool ${String(name)} failed without a message`;
|
|
1840
2614
|
return { content, isError, rich, cancelled: controller.signal.aborted || !isCurrentTurn(turn) };
|
|
1841
2615
|
};
|
|
1842
2616
|
emit("runtime.start");
|
|
@@ -1846,6 +2620,7 @@ export async function createFxAgent(options = {}) {
|
|
|
1846
2620
|
args: ["acp"],
|
|
1847
2621
|
env: agentEnvironment(options),
|
|
1848
2622
|
hostToolExecutor: executeHostTool,
|
|
2623
|
+
journalFlush: () => journalSettled(),
|
|
1849
2624
|
onTransportChunk(byteLength) {
|
|
1850
2625
|
const turn = activeTurn;
|
|
1851
2626
|
if (!isCurrentTurn(turn) || !Number.isSafeInteger(byteLength) || byteLength <= 0) return;
|
|
@@ -1894,13 +2669,19 @@ export async function createFxAgent(options = {}) {
|
|
|
1894
2669
|
async function takeCheckpoint() {
|
|
1895
2670
|
if (closing) throw new Error("fx agent is closed");
|
|
1896
2671
|
if (activeTurn) throw new Error("cannot checkpoint while a prompt is active");
|
|
1897
|
-
const response = await request("libfx/checkpoint", { sessionId });
|
|
2672
|
+
const response = await request("libfx/checkpoint", { sessionId, checkpointMeta });
|
|
1898
2673
|
const id = response?.checkpointAttachment;
|
|
1899
2674
|
const bytes = Number.isSafeInteger(id) && id > 0 ? runtime.takeAttachment?.(id) : null;
|
|
1900
2675
|
if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) throw new Error("fx returned an invalid checkpoint");
|
|
1901
2676
|
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1902
2677
|
}
|
|
1903
|
-
const sendPrompt = (blocks) => {
|
|
2678
|
+
const sendPrompt = (blocks, resuming = false, followUp = null, turnId = null, rerun = [], yieldAt = undefined) => {
|
|
2679
|
+
// A journaled turn given a time to stop at yields before its first model
|
|
2680
|
+
// request after it, keeping the turn open.
|
|
2681
|
+
const stopAt = journal && yieldAt !== undefined ? { yieldAt } : {};
|
|
2682
|
+
if (resuming) {
|
|
2683
|
+
return request("session/prompt", { sessionId, prompt: [], _meta: { fx: { continueRecovery: true, ...(rerun.length ? { rerun } : {}), ...stopAt } } });
|
|
2684
|
+
}
|
|
1904
2685
|
const images = blocks.filter((block) => block.type === "image" && block.bytes !== undefined);
|
|
1905
2686
|
const ids = images.length ? attachBytes(images.map((block) => block.bytes)) : [];
|
|
1906
2687
|
let next = 0;
|
|
@@ -1910,10 +2691,19 @@ export async function createFxAgent(options = {}) {
|
|
|
1910
2691
|
...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
|
|
1911
2692
|
...(block.bytes === undefined ? {} : { _meta: { fx: { attachment: ids[next++] } } }),
|
|
1912
2693
|
});
|
|
1913
|
-
|
|
2694
|
+
// A follow-up's turn places it; the core records it as accepted first
|
|
2695
|
+
// when the host could not. A journaled turn's first progress records its id.
|
|
2696
|
+
const fx = {
|
|
2697
|
+
...(followUp ? { inputId: followUp.id, inputAccepted: followUp.accepted } : {}),
|
|
2698
|
+
...(journal && turnId ? { turnId } : {}),
|
|
2699
|
+
...stopAt,
|
|
2700
|
+
};
|
|
2701
|
+
return request("session/prompt", { sessionId, prompt, ...(Object.keys(fx).length ? { _meta: { fx } } : {}) });
|
|
1914
2702
|
};
|
|
1915
2703
|
runtime.exited.then((code) => {
|
|
1916
2704
|
closing = true;
|
|
2705
|
+
// An agent whose core exited writes nothing more.
|
|
2706
|
+
if (journal) void closeJournal().catch(() => {});
|
|
1917
2707
|
const error = runtime.error ?? new Error(`fx-core exited with code ${code} before completing the ACP request`);
|
|
1918
2708
|
coreExitError = error;
|
|
1919
2709
|
activeTurn?.failImagePrep(error);
|
|
@@ -1923,6 +2713,12 @@ export async function createFxAgent(options = {}) {
|
|
|
1923
2713
|
});
|
|
1924
2714
|
runtime.setLineHandler((message, size) => {
|
|
1925
2715
|
emit("acp.receive", { message });
|
|
2716
|
+
if (message.method === "libfx/journal_append") {
|
|
2717
|
+
if (journal && message.params?.sessionId === sessionId) {
|
|
2718
|
+
queueJournalEvents(message.params.events, message.params.barrier === true);
|
|
2719
|
+
}
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
1926
2722
|
if (message.method === "session/update") {
|
|
1927
2723
|
if (message.params.sessionId === sessionId) return activeTurn?.push(message.params.update, size);
|
|
1928
2724
|
return;
|
|
@@ -1943,11 +2739,22 @@ export async function createFxAgent(options = {}) {
|
|
|
1943
2739
|
send({ jsonrpc: "2.0", id: message.id, result: optionId ? { outcome: { outcome: "selected", optionId } } : { outcome: { outcome: "cancelled" } } });
|
|
1944
2740
|
return;
|
|
1945
2741
|
}
|
|
2742
|
+
if (message.method === "libfx/journal_flush") {
|
|
2743
|
+
// Every append the core sent before this request is already queued.
|
|
2744
|
+
let failure = null;
|
|
2745
|
+
try { await journalSettled(); } catch (error) { failure = error; }
|
|
2746
|
+
if (closing) return;
|
|
2747
|
+
send(failure
|
|
2748
|
+
? { jsonrpc: "2.0", id: message.id, error: { code: -32000, message: failure.message } }
|
|
2749
|
+
: { jsonrpc: "2.0", id: message.id, result: {} });
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
1946
2752
|
if (message.method === "libfx/tool_call") {
|
|
1947
2753
|
const { content, isError, rich, cancelled } = await executeHostTool(
|
|
1948
2754
|
message.params?.name,
|
|
1949
2755
|
message.params?.input,
|
|
1950
2756
|
message.params?.sessionId,
|
|
2757
|
+
message.params?.toolCallId,
|
|
1951
2758
|
);
|
|
1952
2759
|
if (cancelled || closing) return;
|
|
1953
2760
|
const response = { jsonrpc: "2.0", id: message.id, result: { content, isError, ...(rich ? { contentType: "rich" } : {}) } };
|
|
@@ -1974,11 +2781,53 @@ export async function createFxAgent(options = {}) {
|
|
|
1974
2781
|
},
|
|
1975
2782
|
});
|
|
1976
2783
|
|
|
1977
|
-
const
|
|
2784
|
+
const loaded = persistence ? await loadPersistence(persistence) : null;
|
|
2785
|
+
const requestedSessionId = options.sessionId ?? null;
|
|
2786
|
+
const sessionResult = await request("libfx/new", requestedSessionId === null ? {} : { sessionId: requestedSessionId });
|
|
1978
2787
|
sessionId = sessionResult.sessionId;
|
|
1979
2788
|
if (initialCheckpoint) {
|
|
1980
2789
|
const [checkpointAttachment] = attachBytes([initialCheckpoint]);
|
|
1981
|
-
await request("libfx/restore", { sessionId, checkpointAttachment })
|
|
2790
|
+
const restored = await request("libfx/restore", { sessionId, checkpointAttachment }).catch((error) => {
|
|
2791
|
+
if (/newer fx/.test(error?.message ?? "")) throw new FxJournalVersionError(error.message);
|
|
2792
|
+
throw error;
|
|
2793
|
+
});
|
|
2794
|
+
compareCheckpointMeta(restored?.checkpointMeta);
|
|
2795
|
+
}
|
|
2796
|
+
if (journal) {
|
|
2797
|
+
const { events, checkpoint } = loaded;
|
|
2798
|
+
const params = { sessionId, ...(options.inputsDurable === true ? { inputsDurable: true } : {}) };
|
|
2799
|
+
// One call: attaching again would drop the first attachment.
|
|
2800
|
+
const attachments = [];
|
|
2801
|
+
if (events.length > 0) {
|
|
2802
|
+
const bytes = encoder.encode(JSON.stringify(withoutSupersededProgress(events)));
|
|
2803
|
+
if (bytes.byteLength > maxJournalBytes) throw journalLoadError("libfx journal is too large", "FX_JOURNAL_TOO_LARGE");
|
|
2804
|
+
attachments.push(["journalAttachment", bytes]);
|
|
2805
|
+
}
|
|
2806
|
+
if (checkpoint) attachments.push(["snapshotAttachment", checkpoint]);
|
|
2807
|
+
const ids = attachments.length ? attachBytes(attachments.map(([, bytes]) => bytes)) : [];
|
|
2808
|
+
attachments.forEach(([name], index) => { params[name] = ids[index]; });
|
|
2809
|
+
const opened = await request("libfx/journal_open", params).catch((error) => {
|
|
2810
|
+
const message = error?.message ?? "";
|
|
2811
|
+
if (/newer fx/.test(message)) throw new FxJournalVersionError(message);
|
|
2812
|
+
const code = journalOpenErrorCodes.get(message);
|
|
2813
|
+
if (code) throw journalLoadError(message, code, error);
|
|
2814
|
+
throw error;
|
|
2815
|
+
});
|
|
2816
|
+
compareCheckpointMeta(opened?.checkpointMeta);
|
|
2817
|
+
resumable = opened?.resumable === true;
|
|
2818
|
+
openTurnId = resumable && validTurnId(opened?.openTurnId) ? opened.openTurnId : null;
|
|
2819
|
+
lastTurnId = validTurnId(opened?.lastTurnId) ? opened.lastTurnId : null;
|
|
2820
|
+
ambiguousCalls = resumable && Array.isArray(opened?.runningCalls)
|
|
2821
|
+
? opened.runningCalls.filter((call) => typeof call?.id === "string" && typeof call.name === "string")
|
|
2822
|
+
: [];
|
|
2823
|
+
journalHead = loaded.head;
|
|
2824
|
+
appendedSeq = loaded.lastSeq;
|
|
2825
|
+
bytesSinceCheckpoint = loaded.tailBytes;
|
|
2826
|
+
// Follow-ups the journal holds, as accepted; each waits for `resume()`.
|
|
2827
|
+
for (const held of Array.isArray(opened?.followUps) ? opened.followUps : []) {
|
|
2828
|
+
followUps.push({ id: held.id, text: held.text, accepted: true, held: true });
|
|
2829
|
+
}
|
|
2830
|
+
emit("journal.open", { events: events.length, checkpoint: checkpoint !== null, turns: opened?.turns, resumable });
|
|
1982
2831
|
}
|
|
1983
2832
|
} catch (error) {
|
|
1984
2833
|
closing = true;
|
|
@@ -1989,11 +2838,131 @@ export async function createFxAgent(options = {}) {
|
|
|
1989
2838
|
}
|
|
1990
2839
|
|
|
1991
2840
|
const agent = {
|
|
2841
|
+
get sessionId() {
|
|
2842
|
+
return sessionId;
|
|
2843
|
+
},
|
|
2844
|
+
// What libfx's session worker needs beyond the public surface.
|
|
2845
|
+
[engineInternals]: {
|
|
2846
|
+
// Resolves once every record the session started has landed: a
|
|
2847
|
+
// turn's result does not wait for its last ones.
|
|
2848
|
+
settled() {
|
|
2849
|
+
return journal ? journalSettled() : Promise.resolve();
|
|
2850
|
+
},
|
|
2851
|
+
// Saves a checkpoint now, at a turn's end or where it yielded, whatever
|
|
2852
|
+
// has accumulated since the last; resolves once it is stored or skipped.
|
|
2853
|
+
checkpoint() {
|
|
2854
|
+
return maybeCheckpoint(true);
|
|
2855
|
+
},
|
|
2856
|
+
// Resolves when the core this agent runs on exits.
|
|
2857
|
+
exited: runtime.exited,
|
|
2858
|
+
// The turn the journal left open, if any, and the calls it left
|
|
2859
|
+
// running, as `{ executionId, name, input }`.
|
|
2860
|
+
get openTurn() {
|
|
2861
|
+
if (!resumable) return null;
|
|
2862
|
+
return {
|
|
2863
|
+
id: openTurnId,
|
|
2864
|
+
calls: ambiguousCalls.map((call) => ({ executionId: call.id, name: call.name, input: parsedToolInput(call.arguments) })),
|
|
2865
|
+
};
|
|
2866
|
+
},
|
|
2867
|
+
},
|
|
2868
|
+
/**
|
|
2869
|
+
* Starts a turn. With `turnId`, a retried call is the same turn: when the
|
|
2870
|
+
* journal left that turn open, this continues it as `resume()` does, and
|
|
2871
|
+
* when that turn already ended, this returns a turn that has ended too,
|
|
2872
|
+
* without running it again.
|
|
2873
|
+
*/
|
|
1992
2874
|
prompt(input, promptOptions = {}) {
|
|
1993
2875
|
if (closing) throw new Error("fx agent is closed");
|
|
2876
|
+
if (journalFailure) throw reportJournalFailure();
|
|
2877
|
+
if (handedOff) throw handedOffError();
|
|
1994
2878
|
if (activeTurn) throw new Error("a prompt is already in progress for this session");
|
|
1995
|
-
|
|
2879
|
+
const turnId = promptOptions.turnId;
|
|
2880
|
+
if (turnId !== undefined && !validTurnId(turnId)) throw new TypeError(`turnId must be ${turnIdRule}`);
|
|
2881
|
+
checkOnAmbiguous(promptOptions.onAmbiguous);
|
|
2882
|
+
if (turnId !== undefined && journal) {
|
|
2883
|
+
if (resumable && turnId === openTurnId) {
|
|
2884
|
+
resumable = false;
|
|
2885
|
+
return resumeOpenTurn(promptOptions);
|
|
2886
|
+
}
|
|
2887
|
+
if (turnId === lastTurnId) return endedTurn(turnId);
|
|
2888
|
+
}
|
|
2889
|
+
// A new prompt ends a turn the last process left open as interrupted.
|
|
2890
|
+
resumable = false;
|
|
2891
|
+
ambiguousCalls = [];
|
|
2892
|
+
return normalizeTurn(startTurn(input, { ...promptOptions, turnId: turnId ?? `turn_${crypto.randomUUID()}` }));
|
|
1996
2893
|
},
|
|
2894
|
+
/**
|
|
2895
|
+
* Continues the turn the last process left open, with no new input, and
|
|
2896
|
+
* returns it; returns null when the journal holds no open turn. The
|
|
2897
|
+
* model is told the session was interrupted; calls that were running
|
|
2898
|
+
* come back answered as possibly run, for the model to decide about,
|
|
2899
|
+
* unless `onAmbiguous(call)` returns "rerun" for them.
|
|
2900
|
+
*/
|
|
2901
|
+
resume(promptOptions = {}) {
|
|
2902
|
+
if (closing) throw new Error("fx agent is closed");
|
|
2903
|
+
if (journalFailure) throw reportJournalFailure();
|
|
2904
|
+
if (handedOff) throw handedOffError();
|
|
2905
|
+
if (activeTurn) throw new Error("a prompt is already in progress for this session");
|
|
2906
|
+
checkOnAmbiguous(promptOptions.onAmbiguous);
|
|
2907
|
+
if (resumable) {
|
|
2908
|
+
resumable = false;
|
|
2909
|
+
return resumeOpenTurn(promptOptions);
|
|
2910
|
+
}
|
|
2911
|
+
// With no open turn, the follow-ups the journal held are the work left.
|
|
2912
|
+
const next = followUps.find((entry) => entry.held);
|
|
2913
|
+
if (!next) return null;
|
|
2914
|
+
let started = null;
|
|
2915
|
+
const resolveTurn = next.resolveTurn;
|
|
2916
|
+
next.resolveTurn = (turn) => {
|
|
2917
|
+
started = turn;
|
|
2918
|
+
resolveTurn?.(turn);
|
|
2919
|
+
};
|
|
2920
|
+
runNextFollowUp(true);
|
|
2921
|
+
return started;
|
|
2922
|
+
},
|
|
2923
|
+
/**
|
|
2924
|
+
* Queues `input` to run as its own turn after the current one, or at once
|
|
2925
|
+
* when no turn is running. Returns a promise for that turn, carrying the
|
|
2926
|
+
* follow-up's `id` and `accepted`, which resolves `{ id }` once a
|
|
2927
|
+
* journal holds it.
|
|
2928
|
+
*/
|
|
2929
|
+
followUp(input) {
|
|
2930
|
+
if (closing) return Promise.reject(new Error("fx agent is closed"));
|
|
2931
|
+
if (journalFailure) return Promise.reject(reportJournalFailure());
|
|
2932
|
+
if (handedOff) return Promise.reject(handedOffError());
|
|
2933
|
+
let text;
|
|
2934
|
+
try { text = normalizeSteeringInput(input); } catch (error) { return Promise.reject(error); }
|
|
2935
|
+
const id = `in_${crypto.randomUUID()}`;
|
|
2936
|
+
const entry = { id, text, accepted: false };
|
|
2937
|
+
const started = new Promise((resolve, reject) => { entry.resolveTurn = resolve; entry.rejectTurn = reject; });
|
|
2938
|
+
let accepted;
|
|
2939
|
+
if (journal && activeTurn && typeof runtime.steer !== "function") {
|
|
2940
|
+
// The native core records it now, ahead of the turn that will run it.
|
|
2941
|
+
entry.accepted = true;
|
|
2942
|
+
accepted = request("libfx/follow_up", { sessionId, id, text })
|
|
2943
|
+
.then(() => waitForInput(`accepted:${id}`))
|
|
2944
|
+
.then(() => ({ id }), (error) => {
|
|
2945
|
+
const index = followUps.indexOf(entry);
|
|
2946
|
+
if (index >= 0) followUps.splice(index, 1);
|
|
2947
|
+
entry.rejectTurn(error);
|
|
2948
|
+
throw error;
|
|
2949
|
+
});
|
|
2950
|
+
} else {
|
|
2951
|
+
// Its own turn records it with the first progress, a barrier; it is
|
|
2952
|
+
// accepted once that is stored.
|
|
2953
|
+
accepted = started.then(() => (journal ? waitForInput(`accepted:${id}`) : undefined)).then(() => ({ id }));
|
|
2954
|
+
}
|
|
2955
|
+
followUps.push(entry);
|
|
2956
|
+
void accepted.catch(() => {});
|
|
2957
|
+
queueMicrotask(runNextFollowUp);
|
|
2958
|
+
started.id = id;
|
|
2959
|
+
started.accepted = accepted;
|
|
2960
|
+
return started;
|
|
2961
|
+
},
|
|
2962
|
+
/**
|
|
2963
|
+
* The session's state as opaque bytes, when no prompt is running; pass
|
|
2964
|
+
* them as `checkpoint` to restore it in a new agent.
|
|
2965
|
+
*/
|
|
1997
2966
|
checkpoint() {
|
|
1998
2967
|
// One checkpoint runs at a time: each holds an outbound attachment until
|
|
1999
2968
|
// it is taken, and the native table holds only a few. An idle call still
|
|
@@ -2011,18 +2980,39 @@ export async function createFxAgent(options = {}) {
|
|
|
2011
2980
|
return run;
|
|
2012
2981
|
},
|
|
2013
2982
|
async close() {
|
|
2014
|
-
if (closing) {
|
|
2983
|
+
if (closing) {
|
|
2984
|
+
await runtime.exited;
|
|
2985
|
+
if (journal) await closeJournal();
|
|
2986
|
+
return;
|
|
2987
|
+
}
|
|
2015
2988
|
const turn = activeTurn;
|
|
2016
2989
|
turn?.cancel();
|
|
2017
2990
|
if (turn) await turn.result.catch(() => {});
|
|
2991
|
+
// A checkpoint being taken needs the core; it finishes first.
|
|
2992
|
+
await checkpointing;
|
|
2018
2993
|
closing = true;
|
|
2994
|
+
// A journal keeps these; the next `resume()` runs them.
|
|
2995
|
+
for (const entry of followUps.splice(0)) entry.rejectTurn?.(new Error("fx agent closed before the follow-up ran"));
|
|
2019
2996
|
runtime.closeStdin();
|
|
2020
2997
|
await runtime.exited;
|
|
2998
|
+
// Every append the session started has landed, or close reports why.
|
|
2999
|
+
if (journal) {
|
|
3000
|
+
let failure = null;
|
|
3001
|
+
try {
|
|
3002
|
+
await journalSettled();
|
|
3003
|
+
} catch {
|
|
3004
|
+
if (!journalFailureReported) failure = reportJournalFailure();
|
|
3005
|
+
}
|
|
3006
|
+
await closeJournal();
|
|
3007
|
+
if (failure) throw failure;
|
|
3008
|
+
}
|
|
2021
3009
|
},
|
|
2022
3010
|
};
|
|
2023
3011
|
return agent;
|
|
2024
3012
|
|
|
2025
|
-
|
|
3013
|
+
// `answered`: calls a resumed turn answers as possibly run before it goes
|
|
3014
|
+
// on, whose `tool_end` comes first.
|
|
3015
|
+
function normalizeTurn(rawTurn, answered = []) {
|
|
2026
3016
|
const toolNames = new Map();
|
|
2027
3017
|
const started = new Set();
|
|
2028
3018
|
const eventFor = (update) => {
|
|
@@ -2036,7 +3026,9 @@ export async function createFxAgent(options = {}) {
|
|
|
2036
3026
|
return delta ? { type: "reasoning_delta", delta } : null;
|
|
2037
3027
|
}
|
|
2038
3028
|
if (update.sessionUpdate === "user_message_chunk" && update.content?.type === "text") {
|
|
2039
|
-
|
|
3029
|
+
// A steer shows where it joined the turn, with the id it was sent with.
|
|
3030
|
+
const id = update._meta?.fx?.steering?.inputId;
|
|
3031
|
+
return { type: "user_message", text: update.content.text, ...(typeof id === "string" ? { id } : {}) };
|
|
2040
3032
|
}
|
|
2041
3033
|
if (update.sessionUpdate === "tool_call") {
|
|
2042
3034
|
toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool");
|
|
@@ -2068,10 +3060,19 @@ export async function createFxAgent(options = {}) {
|
|
|
2068
3060
|
}));
|
|
2069
3061
|
void result.catch(() => {});
|
|
2070
3062
|
return {
|
|
2071
|
-
|
|
2072
|
-
|
|
3063
|
+
id: rawTurn.id ?? null,
|
|
3064
|
+
cancel(cancelOptions) { rawTurn.cancel(cancelOptions); },
|
|
3065
|
+
steer(input, id) {
|
|
3066
|
+
if (id !== undefined && !validTurnId(id)) throw new TypeError(`steer id must be ${turnIdRule}`);
|
|
3067
|
+
return rawTurn.steer(normalizeSteeringInput(input), id);
|
|
3068
|
+
},
|
|
3069
|
+
withdraw(id) { return rawTurn.withdraw(id); },
|
|
3070
|
+
[engineInternals]: rawTurn[engineInternals],
|
|
2073
3071
|
[Symbol.asyncIterator]() {
|
|
2074
3072
|
const iterator = (async function* () {
|
|
3073
|
+
for (const call of answered) {
|
|
3074
|
+
yield { type: "tool_end", id: call.id, name: call.name, content: unfinishedToolOutput, isError: true };
|
|
3075
|
+
}
|
|
2075
3076
|
for await (const update of rawTurn) {
|
|
2076
3077
|
const event = eventFor(update);
|
|
2077
3078
|
if (event) yield event;
|
|
@@ -2088,6 +3089,18 @@ export async function createFxAgent(options = {}) {
|
|
|
2088
3089
|
};
|
|
2089
3090
|
}
|
|
2090
3091
|
|
|
3092
|
+
// A turn the journal shows already ended, returned for a retried prompt.
|
|
3093
|
+
function endedTurn(id) {
|
|
3094
|
+
return {
|
|
3095
|
+
id,
|
|
3096
|
+
result: Promise.resolve({ stopReason: "end_turn", usage: {} }),
|
|
3097
|
+
cancel() {},
|
|
3098
|
+
steer() { return Promise.reject(new Error("no prompt is running")); },
|
|
3099
|
+
withdraw() { return Promise.resolve("already_placed"); },
|
|
3100
|
+
async *[Symbol.asyncIterator]() {},
|
|
3101
|
+
};
|
|
3102
|
+
}
|
|
3103
|
+
|
|
2091
3104
|
function normalizeTurnUsage(usage) {
|
|
2092
3105
|
const result = {};
|
|
2093
3106
|
if (Number.isSafeInteger(usage?.inputTokens)) result.inputTokens = usage.inputTokens;
|
|
@@ -2111,7 +3124,9 @@ export async function createFxAgent(options = {}) {
|
|
|
2111
3124
|
|
|
2112
3125
|
function startTurn(input, promptOptions) {
|
|
2113
3126
|
const resizeImage = options.resizeImage;
|
|
2114
|
-
const
|
|
3127
|
+
const resuming = promptOptions[resumeTurn] === true;
|
|
3128
|
+
const followUp = promptOptions[followUpInput] ?? null;
|
|
3129
|
+
const normalized = resuming ? [] : normalizePromptInput(input, { deferImageLimits: resizeImage !== undefined });
|
|
2115
3130
|
const hasPixels = normalized.some((block) => block.type === "image" && block.data !== undefined);
|
|
2116
3131
|
// Blob reads and resizeImage run before the prompt frame is sent.
|
|
2117
3132
|
const asyncImages = hasPixels && (resizeImage !== undefined ||
|
|
@@ -2124,6 +3139,8 @@ export async function createFxAgent(options = {}) {
|
|
|
2124
3139
|
: preparePromptImages(normalized);
|
|
2125
3140
|
const signal = promptOptions.signal;
|
|
2126
3141
|
if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal");
|
|
3142
|
+
const yieldAt = promptOptions.yieldAt;
|
|
3143
|
+
if (yieldAt !== undefined && !(Number.isSafeInteger(yieldAt) && yieldAt > 0)) throw new TypeError("yieldAt must be a time in milliseconds since the epoch");
|
|
2127
3144
|
const queue = [];
|
|
2128
3145
|
const waiters = [];
|
|
2129
3146
|
let queuedBytes = 0;
|
|
@@ -2138,10 +3155,55 @@ export async function createFxAgent(options = {}) {
|
|
|
2138
3155
|
const promptStarted = asyncImages ? new Promise((resolve) => { resolvePromptStart = resolve; }) : null;
|
|
2139
3156
|
let pendingSteeringCount = 0;
|
|
2140
3157
|
let pendingSteeringBytes = 0;
|
|
3158
|
+
// This turn's steers: whether each reached the core, or was withdrawn first.
|
|
3159
|
+
const steers = new Map();
|
|
3160
|
+
const withdrawnSteer = (id) => Object.assign(new Error("steer was withdrawn"), { code: "FX_STEER_WITHDRAWN", id });
|
|
3161
|
+
const startSteer = (id, text) => {
|
|
3162
|
+
if (finished || cancelled || activeTurn !== turn) {
|
|
3163
|
+
return Promise.reject(new Error("no prompt is running"));
|
|
3164
|
+
}
|
|
3165
|
+
if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
|
|
3166
|
+
const local = { sent: false, withdrawn: false };
|
|
3167
|
+
steers.set(id, local);
|
|
3168
|
+
const apply = () => {
|
|
3169
|
+
if (local.withdrawn) return Promise.resolve({ id, withdrawn: true });
|
|
3170
|
+
if (finished || cancelled || activeTurn !== turn) {
|
|
3171
|
+
return Promise.reject(new Error("no prompt is running"));
|
|
3172
|
+
}
|
|
3173
|
+
if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
|
|
3174
|
+
try {
|
|
3175
|
+
local.sent = true;
|
|
3176
|
+
const accepted = () => (journal ? waitForInput(`accepted:${id}`) : Promise.resolve());
|
|
3177
|
+
if (typeof runtime.steer === "function") {
|
|
3178
|
+
runtime.steer(text, id);
|
|
3179
|
+
// The web core accepts it when it takes it at the next boundary,
|
|
3180
|
+
// and shows where it joined the turn then.
|
|
3181
|
+
return accepted().then(() => ({ id }), (error) => (error?.code === "FX_STEER_WITHDRAWN" ? { id, withdrawn: true } : Promise.reject(error)));
|
|
3182
|
+
}
|
|
3183
|
+
return request("libfx/steer", { sessionId, text, id }).then(accepted).then(() => ({ id }));
|
|
3184
|
+
} catch (error) {
|
|
3185
|
+
return Promise.reject(error);
|
|
3186
|
+
}
|
|
3187
|
+
};
|
|
3188
|
+
if (!resolvePromptStart) return apply();
|
|
3189
|
+
const bytes = encoder.encode(text).length;
|
|
3190
|
+
if (pendingSteeringCount >= maxSteeringMessages || bytes > maxSteeringQueueBytes - pendingSteeringBytes) {
|
|
3191
|
+
return Promise.reject(new Error("steering queue is full"));
|
|
3192
|
+
}
|
|
3193
|
+
pendingSteeringCount++;
|
|
3194
|
+
pendingSteeringBytes += bytes;
|
|
3195
|
+
return promptStarted.then((started) => {
|
|
3196
|
+
if (coreExitError && !cancelled) throw coreExitError;
|
|
3197
|
+
return started === true ? apply() : Promise.reject(started instanceof Error ? started : new Error("no prompt is running"));
|
|
3198
|
+
}).finally(() => { pendingSteeringCount--; pendingSteeringBytes -= bytes; });
|
|
3199
|
+
};
|
|
2141
3200
|
const toolControllers = new Set();
|
|
2142
3201
|
let finished = false;
|
|
2143
3202
|
let cancelled = false;
|
|
2144
3203
|
const turn = {
|
|
3204
|
+
// The host's id: the one it gave, or for a resumed turn, the one the
|
|
3205
|
+
// turn started with.
|
|
3206
|
+
id: resuming ? openTurnId : promptOptions.turnId ?? null,
|
|
2145
3207
|
push(update, size = encoder.encode(JSON.stringify(update)).length) {
|
|
2146
3208
|
if (cancelled || finished) { discardedBytes += size; return; }
|
|
2147
3209
|
if (size > maxCoreMessageBytes) throw new RangeError("core output message exceeds 64 MiB");
|
|
@@ -2171,44 +3233,60 @@ export async function createFxAgent(options = {}) {
|
|
|
2171
3233
|
resolvePromptStart?.(true);
|
|
2172
3234
|
resolvePromptStart = null;
|
|
2173
3235
|
},
|
|
2174
|
-
steer
|
|
2175
|
-
|
|
2176
|
-
|
|
3236
|
+
// Resolves `{ id }` once the steer is accepted: in a journaled session,
|
|
3237
|
+
// once its acceptance is stored. The promise carries `id` at once.
|
|
3238
|
+
steer(text, id = `in_${crypto.randomUUID()}`) {
|
|
3239
|
+
const steered = startSteer(id, text).then((value) => value ?? { id });
|
|
3240
|
+
steered.id = id;
|
|
3241
|
+
return steered;
|
|
3242
|
+
},
|
|
3243
|
+
// 'withdrawn' when the model never saw the steer, else 'already_placed'.
|
|
3244
|
+
withdraw(id) {
|
|
3245
|
+
const local = steers.get(id);
|
|
3246
|
+
if (!local) return Promise.resolve("already_placed");
|
|
3247
|
+
if (!local.sent) {
|
|
3248
|
+
local.withdrawn = true;
|
|
3249
|
+
return Promise.resolve("withdrawn");
|
|
2177
3250
|
}
|
|
2178
|
-
if (
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
|
|
2184
|
-
try {
|
|
2185
|
-
if (typeof runtime.steer === "function") {
|
|
2186
|
-
runtime.steer(text);
|
|
2187
|
-
void turn.push({
|
|
2188
|
-
sessionUpdate: "user_message_chunk",
|
|
2189
|
-
content: { type: "text", text },
|
|
2190
|
-
});
|
|
2191
|
-
return Promise.resolve();
|
|
2192
|
-
}
|
|
2193
|
-
return request("libfx/steer", { sessionId, text });
|
|
2194
|
-
} catch (error) {
|
|
2195
|
-
return Promise.reject(error);
|
|
2196
|
-
}
|
|
2197
|
-
};
|
|
2198
|
-
if (!resolvePromptStart) return apply();
|
|
2199
|
-
const bytes = encoder.encode(text).length;
|
|
2200
|
-
if (pendingSteeringCount >= maxSteeringMessages || bytes > maxSteeringQueueBytes - pendingSteeringBytes) {
|
|
2201
|
-
return Promise.reject(new Error("steering queue is full"));
|
|
3251
|
+
if (typeof runtime.withdrawSteering === "function") {
|
|
3252
|
+
if (!runtime.withdrawSteering(id)) return Promise.resolve("already_placed");
|
|
3253
|
+
local.withdrawn = true;
|
|
3254
|
+
rejectInputWaiters([`accepted:${id}`], withdrawnSteer(id));
|
|
3255
|
+
return Promise.resolve("withdrawn");
|
|
2202
3256
|
}
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
3257
|
+
return request("libfx/withdraw", { sessionId, id }).then(async (response) => {
|
|
3258
|
+
if (response?.result !== "withdrawn") return "already_placed";
|
|
3259
|
+
if (journal) await waitForInput(`withdrawn:${id}`);
|
|
3260
|
+
return "withdrawn";
|
|
3261
|
+
});
|
|
3262
|
+
},
|
|
3263
|
+
// For libfx's session worker: a steer that cannot join the turn, such
|
|
3264
|
+
// as one with images, ends the turn at its next safe point, at once
|
|
3265
|
+
// while the model streams a reply, so that steer can run next.
|
|
3266
|
+
[engineInternals]: {
|
|
3267
|
+
endForSteer() {
|
|
3268
|
+
const send = () => {
|
|
3269
|
+
if (finished || cancelled || activeTurn !== turn || closing) return undefined;
|
|
3270
|
+
if (typeof runtime.handOffSteering === "function") {
|
|
3271
|
+
runtime.handOffSteering();
|
|
3272
|
+
return undefined;
|
|
3273
|
+
}
|
|
3274
|
+
return request("libfx/handoff", { sessionId }).then(() => undefined);
|
|
3275
|
+
};
|
|
3276
|
+
const sent = promptStarted && resolvePromptStart ? promptStarted.then((started) => (started === true ? send() : undefined)) : Promise.resolve().then(send);
|
|
3277
|
+
return sent.catch(() => {});
|
|
3278
|
+
},
|
|
2209
3279
|
},
|
|
2210
|
-
cancel() {
|
|
3280
|
+
cancel(cancelOptions = {}) {
|
|
3281
|
+
const reason = cancelOptions?.reason;
|
|
3282
|
+
if (reason !== undefined && reason !== "handoff") throw new TypeError('cancel() reason must be "handoff" when given');
|
|
3283
|
+
if (reason === "handoff" && !journal) throw new TypeError("a handoff needs persistence to keep the turn in");
|
|
2211
3284
|
if (finished || cancelled) return;
|
|
3285
|
+
if (reason === "handoff") {
|
|
3286
|
+
handedOff = true;
|
|
3287
|
+
// The journal keeps queued follow-ups for the next agent.
|
|
3288
|
+
for (const entry of followUps.splice(0)) entry.rejectTurn?.(handedOffError());
|
|
3289
|
+
}
|
|
2212
3290
|
cancelled = true;
|
|
2213
3291
|
cancelImagePrep?.();
|
|
2214
3292
|
cancelImagePrep = null;
|
|
@@ -2271,21 +3349,43 @@ export async function createFxAgent(options = {}) {
|
|
|
2271
3349
|
resolvePromptStart = null;
|
|
2272
3350
|
return { stopReason: "cancelled" };
|
|
2273
3351
|
}
|
|
2274
|
-
return sendPrompt(prepared);
|
|
3352
|
+
return sendPrompt(prepared, false, followUp, turn.id, [], yieldAt);
|
|
2275
3353
|
});
|
|
2276
3354
|
} else {
|
|
2277
|
-
try { response = sendPrompt(prompt); } catch (error) { response = Promise.reject(error); }
|
|
3355
|
+
try { response = sendPrompt(prompt, resuming, followUp, turn.id, promptOptions[rerunCallIds] ?? [], yieldAt); } catch (error) { response = Promise.reject(error); }
|
|
2278
3356
|
}
|
|
3357
|
+
// Appends after a turn's first progress are lazy, so the result does not
|
|
3358
|
+
// wait for them; `close()` does. A failure already seen fails the turn.
|
|
2279
3359
|
turn.result = response
|
|
2280
|
-
.then((value) =>
|
|
2281
|
-
|
|
3360
|
+
.then((value) => {
|
|
3361
|
+
if (journalFailure) throw reportJournalFailure();
|
|
3362
|
+
if (value.stopReason === "yielded" && !cancelled) {
|
|
3363
|
+
// The core keeps the turn open: `resume()` continues it here, or
|
|
3364
|
+
// a later agent continues it from the journal.
|
|
3365
|
+
resumable = true;
|
|
3366
|
+
openTurnId = turn.id;
|
|
3367
|
+
ambiguousCalls = [];
|
|
3368
|
+
} else if (!handedOff) {
|
|
3369
|
+
// A handed-off turn stays open for the next agent.
|
|
3370
|
+
if (turn.id) lastTurnId = turn.id;
|
|
3371
|
+
openTurnId = null;
|
|
3372
|
+
}
|
|
3373
|
+
return { stopReason: cancelled ? "cancelled" : value.stopReason, usage: value.usage };
|
|
3374
|
+
})
|
|
3375
|
+
.catch(async (error) => {
|
|
2282
3376
|
cancelImagePrep = null;
|
|
2283
3377
|
rejectImagePrep = null;
|
|
2284
3378
|
resolvePromptStart?.(error);
|
|
2285
3379
|
resolvePromptStart = null;
|
|
3380
|
+
// A failed append is why the core stopped, including when the turn
|
|
3381
|
+
// was cancelled because of it; report the host's error.
|
|
3382
|
+
if (journalFailure) {
|
|
3383
|
+
terminalError = reportJournalFailure();
|
|
3384
|
+
throw terminalError;
|
|
3385
|
+
}
|
|
2286
3386
|
if (cancelled && error.message === "Cancelled") return { stopReason: "cancelled" };
|
|
2287
3387
|
terminalError = error;
|
|
2288
|
-
throw
|
|
3388
|
+
throw terminalError;
|
|
2289
3389
|
})
|
|
2290
3390
|
.finally(() => {
|
|
2291
3391
|
finished = true;
|
|
@@ -2293,6 +3393,17 @@ export async function createFxAgent(options = {}) {
|
|
|
2293
3393
|
resumeOutput = null;
|
|
2294
3394
|
signal?.removeEventListener("abort", abort);
|
|
2295
3395
|
if (activeTurn === turn) activeTurn = null;
|
|
3396
|
+
// A steer still queued when the turn ends never reached the model.
|
|
3397
|
+
if (typeof runtime.withdrawSteering === "function") {
|
|
3398
|
+
const untaken = [...steers.keys()].filter((id) => runtime.withdrawSteering(id));
|
|
3399
|
+
rejectInputWaiters(untaken.map((id) => `accepted:${id}`), new Error("the turn ended before it took the steer"));
|
|
3400
|
+
}
|
|
3401
|
+
for (const id of steers.keys()) {
|
|
3402
|
+
durableInputs.delete(`accepted:${id}`);
|
|
3403
|
+
durableInputs.delete(`withdrawn:${id}`);
|
|
3404
|
+
}
|
|
3405
|
+
queueMicrotask(runNextFollowUp);
|
|
3406
|
+
queueMicrotask(() => { void maybeCheckpoint(); });
|
|
2296
3407
|
runtime.closeSteering?.();
|
|
2297
3408
|
toolControllers.clear();
|
|
2298
3409
|
if (discardedBytes) emit("output.discarded", { reason: "cancelled", bytes: discardedBytes });
|