libfx 0.0.12 → 0.0.13

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
@@ -19,11 +19,20 @@ const streamReadsPerTaskYield = 32;
19
19
  const transportActivityIntervalMs = 250;
20
20
  const maxUnreadEventBytes = 1024 * 1024;
21
21
  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.
22
+ // Prompt images travel as raw bytes beside the ACP frame and are base64
23
+ // encoded only in the model request. An image may use 5 MiB of encoded
24
+ // request data, and a prompt's images 8 MiB, so the raw limits are 3/4 of that.
25
+ // The kernel still validates content and media type.
24
26
  const maxPromptImages = 8;
25
27
  const maxPromptImageDataBytes = 5 * 1024 * 1024;
26
- const maxPromptImagesBytes = 8 * 1024 * 1024;
28
+ const maxPromptImagesDataBytes = 8 * 1024 * 1024;
29
+ const maxPromptImageBytes = (maxPromptImageDataBytes / 4) * 3;
30
+ const maxPromptImagesBytes = (maxPromptImagesDataBytes / 4) * 3;
31
+ // Matches the native attachment table: one prompt's images or one checkpoint.
32
+ const maxPendingAttachments = 8;
33
+ const maxOutboundAttachments = 4;
34
+ // Matches the core's kernel checkpoint limit (max_checkpoint_bytes).
35
+ const maxCheckpointBytes = 4 * 1024 * 1024;
27
36
  // The core's ACP reader drops frames over 8 MiB without a request id to answer
28
37
  // (jsonrpc frame_resource_byte_limit), so the SDK must never emit one. The
29
38
  // envelope allowance covers the method key and request id.
@@ -84,6 +93,14 @@ function normalizeFast(value) {
84
93
  return value;
85
94
  }
86
95
 
96
+ function normalizeUltrafast(value) {
97
+ if (value === undefined) return undefined;
98
+ if (typeof value !== "boolean") {
99
+ throw new TypeError("ultrafast must be a boolean");
100
+ }
101
+ return value;
102
+ }
103
+
87
104
  export function normalizeAgentOptions(value) {
88
105
  if (!value || typeof value !== "object" || Array.isArray(value)) {
89
106
  throw new TypeError("createFxAgent() options must be an object");
@@ -94,24 +111,29 @@ export function normalizeAgentOptions(value) {
94
111
  }
95
112
  options.apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, true);
96
113
  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");
114
+ if (Object.hasOwn(options, "effort") || Object.hasOwn(options, "fast") || Object.hasOwn(options, "ultrafast")) {
115
+ throw new TypeError("model options cannot be mixed with top-level effort, fast, or ultrafast");
99
116
  }
100
117
  const model = options.model;
101
118
  for (const name of Object.keys(model)) {
102
- if (name !== "id" && name !== "effort" && name !== "fast") {
119
+ if (name !== "id" && name !== "effort" && name !== "fast" && name !== "ultrafast") {
103
120
  throw new TypeError(`unsupported model option: ${name}`);
104
121
  }
105
122
  }
106
123
  options.model = boundedString(model.id, "model.id", maxModelBytes, true);
107
124
  options.effort = normalizeEffort(model.effort);
108
125
  options.fast = normalizeFast(model.fast);
126
+ options.ultrafast = normalizeUltrafast(model.ultrafast);
109
127
  } else {
110
128
  options.model = boundedString(options.model, "model", maxModelBytes, false);
111
129
  options.effort = normalizeEffort(options.effort);
112
130
  options.fast = normalizeFast(options.fast);
131
+ options.ultrafast = normalizeUltrafast(options.ultrafast);
113
132
  }
114
133
  validateGatewayChatUrl(options.gatewayChatUrl);
134
+ if (options.resizeImage !== undefined && typeof options.resizeImage !== "function") {
135
+ throw new TypeError("resizeImage must be a function");
136
+ }
115
137
  return options;
116
138
  }
117
139
 
@@ -121,6 +143,7 @@ function agentEnvironment(options) {
121
143
  ...(options.model === undefined ? {} : { FX_MODEL: options.model }),
122
144
  ...(options.effort === undefined ? {} : { FX_EFFORT: options.effort }),
123
145
  ...(options.fast === undefined ? {} : { FX_FAST: options.fast ? "true" : "false" }),
146
+ ...(options.ultrafast === undefined ? {} : { FX_ULTRAFAST: options.ultrafast ? "true" : "false" }),
124
147
  ...(options.gatewayChatUrl === undefined ? {} : { FX_GATEWAY_CHAT_URL: options.gatewayChatUrl }),
125
148
  };
126
149
  }
@@ -128,9 +151,9 @@ function agentEnvironment(options) {
128
151
  function agentRpcError(response) {
129
152
  const error = new Error(response.message);
130
153
  const data = response.data;
131
- if (data && ["LIBFX_MODEL_UNSUPPORTED_EFFORT", "LIBFX_MODEL_UNSUPPORTED_FAST"].includes(data.code) &&
154
+ if (data && ["LIBFX_MODEL_UNSUPPORTED_EFFORT", "LIBFX_MODEL_UNSUPPORTED_FAST", "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST"].includes(data.code) &&
132
155
  typeof data.model === "string" &&
133
- data.capability === (data.code === "LIBFX_MODEL_UNSUPPORTED_FAST" ? "fast" : "effort")) {
156
+ data.capability === (data.code === "LIBFX_MODEL_UNSUPPORTED_FAST" ? "fast" : data.code === "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST" ? "ultrafast" : "effort")) {
134
157
  error.code = data.code;
135
158
  error.model = data.model;
136
159
  error.capability = data.capability;
@@ -465,6 +488,11 @@ function createRuntime(options) {
465
488
  const steering = [];
466
489
  let steeringBytes = 0;
467
490
  let steeringOpen = false;
491
+ // Raw payloads beside ACP frames: the core copies inbound bytes into its
492
+ // own memory and publishes outbound bytes for the agent to take.
493
+ const inboundAttachments = new Map();
494
+ const outboundAttachments = new Map();
495
+ let nextOutboundAttachment = 1;
468
496
  const workspaceExecs = new Set();
469
497
  const workspace = prepareWorkspaceAdapter(options.workspace);
470
498
  const args = ["fx", ...(options.args || [])];
@@ -761,6 +789,38 @@ function createRuntime(options) {
761
789
  return value.length;
762
790
  }
763
791
 
792
+ function writeAttachment(id, data) {
793
+ if (inboundAttachments.size >= maxPendingAttachments) throw new Error("attachment table is full");
794
+ inboundAttachments.set(id, data.slice());
795
+ }
796
+
797
+ function attachmentSize(id) {
798
+ return inboundAttachments.get(id >>> 0)?.length ?? -1;
799
+ }
800
+
801
+ function attachmentTake(id, outputPtr, outputCap) {
802
+ const key = id >>> 0;
803
+ const value = inboundAttachments.get(key);
804
+ if (!value) return -1;
805
+ // An empty payload needs no output buffer, whose pointer may be arbitrary.
806
+ if (value.length > 0) {
807
+ const output = checkedBytes(outputPtr, outputCap);
808
+ if (!output || value.length > output.length) return -1;
809
+ output.set(value);
810
+ }
811
+ inboundAttachments.delete(key);
812
+ return value.length;
813
+ }
814
+
815
+ function attachmentPut(inputPtr, inputLen) {
816
+ const input = checkedBytes(inputPtr, inputLen);
817
+ if (!input || outboundAttachments.size >= maxOutboundAttachments) return -1;
818
+ const id = nextOutboundAttachment;
819
+ nextOutboundAttachment = id === 0x7fffffff ? 1 : id + 1;
820
+ outboundAttachments.set(id, input.slice());
821
+ return id;
822
+ }
823
+
764
824
  let pendingHostToolResult = null;
765
825
  function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr) {
766
826
  pendingHostToolResult = null;
@@ -1132,6 +1192,9 @@ function createRuntime(options) {
1132
1192
  fx_host_tool_result_release() { pendingHostToolResult = null; },
1133
1193
  fx_steering_take: steeringTake,
1134
1194
  fx_steering_close() { steeringOpen = false; clearSteering(); },
1195
+ fx_attachment_size: attachmentSize,
1196
+ fx_attachment_take: attachmentTake,
1197
+ fx_attachment_put: attachmentPut,
1135
1198
  fx_open_url: new WebAssembly.Suspending(openUrl),
1136
1199
  fx_oauth_session_load: new WebAssembly.Suspending(oauthSessionLoad),
1137
1200
  fx_oauth_session_commit: new WebAssembly.Suspending(oauthSessionCommit),
@@ -1158,6 +1221,13 @@ function createRuntime(options) {
1158
1221
  imports: { wasi_snapshot_preview1: wasi, fx }, exited,
1159
1222
  setInstance(value) { instance = value; },
1160
1223
  write(data) { stdin.push(typeof data === "string" ? encoder.encode(data) : data); },
1224
+ writeAttachment,
1225
+ takeAttachment(id) {
1226
+ const value = outboundAttachments.get(id) ?? null;
1227
+ outboundAttachments.delete(id);
1228
+ return value;
1229
+ },
1230
+ discardAttachments() { inboundAttachments.clear(); },
1161
1231
  wake() { stdin.wake(); },
1162
1232
  closeStdin() { steeringOpen = false; clearSteering(); stdin.close(); },
1163
1233
  openSteering() { clearSteering(); steeringOpen = true; },
@@ -1299,42 +1369,128 @@ function blobByteLength(value) {
1299
1369
  }
1300
1370
  }
1301
1371
 
1302
- function normalizePromptInput(input) {
1372
+ function normalizeImageSourceRef(value, name) {
1373
+ boundedString(value, `${name} sourceRef`, 512, false);
1374
+ if (value !== undefined && /[\x00-\x1f\x7f\uD800-\uDFFF]/u.test(value)) {
1375
+ throw new TypeError(`${name} sourceRef must be valid UTF-8 without ASCII controls`);
1376
+ }
1377
+ return value;
1378
+ }
1379
+
1380
+ function omitReferencedImageData(blocks) {
1381
+ return blocks.map((block) => block.type !== "image" || block.sourceRef === undefined ? block : {
1382
+ type: "image",
1383
+ mimeType: block.mimeType,
1384
+ sourceRef: block.sourceRef,
1385
+ });
1386
+ }
1387
+
1388
+ function promptImageDataBytes(prompt) {
1389
+ return prompt.reduce((total, block) => {
1390
+ if (block.type !== "image") return total;
1391
+ if (typeof block.data === "string") return total + block.data.length;
1392
+ const byteLength = block.bytes?.byteLength ?? block.byteLength ?? blobByteLength(block.data) ?? 0;
1393
+ return total + Math.ceil(byteLength / 3) * 4;
1394
+ }, 0);
1395
+ }
1396
+
1397
+ // Returns a Uint8Array view of an ArrayBuffer or typed array, or null.
1398
+ function byteView(value) {
1399
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
1400
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
1401
+ return null;
1402
+ }
1403
+
1404
+ const base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1405
+
1406
+ // Returns the decoded length of canonical, unwrapped base64, or -1.
1407
+ function canonicalBase64ByteLength(value) {
1408
+ if (value.length === 0 || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return -1;
1409
+ const groups = value.length / 4;
1410
+ if (value.endsWith("==")) {
1411
+ return (base64Alphabet.indexOf(value[value.length - 3]) & 0x0f) === 0 ? groups * 3 - 2 : -1;
1412
+ }
1413
+ if (value.endsWith("=")) {
1414
+ return (base64Alphabet.indexOf(value[value.length - 2]) & 0x03) === 0 ? groups * 3 - 1 : -1;
1415
+ }
1416
+ return groups * 3;
1417
+ }
1418
+
1419
+ function requireImageMimeType(mimeType, index) {
1420
+ if (typeof mimeType !== "string" || mimeType.length === 0 || mimeType.length > 128) {
1421
+ throw new TypeError(`image prompt block ${index} requires a mimeType`);
1422
+ }
1423
+ }
1424
+
1425
+ function checkImageByteLength(byteLength, index) {
1426
+ if (byteLength > maxPromptImageBytes) {
1427
+ throw new RangeError(`image prompt block ${index} exceeds the ${maxPromptImageBytes} byte per-image libfx limit`);
1428
+ }
1429
+ }
1430
+
1431
+ function checkPromptImagesByteLength(byteLength) {
1432
+ if (byteLength > maxPromptImagesBytes) {
1433
+ throw new RangeError(`prompt images exceed the ${maxPromptImagesBytes} byte libfx limit`);
1434
+ }
1435
+ }
1436
+
1437
+ // Pixel inputs carry a private source and byteLength until preparation;
1438
+ // reference-only inputs remain public image descriptors without pixel data.
1439
+ function normalizePromptImage(block, index) {
1440
+ const sourceRef = normalizeImageSourceRef(block.sourceRef, `image prompt block ${index}`);
1441
+ const reference = sourceRef === undefined ? {} : { sourceRef };
1442
+ if (block.data === undefined && sourceRef !== undefined) {
1443
+ requireImageMimeType(block.mimeType, index);
1444
+ return { type: "image", mimeType: block.mimeType, ...reference };
1445
+ }
1446
+ const size = blobByteLength(block.data);
1447
+ if (size !== null) {
1448
+ const mimeType = block.data.type;
1449
+ if (block.mimeType !== undefined && block.mimeType !== mimeType) {
1450
+ throw new TypeError(`image prompt block ${index} mimeType disagrees with Blob.type`);
1451
+ }
1452
+ requireImageMimeType(mimeType, index);
1453
+ if (!Number.isSafeInteger(size) || size <= 0) {
1454
+ throw new TypeError(`image prompt block ${index} requires a non-empty Blob with a valid size`);
1455
+ }
1456
+ return { type: "image", source: "blob", data: block.data, mimeType, byteLength: size, ...reference };
1457
+ }
1458
+ if (typeof block.data === "string" && block.data.length > 0) {
1459
+ requireImageMimeType(block.mimeType, index);
1460
+ const byteLength = canonicalBase64ByteLength(block.data);
1461
+ if (byteLength <= 0) throw new TypeError(`image prompt block ${index} requires canonical base64 data`);
1462
+ return { type: "image", source: "base64", data: block.data, mimeType: block.mimeType, byteLength, ...reference };
1463
+ }
1464
+ const bytes = typeof block.data === "string" ? null : byteView(block.data);
1465
+ if (!bytes || bytes.byteLength === 0) {
1466
+ throw new TypeError(`image prompt block ${index} requires base64 data, bytes, or a Blob`);
1467
+ }
1468
+ requireImageMimeType(block.mimeType, index);
1469
+ return { type: "image", source: "bytes", data: bytes, mimeType: block.mimeType, byteLength: bytes.byteLength, ...reference };
1470
+ }
1471
+
1472
+ // With deferImageLimits, byte limits apply after resizeImage instead.
1473
+ function normalizePromptInput(input, { deferImageLimits = false } = {}) {
1303
1474
  if (typeof input === "string") return [{ type: "text", text: input }];
1304
1475
  if (!Array.isArray(input)) throw new TypeError("prompt input must be a string or an array of prompt blocks");
1305
1476
  let imageCount = 0;
1306
1477
  let imageBytes = 0;
1307
- return input.map((block, index) => {
1478
+ let prompt = input.map((block, index) => {
1308
1479
  if (!block || typeof block !== "object") throw new TypeError(`prompt block ${index} must be an object`);
1309
1480
  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
- }
1481
+ const image = normalizePromptImage(block, index);
1329
1482
  imageCount += 1;
1330
1483
  if (imageCount > maxPromptImages) {
1331
1484
  throw new RangeError(`prompt cannot contain more than ${maxPromptImages} images`);
1332
1485
  }
1333
- imageBytes += encodedLength;
1334
- if (imageBytes > maxPromptImagesBytes) {
1335
- throw new RangeError(`prompt images exceed the ${maxPromptImagesBytes} byte libfx frame limit`);
1486
+ if (!deferImageLimits) {
1487
+ if (image.byteLength > maxPromptImageBytes && image.sourceRef !== undefined) {
1488
+ return omitReferencedImageData([image])[0];
1489
+ }
1490
+ checkImageByteLength(image.byteLength ?? 0, index);
1491
+ imageBytes += image.byteLength ?? 0;
1336
1492
  }
1337
- return { type: "image", data: block.data, mimeType };
1493
+ return image;
1338
1494
  }
1339
1495
  if (block.type === "text") {
1340
1496
  if (typeof block.text !== "string") throw new TypeError(`text prompt block ${index} requires text`);
@@ -1348,50 +1504,103 @@ function normalizePromptInput(input) {
1348
1504
  }
1349
1505
  throw new TypeError(`unsupported prompt block type: ${String(block.type)}`);
1350
1506
  });
1507
+ if (!deferImageLimits) {
1508
+ if (imageBytes > maxPromptImagesBytes) {
1509
+ prompt = omitReferencedImageData(prompt);
1510
+ imageBytes = prompt.reduce((total, block) => total + (block.type === "image" ? block.byteLength ?? 0 : 0), 0);
1511
+ }
1512
+ checkPromptImagesByteLength(imageBytes);
1513
+ if (promptFrameSize(prompt) > maxPromptFrameBytes) prompt = omitReferencedImageData(prompt);
1514
+ }
1515
+ return prompt;
1351
1516
  }
1352
1517
 
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 };
1518
+ // Image bytes travel beside the frame as attachment references, but the model
1519
+ // request carries them base64 encoded and the native host caps that request at
1520
+ // 8 MiB. Images therefore count at their encoded size, the same budget they
1521
+ // had inside the frame. With countImages false, only the frame counts.
1522
+ function promptFrameSize(prompt, countImages = true) {
1523
+ const encodedImageBytes = countImages ? promptImageDataBytes(prompt) : 0;
1524
+ const projected = prompt.map((block) => block.type !== "image" ? block : {
1525
+ type: "image",
1526
+ mimeType: block.mimeType,
1527
+ ...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
1528
+ ...(block.data === undefined && block.bytes === undefined ? {} : { _meta: { fx: { attachment: 0xffffffff } } }),
1361
1529
  });
1362
- return encoder.encode(JSON.stringify({ sessionId: "", prompt: projected })).length + blobDataBytes + promptFrameEnvelopeBytes;
1530
+ return encoder.encode(JSON.stringify({ sessionId: "", prompt: projected })).length +
1531
+ encodedImageBytes + promptFrameEnvelopeBytes;
1532
+ }
1533
+
1534
+ function checkPromptFrameSize(prompt, countImages) {
1535
+ if (promptFrameSize(prompt, countImages) > maxPromptFrameBytes) {
1536
+ throw new RangeError(`prompt exceeds the ${maxPromptFrameBytes} byte libfx frame limit`);
1537
+ }
1538
+ }
1539
+
1540
+ // Returns prompt blocks whose images carry { mimeType, bytes }. Synchronous
1541
+ // sources only: base64 is decoded and caller bytes are used in place.
1542
+ function preparePromptImages(blocks) {
1543
+ return blocks.map((block) => block.type !== "image" || block.data === undefined ? block : {
1544
+ type: "image",
1545
+ mimeType: block.mimeType,
1546
+ bytes: block.source === "base64" ? base64ToBytes(block.data) : block.data,
1547
+ ...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
1548
+ });
1549
+ }
1550
+
1551
+ function resizedPromptImage(value, index) {
1552
+ const bytes = byteView(value?.bytes);
1553
+ if (!bytes || bytes.byteLength === 0 || typeof value.mimeType !== "string" ||
1554
+ value.mimeType.length === 0 || value.mimeType.length > 128) {
1555
+ throw new TypeError(`resizeImage must return non-empty bytes and a mimeType for image prompt block ${index}`);
1556
+ }
1557
+ // Copied because a hook may reuse its output buffer for the next image.
1558
+ return { bytes: bytes.slice(), mimeType: value.mimeType };
1363
1559
  }
1364
1560
 
1365
- async function materializePromptBlobs(blocks, isCancelled) {
1366
- const encoded = [];
1561
+ // Reads Blob images and applies resizeImage, then checks the final byte and
1562
+ // frame limits. Returns null when the turn is cancelled first.
1563
+ async function materializePromptImages(blocks, isCancelled, resizeImage) {
1564
+ let prepared = [];
1367
1565
  let imageBytes = 0;
1368
- for (const [index, block] of blocks.entries()) {
1566
+ for (let index = 0; index < blocks.length; index++) {
1369
1567
  if (isCancelled()) return null;
1370
- if (block.type !== "image") {
1371
- encoded.push(block);
1568
+ const block = blocks[index];
1569
+ if (block.type !== "image" || block.data === undefined) {
1570
+ prepared.push(block);
1372
1571
  continue;
1373
1572
  }
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());
1573
+ let image = block.source === "blob"
1574
+ ? { mimeType: block.mimeType, bytes: new Uint8Array(await block.data.arrayBuffer()) }
1575
+ : preparePromptImages([block])[0];
1383
1576
  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`);
1577
+ if (resizeImage) {
1578
+ image = resizedPromptImage(await resizeImage({ bytes: image.bytes, mimeType: image.mimeType }), index);
1579
+ if (isCancelled()) return null;
1387
1580
  }
1388
- imageBytes += encodedLength;
1581
+ image = {
1582
+ type: "image",
1583
+ mimeType: image.mimeType,
1584
+ bytes: image.bytes,
1585
+ ...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
1586
+ };
1587
+ if (image.bytes.byteLength > maxPromptImageBytes && image.sourceRef !== undefined) {
1588
+ image = omitReferencedImageData([image])[0];
1589
+ }
1590
+ checkImageByteLength(image.bytes?.byteLength ?? 0, index);
1591
+ imageBytes += image.bytes?.byteLength ?? 0;
1592
+ prepared.push(image);
1389
1593
  if (imageBytes > maxPromptImagesBytes) {
1390
- throw new RangeError(`prompt images exceed the ${maxPromptImagesBytes} byte libfx frame limit`);
1594
+ prepared = omitReferencedImageData(prepared);
1595
+ // Do not read more referenced Blobs after the actual aggregate overflows.
1596
+ blocks = omitReferencedImageData(blocks);
1597
+ imageBytes = prepared.reduce((total, entry) => total + (entry.bytes?.byteLength ?? 0), 0);
1391
1598
  }
1392
- encoded.push({ type: "image", data: bytesToBase64(bytes), mimeType: block.mimeType });
1599
+ checkPromptImagesByteLength(imageBytes);
1393
1600
  }
1394
- return normalizePromptInput(encoded);
1601
+ if (promptFrameSize(prepared) > maxPromptFrameBytes) prepared = omitReferencedImageData(prepared);
1602
+ checkPromptFrameSize(prepared, true);
1603
+ return prepared;
1395
1604
  }
1396
1605
 
1397
1606
  function normalizeSteeringInput(input) {
@@ -1466,17 +1675,28 @@ function hostToolContent(value) {
1466
1675
  if (typeof value.text !== "string" || !Array.isArray(value.images) || value.images.length > 8) {
1467
1676
  throw new TypeError("invalid typed tool result");
1468
1677
  }
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) {
1678
+ let images = value.images.map((image) => {
1679
+ if (image?.type !== "image") throw new TypeError("invalid tool image");
1680
+ const sourceRef = normalizeImageSourceRef(image.sourceRef, "tool image");
1681
+ const referenceOnly = image.data === undefined && sourceRef !== undefined;
1682
+ 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
1683
  throw new TypeError("invalid tool image");
1473
1684
  }
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 };
1685
+ return {
1686
+ type: "image",
1687
+ ...(!referenceOnly && image.data.length <= maxPromptImageDataBytes ? { data: image.data } : {}),
1688
+ mimeType: image.mimeType,
1689
+ ...(sourceRef === undefined ? {} : { sourceRef }),
1690
+ };
1477
1691
  });
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");
1692
+ if (promptImageDataBytes(images) > maxPromptImagesDataBytes) images = omitReferencedImageData(images);
1693
+ if (promptImageDataBytes(images) > maxPromptImagesDataBytes) throw new RangeError("tool images exceed the result limit");
1694
+ let content = JSON.stringify({ text: value.text, images });
1695
+ if (encoder.encode(content).length > maxPromptImagesDataBytes) {
1696
+ images = omitReferencedImageData(images);
1697
+ content = JSON.stringify({ text: value.text, images });
1698
+ }
1699
+ if (encoder.encode(content).length > maxPromptImagesDataBytes) throw new RangeError("typed tool result exceeds the result limit");
1480
1700
  return { content, rich: true, isError: value.isError === true };
1481
1701
  }
1482
1702
  if (typeof value === "string") return { content: value, rich: false };
@@ -1495,14 +1715,6 @@ function checkpointBytes(value) {
1495
1715
  throw new TypeError("checkpoint must be an ArrayBuffer or typed array");
1496
1716
  }
1497
1717
 
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
1718
  function base64ToBytes(value) {
1507
1719
  const binary = atob(value);
1508
1720
  const bytes = new Uint8Array(binary.length);
@@ -1515,6 +1727,11 @@ export async function createFxAgent(options = {}) {
1515
1727
  const hostTools = normalizeHostTools(options.tools);
1516
1728
  const instructions = normalizeInstructions(options.instructions);
1517
1729
  const initialCheckpoint = checkpointBytes(options.checkpoint);
1730
+ // Checked before the core starts, and with the core's message, so both
1731
+ // backends report it the same way.
1732
+ if (initialCheckpoint && initialCheckpoint.byteLength > maxCheckpointBytes) {
1733
+ throw new Error("libfx checkpoint is too large");
1734
+ }
1518
1735
  const pending = new Map();
1519
1736
  let nextId = 1;
1520
1737
  let sessionId = null;
@@ -1659,11 +1876,47 @@ export async function createFxAgent(options = {}) {
1659
1876
  pending.set(id, { resolve, reject });
1660
1877
  try { send({ jsonrpc: "2.0", id, method, params }); } catch (error) { pending.delete(id); reject(error); }
1661
1878
  });
1879
+ // Raw payloads ride beside the next frame instead of inside it. Payloads
1880
+ // left by an earlier frame that never reached the core are dropped first.
1881
+ let nextAttachmentId = 1;
1882
+ const attachBytes = (payloads) => {
1883
+ if (typeof runtime.writeAttachment !== "function") throw new Error("fx runtime does not accept binary attachments");
1884
+ runtime.discardAttachments?.();
1885
+ return payloads.map((bytes) => {
1886
+ const id = nextAttachmentId;
1887
+ nextAttachmentId = id === 0x7fffffff ? 1 : id + 1;
1888
+ runtime.writeAttachment(id, bytes);
1889
+ return id;
1890
+ });
1891
+ };
1892
+ let checkpointTail = null;
1893
+ let pendingCheckpoints = 0;
1894
+ async function takeCheckpoint() {
1895
+ if (closing) throw new Error("fx agent is closed");
1896
+ if (activeTurn) throw new Error("cannot checkpoint while a prompt is active");
1897
+ const response = await request("libfx/checkpoint", { sessionId });
1898
+ const id = response?.checkpointAttachment;
1899
+ const bytes = Number.isSafeInteger(id) && id > 0 ? runtime.takeAttachment?.(id) : null;
1900
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) throw new Error("fx returned an invalid checkpoint");
1901
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1902
+ }
1903
+ const sendPrompt = (blocks) => {
1904
+ const images = blocks.filter((block) => block.type === "image" && block.bytes !== undefined);
1905
+ const ids = images.length ? attachBytes(images.map((block) => block.bytes)) : [];
1906
+ let next = 0;
1907
+ const prompt = blocks.map((block) => block.type !== "image" ? block : {
1908
+ type: "image",
1909
+ mimeType: block.mimeType,
1910
+ ...(block.sourceRef === undefined ? {} : { sourceRef: block.sourceRef }),
1911
+ ...(block.bytes === undefined ? {} : { _meta: { fx: { attachment: ids[next++] } } }),
1912
+ });
1913
+ return request("session/prompt", { sessionId, prompt });
1914
+ };
1662
1915
  runtime.exited.then((code) => {
1663
1916
  closing = true;
1664
1917
  const error = runtime.error ?? new Error(`fx-core exited with code ${code} before completing the ACP request`);
1665
1918
  coreExitError = error;
1666
- activeTurn?.failPendingBlob(error);
1919
+ activeTurn?.failImagePrep(error);
1667
1920
  for (const waiter of pending.values()) waiter.reject(error);
1668
1921
  pending.clear();
1669
1922
  emit("runtime.exit", { code });
@@ -1698,6 +1951,10 @@ export async function createFxAgent(options = {}) {
1698
1951
  );
1699
1952
  if (cancelled || closing) return;
1700
1953
  const response = { jsonrpc: "2.0", id: message.id, result: { content, isError, ...(rich ? { contentType: "rich" } : {}) } };
1954
+ if (rich && encoder.encode(JSON.stringify(response)).length + 1 > maxPromptFrameBytes) {
1955
+ const result = JSON.parse(content);
1956
+ response.result.content = JSON.stringify({ ...result, images: omitReferencedImageData(result.images) });
1957
+ }
1701
1958
  if (encoder.encode(JSON.stringify(response)).length + 1 > 8 * 1024 * 1024) {
1702
1959
  response.result = { content: "Host tool result exceeded the response frame limit", isError: true };
1703
1960
  }
@@ -1720,10 +1977,8 @@ export async function createFxAgent(options = {}) {
1720
1977
  const sessionResult = await request("libfx/new");
1721
1978
  sessionId = sessionResult.sessionId;
1722
1979
  if (initialCheckpoint) {
1723
- await request("libfx/restore", {
1724
- sessionId,
1725
- checkpoint: bytesToBase64(initialCheckpoint),
1726
- });
1980
+ const [checkpointAttachment] = attachBytes([initialCheckpoint]);
1981
+ await request("libfx/restore", { sessionId, checkpointAttachment });
1727
1982
  }
1728
1983
  } catch (error) {
1729
1984
  closing = true;
@@ -1739,12 +1994,21 @@ export async function createFxAgent(options = {}) {
1739
1994
  if (activeTurn) throw new Error("a prompt is already in progress for this session");
1740
1995
  return normalizeTurn(startTurn(input, promptOptions));
1741
1996
  },
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);
1997
+ checkpoint() {
1998
+ // One checkpoint runs at a time: each holds an outbound attachment until
1999
+ // it is taken, and the native table holds only a few. An idle call still
2000
+ // sends its request before returning, ahead of a later prompt(). The slot
2001
+ // is claimed before that send, so a call from an event handler during it
2002
+ // still waits. The count drops before the caller's own reaction to `run`,
2003
+ // so a call made right after awaiting the previous one is idle.
2004
+ const previous = pendingCheckpoints > 0 ? checkpointTail : null;
2005
+ pendingCheckpoints++;
2006
+ let release;
2007
+ checkpointTail = new Promise((resolve) => { release = resolve; });
2008
+ const run = previous ? previous.then(takeCheckpoint) : takeCheckpoint();
2009
+ const settle = () => { pendingCheckpoints--; release(); };
2010
+ run.then(settle, settle);
2011
+ return run;
1748
2012
  },
1749
2013
  async close() {
1750
2014
  if (closing) { await runtime.exited; return; }
@@ -1846,11 +2110,18 @@ export async function createFxAgent(options = {}) {
1846
2110
  }
1847
2111
 
1848
2112
  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
- }
2113
+ const resizeImage = options.resizeImage;
2114
+ const normalized = normalizePromptInput(input, { deferImageLimits: resizeImage !== undefined });
2115
+ const hasPixels = normalized.some((block) => block.type === "image" && block.data !== undefined);
2116
+ // Blob reads and resizeImage run before the prompt frame is sent.
2117
+ const asyncImages = hasPixels && (resizeImage !== undefined ||
2118
+ normalized.some((block) => block.type === "image" && block.source === "blob"));
2119
+ // resizeImage decides the final image sizes, so they are counted after it runs.
2120
+ checkPromptFrameSize(normalized, resizeImage === undefined);
2121
+ // Snapshot caller-owned bytes, which could change before an async send.
2122
+ const prompt = asyncImages
2123
+ ? normalized.map((block) => block.type === "image" && block.source === "bytes" ? { ...block, data: block.data.slice() } : block)
2124
+ : preparePromptImages(normalized);
1854
2125
  const signal = promptOptions.signal;
1855
2126
  if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal");
1856
2127
  const queue = [];
@@ -1861,10 +2132,10 @@ export async function createFxAgent(options = {}) {
1861
2132
  let terminalError;
1862
2133
  let reportedPressure = false;
1863
2134
  let discardedBytes = 0;
1864
- let cancelBlobRead = null;
1865
- let rejectBlobRead = null;
2135
+ let cancelImagePrep = null;
2136
+ let rejectImagePrep = null;
1866
2137
  let resolvePromptStart = null;
1867
- const promptStarted = hasBlobs ? new Promise((resolve) => { resolvePromptStart = resolve; }) : null;
2138
+ const promptStarted = asyncImages ? new Promise((resolve) => { resolvePromptStart = resolve; }) : null;
1868
2139
  let pendingSteeringCount = 0;
1869
2140
  let pendingSteeringBytes = 0;
1870
2141
  const toolControllers = new Set();
@@ -1891,10 +2162,10 @@ export async function createFxAgent(options = {}) {
1891
2162
  transportBytes: 0,
1892
2163
  lastTransportActivityAt: null,
1893
2164
  get cancelled() { return cancelled; },
1894
- failPendingBlob(error) {
1895
- rejectBlobRead?.(error);
1896
- rejectBlobRead = null;
1897
- cancelBlobRead = null;
2165
+ failImagePrep(error) {
2166
+ rejectImagePrep?.(error);
2167
+ rejectImagePrep = null;
2168
+ cancelImagePrep = null;
1898
2169
  },
1899
2170
  promptWritten() {
1900
2171
  resolvePromptStart?.(true);
@@ -1939,9 +2210,9 @@ export async function createFxAgent(options = {}) {
1939
2210
  cancel() {
1940
2211
  if (finished || cancelled) return;
1941
2212
  cancelled = true;
1942
- cancelBlobRead?.();
1943
- cancelBlobRead = null;
1944
- rejectBlobRead = null;
2213
+ cancelImagePrep?.();
2214
+ cancelImagePrep = null;
2215
+ rejectImagePrep = null;
1945
2216
  resolvePromptStart?.(false);
1946
2217
  resolvePromptStart = null;
1947
2218
  runtime.closeSteering?.();
@@ -1982,34 +2253,34 @@ export async function createFxAgent(options = {}) {
1982
2253
  runtime.openSteering?.();
1983
2254
  const abort = () => turn.cancel();
1984
2255
  signal?.addEventListener("abort", abort, { once: true });
1985
- const blobReadCancelled = hasBlobs ? new Promise((resolve, reject) => {
1986
- cancelBlobRead = () => resolve(null);
1987
- rejectBlobRead = reject;
2256
+ const imagePrepCancelled = asyncImages ? new Promise((resolve, reject) => {
2257
+ cancelImagePrep = () => resolve(null);
2258
+ rejectImagePrep = reject;
1988
2259
  }) : 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;
2260
+ let response;
2261
+ if (asyncImages) {
2262
+ response = Promise.race([
2263
+ Promise.resolve().then(() => materializePromptImages(prompt, () => cancelled || closing, resizeImage)),
2264
+ imagePrepCancelled,
2265
+ ]).then((prepared) => {
2266
+ cancelImagePrep = null;
2267
+ rejectImagePrep = null;
1996
2268
  if (coreExitError && !cancelled) throw coreExitError;
1997
- if (encodedPrompt === null || cancelled || closing) {
2269
+ if (prepared === null || cancelled || closing) {
1998
2270
  resolvePromptStart?.(false);
1999
2271
  resolvePromptStart = null;
2000
2272
  return { stopReason: "cancelled" };
2001
2273
  }
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 });
2274
+ return sendPrompt(prepared);
2275
+ });
2276
+ } else {
2277
+ try { response = sendPrompt(prompt); } catch (error) { response = Promise.reject(error); }
2278
+ }
2008
2279
  turn.result = response
2009
2280
  .then((value) => ({ stopReason: cancelled ? "cancelled" : value.stopReason, usage: value.usage }))
2010
2281
  .catch((error) => {
2011
- cancelBlobRead = null;
2012
- rejectBlobRead = null;
2282
+ cancelImagePrep = null;
2283
+ rejectImagePrep = null;
2013
2284
  resolvePromptStart?.(error);
2014
2285
  resolvePromptStart = null;
2015
2286
  if (cancelled && error.message === "Cancelled") return { stopReason: "cancelled" };