libfx 0.0.11 → 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,7 +93,15 @@ function normalizeFast(value) {
84
93
  return value;
85
94
  }
86
95
 
87
- function normalizeAgentOptions(value) {
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
+
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");
90
107
  }
@@ -93,10 +110,30 @@ function normalizeAgentOptions(value) {
93
110
  throw new TypeError("createFxAgent() does not accept env; pass apiKey and model directly");
94
111
  }
95
112
  options.apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, true);
96
- options.model = boundedString(options.model, "model", maxModelBytes, false);
97
- options.effort = normalizeEffort(options.effort);
98
- options.fast = normalizeFast(options.fast);
113
+ if (options.model !== null && typeof options.model === "object" && !Array.isArray(options.model)) {
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");
116
+ }
117
+ const model = options.model;
118
+ for (const name of Object.keys(model)) {
119
+ if (name !== "id" && name !== "effort" && name !== "fast" && name !== "ultrafast") {
120
+ throw new TypeError(`unsupported model option: ${name}`);
121
+ }
122
+ }
123
+ options.model = boundedString(model.id, "model.id", maxModelBytes, true);
124
+ options.effort = normalizeEffort(model.effort);
125
+ options.fast = normalizeFast(model.fast);
126
+ options.ultrafast = normalizeUltrafast(model.ultrafast);
127
+ } else {
128
+ options.model = boundedString(options.model, "model", maxModelBytes, false);
129
+ options.effort = normalizeEffort(options.effort);
130
+ options.fast = normalizeFast(options.fast);
131
+ options.ultrafast = normalizeUltrafast(options.ultrafast);
132
+ }
99
133
  validateGatewayChatUrl(options.gatewayChatUrl);
134
+ if (options.resizeImage !== undefined && typeof options.resizeImage !== "function") {
135
+ throw new TypeError("resizeImage must be a function");
136
+ }
100
137
  return options;
101
138
  }
102
139
 
@@ -106,20 +143,20 @@ function agentEnvironment(options) {
106
143
  ...(options.model === undefined ? {} : { FX_MODEL: options.model }),
107
144
  ...(options.effort === undefined ? {} : { FX_EFFORT: options.effort }),
108
145
  ...(options.fast === undefined ? {} : { FX_FAST: options.fast ? "true" : "false" }),
146
+ ...(options.ultrafast === undefined ? {} : { FX_ULTRAFAST: options.ultrafast ? "true" : "false" }),
109
147
  ...(options.gatewayChatUrl === undefined ? {} : { FX_GATEWAY_CHAT_URL: options.gatewayChatUrl }),
110
148
  };
111
149
  }
112
150
 
113
- // The kernel rejects an unsupported effort or fast lane during initialize;
114
- // these messages originate only from that validation, so the rejection is
115
- // safe to retype.
116
- function agentBootstrapError(error) {
117
- if (error instanceof Error &&
118
- (error.message === "Invalid reasoning effort" || error.message.startsWith("Reasoning effort"))) {
119
- error.code ??= "LIBFX_UNSUPPORTED_EFFORT";
120
- }
121
- if (error instanceof Error && error.message.startsWith("Fast mode")) {
122
- error.code ??= "LIBFX_UNSUPPORTED_FAST";
151
+ function agentRpcError(response) {
152
+ const error = new Error(response.message);
153
+ const data = response.data;
154
+ if (data && ["LIBFX_MODEL_UNSUPPORTED_EFFORT", "LIBFX_MODEL_UNSUPPORTED_FAST", "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST"].includes(data.code) &&
155
+ typeof data.model === "string" &&
156
+ data.capability === (data.code === "LIBFX_MODEL_UNSUPPORTED_FAST" ? "fast" : data.code === "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST" ? "ultrafast" : "effort")) {
157
+ error.code = data.code;
158
+ error.model = data.model;
159
+ error.capability = data.capability;
123
160
  }
124
161
  return error;
125
162
  }
@@ -451,6 +488,11 @@ function createRuntime(options) {
451
488
  const steering = [];
452
489
  let steeringBytes = 0;
453
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;
454
496
  const workspaceExecs = new Set();
455
497
  const workspace = prepareWorkspaceAdapter(options.workspace);
456
498
  const args = ["fx", ...(options.args || [])];
@@ -747,6 +789,38 @@ function createRuntime(options) {
747
789
  return value.length;
748
790
  }
749
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
+
750
824
  let pendingHostToolResult = null;
751
825
  function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr) {
752
826
  pendingHostToolResult = null;
@@ -1118,6 +1192,9 @@ function createRuntime(options) {
1118
1192
  fx_host_tool_result_release() { pendingHostToolResult = null; },
1119
1193
  fx_steering_take: steeringTake,
1120
1194
  fx_steering_close() { steeringOpen = false; clearSteering(); },
1195
+ fx_attachment_size: attachmentSize,
1196
+ fx_attachment_take: attachmentTake,
1197
+ fx_attachment_put: attachmentPut,
1121
1198
  fx_open_url: new WebAssembly.Suspending(openUrl),
1122
1199
  fx_oauth_session_load: new WebAssembly.Suspending(oauthSessionLoad),
1123
1200
  fx_oauth_session_commit: new WebAssembly.Suspending(oauthSessionCommit),
@@ -1144,6 +1221,13 @@ function createRuntime(options) {
1144
1221
  imports: { wasi_snapshot_preview1: wasi, fx }, exited,
1145
1222
  setInstance(value) { instance = value; },
1146
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(); },
1147
1231
  wake() { stdin.wake(); },
1148
1232
  closeStdin() { steeringOpen = false; clearSteering(); stdin.close(); },
1149
1233
  openSteering() { clearSteering(); steeringOpen = true; },
@@ -1276,32 +1360,137 @@ export async function createFxTerminal(options) {
1276
1360
  };
1277
1361
  }
1278
1362
 
1279
- function normalizePromptInput(input) {
1363
+ function blobByteLength(value) {
1364
+ if (typeof Blob === "undefined" || value == null) return null;
1365
+ try {
1366
+ return Object.getOwnPropertyDescriptor(Blob.prototype, "size").get.call(value);
1367
+ } catch {
1368
+ return null;
1369
+ }
1370
+ }
1371
+
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 } = {}) {
1280
1474
  if (typeof input === "string") return [{ type: "text", text: input }];
1281
1475
  if (!Array.isArray(input)) throw new TypeError("prompt input must be a string or an array of prompt blocks");
1282
1476
  let imageCount = 0;
1283
1477
  let imageBytes = 0;
1284
- return input.map((block, index) => {
1478
+ let prompt = input.map((block, index) => {
1285
1479
  if (!block || typeof block !== "object") throw new TypeError(`prompt block ${index} must be an object`);
1286
1480
  if (block.type === "image") {
1287
- if (typeof block.data !== "string" || block.data.length === 0) {
1288
- throw new TypeError(`image prompt block ${index} requires base64 data`);
1289
- }
1290
- if (typeof block.mimeType !== "string" || block.mimeType.length === 0 || block.mimeType.length > 128) {
1291
- throw new TypeError(`image prompt block ${index} requires a mimeType`);
1292
- }
1293
- if (block.data.length > maxPromptImageDataBytes) {
1294
- throw new RangeError(`image prompt block ${index} exceeds the ${maxPromptImageDataBytes} byte per-image libfx limit`);
1295
- }
1481
+ const image = normalizePromptImage(block, index);
1296
1482
  imageCount += 1;
1297
1483
  if (imageCount > maxPromptImages) {
1298
1484
  throw new RangeError(`prompt cannot contain more than ${maxPromptImages} images`);
1299
1485
  }
1300
- imageBytes += block.data.length;
1301
- if (imageBytes > maxPromptImagesBytes) {
1302
- 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;
1303
1492
  }
1304
- return { type: "image", data: block.data, mimeType: block.mimeType };
1493
+ return image;
1305
1494
  }
1306
1495
  if (block.type === "text") {
1307
1496
  if (typeof block.text !== "string") throw new TypeError(`text prompt block ${index} requires text`);
@@ -1315,6 +1504,103 @@ function normalizePromptInput(input) {
1315
1504
  }
1316
1505
  throw new TypeError(`unsupported prompt block type: ${String(block.type)}`);
1317
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;
1516
+ }
1517
+
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 } } }),
1529
+ });
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 };
1559
+ }
1560
+
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 = [];
1565
+ let imageBytes = 0;
1566
+ for (let index = 0; index < blocks.length; index++) {
1567
+ if (isCancelled()) return null;
1568
+ const block = blocks[index];
1569
+ if (block.type !== "image" || block.data === undefined) {
1570
+ prepared.push(block);
1571
+ continue;
1572
+ }
1573
+ let image = block.source === "blob"
1574
+ ? { mimeType: block.mimeType, bytes: new Uint8Array(await block.data.arrayBuffer()) }
1575
+ : preparePromptImages([block])[0];
1576
+ if (isCancelled()) return null;
1577
+ if (resizeImage) {
1578
+ image = resizedPromptImage(await resizeImage({ bytes: image.bytes, mimeType: image.mimeType }), index);
1579
+ if (isCancelled()) return null;
1580
+ }
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);
1593
+ if (imageBytes > maxPromptImagesBytes) {
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);
1598
+ }
1599
+ checkPromptImagesByteLength(imageBytes);
1600
+ }
1601
+ if (promptFrameSize(prepared) > maxPromptFrameBytes) prepared = omitReferencedImageData(prepared);
1602
+ checkPromptFrameSize(prepared, true);
1603
+ return prepared;
1318
1604
  }
1319
1605
 
1320
1606
  function normalizeSteeringInput(input) {
@@ -1389,17 +1675,28 @@ function hostToolContent(value) {
1389
1675
  if (typeof value.text !== "string" || !Array.isArray(value.images) || value.images.length > 8) {
1390
1676
  throw new TypeError("invalid typed tool result");
1391
1677
  }
1392
- let imageBytes = 0;
1393
- const images = value.images.map((image) => {
1394
- 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)) {
1395
1683
  throw new TypeError("invalid tool image");
1396
1684
  }
1397
- imageBytes += image.data.length;
1398
- if (imageBytes > 8 * 1024 * 1024) throw new RangeError("tool images exceed the result limit");
1399
- 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
+ };
1400
1691
  });
1401
- const content = JSON.stringify({ text: value.text, images });
1402
- 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");
1403
1700
  return { content, rich: true, isError: value.isError === true };
1404
1701
  }
1405
1702
  if (typeof value === "string") return { content: value, rich: false };
@@ -1418,14 +1715,6 @@ function checkpointBytes(value) {
1418
1715
  throw new TypeError("checkpoint must be an ArrayBuffer or typed array");
1419
1716
  }
1420
1717
 
1421
- function bytesToBase64(value) {
1422
- let binary = "";
1423
- for (let offset = 0; offset < value.length; offset += 0x8000) {
1424
- binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000));
1425
- }
1426
- return btoa(binary);
1427
- }
1428
-
1429
1718
  function base64ToBytes(value) {
1430
1719
  const binary = atob(value);
1431
1720
  const bytes = new Uint8Array(binary.length);
@@ -1438,11 +1727,17 @@ export async function createFxAgent(options = {}) {
1438
1727
  const hostTools = normalizeHostTools(options.tools);
1439
1728
  const instructions = normalizeInstructions(options.instructions);
1440
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
+ }
1441
1735
  const pending = new Map();
1442
1736
  let nextId = 1;
1443
1737
  let sessionId = null;
1444
1738
  let activeTurn = null;
1445
1739
  let closing = false;
1740
+ let coreExitError = null;
1446
1741
  const isCurrentTurn = (turn) => turn && activeTurn === turn && !turn.cancelled && !closing;
1447
1742
  const emit = (type, detail = {}) => {
1448
1743
  try { options.onEvent?.({ type, timestamp: performance.now(), ...detail }); } catch {}
@@ -1572,19 +1867,59 @@ export async function createFxAgent(options = {}) {
1572
1867
  const send = (message) => {
1573
1868
  if (closing) throw new Error("fx agent is closing");
1574
1869
  emit("acp.send", { message });
1870
+ if (message.method === "session/prompt" && activeTurn?.cancelled) throw new Error("Cancelled");
1575
1871
  runtime.write(`${JSON.stringify(message)}\n`);
1872
+ if (message.method === "session/prompt") activeTurn?.promptWritten();
1576
1873
  };
1577
1874
  const request = (method, params = {}) => new Promise((resolve, reject) => {
1578
1875
  const id = nextId++;
1579
1876
  pending.set(id, { resolve, reject });
1580
1877
  try { send({ jsonrpc: "2.0", id, method, params }); } catch (error) { pending.delete(id); reject(error); }
1581
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
+ };
1582
1915
  runtime.exited.then((code) => {
1583
- emit("runtime.exit", { code });
1584
1916
  closing = true;
1585
1917
  const error = runtime.error ?? new Error(`fx-core exited with code ${code} before completing the ACP request`);
1918
+ coreExitError = error;
1919
+ activeTurn?.failImagePrep(error);
1586
1920
  for (const waiter of pending.values()) waiter.reject(error);
1587
1921
  pending.clear();
1922
+ emit("runtime.exit", { code });
1588
1923
  });
1589
1924
  runtime.setLineHandler((message, size) => {
1590
1925
  emit("acp.receive", { message });
@@ -1616,6 +1951,10 @@ export async function createFxAgent(options = {}) {
1616
1951
  );
1617
1952
  if (cancelled || closing) return;
1618
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
+ }
1619
1958
  if (encoder.encode(JSON.stringify(response)).length + 1 > 8 * 1024 * 1024) {
1620
1959
  response.result = { content: "Host tool result exceeded the response frame limit", isError: true };
1621
1960
  }
@@ -1623,7 +1962,7 @@ export async function createFxAgent(options = {}) {
1623
1962
  return;
1624
1963
  }
1625
1964
  const waiter = pending.get(message.id); if (!waiter) return; pending.delete(message.id);
1626
- if (message.error) waiter.reject(new Error(message.error.message)); else waiter.resolve(message.result);
1965
+ if (message.error) waiter.reject(agentRpcError(message.error)); else waiter.resolve(message.result);
1627
1966
  }
1628
1967
  try {
1629
1968
  await request("initialize", {
@@ -1638,17 +1977,15 @@ export async function createFxAgent(options = {}) {
1638
1977
  const sessionResult = await request("libfx/new");
1639
1978
  sessionId = sessionResult.sessionId;
1640
1979
  if (initialCheckpoint) {
1641
- await request("libfx/restore", {
1642
- sessionId,
1643
- checkpoint: bytesToBase64(initialCheckpoint),
1644
- });
1980
+ const [checkpointAttachment] = attachBytes([initialCheckpoint]);
1981
+ await request("libfx/restore", { sessionId, checkpointAttachment });
1645
1982
  }
1646
1983
  } catch (error) {
1647
1984
  closing = true;
1648
1985
  try { runtime.abortHostEffects(); } catch {}
1649
1986
  try { runtime.closeStdin(); } catch {}
1650
1987
  try { await runtime.exited; } catch {}
1651
- throw agentBootstrapError(error);
1988
+ throw error;
1652
1989
  }
1653
1990
 
1654
1991
  const agent = {
@@ -1657,12 +1994,21 @@ export async function createFxAgent(options = {}) {
1657
1994
  if (activeTurn) throw new Error("a prompt is already in progress for this session");
1658
1995
  return normalizeTurn(startTurn(input, promptOptions));
1659
1996
  },
1660
- async checkpoint() {
1661
- if (closing) throw new Error("fx agent is closed");
1662
- if (activeTurn) throw new Error("cannot checkpoint while a prompt is active");
1663
- const response = await request("libfx/checkpoint", { sessionId });
1664
- if (typeof response?.checkpoint !== "string") throw new Error("fx returned an invalid checkpoint");
1665
- 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;
1666
2012
  },
1667
2013
  async close() {
1668
2014
  if (closing) { await runtime.exited; return; }
@@ -1764,10 +2110,18 @@ export async function createFxAgent(options = {}) {
1764
2110
  }
1765
2111
 
1766
2112
  function startTurn(input, promptOptions) {
1767
- const prompt = normalizePromptInput(input);
1768
- if (encoder.encode(JSON.stringify({ sessionId: "", prompt })).length + promptFrameEnvelopeBytes > maxPromptFrameBytes) {
1769
- throw new RangeError(`prompt exceeds the ${maxPromptFrameBytes} byte libfx frame limit`);
1770
- }
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);
1771
2125
  const signal = promptOptions.signal;
1772
2126
  if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal");
1773
2127
  const queue = [];
@@ -1778,6 +2132,12 @@ export async function createFxAgent(options = {}) {
1778
2132
  let terminalError;
1779
2133
  let reportedPressure = false;
1780
2134
  let discardedBytes = 0;
2135
+ let cancelImagePrep = null;
2136
+ let rejectImagePrep = null;
2137
+ let resolvePromptStart = null;
2138
+ const promptStarted = asyncImages ? new Promise((resolve) => { resolvePromptStart = resolve; }) : null;
2139
+ let pendingSteeringCount = 0;
2140
+ let pendingSteeringBytes = 0;
1781
2141
  const toolControllers = new Set();
1782
2142
  let finished = false;
1783
2143
  let cancelled = false;
@@ -1802,34 +2162,63 @@ export async function createFxAgent(options = {}) {
1802
2162
  transportBytes: 0,
1803
2163
  lastTransportActivityAt: null,
1804
2164
  get cancelled() { return cancelled; },
2165
+ failImagePrep(error) {
2166
+ rejectImagePrep?.(error);
2167
+ rejectImagePrep = null;
2168
+ cancelImagePrep = null;
2169
+ },
2170
+ promptWritten() {
2171
+ resolvePromptStart?.(true);
2172
+ resolvePromptStart = null;
2173
+ },
1805
2174
  steer(text) {
1806
2175
  if (finished || cancelled || activeTurn !== turn) {
1807
2176
  return Promise.reject(new Error("no prompt is running"));
1808
2177
  }
1809
- let accepted;
1810
- try {
1811
- if (typeof runtime.steer === "function") {
1812
- runtime.steer(text);
1813
- void turn.push({
1814
- sessionUpdate: "user_message_chunk",
1815
- content: { type: "text", text },
1816
- });
1817
- accepted = Promise.resolve();
1818
- } else {
1819
- accepted = request("libfx/steer", { sessionId, text });
2178
+ if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
2179
+ const apply = () => {
2180
+ if (finished || cancelled || activeTurn !== turn) {
2181
+ return Promise.reject(new Error("no prompt is running"));
2182
+ }
2183
+ if (closing) return Promise.reject(coreExitError ?? new Error("fx agent is closing"));
2184
+ try {
2185
+ if (typeof runtime.steer === "function") {
2186
+ runtime.steer(text);
2187
+ void turn.push({
2188
+ sessionUpdate: "user_message_chunk",
2189
+ content: { type: "text", text },
2190
+ });
2191
+ return Promise.resolve();
2192
+ }
2193
+ return request("libfx/steer", { sessionId, text });
2194
+ } catch (error) {
2195
+ return Promise.reject(error);
1820
2196
  }
1821
- } catch (error) {
1822
- return Promise.reject(error);
2197
+ };
2198
+ if (!resolvePromptStart) return apply();
2199
+ const bytes = encoder.encode(text).length;
2200
+ if (pendingSteeringCount >= maxSteeringMessages || bytes > maxSteeringQueueBytes - pendingSteeringBytes) {
2201
+ return Promise.reject(new Error("steering queue is full"));
1823
2202
  }
1824
- return accepted;
2203
+ pendingSteeringCount++;
2204
+ pendingSteeringBytes += bytes;
2205
+ return promptStarted.then((started) => {
2206
+ if (coreExitError && !cancelled) throw coreExitError;
2207
+ return started === true ? apply() : Promise.reject(started instanceof Error ? started : new Error("no prompt is running"));
2208
+ }).finally(() => { pendingSteeringCount--; pendingSteeringBytes -= bytes; });
1825
2209
  },
1826
2210
  cancel() {
1827
2211
  if (finished || cancelled) return;
1828
2212
  cancelled = true;
2213
+ cancelImagePrep?.();
2214
+ cancelImagePrep = null;
2215
+ rejectImagePrep = null;
2216
+ resolvePromptStart?.(false);
2217
+ resolvePromptStart = null;
1829
2218
  runtime.closeSteering?.();
1830
2219
  resumeOutput?.();
1831
2220
  resumeOutput = null;
1832
- send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
2221
+ if (!closing) send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
1833
2222
  for (const controller of toolControllers) controller.abort();
1834
2223
  runtime.abortHostEffects();
1835
2224
  },
@@ -1854,6 +2243,8 @@ export async function createFxAgent(options = {}) {
1854
2243
  },
1855
2244
  };
1856
2245
  if (signal?.aborted) {
2246
+ resolvePromptStart?.(false);
2247
+ resolvePromptStart = null;
1857
2248
  finished = true;
1858
2249
  turn.result = Promise.resolve({ stopReason: "cancelled" });
1859
2250
  return turn;
@@ -1862,10 +2253,37 @@ export async function createFxAgent(options = {}) {
1862
2253
  runtime.openSteering?.();
1863
2254
  const abort = () => turn.cancel();
1864
2255
  signal?.addEventListener("abort", abort, { once: true });
1865
- turn.result = request("session/prompt", { sessionId, prompt })
1866
- .then((response) => ({ stopReason: cancelled ? "cancelled" : response.stopReason, usage: response.usage }))
2256
+ const imagePrepCancelled = asyncImages ? new Promise((resolve, reject) => {
2257
+ cancelImagePrep = () => resolve(null);
2258
+ rejectImagePrep = reject;
2259
+ }) : 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;
2268
+ if (coreExitError && !cancelled) throw coreExitError;
2269
+ if (prepared === null || cancelled || closing) {
2270
+ resolvePromptStart?.(false);
2271
+ resolvePromptStart = null;
2272
+ return { stopReason: "cancelled" };
2273
+ }
2274
+ return sendPrompt(prepared);
2275
+ });
2276
+ } else {
2277
+ try { response = sendPrompt(prompt); } catch (error) { response = Promise.reject(error); }
2278
+ }
2279
+ turn.result = response
2280
+ .then((value) => ({ stopReason: cancelled ? "cancelled" : value.stopReason, usage: value.usage }))
1867
2281
  .catch((error) => {
1868
- if (error.message === "Cancelled") return { stopReason: "cancelled" };
2282
+ cancelImagePrep = null;
2283
+ rejectImagePrep = null;
2284
+ resolvePromptStart?.(error);
2285
+ resolvePromptStart = null;
2286
+ if (cancelled && error.message === "Cancelled") return { stopReason: "cancelled" };
1869
2287
  terminalError = error;
1870
2288
  throw error;
1871
2289
  })