libfx 0.0.12 → 0.0.13-dev.1415.g607b1632fff6
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 +196 -30
- package/fetch-cleanup.js +12 -0
- package/fx-core.wasm +0 -0
- package/fx-sdk.js +494 -149
- package/fx-term.wasm +0 -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/node.cjs +658 -201
- package/node.js +138 -45
- package/package.json +1 -1
package/fx-sdk.js
CHANGED
|
@@ -7,6 +7,10 @@ const strictDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
|
7
7
|
const workspaceInfoLimit = 4 * 1024;
|
|
8
8
|
const workspaceCommandLimit = 64 * 1024;
|
|
9
9
|
const workspaceOutputLimit = 64 * 1024;
|
|
10
|
+
// Matches max_instruction_file_bytes and max_instruction_path_bytes in js_host_workspace.zig.
|
|
11
|
+
const workspaceInstructionFileLimit = 1024 * 1024;
|
|
12
|
+
const workspaceInstructionPathLimit = 4 * 1024;
|
|
13
|
+
const workspaceReadTimeoutMs = 10_000;
|
|
10
14
|
const maxInstructionsBytes = 64 * 1024;
|
|
11
15
|
const maxApiKeyBytes = 64 * 1024;
|
|
12
16
|
const maxModelBytes = 1024;
|
|
@@ -19,11 +23,20 @@ const streamReadsPerTaskYield = 32;
|
|
|
19
23
|
const transportActivityIntervalMs = 250;
|
|
20
24
|
const maxUnreadEventBytes = 1024 * 1024;
|
|
21
25
|
const maxUnreadEvents = 256;
|
|
22
|
-
// Prompt
|
|
23
|
-
//
|
|
26
|
+
// Prompt images travel as raw bytes beside the ACP frame and are base64
|
|
27
|
+
// encoded only in the model request. An image may use 5 MiB of encoded
|
|
28
|
+
// request data, and a prompt's images 8 MiB, so the raw limits are 3/4 of that.
|
|
29
|
+
// The kernel still validates content and media type.
|
|
24
30
|
const maxPromptImages = 8;
|
|
25
31
|
const maxPromptImageDataBytes = 5 * 1024 * 1024;
|
|
26
|
-
const
|
|
32
|
+
const maxPromptImagesDataBytes = 8 * 1024 * 1024;
|
|
33
|
+
const maxPromptImageBytes = (maxPromptImageDataBytes / 4) * 3;
|
|
34
|
+
const maxPromptImagesBytes = (maxPromptImagesDataBytes / 4) * 3;
|
|
35
|
+
// Matches the native attachment table: one prompt's images or one checkpoint.
|
|
36
|
+
const maxPendingAttachments = 8;
|
|
37
|
+
const maxOutboundAttachments = 4;
|
|
38
|
+
// Matches the core's kernel checkpoint limit (max_checkpoint_bytes).
|
|
39
|
+
const maxCheckpointBytes = 4 * 1024 * 1024;
|
|
27
40
|
// The core's ACP reader drops frames over 8 MiB without a request id to answer
|
|
28
41
|
// (jsonrpc frame_resource_byte_limit), so the SDK must never emit one. The
|
|
29
42
|
// envelope allowance covers the method key and request id.
|
|
@@ -84,6 +97,14 @@ function normalizeFast(value) {
|
|
|
84
97
|
return value;
|
|
85
98
|
}
|
|
86
99
|
|
|
100
|
+
function normalizeUltrafast(value) {
|
|
101
|
+
if (value === undefined) return undefined;
|
|
102
|
+
if (typeof value !== "boolean") {
|
|
103
|
+
throw new TypeError("ultrafast must be a boolean");
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
export function normalizeAgentOptions(value) {
|
|
88
109
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
89
110
|
throw new TypeError("createFxAgent() options must be an object");
|
|
@@ -94,24 +115,29 @@ export function normalizeAgentOptions(value) {
|
|
|
94
115
|
}
|
|
95
116
|
options.apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, true);
|
|
96
117
|
if (options.model !== null && typeof options.model === "object" && !Array.isArray(options.model)) {
|
|
97
|
-
if (Object.hasOwn(options, "effort") || Object.hasOwn(options, "fast")) {
|
|
98
|
-
throw new TypeError("model options cannot be mixed with top-level effort or
|
|
118
|
+
if (Object.hasOwn(options, "effort") || Object.hasOwn(options, "fast") || Object.hasOwn(options, "ultrafast")) {
|
|
119
|
+
throw new TypeError("model options cannot be mixed with top-level effort, fast, or ultrafast");
|
|
99
120
|
}
|
|
100
121
|
const model = options.model;
|
|
101
122
|
for (const name of Object.keys(model)) {
|
|
102
|
-
if (name !== "id" && name !== "effort" && name !== "fast") {
|
|
123
|
+
if (name !== "id" && name !== "effort" && name !== "fast" && name !== "ultrafast") {
|
|
103
124
|
throw new TypeError(`unsupported model option: ${name}`);
|
|
104
125
|
}
|
|
105
126
|
}
|
|
106
127
|
options.model = boundedString(model.id, "model.id", maxModelBytes, true);
|
|
107
128
|
options.effort = normalizeEffort(model.effort);
|
|
108
129
|
options.fast = normalizeFast(model.fast);
|
|
130
|
+
options.ultrafast = normalizeUltrafast(model.ultrafast);
|
|
109
131
|
} else {
|
|
110
132
|
options.model = boundedString(options.model, "model", maxModelBytes, false);
|
|
111
133
|
options.effort = normalizeEffort(options.effort);
|
|
112
134
|
options.fast = normalizeFast(options.fast);
|
|
135
|
+
options.ultrafast = normalizeUltrafast(options.ultrafast);
|
|
113
136
|
}
|
|
114
137
|
validateGatewayChatUrl(options.gatewayChatUrl);
|
|
138
|
+
if (options.resizeImage !== undefined && typeof options.resizeImage !== "function") {
|
|
139
|
+
throw new TypeError("resizeImage must be a function");
|
|
140
|
+
}
|
|
115
141
|
return options;
|
|
116
142
|
}
|
|
117
143
|
|
|
@@ -121,6 +147,7 @@ function agentEnvironment(options) {
|
|
|
121
147
|
...(options.model === undefined ? {} : { FX_MODEL: options.model }),
|
|
122
148
|
...(options.effort === undefined ? {} : { FX_EFFORT: options.effort }),
|
|
123
149
|
...(options.fast === undefined ? {} : { FX_FAST: options.fast ? "true" : "false" }),
|
|
150
|
+
...(options.ultrafast === undefined ? {} : { FX_ULTRAFAST: options.ultrafast ? "true" : "false" }),
|
|
124
151
|
...(options.gatewayChatUrl === undefined ? {} : { FX_GATEWAY_CHAT_URL: options.gatewayChatUrl }),
|
|
125
152
|
};
|
|
126
153
|
}
|
|
@@ -128,9 +155,9 @@ function agentEnvironment(options) {
|
|
|
128
155
|
function agentRpcError(response) {
|
|
129
156
|
const error = new Error(response.message);
|
|
130
157
|
const data = response.data;
|
|
131
|
-
if (data && ["LIBFX_MODEL_UNSUPPORTED_EFFORT", "LIBFX_MODEL_UNSUPPORTED_FAST"].includes(data.code) &&
|
|
158
|
+
if (data && ["LIBFX_MODEL_UNSUPPORTED_EFFORT", "LIBFX_MODEL_UNSUPPORTED_FAST", "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST"].includes(data.code) &&
|
|
132
159
|
typeof data.model === "string" &&
|
|
133
|
-
data.capability === (data.code === "LIBFX_MODEL_UNSUPPORTED_FAST" ? "fast" : "effort")) {
|
|
160
|
+
data.capability === (data.code === "LIBFX_MODEL_UNSUPPORTED_FAST" ? "fast" : data.code === "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST" ? "ultrafast" : "effort")) {
|
|
134
161
|
error.code = data.code;
|
|
135
162
|
error.model = data.model;
|
|
136
163
|
error.capability = data.capability;
|
|
@@ -230,12 +257,24 @@ function validWorkspacePath(path) {
|
|
|
230
257
|
return path.slice(1).split("/").every((part) => part && part !== "." && part !== "..");
|
|
231
258
|
}
|
|
232
259
|
|
|
260
|
+
function workspacePathInside(root, path) {
|
|
261
|
+
return root === "/" ? path.startsWith("/") : path === root || path.startsWith(`${root}/`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// fx only reads AGENTS.md files below the workspace root or the host home.
|
|
265
|
+
function workspaceInstructionPathAllowed(info, path) {
|
|
266
|
+
return validWorkspacePath(path) && path.endsWith("/AGENTS.md") &&
|
|
267
|
+
(workspacePathInside(info.root, path) || workspacePathInside(info.home, path));
|
|
268
|
+
}
|
|
269
|
+
|
|
233
270
|
function prepareWorkspaceAdapter(workspace) {
|
|
234
271
|
if (workspace == null) return { present: false, valid: false };
|
|
235
272
|
try {
|
|
236
273
|
const info = workspace.info;
|
|
237
274
|
const permission = workspace.permission;
|
|
275
|
+
const readFile = workspace.readFile;
|
|
238
276
|
if (!info || typeof workspace.exec !== "function" || info.version !== 1 ||
|
|
277
|
+
(readFile !== undefined && typeof readFile !== "function") ||
|
|
239
278
|
!validWorkspacePath(info.root) || !validWorkspacePath(info.cwd) ||
|
|
240
279
|
!validWorkspacePath(info.home) || info.cwd !== info.root ||
|
|
241
280
|
info.gitAvailable !== false || info.ephemeral !== true ||
|
|
@@ -250,6 +289,7 @@ function prepareWorkspaceAdapter(workspace) {
|
|
|
250
289
|
git: false,
|
|
251
290
|
ephemeral: true,
|
|
252
291
|
permission,
|
|
292
|
+
instruction_files: typeof readFile === "function",
|
|
253
293
|
};
|
|
254
294
|
const encoded = encoder.encode(JSON.stringify(value));
|
|
255
295
|
if (encoded.length > workspaceInfoLimit) return { present: true, valid: false };
|
|
@@ -465,7 +505,12 @@ function createRuntime(options) {
|
|
|
465
505
|
const steering = [];
|
|
466
506
|
let steeringBytes = 0;
|
|
467
507
|
let steeringOpen = false;
|
|
468
|
-
|
|
508
|
+
// Raw payloads beside ACP frames: the core copies inbound bytes into its
|
|
509
|
+
// own memory and publishes outbound bytes for the agent to take.
|
|
510
|
+
const inboundAttachments = new Map();
|
|
511
|
+
const outboundAttachments = new Map();
|
|
512
|
+
let nextOutboundAttachment = 1;
|
|
513
|
+
const workspaceOperations = new Set();
|
|
469
514
|
const workspace = prepareWorkspaceAdapter(options.workspace);
|
|
470
515
|
const args = ["fx", ...(options.args || [])];
|
|
471
516
|
const env = Object.entries(options.env || {}).map(([key, value]) => `${key}=${value}`);
|
|
@@ -761,6 +806,38 @@ function createRuntime(options) {
|
|
|
761
806
|
return value.length;
|
|
762
807
|
}
|
|
763
808
|
|
|
809
|
+
function writeAttachment(id, data) {
|
|
810
|
+
if (inboundAttachments.size >= maxPendingAttachments) throw new Error("attachment table is full");
|
|
811
|
+
inboundAttachments.set(id, data.slice());
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function attachmentSize(id) {
|
|
815
|
+
return inboundAttachments.get(id >>> 0)?.length ?? -1;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function attachmentTake(id, outputPtr, outputCap) {
|
|
819
|
+
const key = id >>> 0;
|
|
820
|
+
const value = inboundAttachments.get(key);
|
|
821
|
+
if (!value) return -1;
|
|
822
|
+
// An empty payload needs no output buffer, whose pointer may be arbitrary.
|
|
823
|
+
if (value.length > 0) {
|
|
824
|
+
const output = checkedBytes(outputPtr, outputCap);
|
|
825
|
+
if (!output || value.length > output.length) return -1;
|
|
826
|
+
output.set(value);
|
|
827
|
+
}
|
|
828
|
+
inboundAttachments.delete(key);
|
|
829
|
+
return value.length;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function attachmentPut(inputPtr, inputLen) {
|
|
833
|
+
const input = checkedBytes(inputPtr, inputLen);
|
|
834
|
+
if (!input || outboundAttachments.size >= maxOutboundAttachments) return -1;
|
|
835
|
+
const id = nextOutboundAttachment;
|
|
836
|
+
nextOutboundAttachment = id === 0x7fffffff ? 1 : id + 1;
|
|
837
|
+
outboundAttachments.set(id, input.slice());
|
|
838
|
+
return id;
|
|
839
|
+
}
|
|
840
|
+
|
|
764
841
|
let pendingHostToolResult = null;
|
|
765
842
|
function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr) {
|
|
766
843
|
pendingHostToolResult = null;
|
|
@@ -987,32 +1064,13 @@ function createRuntime(options) {
|
|
|
987
1064
|
}
|
|
988
1065
|
if (command.includes("\0")) return Promise.resolve(-4);
|
|
989
1066
|
|
|
990
|
-
|
|
991
|
-
let resolveAbort;
|
|
992
|
-
const aborted = new Promise((resolve) => { resolveAbort = resolve; });
|
|
993
|
-
const state = {
|
|
994
|
-
controller,
|
|
995
|
-
status: null,
|
|
996
|
-
abort(status) {
|
|
997
|
-
if (this.status !== null) return;
|
|
998
|
-
this.status = status;
|
|
999
|
-
resolveAbort(status);
|
|
1000
|
-
controller.abort(new DOMException(
|
|
1001
|
-
status === -5 ? "workspace command timed out" : "workspace command aborted",
|
|
1002
|
-
status === -5 ? "TimeoutError" : "AbortError",
|
|
1003
|
-
));
|
|
1004
|
-
},
|
|
1005
|
-
};
|
|
1006
|
-
workspaceExecs.add(state);
|
|
1007
|
-
const timer = setTimeout(() => state.abort(-5), timeoutMs);
|
|
1008
|
-
const execution = Promise.resolve().then(() => workspace.adapter.exec({
|
|
1067
|
+
return runWorkspaceOperation(timeoutMs, "workspace command", (signal) => workspace.adapter.exec({
|
|
1009
1068
|
command,
|
|
1010
1069
|
cwd: workspace.info.cwd,
|
|
1011
|
-
signal
|
|
1070
|
+
signal,
|
|
1012
1071
|
timeoutMs,
|
|
1013
1072
|
outputLimitBytes: workspaceOutputLimit,
|
|
1014
|
-
})
|
|
1015
|
-
if (state.status !== null) return state.status;
|
|
1073
|
+
}), (value) => {
|
|
1016
1074
|
if (!value || !Number.isInteger(value.exitCode) || value.exitCode < -0x80000000 ||
|
|
1017
1075
|
value.exitCode > 0x7fffffff || typeof value.stdout !== "string" ||
|
|
1018
1076
|
typeof value.stderr !== "string") return -1;
|
|
@@ -1046,6 +1104,81 @@ function createRuntime(options) {
|
|
|
1046
1104
|
view.setUint32(24, stderr.length, true);
|
|
1047
1105
|
view.setUint32(28, copied < stdout.length + stderr.length ? 1 : 0, true);
|
|
1048
1106
|
return 0;
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function workspaceReadFile(pathPtr, pathLen, outPtr, outCap, resultPtr) {
|
|
1111
|
+
if (!workspace.present || (workspace.valid && typeof workspace.adapter.readFile !== "function")) {
|
|
1112
|
+
return Promise.resolve(-2);
|
|
1113
|
+
}
|
|
1114
|
+
if (!workspace.valid) return Promise.resolve(-4);
|
|
1115
|
+
const pathBytes = checkedBytes(pathPtr, pathLen);
|
|
1116
|
+
if (!pathBytes || !checkedBytes(outPtr, outCap) || !checkedBytes(resultPtr, 8) ||
|
|
1117
|
+
pathLen > workspaceInstructionPathLimit || outCap > workspaceInstructionFileLimit) {
|
|
1118
|
+
return Promise.resolve(-4);
|
|
1119
|
+
}
|
|
1120
|
+
let path;
|
|
1121
|
+
try {
|
|
1122
|
+
path = strictDecoder.decode(pathBytes);
|
|
1123
|
+
} catch {
|
|
1124
|
+
return Promise.resolve(-4);
|
|
1125
|
+
}
|
|
1126
|
+
if (!workspaceInstructionPathAllowed(workspace.info, path)) return Promise.resolve(-4);
|
|
1127
|
+
|
|
1128
|
+
return runWorkspaceOperation(workspaceReadTimeoutMs, "workspace read", (signal) => workspace.adapter.readFile({
|
|
1129
|
+
path,
|
|
1130
|
+
signal,
|
|
1131
|
+
}), (value) => {
|
|
1132
|
+
if (value == null) return -6;
|
|
1133
|
+
let content;
|
|
1134
|
+
if (typeof value === "string") {
|
|
1135
|
+
content = encoder.encode(value);
|
|
1136
|
+
} else if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
|
1137
|
+
content = value instanceof ArrayBuffer
|
|
1138
|
+
? new Uint8Array(value)
|
|
1139
|
+
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
1140
|
+
try {
|
|
1141
|
+
strictDecoder.decode(content);
|
|
1142
|
+
} catch {
|
|
1143
|
+
return -7;
|
|
1144
|
+
}
|
|
1145
|
+
} else {
|
|
1146
|
+
return -1;
|
|
1147
|
+
}
|
|
1148
|
+
if (content.length > 0xffffffff) return -1;
|
|
1149
|
+
const copied = Math.min(content.length, outCap);
|
|
1150
|
+
// Reacquire views: memory may have grown while the host read was pending.
|
|
1151
|
+
bytes(outPtr, copied).set(content.subarray(0, copied));
|
|
1152
|
+
const view = new DataView(memory().buffer, resultPtr, 8);
|
|
1153
|
+
view.setUint32(0, content.length, true);
|
|
1154
|
+
view.setUint32(4, copied, true);
|
|
1155
|
+
return 0;
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Runs one host workspace effect under a deadline and the shared Ctrl+C
|
|
1160
|
+
// abort path. `finish` maps the host value to an ABI status.
|
|
1161
|
+
function runWorkspaceOperation(timeoutMs, label, start, finish) {
|
|
1162
|
+
const controller = new AbortController();
|
|
1163
|
+
let resolveAbort;
|
|
1164
|
+
const aborted = new Promise((resolve) => { resolveAbort = resolve; });
|
|
1165
|
+
const state = {
|
|
1166
|
+
status: null,
|
|
1167
|
+
abort(status) {
|
|
1168
|
+
if (this.status !== null) return;
|
|
1169
|
+
this.status = status;
|
|
1170
|
+
resolveAbort(status);
|
|
1171
|
+
controller.abort(new DOMException(
|
|
1172
|
+
status === -5 ? `${label} timed out` : `${label} aborted`,
|
|
1173
|
+
status === -5 ? "TimeoutError" : "AbortError",
|
|
1174
|
+
));
|
|
1175
|
+
},
|
|
1176
|
+
};
|
|
1177
|
+
workspaceOperations.add(state);
|
|
1178
|
+
const timer = setTimeout(() => state.abort(-5), timeoutMs);
|
|
1179
|
+
const execution = Promise.resolve().then(() => start(controller.signal)).then((value) => {
|
|
1180
|
+
if (state.status !== null) return state.status;
|
|
1181
|
+
return finish(value);
|
|
1049
1182
|
}).catch((error) => {
|
|
1050
1183
|
if (state.status !== null) return state.status;
|
|
1051
1184
|
if (error?.name === "TimeoutError") return -5;
|
|
@@ -1054,7 +1187,7 @@ function createRuntime(options) {
|
|
|
1054
1187
|
});
|
|
1055
1188
|
return Promise.race([execution, aborted]).finally(() => {
|
|
1056
1189
|
clearTimeout(timer);
|
|
1057
|
-
|
|
1190
|
+
workspaceOperations.delete(state);
|
|
1058
1191
|
});
|
|
1059
1192
|
}
|
|
1060
1193
|
|
|
@@ -1062,7 +1195,7 @@ function createRuntime(options) {
|
|
|
1062
1195
|
pendingHostToolResult = null;
|
|
1063
1196
|
streams.forEach((state) => state.controller.abort(abortReason));
|
|
1064
1197
|
httpRequests.forEach((controller) => controller.abort(abortReason));
|
|
1065
|
-
|
|
1198
|
+
workspaceOperations.forEach((state) => state.abort(-3));
|
|
1066
1199
|
}
|
|
1067
1200
|
|
|
1068
1201
|
const unavailable = () => 52;
|
|
@@ -1117,6 +1250,7 @@ function createRuntime(options) {
|
|
|
1117
1250
|
fx_workspace_available() { return workspace.present ? 1 : 0; },
|
|
1118
1251
|
fx_workspace_info: workspaceInfo,
|
|
1119
1252
|
fx_workspace_exec: new WebAssembly.Suspending(workspaceExec),
|
|
1253
|
+
fx_workspace_read_file: new WebAssembly.Suspending(workspaceReadFile),
|
|
1120
1254
|
fx_http_stream_open: streamOpen,
|
|
1121
1255
|
fx_http_stream_status: new WebAssembly.Suspending(streamStatus),
|
|
1122
1256
|
fx_http_stream_next: new WebAssembly.Suspending(streamNext),
|
|
@@ -1132,6 +1266,9 @@ function createRuntime(options) {
|
|
|
1132
1266
|
fx_host_tool_result_release() { pendingHostToolResult = null; },
|
|
1133
1267
|
fx_steering_take: steeringTake,
|
|
1134
1268
|
fx_steering_close() { steeringOpen = false; clearSteering(); },
|
|
1269
|
+
fx_attachment_size: attachmentSize,
|
|
1270
|
+
fx_attachment_take: attachmentTake,
|
|
1271
|
+
fx_attachment_put: attachmentPut,
|
|
1135
1272
|
fx_open_url: new WebAssembly.Suspending(openUrl),
|
|
1136
1273
|
fx_oauth_session_load: new WebAssembly.Suspending(oauthSessionLoad),
|
|
1137
1274
|
fx_oauth_session_commit: new WebAssembly.Suspending(oauthSessionCommit),
|
|
@@ -1158,6 +1295,13 @@ function createRuntime(options) {
|
|
|
1158
1295
|
imports: { wasi_snapshot_preview1: wasi, fx }, exited,
|
|
1159
1296
|
setInstance(value) { instance = value; },
|
|
1160
1297
|
write(data) { stdin.push(typeof data === "string" ? encoder.encode(data) : data); },
|
|
1298
|
+
writeAttachment,
|
|
1299
|
+
takeAttachment(id) {
|
|
1300
|
+
const value = outboundAttachments.get(id) ?? null;
|
|
1301
|
+
outboundAttachments.delete(id);
|
|
1302
|
+
return value;
|
|
1303
|
+
},
|
|
1304
|
+
discardAttachments() { inboundAttachments.clear(); },
|
|
1161
1305
|
wake() { stdin.wake(); },
|
|
1162
1306
|
closeStdin() { steeringOpen = false; clearSteering(); stdin.close(); },
|
|
1163
1307
|
openSteering() { clearSteering(); steeringOpen = true; },
|
|
@@ -1299,42 +1443,128 @@ function blobByteLength(value) {
|
|
|
1299
1443
|
}
|
|
1300
1444
|
}
|
|
1301
1445
|
|
|
1302
|
-
function
|
|
1446
|
+
function normalizeImageSourceRef(value, name) {
|
|
1447
|
+
boundedString(value, `${name} sourceRef`, 512, false);
|
|
1448
|
+
if (value !== undefined && /[\x00-\x1f\x7f\uD800-\uDFFF]/u.test(value)) {
|
|
1449
|
+
throw new TypeError(`${name} sourceRef must be valid UTF-8 without ASCII controls`);
|
|
1450
|
+
}
|
|
1451
|
+
return value;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function omitReferencedImageData(blocks) {
|
|
1455
|
+
return blocks.map((block) => block.type !== "image" || block.sourceRef === undefined ? block : {
|
|
1456
|
+
type: "image",
|
|
1457
|
+
mimeType: block.mimeType,
|
|
1458
|
+
sourceRef: block.sourceRef,
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function promptImageDataBytes(prompt) {
|
|
1463
|
+
return prompt.reduce((total, block) => {
|
|
1464
|
+
if (block.type !== "image") return total;
|
|
1465
|
+
if (typeof block.data === "string") return total + block.data.length;
|
|
1466
|
+
const byteLength = block.bytes?.byteLength ?? block.byteLength ?? blobByteLength(block.data) ?? 0;
|
|
1467
|
+
return total + Math.ceil(byteLength / 3) * 4;
|
|
1468
|
+
}, 0);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Returns a Uint8Array view of an ArrayBuffer or typed array, or null.
|
|
1472
|
+
function byteView(value) {
|
|
1473
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
1474
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
1475
|
+
return null;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
1479
|
+
|
|
1480
|
+
// Returns the decoded length of canonical, unwrapped base64, or -1.
|
|
1481
|
+
function canonicalBase64ByteLength(value) {
|
|
1482
|
+
if (value.length === 0 || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return -1;
|
|
1483
|
+
const groups = value.length / 4;
|
|
1484
|
+
if (value.endsWith("==")) {
|
|
1485
|
+
return (base64Alphabet.indexOf(value[value.length - 3]) & 0x0f) === 0 ? groups * 3 - 2 : -1;
|
|
1486
|
+
}
|
|
1487
|
+
if (value.endsWith("=")) {
|
|
1488
|
+
return (base64Alphabet.indexOf(value[value.length - 2]) & 0x03) === 0 ? groups * 3 - 1 : -1;
|
|
1489
|
+
}
|
|
1490
|
+
return groups * 3;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function requireImageMimeType(mimeType, index) {
|
|
1494
|
+
if (typeof mimeType !== "string" || mimeType.length === 0 || mimeType.length > 128) {
|
|
1495
|
+
throw new TypeError(`image prompt block ${index} requires a mimeType`);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function checkImageByteLength(byteLength, index) {
|
|
1500
|
+
if (byteLength > maxPromptImageBytes) {
|
|
1501
|
+
throw new RangeError(`image prompt block ${index} exceeds the ${maxPromptImageBytes} byte per-image libfx limit`);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function checkPromptImagesByteLength(byteLength) {
|
|
1506
|
+
if (byteLength > maxPromptImagesBytes) {
|
|
1507
|
+
throw new RangeError(`prompt images exceed the ${maxPromptImagesBytes} byte libfx limit`);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// Pixel inputs carry a private source and byteLength until preparation;
|
|
1512
|
+
// reference-only inputs remain public image descriptors without pixel data.
|
|
1513
|
+
function normalizePromptImage(block, index) {
|
|
1514
|
+
const sourceRef = normalizeImageSourceRef(block.sourceRef, `image prompt block ${index}`);
|
|
1515
|
+
const reference = sourceRef === undefined ? {} : { sourceRef };
|
|
1516
|
+
if (block.data === undefined && sourceRef !== undefined) {
|
|
1517
|
+
requireImageMimeType(block.mimeType, index);
|
|
1518
|
+
return { type: "image", mimeType: block.mimeType, ...reference };
|
|
1519
|
+
}
|
|
1520
|
+
const size = blobByteLength(block.data);
|
|
1521
|
+
if (size !== null) {
|
|
1522
|
+
const mimeType = block.data.type;
|
|
1523
|
+
if (block.mimeType !== undefined && block.mimeType !== mimeType) {
|
|
1524
|
+
throw new TypeError(`image prompt block ${index} mimeType disagrees with Blob.type`);
|
|
1525
|
+
}
|
|
1526
|
+
requireImageMimeType(mimeType, index);
|
|
1527
|
+
if (!Number.isSafeInteger(size) || size <= 0) {
|
|
1528
|
+
throw new TypeError(`image prompt block ${index} requires a non-empty Blob with a valid size`);
|
|
1529
|
+
}
|
|
1530
|
+
return { type: "image", source: "blob", data: block.data, mimeType, byteLength: size, ...reference };
|
|
1531
|
+
}
|
|
1532
|
+
if (typeof block.data === "string" && block.data.length > 0) {
|
|
1533
|
+
requireImageMimeType(block.mimeType, index);
|
|
1534
|
+
const byteLength = canonicalBase64ByteLength(block.data);
|
|
1535
|
+
if (byteLength <= 0) throw new TypeError(`image prompt block ${index} requires canonical base64 data`);
|
|
1536
|
+
return { type: "image", source: "base64", data: block.data, mimeType: block.mimeType, byteLength, ...reference };
|
|
1537
|
+
}
|
|
1538
|
+
const bytes = typeof block.data === "string" ? null : byteView(block.data);
|
|
1539
|
+
if (!bytes || bytes.byteLength === 0) {
|
|
1540
|
+
throw new TypeError(`image prompt block ${index} requires base64 data, bytes, or a Blob`);
|
|
1541
|
+
}
|
|
1542
|
+
requireImageMimeType(block.mimeType, index);
|
|
1543
|
+
return { type: "image", source: "bytes", data: bytes, mimeType: block.mimeType, byteLength: bytes.byteLength, ...reference };
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// With deferImageLimits, byte limits apply after resizeImage instead.
|
|
1547
|
+
function normalizePromptInput(input, { deferImageLimits = false } = {}) {
|
|
1303
1548
|
if (typeof input === "string") return [{ type: "text", text: input }];
|
|
1304
1549
|
if (!Array.isArray(input)) throw new TypeError("prompt input must be a string or an array of prompt blocks");
|
|
1305
1550
|
let imageCount = 0;
|
|
1306
1551
|
let imageBytes = 0;
|
|
1307
|
-
|
|
1552
|
+
let prompt = input.map((block, index) => {
|
|
1308
1553
|
if (!block || typeof block !== "object") throw new TypeError(`prompt block ${index} must be an object`);
|
|
1309
1554
|
if (block.type === "image") {
|
|
1310
|
-
const
|
|
1311
|
-
const blob = size !== null;
|
|
1312
|
-
if (!blob && (typeof block.data !== "string" || block.data.length === 0)) {
|
|
1313
|
-
throw new TypeError(`image prompt block ${index} requires base64 data or a Blob`);
|
|
1314
|
-
}
|
|
1315
|
-
const mimeType = blob ? block.data.type : block.mimeType;
|
|
1316
|
-
if (blob && block.mimeType !== undefined && block.mimeType !== mimeType) {
|
|
1317
|
-
throw new TypeError(`image prompt block ${index} mimeType disagrees with Blob.type`);
|
|
1318
|
-
}
|
|
1319
|
-
if (typeof mimeType !== "string" || mimeType.length === 0 || mimeType.length > 128) {
|
|
1320
|
-
throw new TypeError(`image prompt block ${index} requires a mimeType`);
|
|
1321
|
-
}
|
|
1322
|
-
if (blob && (!Number.isSafeInteger(size) || size <= 0)) {
|
|
1323
|
-
throw new TypeError(`image prompt block ${index} requires a non-empty Blob with a valid size`);
|
|
1324
|
-
}
|
|
1325
|
-
const encodedLength = blob ? Math.ceil(size / 3) * 4 : block.data.length;
|
|
1326
|
-
if (encodedLength > maxPromptImageDataBytes) {
|
|
1327
|
-
throw new RangeError(`image prompt block ${index} exceeds the ${maxPromptImageDataBytes} byte per-image libfx limit`);
|
|
1328
|
-
}
|
|
1555
|
+
const image = normalizePromptImage(block, index);
|
|
1329
1556
|
imageCount += 1;
|
|
1330
1557
|
if (imageCount > maxPromptImages) {
|
|
1331
1558
|
throw new RangeError(`prompt cannot contain more than ${maxPromptImages} images`);
|
|
1332
1559
|
}
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1560
|
+
if (!deferImageLimits) {
|
|
1561
|
+
if (image.byteLength > maxPromptImageBytes && image.sourceRef !== undefined) {
|
|
1562
|
+
return omitReferencedImageData([image])[0];
|
|
1563
|
+
}
|
|
1564
|
+
checkImageByteLength(image.byteLength ?? 0, index);
|
|
1565
|
+
imageBytes += image.byteLength ?? 0;
|
|
1336
1566
|
}
|
|
1337
|
-
return
|
|
1567
|
+
return image;
|
|
1338
1568
|
}
|
|
1339
1569
|
if (block.type === "text") {
|
|
1340
1570
|
if (typeof block.text !== "string") throw new TypeError(`text prompt block ${index} requires text`);
|
|
@@ -1348,50 +1578,103 @@ function normalizePromptInput(input) {
|
|
|
1348
1578
|
}
|
|
1349
1579
|
throw new TypeError(`unsupported prompt block type: ${String(block.type)}`);
|
|
1350
1580
|
});
|
|
1581
|
+
if (!deferImageLimits) {
|
|
1582
|
+
if (imageBytes > maxPromptImagesBytes) {
|
|
1583
|
+
prompt = omitReferencedImageData(prompt);
|
|
1584
|
+
imageBytes = prompt.reduce((total, block) => total + (block.type === "image" ? block.byteLength ?? 0 : 0), 0);
|
|
1585
|
+
}
|
|
1586
|
+
checkPromptImagesByteLength(imageBytes);
|
|
1587
|
+
if (promptFrameSize(prompt) > maxPromptFrameBytes) prompt = omitReferencedImageData(prompt);
|
|
1588
|
+
}
|
|
1589
|
+
return prompt;
|
|
1351
1590
|
}
|
|
1352
1591
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1592
|
+
// Image bytes travel beside the frame as attachment references, but the model
|
|
1593
|
+
// request carries them base64 encoded and the native host caps that request at
|
|
1594
|
+
// 8 MiB. Images therefore count at their encoded size, the same budget they
|
|
1595
|
+
// had inside the frame. With countImages false, only the frame counts.
|
|
1596
|
+
function promptFrameSize(prompt, countImages = true) {
|
|
1597
|
+
const encodedImageBytes = countImages ? promptImageDataBytes(prompt) : 0;
|
|
1598
|
+
const projected = prompt.map((block) => block.type !== "image" ? block : {
|
|
1599
|
+
type: "image",
|
|
1600
|
+
mimeType: block.mimeType,
|
|
1601
|
+
...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
|
|
1602
|
+
...(block.data === undefined && block.bytes === undefined ? {} : { _meta: { fx: { attachment: 0xffffffff } } }),
|
|
1361
1603
|
});
|
|
1362
|
-
return encoder.encode(JSON.stringify({ sessionId: "", prompt: projected })).length +
|
|
1604
|
+
return encoder.encode(JSON.stringify({ sessionId: "", prompt: projected })).length +
|
|
1605
|
+
encodedImageBytes + promptFrameEnvelopeBytes;
|
|
1363
1606
|
}
|
|
1364
1607
|
|
|
1365
|
-
|
|
1366
|
-
|
|
1608
|
+
function checkPromptFrameSize(prompt, countImages) {
|
|
1609
|
+
if (promptFrameSize(prompt, countImages) > maxPromptFrameBytes) {
|
|
1610
|
+
throw new RangeError(`prompt exceeds the ${maxPromptFrameBytes} byte libfx frame limit`);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// Returns prompt blocks whose images carry { mimeType, bytes }. Synchronous
|
|
1615
|
+
// sources only: base64 is decoded and caller bytes are used in place.
|
|
1616
|
+
function preparePromptImages(blocks) {
|
|
1617
|
+
return blocks.map((block) => block.type !== "image" || block.data === undefined ? block : {
|
|
1618
|
+
type: "image",
|
|
1619
|
+
mimeType: block.mimeType,
|
|
1620
|
+
bytes: block.source === "base64" ? base64ToBytes(block.data) : block.data,
|
|
1621
|
+
...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function resizedPromptImage(value, index) {
|
|
1626
|
+
const bytes = byteView(value?.bytes);
|
|
1627
|
+
if (!bytes || bytes.byteLength === 0 || typeof value.mimeType !== "string" ||
|
|
1628
|
+
value.mimeType.length === 0 || value.mimeType.length > 128) {
|
|
1629
|
+
throw new TypeError(`resizeImage must return non-empty bytes and a mimeType for image prompt block ${index}`);
|
|
1630
|
+
}
|
|
1631
|
+
// Copied because a hook may reuse its output buffer for the next image.
|
|
1632
|
+
return { bytes: bytes.slice(), mimeType: value.mimeType };
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// Reads Blob images and applies resizeImage, then checks the final byte and
|
|
1636
|
+
// frame limits. Returns null when the turn is cancelled first.
|
|
1637
|
+
async function materializePromptImages(blocks, isCancelled, resizeImage) {
|
|
1638
|
+
let prepared = [];
|
|
1367
1639
|
let imageBytes = 0;
|
|
1368
|
-
for (
|
|
1640
|
+
for (let index = 0; index < blocks.length; index++) {
|
|
1369
1641
|
if (isCancelled()) return null;
|
|
1370
|
-
|
|
1371
|
-
|
|
1642
|
+
const block = blocks[index];
|
|
1643
|
+
if (block.type !== "image" || block.data === undefined) {
|
|
1644
|
+
prepared.push(block);
|
|
1372
1645
|
continue;
|
|
1373
1646
|
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
throw new RangeError(`prompt images exceed the ${maxPromptImagesBytes} byte libfx frame limit`);
|
|
1378
|
-
}
|
|
1379
|
-
encoded.push(block);
|
|
1380
|
-
continue;
|
|
1381
|
-
}
|
|
1382
|
-
const bytes = new Uint8Array(await block.data.arrayBuffer());
|
|
1647
|
+
let image = block.source === "blob"
|
|
1648
|
+
? { mimeType: block.mimeType, bytes: new Uint8Array(await block.data.arrayBuffer()) }
|
|
1649
|
+
: preparePromptImages([block])[0];
|
|
1383
1650
|
if (isCancelled()) return null;
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1651
|
+
if (resizeImage) {
|
|
1652
|
+
image = resizedPromptImage(await resizeImage({ bytes: image.bytes, mimeType: image.mimeType }), index);
|
|
1653
|
+
if (isCancelled()) return null;
|
|
1654
|
+
}
|
|
1655
|
+
image = {
|
|
1656
|
+
type: "image",
|
|
1657
|
+
mimeType: image.mimeType,
|
|
1658
|
+
bytes: image.bytes,
|
|
1659
|
+
...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
|
|
1660
|
+
};
|
|
1661
|
+
if (image.bytes.byteLength > maxPromptImageBytes && image.sourceRef !== undefined) {
|
|
1662
|
+
image = omitReferencedImageData([image])[0];
|
|
1387
1663
|
}
|
|
1388
|
-
|
|
1664
|
+
checkImageByteLength(image.bytes?.byteLength ?? 0, index);
|
|
1665
|
+
imageBytes += image.bytes?.byteLength ?? 0;
|
|
1666
|
+
prepared.push(image);
|
|
1389
1667
|
if (imageBytes > maxPromptImagesBytes) {
|
|
1390
|
-
|
|
1668
|
+
prepared = omitReferencedImageData(prepared);
|
|
1669
|
+
// Do not read more referenced Blobs after the actual aggregate overflows.
|
|
1670
|
+
blocks = omitReferencedImageData(blocks);
|
|
1671
|
+
imageBytes = prepared.reduce((total, entry) => total + (entry.bytes?.byteLength ?? 0), 0);
|
|
1391
1672
|
}
|
|
1392
|
-
|
|
1673
|
+
checkPromptImagesByteLength(imageBytes);
|
|
1393
1674
|
}
|
|
1394
|
-
|
|
1675
|
+
if (promptFrameSize(prepared) > maxPromptFrameBytes) prepared = omitReferencedImageData(prepared);
|
|
1676
|
+
checkPromptFrameSize(prepared, true);
|
|
1677
|
+
return prepared;
|
|
1395
1678
|
}
|
|
1396
1679
|
|
|
1397
1680
|
function normalizeSteeringInput(input) {
|
|
@@ -1466,17 +1749,28 @@ function hostToolContent(value) {
|
|
|
1466
1749
|
if (typeof value.text !== "string" || !Array.isArray(value.images) || value.images.length > 8) {
|
|
1467
1750
|
throw new TypeError("invalid typed tool result");
|
|
1468
1751
|
}
|
|
1469
|
-
let
|
|
1470
|
-
|
|
1471
|
-
|
|
1752
|
+
let images = value.images.map((image) => {
|
|
1753
|
+
if (image?.type !== "image") throw new TypeError("invalid tool image");
|
|
1754
|
+
const sourceRef = normalizeImageSourceRef(image.sourceRef, "tool image");
|
|
1755
|
+
const referenceOnly = image.data === undefined && sourceRef !== undefined;
|
|
1756
|
+
if ((!referenceOnly && typeof image.data !== "string") || typeof image.mimeType !== "string" || image.mimeType.length === 0 || image.mimeType.length > 128 || (image.data?.length > maxPromptImageDataBytes && sourceRef === undefined)) {
|
|
1472
1757
|
throw new TypeError("invalid tool image");
|
|
1473
1758
|
}
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1759
|
+
return {
|
|
1760
|
+
type: "image",
|
|
1761
|
+
...(!referenceOnly && image.data.length <= maxPromptImageDataBytes ? { data: image.data } : {}),
|
|
1762
|
+
mimeType: image.mimeType,
|
|
1763
|
+
...(sourceRef === undefined ? {} : { sourceRef }),
|
|
1764
|
+
};
|
|
1477
1765
|
});
|
|
1478
|
-
|
|
1479
|
-
if (
|
|
1766
|
+
if (promptImageDataBytes(images) > maxPromptImagesDataBytes) images = omitReferencedImageData(images);
|
|
1767
|
+
if (promptImageDataBytes(images) > maxPromptImagesDataBytes) throw new RangeError("tool images exceed the result limit");
|
|
1768
|
+
let content = JSON.stringify({ text: value.text, images });
|
|
1769
|
+
if (encoder.encode(content).length > maxPromptImagesDataBytes) {
|
|
1770
|
+
images = omitReferencedImageData(images);
|
|
1771
|
+
content = JSON.stringify({ text: value.text, images });
|
|
1772
|
+
}
|
|
1773
|
+
if (encoder.encode(content).length > maxPromptImagesDataBytes) throw new RangeError("typed tool result exceeds the result limit");
|
|
1480
1774
|
return { content, rich: true, isError: value.isError === true };
|
|
1481
1775
|
}
|
|
1482
1776
|
if (typeof value === "string") return { content: value, rich: false };
|
|
@@ -1495,14 +1789,6 @@ function checkpointBytes(value) {
|
|
|
1495
1789
|
throw new TypeError("checkpoint must be an ArrayBuffer or typed array");
|
|
1496
1790
|
}
|
|
1497
1791
|
|
|
1498
|
-
function bytesToBase64(value) {
|
|
1499
|
-
let binary = "";
|
|
1500
|
-
for (let offset = 0; offset < value.length; offset += 0x8000) {
|
|
1501
|
-
binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000));
|
|
1502
|
-
}
|
|
1503
|
-
return btoa(binary);
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
1792
|
function base64ToBytes(value) {
|
|
1507
1793
|
const binary = atob(value);
|
|
1508
1794
|
const bytes = new Uint8Array(binary.length);
|
|
@@ -1515,6 +1801,11 @@ export async function createFxAgent(options = {}) {
|
|
|
1515
1801
|
const hostTools = normalizeHostTools(options.tools);
|
|
1516
1802
|
const instructions = normalizeInstructions(options.instructions);
|
|
1517
1803
|
const initialCheckpoint = checkpointBytes(options.checkpoint);
|
|
1804
|
+
// Checked before the core starts, and with the core's message, so both
|
|
1805
|
+
// backends report it the same way.
|
|
1806
|
+
if (initialCheckpoint && initialCheckpoint.byteLength > maxCheckpointBytes) {
|
|
1807
|
+
throw new Error("libfx checkpoint is too large");
|
|
1808
|
+
}
|
|
1518
1809
|
const pending = new Map();
|
|
1519
1810
|
let nextId = 1;
|
|
1520
1811
|
let sessionId = null;
|
|
@@ -1659,11 +1950,47 @@ export async function createFxAgent(options = {}) {
|
|
|
1659
1950
|
pending.set(id, { resolve, reject });
|
|
1660
1951
|
try { send({ jsonrpc: "2.0", id, method, params }); } catch (error) { pending.delete(id); reject(error); }
|
|
1661
1952
|
});
|
|
1953
|
+
// Raw payloads ride beside the next frame instead of inside it. Payloads
|
|
1954
|
+
// left by an earlier frame that never reached the core are dropped first.
|
|
1955
|
+
let nextAttachmentId = 1;
|
|
1956
|
+
const attachBytes = (payloads) => {
|
|
1957
|
+
if (typeof runtime.writeAttachment !== "function") throw new Error("fx runtime does not accept binary attachments");
|
|
1958
|
+
runtime.discardAttachments?.();
|
|
1959
|
+
return payloads.map((bytes) => {
|
|
1960
|
+
const id = nextAttachmentId;
|
|
1961
|
+
nextAttachmentId = id === 0x7fffffff ? 1 : id + 1;
|
|
1962
|
+
runtime.writeAttachment(id, bytes);
|
|
1963
|
+
return id;
|
|
1964
|
+
});
|
|
1965
|
+
};
|
|
1966
|
+
let checkpointTail = null;
|
|
1967
|
+
let pendingCheckpoints = 0;
|
|
1968
|
+
async function takeCheckpoint() {
|
|
1969
|
+
if (closing) throw new Error("fx agent is closed");
|
|
1970
|
+
if (activeTurn) throw new Error("cannot checkpoint while a prompt is active");
|
|
1971
|
+
const response = await request("libfx/checkpoint", { sessionId });
|
|
1972
|
+
const id = response?.checkpointAttachment;
|
|
1973
|
+
const bytes = Number.isSafeInteger(id) && id > 0 ? runtime.takeAttachment?.(id) : null;
|
|
1974
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) throw new Error("fx returned an invalid checkpoint");
|
|
1975
|
+
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1976
|
+
}
|
|
1977
|
+
const sendPrompt = (blocks) => {
|
|
1978
|
+
const images = blocks.filter((block) => block.type === "image" && block.bytes !== undefined);
|
|
1979
|
+
const ids = images.length ? attachBytes(images.map((block) => block.bytes)) : [];
|
|
1980
|
+
let next = 0;
|
|
1981
|
+
const prompt = blocks.map((block) => block.type !== "image" ? block : {
|
|
1982
|
+
type: "image",
|
|
1983
|
+
mimeType: block.mimeType,
|
|
1984
|
+
...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
|
|
1985
|
+
...(block.bytes === undefined ? {} : { _meta: { fx: { attachment: ids[next++] } } }),
|
|
1986
|
+
});
|
|
1987
|
+
return request("session/prompt", { sessionId, prompt });
|
|
1988
|
+
};
|
|
1662
1989
|
runtime.exited.then((code) => {
|
|
1663
1990
|
closing = true;
|
|
1664
1991
|
const error = runtime.error ?? new Error(`fx-core exited with code ${code} before completing the ACP request`);
|
|
1665
1992
|
coreExitError = error;
|
|
1666
|
-
activeTurn?.
|
|
1993
|
+
activeTurn?.failImagePrep(error);
|
|
1667
1994
|
for (const waiter of pending.values()) waiter.reject(error);
|
|
1668
1995
|
pending.clear();
|
|
1669
1996
|
emit("runtime.exit", { code });
|
|
@@ -1698,6 +2025,10 @@ export async function createFxAgent(options = {}) {
|
|
|
1698
2025
|
);
|
|
1699
2026
|
if (cancelled || closing) return;
|
|
1700
2027
|
const response = { jsonrpc: "2.0", id: message.id, result: { content, isError, ...(rich ? { contentType: "rich" } : {}) } };
|
|
2028
|
+
if (rich && encoder.encode(JSON.stringify(response)).length + 1 > maxPromptFrameBytes) {
|
|
2029
|
+
const result = JSON.parse(content);
|
|
2030
|
+
response.result.content = JSON.stringify({ ...result, images: omitReferencedImageData(result.images) });
|
|
2031
|
+
}
|
|
1701
2032
|
if (encoder.encode(JSON.stringify(response)).length + 1 > 8 * 1024 * 1024) {
|
|
1702
2033
|
response.result = { content: "Host tool result exceeded the response frame limit", isError: true };
|
|
1703
2034
|
}
|
|
@@ -1720,10 +2051,8 @@ export async function createFxAgent(options = {}) {
|
|
|
1720
2051
|
const sessionResult = await request("libfx/new");
|
|
1721
2052
|
sessionId = sessionResult.sessionId;
|
|
1722
2053
|
if (initialCheckpoint) {
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
checkpoint: bytesToBase64(initialCheckpoint),
|
|
1726
|
-
});
|
|
2054
|
+
const [checkpointAttachment] = attachBytes([initialCheckpoint]);
|
|
2055
|
+
await request("libfx/restore", { sessionId, checkpointAttachment });
|
|
1727
2056
|
}
|
|
1728
2057
|
} catch (error) {
|
|
1729
2058
|
closing = true;
|
|
@@ -1739,12 +2068,21 @@ export async function createFxAgent(options = {}) {
|
|
|
1739
2068
|
if (activeTurn) throw new Error("a prompt is already in progress for this session");
|
|
1740
2069
|
return normalizeTurn(startTurn(input, promptOptions));
|
|
1741
2070
|
},
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
2071
|
+
checkpoint() {
|
|
2072
|
+
// One checkpoint runs at a time: each holds an outbound attachment until
|
|
2073
|
+
// it is taken, and the native table holds only a few. An idle call still
|
|
2074
|
+
// sends its request before returning, ahead of a later prompt(). The slot
|
|
2075
|
+
// is claimed before that send, so a call from an event handler during it
|
|
2076
|
+
// still waits. The count drops before the caller's own reaction to `run`,
|
|
2077
|
+
// so a call made right after awaiting the previous one is idle.
|
|
2078
|
+
const previous = pendingCheckpoints > 0 ? checkpointTail : null;
|
|
2079
|
+
pendingCheckpoints++;
|
|
2080
|
+
let release;
|
|
2081
|
+
checkpointTail = new Promise((resolve) => { release = resolve; });
|
|
2082
|
+
const run = previous ? previous.then(takeCheckpoint) : takeCheckpoint();
|
|
2083
|
+
const settle = () => { pendingCheckpoints--; release(); };
|
|
2084
|
+
run.then(settle, settle);
|
|
2085
|
+
return run;
|
|
1748
2086
|
},
|
|
1749
2087
|
async close() {
|
|
1750
2088
|
if (closing) { await runtime.exited; return; }
|
|
@@ -1846,11 +2184,18 @@ export async function createFxAgent(options = {}) {
|
|
|
1846
2184
|
}
|
|
1847
2185
|
|
|
1848
2186
|
function startTurn(input, promptOptions) {
|
|
1849
|
-
const
|
|
1850
|
-
const
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
2187
|
+
const resizeImage = options.resizeImage;
|
|
2188
|
+
const normalized = normalizePromptInput(input, { deferImageLimits: resizeImage !== undefined });
|
|
2189
|
+
const hasPixels = normalized.some((block) => block.type === "image" && block.data !== undefined);
|
|
2190
|
+
// Blob reads and resizeImage run before the prompt frame is sent.
|
|
2191
|
+
const asyncImages = hasPixels && (resizeImage !== undefined ||
|
|
2192
|
+
normalized.some((block) => block.type === "image" && block.source === "blob"));
|
|
2193
|
+
// resizeImage decides the final image sizes, so they are counted after it runs.
|
|
2194
|
+
checkPromptFrameSize(normalized, resizeImage === undefined);
|
|
2195
|
+
// Snapshot caller-owned bytes, which could change before an async send.
|
|
2196
|
+
const prompt = asyncImages
|
|
2197
|
+
? normalized.map((block) => block.type === "image" && block.source === "bytes" ? { ...block, data: block.data.slice() } : block)
|
|
2198
|
+
: preparePromptImages(normalized);
|
|
1854
2199
|
const signal = promptOptions.signal;
|
|
1855
2200
|
if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal");
|
|
1856
2201
|
const queue = [];
|
|
@@ -1861,10 +2206,10 @@ export async function createFxAgent(options = {}) {
|
|
|
1861
2206
|
let terminalError;
|
|
1862
2207
|
let reportedPressure = false;
|
|
1863
2208
|
let discardedBytes = 0;
|
|
1864
|
-
let
|
|
1865
|
-
let
|
|
2209
|
+
let cancelImagePrep = null;
|
|
2210
|
+
let rejectImagePrep = null;
|
|
1866
2211
|
let resolvePromptStart = null;
|
|
1867
|
-
const promptStarted =
|
|
2212
|
+
const promptStarted = asyncImages ? new Promise((resolve) => { resolvePromptStart = resolve; }) : null;
|
|
1868
2213
|
let pendingSteeringCount = 0;
|
|
1869
2214
|
let pendingSteeringBytes = 0;
|
|
1870
2215
|
const toolControllers = new Set();
|
|
@@ -1891,10 +2236,10 @@ export async function createFxAgent(options = {}) {
|
|
|
1891
2236
|
transportBytes: 0,
|
|
1892
2237
|
lastTransportActivityAt: null,
|
|
1893
2238
|
get cancelled() { return cancelled; },
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
2239
|
+
failImagePrep(error) {
|
|
2240
|
+
rejectImagePrep?.(error);
|
|
2241
|
+
rejectImagePrep = null;
|
|
2242
|
+
cancelImagePrep = null;
|
|
1898
2243
|
},
|
|
1899
2244
|
promptWritten() {
|
|
1900
2245
|
resolvePromptStart?.(true);
|
|
@@ -1939,9 +2284,9 @@ export async function createFxAgent(options = {}) {
|
|
|
1939
2284
|
cancel() {
|
|
1940
2285
|
if (finished || cancelled) return;
|
|
1941
2286
|
cancelled = true;
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
2287
|
+
cancelImagePrep?.();
|
|
2288
|
+
cancelImagePrep = null;
|
|
2289
|
+
rejectImagePrep = null;
|
|
1945
2290
|
resolvePromptStart?.(false);
|
|
1946
2291
|
resolvePromptStart = null;
|
|
1947
2292
|
runtime.closeSteering?.();
|
|
@@ -1982,34 +2327,34 @@ export async function createFxAgent(options = {}) {
|
|
|
1982
2327
|
runtime.openSteering?.();
|
|
1983
2328
|
const abort = () => turn.cancel();
|
|
1984
2329
|
signal?.addEventListener("abort", abort, { once: true });
|
|
1985
|
-
const
|
|
1986
|
-
|
|
1987
|
-
|
|
2330
|
+
const imagePrepCancelled = asyncImages ? new Promise((resolve, reject) => {
|
|
2331
|
+
cancelImagePrep = () => resolve(null);
|
|
2332
|
+
rejectImagePrep = reject;
|
|
1988
2333
|
}) : null;
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
2334
|
+
let response;
|
|
2335
|
+
if (asyncImages) {
|
|
2336
|
+
response = Promise.race([
|
|
2337
|
+
Promise.resolve().then(() => materializePromptImages(prompt, () => cancelled || closing, resizeImage)),
|
|
2338
|
+
imagePrepCancelled,
|
|
2339
|
+
]).then((prepared) => {
|
|
2340
|
+
cancelImagePrep = null;
|
|
2341
|
+
rejectImagePrep = null;
|
|
1996
2342
|
if (coreExitError && !cancelled) throw coreExitError;
|
|
1997
|
-
if (
|
|
2343
|
+
if (prepared === null || cancelled || closing) {
|
|
1998
2344
|
resolvePromptStart?.(false);
|
|
1999
2345
|
resolvePromptStart = null;
|
|
2000
2346
|
return { stopReason: "cancelled" };
|
|
2001
2347
|
}
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
: request("session/prompt", { sessionId, prompt });
|
|
2348
|
+
return sendPrompt(prepared);
|
|
2349
|
+
});
|
|
2350
|
+
} else {
|
|
2351
|
+
try { response = sendPrompt(prompt); } catch (error) { response = Promise.reject(error); }
|
|
2352
|
+
}
|
|
2008
2353
|
turn.result = response
|
|
2009
2354
|
.then((value) => ({ stopReason: cancelled ? "cancelled" : value.stopReason, usage: value.usage }))
|
|
2010
2355
|
.catch((error) => {
|
|
2011
|
-
|
|
2012
|
-
|
|
2356
|
+
cancelImagePrep = null;
|
|
2357
|
+
rejectImagePrep = null;
|
|
2013
2358
|
resolvePromptStart?.(error);
|
|
2014
2359
|
resolvePromptStart = null;
|
|
2015
2360
|
if (cancelled && error.message === "Cancelled") return { stopReason: "cancelled" };
|