libfx 0.0.10-dev.1213.g4c99a4b2ea67 → 0.0.10-dev.1217.g707afac508e1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # libfx
2
2
 
3
3
  `libfx` is the small fx agent kernel for JavaScript hosts. One agent is one
4
- in-memory conversation with three operations: `prompt`, `checkpoint`, and
5
- `close`.
4
+ in-memory conversation with `prompt`, `checkpoint`, and `close` operations,
5
+ plus mid-turn steering on each running turn.
6
6
 
7
7
  ```sh
8
8
  npm install libfx
@@ -63,7 +63,12 @@ can resolve model capabilities and context capacity through the supplied
63
63
 
64
64
  `onEvent` receives runtime diagnostics separately from model output. Transport
65
65
  events report request start, response status and elapsed time, safe Gateway
66
- request metadata, and failures. Credentials and raw headers are never included.
66
+ request metadata, failures, and throttled `transport.activity` liveness while a
67
+ response body is streaming. Activity events include the attempt, current chunk
68
+ bytes, and cumulative response bytes. They call the host directly at most once
69
+ per 250 ms rather than entering the normalized turn queue, so an unread turn
70
+ cannot accumulate heartbeat events. Credentials and raw headers are never
71
+ included.
67
72
 
68
73
  libfx makes at most one automatic retry after a retryable transport failure and
69
74
  only before model output or tool effects escape. Cancellation prevents a retry.
@@ -77,6 +82,7 @@ blocks. It returns an async iterable of normalized events:
77
82
  64 KiB of JSON arrive as an `inputPreview` string prefix plus
78
83
  `inputTruncated: true` instead; the tool still receives complete arguments.
79
84
  - `tool_end`
85
+ - `user_message` with `text` when accepted mid-turn steering enters the turn
80
86
 
81
87
  Consume the turn while it runs, then await `turn.result`. Streamed text and
82
88
  tool results are lossless and backpressured: a slow reader pauses production
@@ -121,9 +127,29 @@ leave the process. Prompt images are retained in checkpoints within the
121
127
  existing 4 MiB checkpoint bound, so a restored agent can refer to earlier
122
128
  images on either backend.
123
129
 
124
- Only one prompt may run at a time. `checkpoint()` is idle-only and returns
125
- opaque, bounded, versioned bytes. Restore them only when creating a fresh
126
- agent:
130
+ Only one top-level prompt may run at a time. While it runs,
131
+ `await turn.steer(text)` appends guidance at the next safe model boundary
132
+ without discarding the in-flight response or completed tool work. Steering also
133
+ accepts an array of text blocks; image and resource steering blocks are rejected.
134
+ Each message is limited to 64 KiB, with at most 64 queued messages and 1 MiB of
135
+ queued steering text. Accepted steering appears as a `user_message` event before
136
+ the model's continued output. Calling `steer()` after the turn settles rejects
137
+ with `no prompt is running`.
138
+
139
+ ```js
140
+ const turn = agent.prompt("Build the feature.");
141
+ for await (const event of turn) {
142
+ if (event.type === "tool_end" && event.name === "read_file") {
143
+ await turn.steer("Keep the public API backward compatible.");
144
+ }
145
+ }
146
+ ```
147
+
148
+ Cancelling a steered turn drops any guidance that has not reached a safe
149
+ boundary and releases its queue. Applied guidance is part of the same history
150
+ turn, so an idle `checkpoint()` includes the full steered conversation.
151
+ `checkpoint()` returns opaque, bounded, versioned bytes. Restore them only when
152
+ creating a fresh agent:
127
153
 
128
154
  ```js
129
155
  const restored = await createFxAgent({ apiKey, model, checkpoint });
@@ -178,8 +204,27 @@ const agent = await createFxAgent({
178
204
  });
179
205
  ```
180
206
 
181
- The JavaScript host is the authority for tool effects. The same descriptors,
182
- schemas, cancellation, results, and events are used by N-API and WebAssembly.
207
+ Gateway web search can run at the provider instead of in the JavaScript host.
208
+ Mark its canonical tool name with `providerExecuted: true` and omit `execute`:
209
+
210
+ ```js
211
+ const agent = await createFxAgent({
212
+ apiKey,
213
+ tools: [{ name: "web_search", providerExecuted: true }],
214
+ });
215
+ ```
216
+
217
+ The kernel supplies the canonical schema and Gateway advertisement. Currently
218
+ `web_search` is the supported provider-executed descriptor; unknown or local
219
+ names reject agent creation. Provider-executed tools do not
220
+ call host code or require a separate provider key; their `tool_start` and
221
+ `tool_end` events, results, and checkpoint history use the same turn contract.
222
+ Their built-in permission policy is enforced when the request is projected, as
223
+ there is no local call-time effect to approve.
224
+
225
+ For ordinary tools, the JavaScript host is the authority for effects. The same
226
+ descriptors, schemas, cancellation, results, and events are used by N-API and
227
+ WebAssembly.
183
228
  Cancelling a prompt aborts its tools' signals and stops waiting for their
184
229
  callbacks. Late results and rejections are ignored. Tools remain responsible
185
230
  for stopping their own work when their signal is aborted.
package/fx-core.wasm CHANGED
Binary file
package/fx-sdk.js CHANGED
@@ -16,6 +16,7 @@ const maxUrlBytes = 16 * 1024;
16
16
  const maxModelCatalogBytes = 4 * 1024 * 1024;
17
17
  const maxModelCatalogEntries = 10_000;
18
18
  const streamReadsPerTaskYield = 32;
19
+ const transportActivityIntervalMs = 250;
19
20
  const maxUnreadEventBytes = 1024 * 1024;
20
21
  const maxUnreadEvents = 256;
21
22
  // Prompt image limits mirror the host tool result image contract: the kernel
@@ -28,6 +29,9 @@ const maxPromptImagesBytes = 8 * 1024 * 1024;
28
29
  // envelope allowance covers the method key and request id.
29
30
  const maxPromptFrameBytes = 8 * 1024 * 1024;
30
31
  const promptFrameEnvelopeBytes = 128;
32
+ const maxSteeringMessageBytes = 64 * 1024;
33
+ const maxSteeringMessages = 64;
34
+ const maxSteeringQueueBytes = 1024 * 1024;
31
35
  // tool_start events carry a bounded preview of the tool input; larger inputs
32
36
  // are marked truncated instead of dropped or sent whole.
33
37
  const maxToolStartInputBytes = 64 * 1024;
@@ -444,6 +448,9 @@ function createRuntime(options) {
444
448
  const stdin = new ByteQueue();
445
449
  const streams = new Map();
446
450
  const httpRequests = new Set();
451
+ const steering = [];
452
+ let steeringBytes = 0;
453
+ let steeringOpen = false;
447
454
  const workspaceExecs = new Set();
448
455
  const workspace = prepareWorkspaceAdapter(options.workspace);
449
456
  const args = ["fx", ...(options.args || [])];
@@ -656,6 +663,7 @@ function createRuntime(options) {
656
663
  state.readResult = null;
657
664
  if (done) return 0;
658
665
  if (!value?.length) return null;
666
+ options.onTransportChunk?.(value.length);
659
667
  return copy(value);
660
668
  };
661
669
  const immediate = consume();
@@ -709,6 +717,36 @@ function createRuntime(options) {
709
717
  });
710
718
  }
711
719
 
720
+ function clearSteering() {
721
+ steering.length = 0;
722
+ steeringBytes = 0;
723
+ }
724
+
725
+ function queueSteering(text) {
726
+ if (!steeringOpen) throw new Error("no prompt is running");
727
+ const value = encoder.encode(text);
728
+ if (value.length === 0) throw new TypeError("steering text cannot be empty");
729
+ if (value.length > maxSteeringMessageBytes) {
730
+ throw new RangeError(`steering text exceeds the ${maxSteeringMessageBytes} byte libfx limit`);
731
+ }
732
+ if (steering.length >= maxSteeringMessages || value.length > maxSteeringQueueBytes - steeringBytes) {
733
+ throw new Error("steering queue is full");
734
+ }
735
+ steering.push(value);
736
+ steeringBytes += value.length;
737
+ }
738
+
739
+ function steeringTake(outputPtr, outputCap) {
740
+ const value = steering[0];
741
+ if (!value) return 0;
742
+ const output = checkedBytes(outputPtr, outputCap);
743
+ if (!output || value.length > output.length) return -1;
744
+ output.subarray(0, value.length).set(value);
745
+ steering.shift();
746
+ steeringBytes -= value.length;
747
+ return value.length;
748
+ }
749
+
712
750
  let pendingHostToolResult = null;
713
751
  function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr) {
714
752
  pendingHostToolResult = null;
@@ -1078,6 +1116,8 @@ function createRuntime(options) {
1078
1116
  return chunk.length;
1079
1117
  },
1080
1118
  fx_host_tool_result_release() { pendingHostToolResult = null; },
1119
+ fx_steering_take: steeringTake,
1120
+ fx_steering_close() { steeringOpen = false; clearSteering(); },
1081
1121
  fx_open_url: new WebAssembly.Suspending(openUrl),
1082
1122
  fx_oauth_session_load: new WebAssembly.Suspending(oauthSessionLoad),
1083
1123
  fx_oauth_session_commit: new WebAssembly.Suspending(oauthSessionCommit),
@@ -1105,7 +1145,10 @@ function createRuntime(options) {
1105
1145
  setInstance(value) { instance = value; },
1106
1146
  write(data) { stdin.push(typeof data === "string" ? encoder.encode(data) : data); },
1107
1147
  wake() { stdin.wake(); },
1108
- closeStdin() { stdin.close(); },
1148
+ closeStdin() { steeringOpen = false; clearSteering(); stdin.close(); },
1149
+ openSteering() { clearSteering(); steeringOpen = true; },
1150
+ steer: queueSteering,
1151
+ closeSteering() { steeringOpen = false; clearSteering(); },
1109
1152
  abortHostEffects,
1110
1153
  abort(error) {
1111
1154
  aborted = true;
@@ -1274,19 +1317,42 @@ function normalizePromptInput(input) {
1274
1317
  });
1275
1318
  }
1276
1319
 
1320
+ function normalizeSteeringInput(input) {
1321
+ const blocks = normalizePromptInput(input);
1322
+ if (blocks.some((block) => block.type !== "text")) {
1323
+ throw new TypeError("steering accepts only text blocks");
1324
+ }
1325
+ const text = blocks.map((block) => block.text).join("\n");
1326
+ if (text.length === 0) throw new TypeError("steering text cannot be empty");
1327
+ if (encoder.encode(text).length > maxSteeringMessageBytes) {
1328
+ throw new RangeError(`steering text exceeds the ${maxSteeringMessageBytes} byte libfx limit`);
1329
+ }
1330
+ return text;
1331
+ }
1332
+
1277
1333
  function normalizeHostTools(value) {
1278
1334
  if (value === undefined) return { descriptors: [], executors: new Map() };
1279
1335
  if (!Array.isArray(value)) throw new TypeError("tools must be an array");
1280
1336
  if (value.length > 64) throw new RangeError("tools cannot contain more than 64 entries");
1281
1337
  const descriptors = [];
1282
1338
  const executors = new Map();
1339
+ const names = new Set();
1283
1340
  for (const [index, tool] of value.entries()) {
1284
1341
  if (!tool || typeof tool !== "object") throw new TypeError(`tool ${index} must be an object`);
1285
- const { name, description, inputSchema, execute } = tool;
1342
+ const { name, description, inputSchema, execute, providerExecuted } = tool;
1286
1343
  if (typeof name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
1287
1344
  throw new TypeError(`tool ${index} has an invalid name`);
1288
1345
  }
1289
- if (executors.has(name)) throw new TypeError(`duplicate tool name: ${name}`);
1346
+ if (names.has(name)) throw new TypeError(`duplicate tool name: ${name}`);
1347
+ names.add(name);
1348
+ if (providerExecuted !== undefined && typeof providerExecuted !== "boolean") {
1349
+ throw new TypeError(`tool ${name} providerExecuted must be a boolean`);
1350
+ }
1351
+ if (providerExecuted === true) {
1352
+ if (execute !== undefined) throw new TypeError(`provider-executed tool ${name} must not define execute()`);
1353
+ descriptors.push({ name, providerExecuted: true });
1354
+ continue;
1355
+ }
1290
1356
  if (typeof description !== "string") throw new TypeError(`tool ${name} requires a description`);
1291
1357
  if (typeof execute !== "function") throw new TypeError(`tool ${name} requires execute()`);
1292
1358
  if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
@@ -1392,6 +1458,10 @@ export async function createFxAgent(options = {}) {
1392
1458
  for (let attemptIndex = 0; attemptIndex < 2; attemptIndex++) {
1393
1459
  const startedAt = performance.now();
1394
1460
  const attempt = activeTurn ? ++activeTurn.transportAttempts : attemptIndex + 1;
1461
+ if (activeTurn) {
1462
+ activeTurn.transportBytes = 0;
1463
+ activeTurn.lastTransportActivityAt = null;
1464
+ }
1395
1465
  emit("transport.start", { attempt, method, endpoint, model: options.model });
1396
1466
  try {
1397
1467
  if (activeTurn?.cancelled) {
@@ -1481,6 +1551,19 @@ export async function createFxAgent(options = {}) {
1481
1551
  args: ["acp"],
1482
1552
  env: agentEnvironment(options),
1483
1553
  hostToolExecutor: executeHostTool,
1554
+ onTransportChunk(byteLength) {
1555
+ const turn = activeTurn;
1556
+ if (!isCurrentTurn(turn) || !Number.isSafeInteger(byteLength) || byteLength <= 0) return;
1557
+ turn.transportBytes += byteLength;
1558
+ const now = performance.now();
1559
+ if (turn.lastTransportActivityAt !== null && now - turn.lastTransportActivityAt < transportActivityIntervalMs) return;
1560
+ turn.lastTransportActivityAt = now;
1561
+ emit("transport.activity", {
1562
+ attempt: turn.transportAttempts,
1563
+ chunkBytes: byteLength,
1564
+ totalBytes: turn.transportBytes,
1565
+ });
1566
+ },
1484
1567
  };
1485
1568
  const runtime = options.runtimeFactory
1486
1569
  ? await options.runtimeFactory(runtimeOptions)
@@ -1606,6 +1689,9 @@ export async function createFxAgent(options = {}) {
1606
1689
  const delta = update.content?.text;
1607
1690
  return delta ? { type: "reasoning_delta", delta } : null;
1608
1691
  }
1692
+ if (update.sessionUpdate === "user_message_chunk" && update.content?.type === "text") {
1693
+ return { type: "user_message", text: update.content.text };
1694
+ }
1609
1695
  if (update.sessionUpdate === "tool_call") {
1610
1696
  toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool");
1611
1697
  if (started.has(update.toolCallId)) return null;
@@ -1637,6 +1723,7 @@ export async function createFxAgent(options = {}) {
1637
1723
  void result.catch(() => {});
1638
1724
  return {
1639
1725
  cancel() { rawTurn.cancel(); },
1726
+ steer(input) { return rawTurn.steer(normalizeSteeringInput(input)); },
1640
1727
  [Symbol.asyncIterator]() {
1641
1728
  const iterator = (async function* () {
1642
1729
  for await (const update of rawTurn) {
@@ -1712,10 +1799,34 @@ export async function createFxAgent(options = {}) {
1712
1799
  },
1713
1800
  toolControllers,
1714
1801
  transportAttempts: 0,
1802
+ transportBytes: 0,
1803
+ lastTransportActivityAt: null,
1715
1804
  get cancelled() { return cancelled; },
1805
+ steer(text) {
1806
+ if (finished || cancelled || activeTurn !== turn) {
1807
+ return Promise.reject(new Error("no prompt is running"));
1808
+ }
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 });
1820
+ }
1821
+ } catch (error) {
1822
+ return Promise.reject(error);
1823
+ }
1824
+ return accepted;
1825
+ },
1716
1826
  cancel() {
1717
1827
  if (finished || cancelled) return;
1718
1828
  cancelled = true;
1829
+ runtime.closeSteering?.();
1719
1830
  resumeOutput?.();
1720
1831
  resumeOutput = null;
1721
1832
  send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
@@ -1748,6 +1859,7 @@ export async function createFxAgent(options = {}) {
1748
1859
  return turn;
1749
1860
  }
1750
1861
  activeTurn = turn;
1862
+ runtime.openSteering?.();
1751
1863
  const abort = () => turn.cancel();
1752
1864
  signal?.addEventListener("abort", abort, { once: true });
1753
1865
  turn.result = request("session/prompt", { sessionId, prompt })
@@ -1763,6 +1875,7 @@ export async function createFxAgent(options = {}) {
1763
1875
  resumeOutput = null;
1764
1876
  signal?.removeEventListener("abort", abort);
1765
1877
  if (activeTurn === turn) activeTurn = null;
1878
+ runtime.closeSteering?.();
1766
1879
  toolControllers.clear();
1767
1880
  if (discardedBytes) emit("output.discarded", { reason: "cancelled", bytes: discardedBytes });
1768
1881
  for (const waiter of waiters.splice(0)) {
package/fx-term.wasm CHANGED
Binary file
Binary file
Binary file
Binary file
Binary file
package/node.cjs CHANGED
@@ -193,6 +193,7 @@ var maxUrlBytes = 16 * 1024;
193
193
  var maxModelCatalogBytes = 4 * 1024 * 1024;
194
194
  var maxModelCatalogEntries = 1e4;
195
195
  var streamReadsPerTaskYield = 32;
196
+ var transportActivityIntervalMs = 250;
196
197
  var maxUnreadEventBytes = 1024 * 1024;
197
198
  var maxUnreadEvents = 256;
198
199
  var maxPromptImages = 8;
@@ -200,6 +201,9 @@ var maxPromptImageDataBytes = 5 * 1024 * 1024;
200
201
  var maxPromptImagesBytes = 8 * 1024 * 1024;
201
202
  var maxPromptFrameBytes = 8 * 1024 * 1024;
202
203
  var promptFrameEnvelopeBytes = 128;
204
+ var maxSteeringMessageBytes = 64 * 1024;
205
+ var maxSteeringMessages = 64;
206
+ var maxSteeringQueueBytes = 1024 * 1024;
203
207
  var maxToolStartInputBytes = 64 * 1024;
204
208
  function boundedString(value, name, maxBytes, required) {
205
209
  if (value === undefined && !required)
@@ -637,6 +641,9 @@ function createRuntime(options) {
637
641
  const stdin = new ByteQueue;
638
642
  const streams = new Map;
639
643
  const httpRequests = new Set;
644
+ const steering = [];
645
+ let steeringBytes = 0;
646
+ let steeringOpen = false;
640
647
  const workspaceExecs = new Set;
641
648
  const workspace = prepareWorkspaceAdapter(options.workspace);
642
649
  const args = ["fx", ...options.args || []];
@@ -872,6 +879,7 @@ function createRuntime(options) {
872
879
  return 0;
873
880
  if (!value?.length)
874
881
  return null;
882
+ options.onTransportChunk?.(value.length);
875
883
  return copy(value);
876
884
  };
877
885
  const immediate = consume();
@@ -928,6 +936,37 @@ function createRuntime(options) {
928
936
  httpRequests.delete(controller);
929
937
  });
930
938
  }
939
+ function clearSteering() {
940
+ steering.length = 0;
941
+ steeringBytes = 0;
942
+ }
943
+ function queueSteering(text) {
944
+ if (!steeringOpen)
945
+ throw new Error("no prompt is running");
946
+ const value = encoder.encode(text);
947
+ if (value.length === 0)
948
+ throw new TypeError("steering text cannot be empty");
949
+ if (value.length > maxSteeringMessageBytes) {
950
+ throw new RangeError(`steering text exceeds the ${maxSteeringMessageBytes} byte libfx limit`);
951
+ }
952
+ if (steering.length >= maxSteeringMessages || value.length > maxSteeringQueueBytes - steeringBytes) {
953
+ throw new Error("steering queue is full");
954
+ }
955
+ steering.push(value);
956
+ steeringBytes += value.length;
957
+ }
958
+ function steeringTake(outputPtr, outputCap) {
959
+ const value = steering[0];
960
+ if (!value)
961
+ return 0;
962
+ const output = checkedBytes(outputPtr, outputCap);
963
+ if (!output || value.length > output.length)
964
+ return -1;
965
+ output.subarray(0, value.length).set(value);
966
+ steering.shift();
967
+ steeringBytes -= value.length;
968
+ return value.length;
969
+ }
931
970
  let pendingHostToolResult = null;
932
971
  function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr) {
933
972
  pendingHostToolResult = null;
@@ -1386,6 +1425,11 @@ function createRuntime(options) {
1386
1425
  fx_host_tool_result_release() {
1387
1426
  pendingHostToolResult = null;
1388
1427
  },
1428
+ fx_steering_take: steeringTake,
1429
+ fx_steering_close() {
1430
+ steeringOpen = false;
1431
+ clearSteering();
1432
+ },
1389
1433
  fx_open_url: new WebAssembly.Suspending(openUrl),
1390
1434
  fx_oauth_session_load: new WebAssembly.Suspending(oauthSessionLoad),
1391
1435
  fx_oauth_session_commit: new WebAssembly.Suspending(oauthSessionCommit),
@@ -1420,8 +1464,19 @@ function createRuntime(options) {
1420
1464
  stdin.wake();
1421
1465
  },
1422
1466
  closeStdin() {
1467
+ steeringOpen = false;
1468
+ clearSteering();
1423
1469
  stdin.close();
1424
1470
  },
1471
+ openSteering() {
1472
+ clearSteering();
1473
+ steeringOpen = true;
1474
+ },
1475
+ steer: queueSteering,
1476
+ closeSteering() {
1477
+ steeringOpen = false;
1478
+ clearSteering();
1479
+ },
1425
1480
  abortHostEffects,
1426
1481
  abort(error) {
1427
1482
  aborted = true;
@@ -1625,6 +1680,20 @@ function normalizePromptInput(input) {
1625
1680
  throw new TypeError(`unsupported prompt block type: ${String(block.type)}`);
1626
1681
  });
1627
1682
  }
1683
+ function normalizeSteeringInput(input) {
1684
+ const blocks = normalizePromptInput(input);
1685
+ if (blocks.some((block) => block.type !== "text")) {
1686
+ throw new TypeError("steering accepts only text blocks");
1687
+ }
1688
+ const text = blocks.map((block) => block.text).join(`
1689
+ `);
1690
+ if (text.length === 0)
1691
+ throw new TypeError("steering text cannot be empty");
1692
+ if (encoder.encode(text).length > maxSteeringMessageBytes) {
1693
+ throw new RangeError(`steering text exceeds the ${maxSteeringMessageBytes} byte libfx limit`);
1694
+ }
1695
+ return text;
1696
+ }
1628
1697
  function normalizeHostTools(value) {
1629
1698
  if (value === undefined)
1630
1699
  return { descriptors: [], executors: new Map };
@@ -1634,15 +1703,26 @@ function normalizeHostTools(value) {
1634
1703
  throw new RangeError("tools cannot contain more than 64 entries");
1635
1704
  const descriptors = [];
1636
1705
  const executors = new Map;
1706
+ const names = new Set;
1637
1707
  for (const [index, tool] of value.entries()) {
1638
1708
  if (!tool || typeof tool !== "object")
1639
1709
  throw new TypeError(`tool ${index} must be an object`);
1640
- const { name, description, inputSchema, execute } = tool;
1710
+ const { name, description, inputSchema, execute, providerExecuted } = tool;
1641
1711
  if (typeof name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
1642
1712
  throw new TypeError(`tool ${index} has an invalid name`);
1643
1713
  }
1644
- if (executors.has(name))
1714
+ if (names.has(name))
1645
1715
  throw new TypeError(`duplicate tool name: ${name}`);
1716
+ names.add(name);
1717
+ if (providerExecuted !== undefined && typeof providerExecuted !== "boolean") {
1718
+ throw new TypeError(`tool ${name} providerExecuted must be a boolean`);
1719
+ }
1720
+ if (providerExecuted === true) {
1721
+ if (execute !== undefined)
1722
+ throw new TypeError(`provider-executed tool ${name} must not define execute()`);
1723
+ descriptors.push({ name, providerExecuted: true });
1724
+ continue;
1725
+ }
1646
1726
  if (typeof description !== "string")
1647
1727
  throw new TypeError(`tool ${name} requires a description`);
1648
1728
  if (typeof execute !== "function")
@@ -1760,6 +1840,10 @@ async function createFxAgent(options = {}) {
1760
1840
  for (let attemptIndex = 0;attemptIndex < 2; attemptIndex++) {
1761
1841
  const startedAt = performance.now();
1762
1842
  const attempt = activeTurn ? ++activeTurn.transportAttempts : attemptIndex + 1;
1843
+ if (activeTurn) {
1844
+ activeTurn.transportBytes = 0;
1845
+ activeTurn.lastTransportActivityAt = null;
1846
+ }
1763
1847
  emit("transport.start", { attempt, method, endpoint, model: options.model });
1764
1848
  try {
1765
1849
  if (activeTurn?.cancelled) {
@@ -1855,7 +1939,22 @@ async function createFxAgent(options = {}) {
1855
1939
  fetch: transportFetch,
1856
1940
  args: ["acp"],
1857
1941
  env: agentEnvironment(options),
1858
- hostToolExecutor: executeHostTool
1942
+ hostToolExecutor: executeHostTool,
1943
+ onTransportChunk(byteLength) {
1944
+ const turn = activeTurn;
1945
+ if (!isCurrentTurn(turn) || !Number.isSafeInteger(byteLength) || byteLength <= 0)
1946
+ return;
1947
+ turn.transportBytes += byteLength;
1948
+ const now = performance.now();
1949
+ if (turn.lastTransportActivityAt !== null && now - turn.lastTransportActivityAt < transportActivityIntervalMs)
1950
+ return;
1951
+ turn.lastTransportActivityAt = now;
1952
+ emit("transport.activity", {
1953
+ attempt: turn.transportAttempts,
1954
+ chunkBytes: byteLength,
1955
+ totalBytes: turn.transportBytes
1956
+ });
1957
+ }
1859
1958
  };
1860
1959
  const runtime = options.runtimeFactory ? await options.runtimeFactory(runtimeOptions) : await instantiate(runtimeOptions);
1861
1960
  emit("runtime.ready");
@@ -2008,6 +2107,9 @@ async function createFxAgent(options = {}) {
2008
2107
  const delta = update.content?.text;
2009
2108
  return delta ? { type: "reasoning_delta", delta } : null;
2010
2109
  }
2110
+ if (update.sessionUpdate === "user_message_chunk" && update.content?.type === "text") {
2111
+ return { type: "user_message", text: update.content.text };
2112
+ }
2011
2113
  if (update.sessionUpdate === "tool_call") {
2012
2114
  toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool");
2013
2115
  if (started.has(update.toolCallId))
@@ -2041,6 +2143,9 @@ async function createFxAgent(options = {}) {
2041
2143
  cancel() {
2042
2144
  rawTurn.cancel();
2043
2145
  },
2146
+ steer(input) {
2147
+ return rawTurn.steer(normalizeSteeringInput(input));
2148
+ },
2044
2149
  [Symbol.asyncIterator]() {
2045
2150
  const iterator = async function* () {
2046
2151
  for await (const update of rawTurn) {
@@ -2141,13 +2246,37 @@ async function createFxAgent(options = {}) {
2141
2246
  },
2142
2247
  toolControllers,
2143
2248
  transportAttempts: 0,
2249
+ transportBytes: 0,
2250
+ lastTransportActivityAt: null,
2144
2251
  get cancelled() {
2145
2252
  return cancelled;
2146
2253
  },
2254
+ steer(text) {
2255
+ if (finished || cancelled || activeTurn !== turn) {
2256
+ return Promise.reject(new Error("no prompt is running"));
2257
+ }
2258
+ let accepted;
2259
+ try {
2260
+ if (typeof runtime.steer === "function") {
2261
+ runtime.steer(text);
2262
+ turn.push({
2263
+ sessionUpdate: "user_message_chunk",
2264
+ content: { type: "text", text }
2265
+ });
2266
+ accepted = Promise.resolve();
2267
+ } else {
2268
+ accepted = request("libfx/steer", { sessionId, text });
2269
+ }
2270
+ } catch (error) {
2271
+ return Promise.reject(error);
2272
+ }
2273
+ return accepted;
2274
+ },
2147
2275
  cancel() {
2148
2276
  if (finished || cancelled)
2149
2277
  return;
2150
2278
  cancelled = true;
2279
+ runtime.closeSteering?.();
2151
2280
  resumeOutput?.();
2152
2281
  resumeOutput = null;
2153
2282
  send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
@@ -2187,6 +2316,7 @@ async function createFxAgent(options = {}) {
2187
2316
  return turn;
2188
2317
  }
2189
2318
  activeTurn = turn;
2319
+ runtime.openSteering?.();
2190
2320
  const abort = () => turn.cancel();
2191
2321
  signal?.addEventListener("abort", abort, { once: true });
2192
2322
  turn.result = request("session/prompt", { sessionId, prompt }).then((response) => ({ stopReason: cancelled ? "cancelled" : response.stopReason, usage: response.usage })).catch((error) => {
@@ -2201,6 +2331,7 @@ async function createFxAgent(options = {}) {
2201
2331
  signal?.removeEventListener("abort", abort);
2202
2332
  if (activeTurn === turn)
2203
2333
  activeTurn = null;
2334
+ runtime.closeSteering?.();
2204
2335
  toolControllers.clear();
2205
2336
  if (discardedBytes)
2206
2337
  emit("output.discarded", { reason: "cancelled", bytes: discardedBytes });
@@ -2584,6 +2715,7 @@ function createNativeCoreRuntime(addon, options) {
2584
2715
  if (response.body) {
2585
2716
  for await (const chunk of response.body) {
2586
2717
  const buffer = Buffer.from(chunk);
2718
+ options.onTransportChunk?.(buffer.length);
2587
2719
  let offset = 0;
2588
2720
  while (offset < buffer.length) {
2589
2721
  const end = Math.min(offset + 65536, buffer.length);
package/node.js CHANGED
@@ -407,6 +407,7 @@ function createNativeCoreRuntime(addon, options) {
407
407
  if (response.body) {
408
408
  for await (const chunk of response.body) {
409
409
  const buffer = Buffer.from(chunk);
410
+ options.onTransportChunk?.(buffer.length);
410
411
  let offset = 0;
411
412
  while (offset < buffer.length) {
412
413
  const end = Math.min(offset + 64 * 1024, buffer.length);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libfx",
3
- "version": "0.0.10-dev.1213.g4c99a4b2ea67",
3
+ "version": "0.0.10-dev.1217.g707afac508e1",
4
4
  "description": "Embed fx agents and terminals in JavaScript hosts",
5
5
  "type": "module",
6
6
  "repository": {