libfx 0.0.10 → 0.0.11

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
@@ -41,28 +41,52 @@ await agent.close();
41
41
  Agent configuration uses named options; `env` is reserved for
42
42
  `createFxTerminal()`.
43
43
 
44
+ `effort` sets the reasoning effort for models that advertise effort levels. It
45
+ uses the same vocabulary as the fx CLI's `--effort` flag: `"default"` leaves
46
+ the choice to the model, and named levels such as `"low"`, `"medium"`,
47
+ `"high"`, or `"xhigh"` request a specific level. A named level is validated
48
+ against the selected model's advertised levels at creation; an unsupported
49
+ level rejects with an error (code `LIBFX_UNSUPPORTED_EFFORT`) naming the
50
+ supported set. When `effort` is omitted or `"default"`, the model default
51
+ applies.
52
+
53
+ `fast` enables the fast lane for models that advertise one, matching the fx
54
+ CLI's `--fast` flag. Enabling it is validated against the selected model at
55
+ creation; a model without a fast path rejects with an error (code
56
+ `LIBFX_UNSUPPORTED_FAST`). When `fast` is omitted or `false`, the model
57
+ default applies.
58
+
44
59
  The host selects the model. Agent creation does not fetch the Gateway model
45
- catalog. Prompting can resolve model capabilities and context capacity through
46
- the supplied `fetch`; fx caches that metadata for the agent.
60
+ catalog unless `effort` requests a named level or `fast` is enabled. Prompting
61
+ can resolve model capabilities and context capacity through the supplied
62
+ `fetch`; fx caches that metadata for the agent.
47
63
 
48
64
  `onEvent` receives runtime diagnostics separately from model output. Transport
49
65
  events report request start, response status and elapsed time, safe Gateway
50
- 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.
51
72
 
52
73
  libfx makes at most one automatic retry after a retryable transport failure and
53
74
  only before model output or tool effects escape. Cancellation prevents a retry.
54
75
 
55
- `prompt(input, { signal? })` accepts a string or text/resource blocks. It
56
- returns an async iterable of normalized events:
76
+ `prompt(input, { signal? })` accepts a string or text, image, and resource
77
+ blocks. It returns an async iterable of normalized events:
57
78
 
58
79
  - `text_delta`
59
80
  - `reasoning_delta` when supplied by the provider
60
- - `tool_start`
81
+ - `tool_start` with `id`, `name`, and the tool's `input` object. Inputs over
82
+ 64 KiB of JSON arrive as an `inputPreview` string prefix plus
83
+ `inputTruncated: true` instead; the tool still receives complete arguments.
61
84
  - `tool_end`
85
+ - `user_message` with `text` when accepted mid-turn steering enters the turn
62
86
 
63
- Consume the turn while it runs, then await `turn.result`. Output is lossless and
64
- backpressured: a slow reader pauses production instead of growing an unlimited
65
- event queue. Awaiting only `turn.result` can wait for an unread stream to drain.
87
+ Consume the turn while it runs, then await `turn.result`. Streamed text and
88
+ tool results are lossless and backpressured: a slow reader pauses production
89
+ instead of growing an unlimited event queue. Awaiting only `turn.result` can wait for an unread stream to drain.
66
90
  If you only need the result, explicitly discard events:
67
91
 
68
92
  ```js
@@ -82,9 +106,50 @@ that threshold when the queue is empty; an individual encoded ACP message is
82
106
  limited to 64 MiB on both backends. These are transport bounds, not a total
83
107
  answer-size limit or a bound on retained conversation history.
84
108
 
85
- Only one prompt may run at a time. `checkpoint()` is idle-only and returns
86
- opaque, bounded, versioned bytes. Restore them only when creating a fresh
87
- agent:
109
+ Image blocks use ACP's content shape and carry canonical base64 (no line
110
+ wrapping) of a PNG, JPEG, GIF, or WebP payload:
111
+
112
+ ```js
113
+ const turn = agent.prompt([
114
+ { type: "text", text: "What does this screenshot show?" },
115
+ { type: "image", data: base64Png, mimeType: "image/png" },
116
+ ]);
117
+ ```
118
+
119
+ A prompt may contain up to 8 images, each with up to 5 MiB of base64 data,
120
+ with at most 8 MiB of image data per prompt; the SDK rejects larger input with
121
+ typed `RangeError`s before any request. The kernel then validates the decoded
122
+ bytes against the declared `mimeType` and fails the turn with
123
+ `Invalid image prompt block` on a mismatch. Images are routed only to models
124
+ that advertise image input; for any other model the turn fails with
125
+ `Image prompts are unavailable for the selected model` and no image bytes
126
+ leave the process. Prompt images are retained in checkpoints within the
127
+ existing 4 MiB checkpoint bound, so a restored agent can refer to earlier
128
+ images on either backend.
129
+
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:
88
153
 
89
154
  ```js
90
155
  const restored = await createFxAgent({ apiKey, model, checkpoint });
@@ -95,7 +160,10 @@ or a history change. The next prompt can run normally.
95
160
 
96
161
  The checkpoint contains conversation history and usage only. The host owns
97
162
  durable storage and must resupply models, credentials, instructions, tools,
98
- MCP clients, and skill records.
163
+ MCP clients, and skill records. Reasoning effort and fast mode are
164
+ agent-creation options and are not stored in a checkpoint: recreate the agent
165
+ with new `effort` or `fast` values to change them, the same path as switching
166
+ models.
99
167
 
100
168
  ## Models
101
169
 
@@ -136,11 +204,35 @@ const agent = await createFxAgent({
136
204
  });
137
205
  ```
138
206
 
139
- The JavaScript host is the authority for tool effects. The same descriptors,
140
- 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.
141
228
  Cancelling a prompt aborts its tools' signals and stops waiting for their
142
229
  callbacks. Late results and rejections are ignored. Tools remain responsible
143
230
  for stopping their own work when their signal is aborted.
231
+
232
+ A host tool may use any name, including the kernel's builtin names such as
233
+ `write_file` and `edit_file`: the kernel routes by the registered executor, so
234
+ a host-defined `write_file` calls the host's `execute()` rather than the
235
+ builtin file mutation.
144
236
  Instructions are limited to 64 KiB of UTF-8 text, including text assembled by
145
237
  the MCP and skills adapters. They are the complete host-owned system context:
146
238
  libfx adds no hidden base prompt, and omitting `instructions` sends no system
package/fx-core.wasm CHANGED
Binary file
package/fx-sdk.js CHANGED
@@ -10,12 +10,31 @@ const workspaceOutputLimit = 64 * 1024;
10
10
  const maxInstructionsBytes = 64 * 1024;
11
11
  const maxApiKeyBytes = 64 * 1024;
12
12
  const maxModelBytes = 1024;
13
+ // Matches the kernel's ReasoningEffort.max_name_bytes.
14
+ const maxEffortBytes = 64;
13
15
  const maxUrlBytes = 16 * 1024;
14
16
  const maxModelCatalogBytes = 4 * 1024 * 1024;
15
17
  const maxModelCatalogEntries = 10_000;
16
18
  const streamReadsPerTaskYield = 32;
19
+ const transportActivityIntervalMs = 250;
17
20
  const maxUnreadEventBytes = 1024 * 1024;
18
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.
24
+ const maxPromptImages = 8;
25
+ const maxPromptImageDataBytes = 5 * 1024 * 1024;
26
+ const maxPromptImagesBytes = 8 * 1024 * 1024;
27
+ // The core's ACP reader drops frames over 8 MiB without a request id to answer
28
+ // (jsonrpc frame_resource_byte_limit), so the SDK must never emit one. The
29
+ // envelope allowance covers the method key and request id.
30
+ const maxPromptFrameBytes = 8 * 1024 * 1024;
31
+ const promptFrameEnvelopeBytes = 128;
32
+ const maxSteeringMessageBytes = 64 * 1024;
33
+ const maxSteeringMessages = 64;
34
+ const maxSteeringQueueBytes = 1024 * 1024;
35
+ // tool_start events carry a bounded preview of the tool input; larger inputs
36
+ // are marked truncated instead of dropped or sent whole.
37
+ const maxToolStartInputBytes = 64 * 1024;
19
38
 
20
39
  function boundedString(value, name, maxBytes, required) {
21
40
  if (value === undefined && !required) return undefined;
@@ -44,6 +63,27 @@ function validateGatewayChatUrl(value) {
44
63
  }
45
64
  }
46
65
 
66
+ // Mirrors the kernel's ReasoningEffort.parse: "auto"/"adaptive"/"default" pick
67
+ // the model default; anything else must be a bounded effort name.
68
+ function normalizeEffort(value) {
69
+ if (value === undefined) return undefined;
70
+ boundedString(value, "effort", maxEffortBytes, false);
71
+ if (!/^[A-Za-z0-9._-]+$/.test(value)) {
72
+ throw new TypeError('effort must use only letters, digits, ".", "-", or "_"');
73
+ }
74
+ return value;
75
+ }
76
+
77
+ // Mirrors the CLI's --fast/--no-fast toggle: a strict boolean, with undefined
78
+ // leaving the model default in place.
79
+ function normalizeFast(value) {
80
+ if (value === undefined) return undefined;
81
+ if (typeof value !== "boolean") {
82
+ throw new TypeError("fast must be a boolean");
83
+ }
84
+ return value;
85
+ }
86
+
47
87
  function normalizeAgentOptions(value) {
48
88
  if (!value || typeof value !== "object" || Array.isArray(value)) {
49
89
  throw new TypeError("createFxAgent() options must be an object");
@@ -54,6 +94,8 @@ function normalizeAgentOptions(value) {
54
94
  }
55
95
  options.apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, true);
56
96
  options.model = boundedString(options.model, "model", maxModelBytes, false);
97
+ options.effort = normalizeEffort(options.effort);
98
+ options.fast = normalizeFast(options.fast);
57
99
  validateGatewayChatUrl(options.gatewayChatUrl);
58
100
  return options;
59
101
  }
@@ -62,10 +104,26 @@ function agentEnvironment(options) {
62
104
  return {
63
105
  AI_GATEWAY_API_KEY: options.apiKey,
64
106
  ...(options.model === undefined ? {} : { FX_MODEL: options.model }),
107
+ ...(options.effort === undefined ? {} : { FX_EFFORT: options.effort }),
108
+ ...(options.fast === undefined ? {} : { FX_FAST: options.fast ? "true" : "false" }),
65
109
  ...(options.gatewayChatUrl === undefined ? {} : { FX_GATEWAY_CHAT_URL: options.gatewayChatUrl }),
66
110
  };
67
111
  }
68
112
 
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";
123
+ }
124
+ return error;
125
+ }
126
+
69
127
  async function cancelResponseBody(response) {
70
128
  try {
71
129
  await response.body?.cancel();
@@ -390,6 +448,9 @@ function createRuntime(options) {
390
448
  const stdin = new ByteQueue();
391
449
  const streams = new Map();
392
450
  const httpRequests = new Set();
451
+ const steering = [];
452
+ let steeringBytes = 0;
453
+ let steeringOpen = false;
393
454
  const workspaceExecs = new Set();
394
455
  const workspace = prepareWorkspaceAdapter(options.workspace);
395
456
  const args = ["fx", ...(options.args || [])];
@@ -602,6 +663,7 @@ function createRuntime(options) {
602
663
  state.readResult = null;
603
664
  if (done) return 0;
604
665
  if (!value?.length) return null;
666
+ options.onTransportChunk?.(value.length);
605
667
  return copy(value);
606
668
  };
607
669
  const immediate = consume();
@@ -655,6 +717,36 @@ function createRuntime(options) {
655
717
  });
656
718
  }
657
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
+
658
750
  let pendingHostToolResult = null;
659
751
  function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr) {
660
752
  pendingHostToolResult = null;
@@ -1024,6 +1116,8 @@ function createRuntime(options) {
1024
1116
  return chunk.length;
1025
1117
  },
1026
1118
  fx_host_tool_result_release() { pendingHostToolResult = null; },
1119
+ fx_steering_take: steeringTake,
1120
+ fx_steering_close() { steeringOpen = false; clearSteering(); },
1027
1121
  fx_open_url: new WebAssembly.Suspending(openUrl),
1028
1122
  fx_oauth_session_load: new WebAssembly.Suspending(oauthSessionLoad),
1029
1123
  fx_oauth_session_commit: new WebAssembly.Suspending(oauthSessionCommit),
@@ -1051,7 +1145,10 @@ function createRuntime(options) {
1051
1145
  setInstance(value) { instance = value; },
1052
1146
  write(data) { stdin.push(typeof data === "string" ? encoder.encode(data) : data); },
1053
1147
  wake() { stdin.wake(); },
1054
- closeStdin() { stdin.close(); },
1148
+ closeStdin() { steeringOpen = false; clearSteering(); stdin.close(); },
1149
+ openSteering() { clearSteering(); steeringOpen = true; },
1150
+ steer: queueSteering,
1151
+ closeSteering() { steeringOpen = false; clearSteering(); },
1055
1152
  abortHostEffects,
1056
1153
  abort(error) {
1057
1154
  aborted = true;
@@ -1182,9 +1279,30 @@ export async function createFxTerminal(options) {
1182
1279
  function normalizePromptInput(input) {
1183
1280
  if (typeof input === "string") return [{ type: "text", text: input }];
1184
1281
  if (!Array.isArray(input)) throw new TypeError("prompt input must be a string or an array of prompt blocks");
1282
+ let imageCount = 0;
1283
+ let imageBytes = 0;
1185
1284
  return input.map((block, index) => {
1186
1285
  if (!block || typeof block !== "object") throw new TypeError(`prompt block ${index} must be an object`);
1187
- if (block.type === "image") throw new TypeError("image prompt blocks are unsupported");
1286
+ 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
+ }
1296
+ imageCount += 1;
1297
+ if (imageCount > maxPromptImages) {
1298
+ throw new RangeError(`prompt cannot contain more than ${maxPromptImages} images`);
1299
+ }
1300
+ imageBytes += block.data.length;
1301
+ if (imageBytes > maxPromptImagesBytes) {
1302
+ throw new RangeError(`prompt images exceed the ${maxPromptImagesBytes} byte libfx frame limit`);
1303
+ }
1304
+ return { type: "image", data: block.data, mimeType: block.mimeType };
1305
+ }
1188
1306
  if (block.type === "text") {
1189
1307
  if (typeof block.text !== "string") throw new TypeError(`text prompt block ${index} requires text`);
1190
1308
  return { type: "text", text: block.text };
@@ -1199,19 +1317,42 @@ function normalizePromptInput(input) {
1199
1317
  });
1200
1318
  }
1201
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
+
1202
1333
  function normalizeHostTools(value) {
1203
1334
  if (value === undefined) return { descriptors: [], executors: new Map() };
1204
1335
  if (!Array.isArray(value)) throw new TypeError("tools must be an array");
1205
1336
  if (value.length > 64) throw new RangeError("tools cannot contain more than 64 entries");
1206
1337
  const descriptors = [];
1207
1338
  const executors = new Map();
1339
+ const names = new Set();
1208
1340
  for (const [index, tool] of value.entries()) {
1209
1341
  if (!tool || typeof tool !== "object") throw new TypeError(`tool ${index} must be an object`);
1210
- const { name, description, inputSchema, execute } = tool;
1342
+ const { name, description, inputSchema, execute, providerExecuted } = tool;
1211
1343
  if (typeof name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
1212
1344
  throw new TypeError(`tool ${index} has an invalid name`);
1213
1345
  }
1214
- 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
+ }
1215
1356
  if (typeof description !== "string") throw new TypeError(`tool ${name} requires a description`);
1216
1357
  if (typeof execute !== "function") throw new TypeError(`tool ${name} requires execute()`);
1217
1358
  if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
@@ -1317,6 +1458,10 @@ export async function createFxAgent(options = {}) {
1317
1458
  for (let attemptIndex = 0; attemptIndex < 2; attemptIndex++) {
1318
1459
  const startedAt = performance.now();
1319
1460
  const attempt = activeTurn ? ++activeTurn.transportAttempts : attemptIndex + 1;
1461
+ if (activeTurn) {
1462
+ activeTurn.transportBytes = 0;
1463
+ activeTurn.lastTransportActivityAt = null;
1464
+ }
1320
1465
  emit("transport.start", { attempt, method, endpoint, model: options.model });
1321
1466
  try {
1322
1467
  if (activeTurn?.cancelled) {
@@ -1406,6 +1551,19 @@ export async function createFxAgent(options = {}) {
1406
1551
  args: ["acp"],
1407
1552
  env: agentEnvironment(options),
1408
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
+ },
1409
1567
  };
1410
1568
  const runtime = options.runtimeFactory
1411
1569
  ? await options.runtimeFactory(runtimeOptions)
@@ -1490,7 +1648,7 @@ export async function createFxAgent(options = {}) {
1490
1648
  try { runtime.abortHostEffects(); } catch {}
1491
1649
  try { runtime.closeStdin(); } catch {}
1492
1650
  try { await runtime.exited; } catch {}
1493
- throw error;
1651
+ throw agentBootstrapError(error);
1494
1652
  }
1495
1653
 
1496
1654
  const agent = {
@@ -1531,6 +1689,9 @@ export async function createFxAgent(options = {}) {
1531
1689
  const delta = update.content?.text;
1532
1690
  return delta ? { type: "reasoning_delta", delta } : null;
1533
1691
  }
1692
+ if (update.sessionUpdate === "user_message_chunk" && update.content?.type === "text") {
1693
+ return { type: "user_message", text: update.content.text };
1694
+ }
1534
1695
  if (update.sessionUpdate === "tool_call") {
1535
1696
  toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool");
1536
1697
  if (started.has(update.toolCallId)) return null;
@@ -1539,6 +1700,7 @@ export async function createFxAgent(options = {}) {
1539
1700
  type: "tool_start",
1540
1701
  id: update.toolCallId,
1541
1702
  name: toolNames.get(update.toolCallId),
1703
+ ...toolStartInput(update.rawInput),
1542
1704
  };
1543
1705
  }
1544
1706
  if (update.sessionUpdate === "tool_call_update" &&
@@ -1561,6 +1723,7 @@ export async function createFxAgent(options = {}) {
1561
1723
  void result.catch(() => {});
1562
1724
  return {
1563
1725
  cancel() { rawTurn.cancel(); },
1726
+ steer(input) { return rawTurn.steer(normalizeSteeringInput(input)); },
1564
1727
  [Symbol.asyncIterator]() {
1565
1728
  const iterator = (async function* () {
1566
1729
  for await (const update of rawTurn) {
@@ -1589,8 +1752,22 @@ export async function createFxAgent(options = {}) {
1589
1752
  return result;
1590
1753
  }
1591
1754
 
1755
+ // The input object rides the event when it fits the preview budget; larger
1756
+ // inputs become a bounded JSON prefix plus an explicit marker.
1757
+ function toolStartInput(rawInput) {
1758
+ if (rawInput === undefined || rawInput === null) return {};
1759
+ const serialized = JSON.stringify(rawInput);
1760
+ if (serialized === undefined) return {};
1761
+ const bytes = encoder.encode(serialized);
1762
+ if (bytes.length <= maxToolStartInputBytes) return { input: rawInput };
1763
+ return { inputTruncated: true, inputPreview: decoder.decode(utf8Prefix(bytes, maxToolStartInputBytes)) };
1764
+ }
1765
+
1592
1766
  function startTurn(input, promptOptions) {
1593
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
+ }
1594
1771
  const signal = promptOptions.signal;
1595
1772
  if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal");
1596
1773
  const queue = [];
@@ -1622,10 +1799,34 @@ export async function createFxAgent(options = {}) {
1622
1799
  },
1623
1800
  toolControllers,
1624
1801
  transportAttempts: 0,
1802
+ transportBytes: 0,
1803
+ lastTransportActivityAt: null,
1625
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
+ },
1626
1826
  cancel() {
1627
1827
  if (finished || cancelled) return;
1628
1828
  cancelled = true;
1829
+ runtime.closeSteering?.();
1629
1830
  resumeOutput?.();
1630
1831
  resumeOutput = null;
1631
1832
  send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
@@ -1658,6 +1859,7 @@ export async function createFxAgent(options = {}) {
1658
1859
  return turn;
1659
1860
  }
1660
1861
  activeTurn = turn;
1862
+ runtime.openSteering?.();
1661
1863
  const abort = () => turn.cancel();
1662
1864
  signal?.addEventListener("abort", abort, { once: true });
1663
1865
  turn.result = request("session/prompt", { sessionId, prompt })
@@ -1673,6 +1875,7 @@ export async function createFxAgent(options = {}) {
1673
1875
  resumeOutput = null;
1674
1876
  signal?.removeEventListener("abort", abort);
1675
1877
  if (activeTurn === turn) activeTurn = null;
1878
+ runtime.closeSteering?.();
1676
1879
  toolControllers.clear();
1677
1880
  if (discardedBytes) emit("output.discarded", { reason: "cancelled", bytes: discardedBytes });
1678
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
@@ -188,12 +188,23 @@ var workspaceOutputLimit = 64 * 1024;
188
188
  var maxInstructionsBytes = 64 * 1024;
189
189
  var maxApiKeyBytes = 64 * 1024;
190
190
  var maxModelBytes = 1024;
191
+ var maxEffortBytes = 64;
191
192
  var maxUrlBytes = 16 * 1024;
192
193
  var maxModelCatalogBytes = 4 * 1024 * 1024;
193
194
  var maxModelCatalogEntries = 1e4;
194
195
  var streamReadsPerTaskYield = 32;
196
+ var transportActivityIntervalMs = 250;
195
197
  var maxUnreadEventBytes = 1024 * 1024;
196
198
  var maxUnreadEvents = 256;
199
+ var maxPromptImages = 8;
200
+ var maxPromptImageDataBytes = 5 * 1024 * 1024;
201
+ var maxPromptImagesBytes = 8 * 1024 * 1024;
202
+ var maxPromptFrameBytes = 8 * 1024 * 1024;
203
+ var promptFrameEnvelopeBytes = 128;
204
+ var maxSteeringMessageBytes = 64 * 1024;
205
+ var maxSteeringMessages = 64;
206
+ var maxSteeringQueueBytes = 1024 * 1024;
207
+ var maxToolStartInputBytes = 64 * 1024;
197
208
  function boundedString(value, name, maxBytes, required) {
198
209
  if (value === undefined && !required)
199
210
  return;
@@ -227,6 +238,23 @@ function validateGatewayChatUrl(value) {
227
238
  throw new TypeError("gatewayChatUrl must use the canonical Gateway or explicit loopback HTTP");
228
239
  }
229
240
  }
241
+ function normalizeEffort(value) {
242
+ if (value === undefined)
243
+ return;
244
+ boundedString(value, "effort", maxEffortBytes, false);
245
+ if (!/^[A-Za-z0-9._-]+$/.test(value)) {
246
+ throw new TypeError('effort must use only letters, digits, ".", "-", or "_"');
247
+ }
248
+ return value;
249
+ }
250
+ function normalizeFast(value) {
251
+ if (value === undefined)
252
+ return;
253
+ if (typeof value !== "boolean") {
254
+ throw new TypeError("fast must be a boolean");
255
+ }
256
+ return value;
257
+ }
230
258
  function normalizeAgentOptions(value) {
231
259
  if (!value || typeof value !== "object" || Array.isArray(value)) {
232
260
  throw new TypeError("createFxAgent() options must be an object");
@@ -237,6 +265,8 @@ function normalizeAgentOptions(value) {
237
265
  }
238
266
  options.apiKey = boundedString(options.apiKey, "apiKey", maxApiKeyBytes, true);
239
267
  options.model = boundedString(options.model, "model", maxModelBytes, false);
268
+ options.effort = normalizeEffort(options.effort);
269
+ options.fast = normalizeFast(options.fast);
240
270
  validateGatewayChatUrl(options.gatewayChatUrl);
241
271
  return options;
242
272
  }
@@ -244,9 +274,20 @@ function agentEnvironment(options) {
244
274
  return {
245
275
  AI_GATEWAY_API_KEY: options.apiKey,
246
276
  ...options.model === undefined ? {} : { FX_MODEL: options.model },
277
+ ...options.effort === undefined ? {} : { FX_EFFORT: options.effort },
278
+ ...options.fast === undefined ? {} : { FX_FAST: options.fast ? "true" : "false" },
247
279
  ...options.gatewayChatUrl === undefined ? {} : { FX_GATEWAY_CHAT_URL: options.gatewayChatUrl }
248
280
  };
249
281
  }
282
+ function agentBootstrapError(error) {
283
+ if (error instanceof Error && (error.message === "Invalid reasoning effort" || error.message.startsWith("Reasoning effort"))) {
284
+ error.code ??= "LIBFX_UNSUPPORTED_EFFORT";
285
+ }
286
+ if (error instanceof Error && error.message.startsWith("Fast mode")) {
287
+ error.code ??= "LIBFX_UNSUPPORTED_FAST";
288
+ }
289
+ return error;
290
+ }
250
291
  async function cancelResponseBody(response) {
251
292
  try {
252
293
  await response.body?.cancel();
@@ -600,6 +641,9 @@ function createRuntime(options) {
600
641
  const stdin = new ByteQueue;
601
642
  const streams = new Map;
602
643
  const httpRequests = new Set;
644
+ const steering = [];
645
+ let steeringBytes = 0;
646
+ let steeringOpen = false;
603
647
  const workspaceExecs = new Set;
604
648
  const workspace = prepareWorkspaceAdapter(options.workspace);
605
649
  const args = ["fx", ...options.args || []];
@@ -835,6 +879,7 @@ function createRuntime(options) {
835
879
  return 0;
836
880
  if (!value?.length)
837
881
  return null;
882
+ options.onTransportChunk?.(value.length);
838
883
  return copy(value);
839
884
  };
840
885
  const immediate = consume();
@@ -891,6 +936,37 @@ function createRuntime(options) {
891
936
  httpRequests.delete(controller);
892
937
  });
893
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
+ }
894
970
  let pendingHostToolResult = null;
895
971
  function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr) {
896
972
  pendingHostToolResult = null;
@@ -1349,6 +1425,11 @@ function createRuntime(options) {
1349
1425
  fx_host_tool_result_release() {
1350
1426
  pendingHostToolResult = null;
1351
1427
  },
1428
+ fx_steering_take: steeringTake,
1429
+ fx_steering_close() {
1430
+ steeringOpen = false;
1431
+ clearSteering();
1432
+ },
1352
1433
  fx_open_url: new WebAssembly.Suspending(openUrl),
1353
1434
  fx_oauth_session_load: new WebAssembly.Suspending(oauthSessionLoad),
1354
1435
  fx_oauth_session_commit: new WebAssembly.Suspending(oauthSessionCommit),
@@ -1383,8 +1464,19 @@ function createRuntime(options) {
1383
1464
  stdin.wake();
1384
1465
  },
1385
1466
  closeStdin() {
1467
+ steeringOpen = false;
1468
+ clearSteering();
1386
1469
  stdin.close();
1387
1470
  },
1471
+ openSteering() {
1472
+ clearSteering();
1473
+ steeringOpen = true;
1474
+ },
1475
+ steer: queueSteering,
1476
+ closeSteering() {
1477
+ steeringOpen = false;
1478
+ clearSteering();
1479
+ },
1388
1480
  abortHostEffects,
1389
1481
  abort(error) {
1390
1482
  aborted = true;
@@ -1547,11 +1639,31 @@ function normalizePromptInput(input) {
1547
1639
  return [{ type: "text", text: input }];
1548
1640
  if (!Array.isArray(input))
1549
1641
  throw new TypeError("prompt input must be a string or an array of prompt blocks");
1642
+ let imageCount = 0;
1643
+ let imageBytes = 0;
1550
1644
  return input.map((block, index) => {
1551
1645
  if (!block || typeof block !== "object")
1552
1646
  throw new TypeError(`prompt block ${index} must be an object`);
1553
- if (block.type === "image")
1554
- throw new TypeError("image prompt blocks are unsupported");
1647
+ if (block.type === "image") {
1648
+ if (typeof block.data !== "string" || block.data.length === 0) {
1649
+ throw new TypeError(`image prompt block ${index} requires base64 data`);
1650
+ }
1651
+ if (typeof block.mimeType !== "string" || block.mimeType.length === 0 || block.mimeType.length > 128) {
1652
+ throw new TypeError(`image prompt block ${index} requires a mimeType`);
1653
+ }
1654
+ if (block.data.length > maxPromptImageDataBytes) {
1655
+ throw new RangeError(`image prompt block ${index} exceeds the ${maxPromptImageDataBytes} byte per-image libfx limit`);
1656
+ }
1657
+ imageCount += 1;
1658
+ if (imageCount > maxPromptImages) {
1659
+ throw new RangeError(`prompt cannot contain more than ${maxPromptImages} images`);
1660
+ }
1661
+ imageBytes += block.data.length;
1662
+ if (imageBytes > maxPromptImagesBytes) {
1663
+ throw new RangeError(`prompt images exceed the ${maxPromptImagesBytes} byte libfx frame limit`);
1664
+ }
1665
+ return { type: "image", data: block.data, mimeType: block.mimeType };
1666
+ }
1555
1667
  if (block.type === "text") {
1556
1668
  if (typeof block.text !== "string")
1557
1669
  throw new TypeError(`text prompt block ${index} requires text`);
@@ -1568,6 +1680,20 @@ function normalizePromptInput(input) {
1568
1680
  throw new TypeError(`unsupported prompt block type: ${String(block.type)}`);
1569
1681
  });
1570
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
+ }
1571
1697
  function normalizeHostTools(value) {
1572
1698
  if (value === undefined)
1573
1699
  return { descriptors: [], executors: new Map };
@@ -1577,15 +1703,26 @@ function normalizeHostTools(value) {
1577
1703
  throw new RangeError("tools cannot contain more than 64 entries");
1578
1704
  const descriptors = [];
1579
1705
  const executors = new Map;
1706
+ const names = new Set;
1580
1707
  for (const [index, tool] of value.entries()) {
1581
1708
  if (!tool || typeof tool !== "object")
1582
1709
  throw new TypeError(`tool ${index} must be an object`);
1583
- const { name, description, inputSchema, execute } = tool;
1710
+ const { name, description, inputSchema, execute, providerExecuted } = tool;
1584
1711
  if (typeof name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
1585
1712
  throw new TypeError(`tool ${index} has an invalid name`);
1586
1713
  }
1587
- if (executors.has(name))
1714
+ if (names.has(name))
1588
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
+ }
1589
1726
  if (typeof description !== "string")
1590
1727
  throw new TypeError(`tool ${name} requires a description`);
1591
1728
  if (typeof execute !== "function")
@@ -1703,6 +1840,10 @@ async function createFxAgent(options = {}) {
1703
1840
  for (let attemptIndex = 0;attemptIndex < 2; attemptIndex++) {
1704
1841
  const startedAt = performance.now();
1705
1842
  const attempt = activeTurn ? ++activeTurn.transportAttempts : attemptIndex + 1;
1843
+ if (activeTurn) {
1844
+ activeTurn.transportBytes = 0;
1845
+ activeTurn.lastTransportActivityAt = null;
1846
+ }
1706
1847
  emit("transport.start", { attempt, method, endpoint, model: options.model });
1707
1848
  try {
1708
1849
  if (activeTurn?.cancelled) {
@@ -1798,7 +1939,22 @@ async function createFxAgent(options = {}) {
1798
1939
  fetch: transportFetch,
1799
1940
  args: ["acp"],
1800
1941
  env: agentEnvironment(options),
1801
- 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
+ }
1802
1958
  };
1803
1959
  const runtime = options.runtimeFactory ? await options.runtimeFactory(runtimeOptions) : await instantiate(runtimeOptions);
1804
1960
  emit("runtime.ready");
@@ -1902,7 +2058,7 @@ async function createFxAgent(options = {}) {
1902
2058
  try {
1903
2059
  await runtime.exited;
1904
2060
  } catch {}
1905
- throw error;
2061
+ throw agentBootstrapError(error);
1906
2062
  }
1907
2063
  const agent = {
1908
2064
  prompt(input, promptOptions = {}) {
@@ -1951,6 +2107,9 @@ async function createFxAgent(options = {}) {
1951
2107
  const delta = update.content?.text;
1952
2108
  return delta ? { type: "reasoning_delta", delta } : null;
1953
2109
  }
2110
+ if (update.sessionUpdate === "user_message_chunk" && update.content?.type === "text") {
2111
+ return { type: "user_message", text: update.content.text };
2112
+ }
1954
2113
  if (update.sessionUpdate === "tool_call") {
1955
2114
  toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool");
1956
2115
  if (started.has(update.toolCallId))
@@ -1959,7 +2118,8 @@ async function createFxAgent(options = {}) {
1959
2118
  return {
1960
2119
  type: "tool_start",
1961
2120
  id: update.toolCallId,
1962
- name: toolNames.get(update.toolCallId)
2121
+ name: toolNames.get(update.toolCallId),
2122
+ ...toolStartInput(update.rawInput)
1963
2123
  };
1964
2124
  }
1965
2125
  if (update.sessionUpdate === "tool_call_update" && (update.status === "completed" || update.status === "failed")) {
@@ -1983,6 +2143,9 @@ async function createFxAgent(options = {}) {
1983
2143
  cancel() {
1984
2144
  rawTurn.cancel();
1985
2145
  },
2146
+ steer(input) {
2147
+ return rawTurn.steer(normalizeSteeringInput(input));
2148
+ },
1986
2149
  [Symbol.asyncIterator]() {
1987
2150
  const iterator = async function* () {
1988
2151
  for await (const update of rawTurn) {
@@ -2025,8 +2188,22 @@ async function createFxAgent(options = {}) {
2025
2188
  result.reasoningTokens = usage.reasoningTokens;
2026
2189
  return result;
2027
2190
  }
2191
+ function toolStartInput(rawInput) {
2192
+ if (rawInput === undefined || rawInput === null)
2193
+ return {};
2194
+ const serialized = JSON.stringify(rawInput);
2195
+ if (serialized === undefined)
2196
+ return {};
2197
+ const bytes = encoder.encode(serialized);
2198
+ if (bytes.length <= maxToolStartInputBytes)
2199
+ return { input: rawInput };
2200
+ return { inputTruncated: true, inputPreview: decoder.decode(utf8Prefix(bytes, maxToolStartInputBytes)) };
2201
+ }
2028
2202
  function startTurn(input, promptOptions) {
2029
2203
  const prompt = normalizePromptInput(input);
2204
+ if (encoder.encode(JSON.stringify({ sessionId: "", prompt })).length + promptFrameEnvelopeBytes > maxPromptFrameBytes) {
2205
+ throw new RangeError(`prompt exceeds the ${maxPromptFrameBytes} byte libfx frame limit`);
2206
+ }
2030
2207
  const signal = promptOptions.signal;
2031
2208
  if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function"))
2032
2209
  throw new TypeError("prompt signal must be an AbortSignal");
@@ -2069,13 +2246,37 @@ async function createFxAgent(options = {}) {
2069
2246
  },
2070
2247
  toolControllers,
2071
2248
  transportAttempts: 0,
2249
+ transportBytes: 0,
2250
+ lastTransportActivityAt: null,
2072
2251
  get cancelled() {
2073
2252
  return cancelled;
2074
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
+ },
2075
2275
  cancel() {
2076
2276
  if (finished || cancelled)
2077
2277
  return;
2078
2278
  cancelled = true;
2279
+ runtime.closeSteering?.();
2079
2280
  resumeOutput?.();
2080
2281
  resumeOutput = null;
2081
2282
  send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
@@ -2115,6 +2316,7 @@ async function createFxAgent(options = {}) {
2115
2316
  return turn;
2116
2317
  }
2117
2318
  activeTurn = turn;
2319
+ runtime.openSteering?.();
2118
2320
  const abort = () => turn.cancel();
2119
2321
  signal?.addEventListener("abort", abort, { once: true });
2120
2322
  turn.result = request("session/prompt", { sessionId, prompt }).then((response) => ({ stopReason: cancelled ? "cancelled" : response.stopReason, usage: response.usage })).catch((error) => {
@@ -2129,6 +2331,7 @@ async function createFxAgent(options = {}) {
2129
2331
  signal?.removeEventListener("abort", abort);
2130
2332
  if (activeTurn === turn)
2131
2333
  activeTurn = null;
2334
+ runtime.closeSteering?.();
2132
2335
  toolControllers.clear();
2133
2336
  if (discardedBytes)
2134
2337
  emit("output.discarded", { reason: "cancelled", bytes: discardedBytes });
@@ -2439,12 +2642,14 @@ async function getBackendInfo(value = {}) {
2439
2642
  }
2440
2643
  }
2441
2644
  function createNativeCoreRuntime(addon, options) {
2442
- const { apiKey, model, gatewayChatUrl } = options;
2645
+ const { apiKey, model, effort, fast, gatewayChatUrl } = options;
2443
2646
  const core = addon.createCore({
2444
2647
  apiKey,
2445
2648
  home: options.home ?? import_node_os.homedir(),
2446
2649
  workspaceRoot: options.workspaceRoot ?? process.cwd(),
2447
2650
  ...model === undefined ? {} : { model },
2651
+ ...effort === undefined ? {} : { effort },
2652
+ ...fast === undefined ? {} : { fast },
2448
2653
  ...gatewayChatUrl === undefined ? {} : { gatewayChatUrl }
2449
2654
  });
2450
2655
  let readyFd;
@@ -2510,6 +2715,7 @@ function createNativeCoreRuntime(addon, options) {
2510
2715
  if (response.body) {
2511
2716
  for await (const chunk of response.body) {
2512
2717
  const buffer = Buffer.from(chunk);
2718
+ options.onTransportChunk?.(buffer.length);
2513
2719
  let offset = 0;
2514
2720
  while (offset < buffer.length) {
2515
2721
  const end = Math.min(offset + 65536, buffer.length);
package/node.js CHANGED
@@ -345,12 +345,14 @@ export async function getBackendInfo(value = {}) {
345
345
  }
346
346
 
347
347
  function createNativeCoreRuntime(addon, options) {
348
- const { apiKey, model, gatewayChatUrl } = options;
348
+ const { apiKey, model, effort, fast, gatewayChatUrl } = options;
349
349
  const core = addon.createCore({
350
350
  apiKey,
351
351
  home: options.home ?? homedir(),
352
352
  workspaceRoot: options.workspaceRoot ?? process.cwd(),
353
353
  ...(model === undefined ? {} : { model }),
354
+ ...(effort === undefined ? {} : { effort }),
355
+ ...(fast === undefined ? {} : { fast }),
354
356
  ...(gatewayChatUrl === undefined ? {} : { gatewayChatUrl }),
355
357
  });
356
358
  let readyFd;
@@ -405,6 +407,7 @@ function createNativeCoreRuntime(addon, options) {
405
407
  if (response.body) {
406
408
  for await (const chunk of response.body) {
407
409
  const buffer = Buffer.from(chunk);
410
+ options.onTransportChunk?.(buffer.length);
408
411
  let offset = 0;
409
412
  while (offset < buffer.length) {
410
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",
3
+ "version": "0.0.11",
4
4
  "description": "Embed fx agents and terminals in JavaScript hosts",
5
5
  "type": "module",
6
6
  "repository": {