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/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 image limits mirror the host tool result image contract: the kernel
23
- // validates content, the SDK bounds the frame before it reaches the core.
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 maxPromptImagesBytes = 8 * 1024 * 1024;
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 fast");
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
- const workspaceExecs = new Set();
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
- const controller = new AbortController();
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: controller.signal,
1070
+ signal,
1012
1071
  timeoutMs,
1013
1072
  outputLimitBytes: workspaceOutputLimit,
1014
- })).then((value) => {
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
- workspaceExecs.delete(state);
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
- workspaceExecs.forEach((state) => state.abort(-3));
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 normalizePromptInput(input) {
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
- return input.map((block, index) => {
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 size = blobByteLength(block.data);
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
- imageBytes += encodedLength;
1334
- if (imageBytes > maxPromptImagesBytes) {
1335
- throw new RangeError(`prompt images exceed the ${maxPromptImagesBytes} byte libfx frame limit`);
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 { type: "image", data: block.data, mimeType };
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
- function promptFrameSize(prompt) {
1354
- let blobDataBytes = 0;
1355
- const projected = prompt.map((block) => {
1356
- if (block.type !== "image" || typeof block.data === "string") return block;
1357
- const size = blobByteLength(block.data);
1358
- if (!Number.isSafeInteger(size)) throw new TypeError("image prompt requires a valid Blob size");
1359
- blobDataBytes += Math.ceil(size / 3) * 4;
1360
- return { type: "image", data: "", mimeType: block.mimeType };
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 + blobDataBytes + promptFrameEnvelopeBytes;
1604
+ return encoder.encode(JSON.stringify({ sessionId: "", prompt: projected })).length +
1605
+ encodedImageBytes + promptFrameEnvelopeBytes;
1363
1606
  }
1364
1607
 
1365
- async function materializePromptBlobs(blocks, isCancelled) {
1366
- const encoded = [];
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 (const [index, block] of blocks.entries()) {
1640
+ for (let index = 0; index < blocks.length; index++) {
1369
1641
  if (isCancelled()) return null;
1370
- if (block.type !== "image") {
1371
- encoded.push(block);
1642
+ const block = blocks[index];
1643
+ if (block.type !== "image" || block.data === undefined) {
1644
+ prepared.push(block);
1372
1645
  continue;
1373
1646
  }
1374
- if (typeof block.data === "string") {
1375
- imageBytes += block.data.length;
1376
- if (imageBytes > maxPromptImagesBytes) {
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
- const encodedLength = Math.ceil(bytes.length / 3) * 4;
1385
- if (encodedLength > maxPromptImageDataBytes) {
1386
- throw new RangeError(`image prompt block ${index} exceeds the ${maxPromptImageDataBytes} byte per-image libfx limit`);
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
- imageBytes += encodedLength;
1664
+ checkImageByteLength(image.bytes?.byteLength ?? 0, index);
1665
+ imageBytes += image.bytes?.byteLength ?? 0;
1666
+ prepared.push(image);
1389
1667
  if (imageBytes > maxPromptImagesBytes) {
1390
- throw new RangeError(`prompt images exceed the ${maxPromptImagesBytes} byte libfx frame limit`);
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
- encoded.push({ type: "image", data: bytesToBase64(bytes), mimeType: block.mimeType });
1673
+ checkPromptImagesByteLength(imageBytes);
1393
1674
  }
1394
- return normalizePromptInput(encoded);
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 imageBytes = 0;
1470
- const images = value.images.map((image) => {
1471
- if (image?.type !== "image" || typeof image.data !== "string" || typeof image.mimeType !== "string" || image.mimeType.length > 128 || image.data.length > 5 * 1024 * 1024) {
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
- imageBytes += image.data.length;
1475
- if (imageBytes > 8 * 1024 * 1024) throw new RangeError("tool images exceed the result limit");
1476
- return { type: "image", data: image.data, mimeType: image.mimeType };
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
- const content = JSON.stringify({ text: value.text, images });
1479
- if (new TextEncoder().encode(content).length > 8 * 1024 * 1024) throw new RangeError("typed tool result exceeds the result limit");
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?.failPendingBlob(error);
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
- await request("libfx/restore", {
1724
- sessionId,
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
- async checkpoint() {
1743
- if (closing) throw new Error("fx agent is closed");
1744
- if (activeTurn) throw new Error("cannot checkpoint while a prompt is active");
1745
- const response = await request("libfx/checkpoint", { sessionId });
1746
- if (typeof response?.checkpoint !== "string") throw new Error("fx returned an invalid checkpoint");
1747
- return base64ToBytes(response.checkpoint);
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 prompt = normalizePromptInput(input);
1850
- const hasBlobs = prompt.some((block) => block.type === "image" && typeof block.data !== "string");
1851
- if (promptFrameSize(prompt) > maxPromptFrameBytes) {
1852
- throw new RangeError(`prompt exceeds the ${maxPromptFrameBytes} byte libfx frame limit`);
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 cancelBlobRead = null;
1865
- let rejectBlobRead = null;
2209
+ let cancelImagePrep = null;
2210
+ let rejectImagePrep = null;
1866
2211
  let resolvePromptStart = null;
1867
- const promptStarted = hasBlobs ? new Promise((resolve) => { resolvePromptStart = resolve; }) : null;
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
- failPendingBlob(error) {
1895
- rejectBlobRead?.(error);
1896
- rejectBlobRead = null;
1897
- cancelBlobRead = null;
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
- cancelBlobRead?.();
1943
- cancelBlobRead = null;
1944
- rejectBlobRead = null;
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 blobReadCancelled = hasBlobs ? new Promise((resolve, reject) => {
1986
- cancelBlobRead = () => resolve(null);
1987
- rejectBlobRead = reject;
2330
+ const imagePrepCancelled = asyncImages ? new Promise((resolve, reject) => {
2331
+ cancelImagePrep = () => resolve(null);
2332
+ rejectImagePrep = reject;
1988
2333
  }) : null;
1989
- const response = hasBlobs
1990
- ? Promise.race([
1991
- Promise.resolve().then(() => materializePromptBlobs(prompt, () => cancelled || closing)),
1992
- blobReadCancelled,
1993
- ]).then((encodedPrompt) => {
1994
- cancelBlobRead = null;
1995
- rejectBlobRead = null;
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 (encodedPrompt === null || cancelled || closing) {
2343
+ if (prepared === null || cancelled || closing) {
1998
2344
  resolvePromptStart?.(false);
1999
2345
  resolvePromptStart = null;
2000
2346
  return { stopReason: "cancelled" };
2001
2347
  }
2002
- if (promptFrameSize(encodedPrompt) > maxPromptFrameBytes) {
2003
- throw new RangeError(`prompt exceeds the ${maxPromptFrameBytes} byte libfx frame limit`);
2004
- }
2005
- return request("session/prompt", { sessionId, prompt: encodedPrompt });
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
- cancelBlobRead = null;
2012
- rejectBlobRead = null;
2356
+ cancelImagePrep = null;
2357
+ rejectImagePrep = null;
2013
2358
  resolvePromptStart?.(error);
2014
2359
  resolvePromptStart = null;
2015
2360
  if (cancelled && error.message === "Cancelled") return { stopReason: "cancelled" };