libfx 0.0.6 → 0.0.7-dev.607.ga90fc3585568
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 +96 -291
- package/fx-core.wasm +0 -0
- package/fx-sdk.js +290 -169
- package/fx-term.wasm +0 -0
- package/libfx.darwin-arm64.node +0 -0
- package/libfx.darwin-x64.node +0 -0
- package/libfx.linux-arm64.node +0 -0
- package/libfx.linux-x64.node +0 -0
- package/mcp.js +89 -0
- package/node.js +21 -6
- package/package.json +5 -2
- package/skills-node.js +29 -0
- package/skills.js +44 -0
package/README.md
CHANGED
|
@@ -1,357 +1,162 @@
|
|
|
1
1
|
# libfx
|
|
2
2
|
|
|
3
|
-
`libfx`
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
## Installation
|
|
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`.
|
|
8
6
|
|
|
9
7
|
```sh
|
|
10
8
|
npm install libfx
|
|
11
9
|
```
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- JSPI when using the WebAssembly backend
|
|
18
|
-
- A Vercel AI Gateway credential or a host-provided authenticated `fetch`
|
|
19
|
-
|
|
20
|
-
The package includes:
|
|
21
|
-
|
|
22
|
-
- Native Node addons for Linux and macOS on x64 and arm64
|
|
23
|
-
- `fx-core.wasm` for headless agents
|
|
24
|
-
- `fx-term.wasm` for interactive terminals
|
|
25
|
-
- A dependency-free JavaScript host layer
|
|
26
|
-
|
|
27
|
-
## Exports
|
|
28
|
-
|
|
29
|
-
| Import | Environment | Description |
|
|
30
|
-
| --- | --- | --- |
|
|
31
|
-
| `libfx` | Node.js or browser | Environment-aware default |
|
|
32
|
-
| `libfx/node` | Node.js | Native-first Node entry point |
|
|
33
|
-
| `libfx/browser` | Browser | WebAssembly browser entry point |
|
|
34
|
-
| `libfx/wasm` | Browser or Node.js | Direct WebAssembly host layer |
|
|
11
|
+
Node.js uses the native addon when available and falls back to WebAssembly.
|
|
12
|
+
Browsers use WebAssembly with JSPI. The default package has no runtime
|
|
13
|
+
dependencies and performs no MCP connection, skill scan, process spawn, or
|
|
14
|
+
filesystem read when imported.
|
|
35
15
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
- `createFxAgent()` creates a headless ACP agent.
|
|
39
|
-
- `createFxTerminal()` runs the interactive fx terminal.
|
|
40
|
-
- `supportsJspi()` detects WebAssembly JSPI support.
|
|
41
|
-
- `xtermAdapter()` connects fx to an xterm.js terminal.
|
|
42
|
-
- `encodeXtermKeyEvent()` translates browser keyboard events into terminal input.
|
|
43
|
-
|
|
44
|
-
## Headless agent
|
|
45
|
-
|
|
46
|
-
The default Node entry point prefers the native addon and falls back to
|
|
47
|
-
WebAssembly when necessary.
|
|
16
|
+
## Agent
|
|
48
17
|
|
|
49
18
|
```js
|
|
50
19
|
import { createFxAgent } from "libfx";
|
|
51
20
|
|
|
52
21
|
const agent = await createFxAgent({
|
|
53
|
-
env: {
|
|
54
|
-
AI_GATEWAY_API_KEY: process.env.AI_GATEWAY_API_KEY,
|
|
55
|
-
},
|
|
56
|
-
onEvent(event) {
|
|
57
|
-
console.log(event.type);
|
|
58
|
-
},
|
|
59
|
-
async onPermission(request) {
|
|
60
|
-
// Return one of request.options[*].optionId to approve it.
|
|
61
|
-
// Returning null or undefined cancels the request.
|
|
62
|
-
return null;
|
|
63
|
-
},
|
|
22
|
+
env: { AI_GATEWAY_API_KEY: process.env.AI_GATEWAY_API_KEY },
|
|
64
23
|
});
|
|
65
24
|
|
|
66
|
-
const
|
|
67
|
-
const turn = session.prompt("Explain the files in this project.");
|
|
25
|
+
const turn = agent.prompt("Explain this project.");
|
|
68
26
|
|
|
69
|
-
for await (const
|
|
70
|
-
|
|
27
|
+
for await (const event of turn) {
|
|
28
|
+
if (event.type === "text_delta") process.stdout.write(event.delta);
|
|
71
29
|
}
|
|
72
30
|
|
|
73
|
-
console.log(
|
|
74
|
-
|
|
75
|
-
await session.close();
|
|
31
|
+
console.log(await turn.result); // { stopReason, usage }
|
|
32
|
+
const checkpoint = await agent.checkpoint();
|
|
76
33
|
await agent.close();
|
|
77
34
|
```
|
|
78
35
|
|
|
79
|
-
|
|
36
|
+
`prompt(input, { signal? })` accepts a string or text/resource blocks. It
|
|
37
|
+
returns an async iterable of normalized events:
|
|
80
38
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
type: "resource",
|
|
86
|
-
resource: {
|
|
87
|
-
uri: "file:///workspace/README.md",
|
|
88
|
-
text: readmeContents,
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
]);
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
Image prompt blocks are not currently supported.
|
|
95
|
-
|
|
96
|
-
### Agent lifecycle
|
|
97
|
-
|
|
98
|
-
The object returned by `createFxAgent()` provides:
|
|
39
|
+
- `text_delta`
|
|
40
|
+
- `reasoning_delta` when supplied by the provider
|
|
41
|
+
- `tool_start`
|
|
42
|
+
- `tool_end`
|
|
99
43
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
| `openSession(id)` | Loads a stored session |
|
|
104
|
-
| `listSessions()` | Lists stored sessions |
|
|
105
|
-
| `close()` | Closes the active session and shuts down cleanly |
|
|
106
|
-
| `abort()` | Immediately aborts the runtime |
|
|
107
|
-
| `exited` | Promise that resolves with the process exit code |
|
|
108
|
-
|
|
109
|
-
A session provides:
|
|
110
|
-
|
|
111
|
-
| Member | Description |
|
|
112
|
-
| --- | --- |
|
|
113
|
-
| `prompt(input, options?)` | Starts an async iterable turn |
|
|
114
|
-
| `setModel(model)` | Changes the active model |
|
|
115
|
-
| `setMode(mode)` | Changes the active mode |
|
|
116
|
-
| `setConfig(config)` | Applies multiple configuration values |
|
|
117
|
-
| `close()` | Closes the active session |
|
|
118
|
-
| `remove()` | Removes the stored session |
|
|
119
|
-
| `history` | Previously loaded session updates |
|
|
120
|
-
| `configOptions` | Current configurable values |
|
|
121
|
-
|
|
122
|
-
Each session allows one active prompt at a time. Cancel a turn directly or
|
|
123
|
-
with an `AbortSignal`:
|
|
44
|
+
Only one prompt may run at a time. `checkpoint()` is idle-only and returns
|
|
45
|
+
opaque, bounded, versioned bytes. Restore them only when creating a fresh
|
|
46
|
+
agent:
|
|
124
47
|
|
|
125
48
|
```js
|
|
126
|
-
const
|
|
127
|
-
const turn = session.prompt("Wait for more instructions.", {
|
|
128
|
-
signal: controller.signal,
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
controller.abort();
|
|
132
|
-
console.log(await turn.stopReason); // "cancelled"
|
|
49
|
+
const restored = await createFxAgent({ checkpoint, env });
|
|
133
50
|
```
|
|
134
51
|
|
|
135
|
-
|
|
52
|
+
The checkpoint contains conversation history and usage only. The host owns
|
|
53
|
+
durable storage and must resupply models, credentials, instructions, tools,
|
|
54
|
+
MCP clients, and skill records.
|
|
136
55
|
|
|
137
|
-
|
|
56
|
+
## JavaScript tools and instructions
|
|
138
57
|
|
|
139
58
|
```js
|
|
140
|
-
import {
|
|
141
|
-
createFxAgent,
|
|
142
|
-
supportsJspi,
|
|
143
|
-
} from "libfx/browser";
|
|
144
|
-
|
|
145
|
-
if (!supportsJspi()) {
|
|
146
|
-
throw new Error("This browser does not support WebAssembly JSPI.");
|
|
147
|
-
}
|
|
148
|
-
|
|
149
59
|
const agent = await createFxAgent({
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
60
|
+
instructions: "Keep answers concise.",
|
|
61
|
+
tools: [{
|
|
62
|
+
name: "lookup",
|
|
63
|
+
description: "Look up a value.",
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: { key: { type: "string" } },
|
|
67
|
+
required: ["key"],
|
|
68
|
+
},
|
|
69
|
+
async execute(input, { signal }) {
|
|
70
|
+
return database.get(input.key, { signal });
|
|
71
|
+
},
|
|
72
|
+
}],
|
|
73
|
+
env,
|
|
153
74
|
});
|
|
154
|
-
|
|
155
|
-
const session = await agent.createSession();
|
|
156
|
-
const turn = session.prompt("Describe this workspace.");
|
|
157
|
-
|
|
158
|
-
for await (const update of turn) {
|
|
159
|
-
console.log(update);
|
|
160
|
-
}
|
|
161
75
|
```
|
|
162
76
|
|
|
163
|
-
The
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
Do not embed a long-lived API key in public browser code. Use a short-lived
|
|
168
|
-
credential or an authenticated server-side proxy.
|
|
169
|
-
|
|
170
|
-
## Interactive terminal
|
|
77
|
+
The JavaScript host is the authority for tool effects. The same descriptors,
|
|
78
|
+
schemas, cancellation, results, and events are used by N-API and WebAssembly.
|
|
79
|
+
Instructions are limited to 64 KiB of UTF-8 text, including text assembled by
|
|
80
|
+
the MCP and skills adapters.
|
|
171
81
|
|
|
172
|
-
|
|
82
|
+
## MCP
|
|
173
83
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
Create the terminal and connect it to fx:
|
|
84
|
+
`libfx/mcp` accepts a host-owned MCP client. Transport, authentication,
|
|
85
|
+
elicitation, and cleanup remain outside the kernel.
|
|
179
86
|
|
|
180
87
|
```js
|
|
181
|
-
import {
|
|
182
|
-
import { FitAddon } from "@xterm/addon-fit";
|
|
183
|
-
import "@xterm/xterm/css/xterm.css";
|
|
184
|
-
import {
|
|
185
|
-
createFxTerminal,
|
|
186
|
-
supportsJspi,
|
|
187
|
-
xtermAdapter,
|
|
188
|
-
} from "libfx/browser";
|
|
189
|
-
|
|
190
|
-
if (!supportsJspi()) {
|
|
191
|
-
throw new Error("This browser does not support WebAssembly JSPI.");
|
|
192
|
-
}
|
|
88
|
+
import { createMcpAdapter } from "libfx/mcp";
|
|
193
89
|
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
90
|
+
const mcp = await createMcpAdapter(client, {
|
|
91
|
+
prefix: "github_",
|
|
92
|
+
resources: ["repo://instructions"],
|
|
93
|
+
prompts: ["review"],
|
|
197
94
|
});
|
|
198
95
|
|
|
199
|
-
const fit = new FitAddon();
|
|
200
|
-
terminal.loadAddon(fit);
|
|
201
|
-
terminal.open(document.querySelector("#terminal"));
|
|
202
|
-
fit.fit();
|
|
203
|
-
|
|
204
|
-
const runtime = await createFxTerminal({
|
|
205
|
-
terminal: xtermAdapter(terminal),
|
|
206
|
-
env: {
|
|
207
|
-
AI_GATEWAY_API_KEY: "<short-lived credential>",
|
|
208
|
-
},
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
await runtime.interactive;
|
|
212
|
-
|
|
213
|
-
window.addEventListener("resize", () => {
|
|
214
|
-
fit.fit();
|
|
215
|
-
runtime.resize();
|
|
216
|
-
});
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
The terminal runtime provides:
|
|
220
|
-
|
|
221
|
-
| Member | Description |
|
|
222
|
-
| --- | --- |
|
|
223
|
-
| `interactive` | Resolves after the terminal is ready for input |
|
|
224
|
-
| `exited` | Resolves with the terminal exit code |
|
|
225
|
-
| `write(data)` | Writes input directly to fx |
|
|
226
|
-
| `resize()` | Notifies fx of terminal geometry changes |
|
|
227
|
-
| `abort()` | Stops the terminal and releases subscriptions |
|
|
228
|
-
|
|
229
|
-
Try the hosted terminal at [fx.sh/try](https://fx.sh/try).
|
|
230
|
-
|
|
231
|
-
## Backend selection
|
|
232
|
-
|
|
233
|
-
Node hosts may select a backend explicitly:
|
|
234
|
-
|
|
235
|
-
```js
|
|
236
96
|
const agent = await createFxAgent({
|
|
237
|
-
|
|
97
|
+
tools: mcp.tools,
|
|
98
|
+
instructions: mcp.instructions,
|
|
99
|
+
env,
|
|
238
100
|
});
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
| Backend | Behavior |
|
|
242
|
-
| --- | --- |
|
|
243
|
-
| `auto` | Prefer a compatible native addon and fall back to WebAssembly |
|
|
244
|
-
| `native` | Require the native backend and fail if it cannot load |
|
|
245
|
-
| `wasm` | Require WebAssembly and JSPI |
|
|
246
|
-
|
|
247
|
-
The native loader checks `libfx.node` followed by the platform-specific addon:
|
|
248
101
|
|
|
249
|
-
|
|
250
|
-
|
|
102
|
+
// ...
|
|
103
|
+
await agent.close();
|
|
104
|
+
await mcp.close();
|
|
251
105
|
```
|
|
252
106
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
- `linux-x64`
|
|
256
|
-
- `linux-arm64`
|
|
257
|
-
- `darwin-x64`
|
|
258
|
-
- `darwin-arm64`
|
|
107
|
+
## Skills
|
|
259
108
|
|
|
260
|
-
|
|
261
|
-
|
|
109
|
+
Use `libfx/skills` for already-loaded records or `libfx/skills/node` to load a
|
|
110
|
+
`SKILL.md` explicitly in Node or Bun.
|
|
262
111
|
|
|
263
112
|
```js
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
On Node versions where JSPI remains behind a flag, start the process with:
|
|
113
|
+
import { loadSkillFile } from "libfx/skills/node";
|
|
114
|
+
import { createSkillsAdapter } from "libfx/skills";
|
|
268
115
|
|
|
269
|
-
|
|
270
|
-
|
|
116
|
+
const record = await loadSkillFile("./skills/review/SKILL.md");
|
|
117
|
+
const skills = createSkillsAdapter([record]);
|
|
118
|
+
const agent = await createFxAgent({ ...skills, env });
|
|
271
119
|
```
|
|
272
120
|
|
|
273
|
-
##
|
|
274
|
-
|
|
275
|
-
Hosts may provide adapters for runtime state and external effects:
|
|
276
|
-
|
|
277
|
-
| Option | Purpose |
|
|
278
|
-
| --- | --- |
|
|
279
|
-
| `fetch` | Routes Gateway requests through the host |
|
|
280
|
-
| `env` | Supplies runtime configuration without changing process globals |
|
|
281
|
-
| `onEvent` | Receives runtime, ACP, terminal, and lifecycle events |
|
|
282
|
-
| `onPermission` | Resolves agent permission requests |
|
|
283
|
-
| `configStore` | Persists accepted configuration values |
|
|
284
|
-
| `sessionStore` | Persists agent or terminal sessions |
|
|
285
|
-
| `oauthSessionStore` | Persists browser device-login sessions |
|
|
286
|
-
| `promptHistoryStore` | Stores terminal prompt history |
|
|
287
|
-
| `openUrl` | Opens authentication and verification URLs |
|
|
288
|
-
| `workspace` | Provides the constrained browser workspace adapter |
|
|
289
|
-
|
|
290
|
-
## Security boundaries
|
|
291
|
-
|
|
292
|
-
`nativeAddon` and `env.FX_GATEWAY_CHAT_URL` are trusted host configuration. Do
|
|
293
|
-
not populate them from request, tenant, or other untrusted input.
|
|
294
|
-
|
|
295
|
-
The native backend sends production credentials only to the canonical Vercel
|
|
296
|
-
AI Gateway endpoint. Custom Gateway endpoints are limited to explicit loopback
|
|
297
|
-
HTTP URLs for local development.
|
|
298
|
-
|
|
299
|
-
The WebAssembly runtime intentionally does not provide:
|
|
300
|
-
|
|
301
|
-
- Native processes
|
|
302
|
-
- OS sandboxing
|
|
303
|
-
- Native MCP servers
|
|
304
|
-
- Subagents or skills
|
|
305
|
-
- Automatic upgrades
|
|
306
|
-
- Clipboard integration
|
|
307
|
-
- Arbitrary WASI filesystem access
|
|
308
|
-
- Web search
|
|
309
|
-
|
|
310
|
-
The optional browser workspace exposes foreground terminal execution through
|
|
311
|
-
the typed contract:
|
|
121
|
+
## Backends
|
|
312
122
|
|
|
313
123
|
```js
|
|
314
|
-
{
|
|
124
|
+
await createFxAgent({ backend: "auto" }); // native, then Wasm fallback
|
|
125
|
+
await createFxAgent({ backend: "native" }); // require N-API
|
|
126
|
+
await createFxAgent({ backend: "wasm" }); // require Wasm + JSPI
|
|
315
127
|
```
|
|
316
128
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
From the fx repository root, build the native addon and both WebAssembly
|
|
323
|
-
surfaces:
|
|
324
|
-
|
|
325
|
-
```sh
|
|
326
|
-
zig build -Dnapi-surface=core -Doptimize=ReleaseSafe
|
|
327
|
-
zig build -Dwasm-surface=core -Doptimize=ReleaseSmall
|
|
328
|
-
zig build -Dwasm-surface=term -Doptimize=ReleaseSmall
|
|
329
|
-
```
|
|
129
|
+
Within one JavaScript realm, libfx compiles each stable Wasm source once and
|
|
130
|
+
creates a separate WebAssembly instance for every Agent. Agent memory, history,
|
|
131
|
+
tools, cancellation, and shutdown remain isolated. Workers and separate
|
|
132
|
+
processes maintain their own module caches.
|
|
330
133
|
|
|
331
|
-
|
|
134
|
+
Node.js 20+ is supported. Browser WebAssembly requires a JSPI-capable browser.
|
|
135
|
+
Some Node versions require `--experimental-wasm-jspi`.
|
|
332
136
|
|
|
333
|
-
|
|
334
|
-
npm ci --prefix sdk/node
|
|
335
|
-
npm run --prefix sdk test:node-napi
|
|
336
|
-
npm run --prefix sdk test:node-wasm
|
|
337
|
-
```
|
|
137
|
+
## Interactive terminal
|
|
338
138
|
|
|
339
|
-
|
|
139
|
+
`createFxTerminal()` remains a separate terminal harness API. In browsers,
|
|
140
|
+
connect it to xterm.js with `xtermAdapter()`:
|
|
340
141
|
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
```
|
|
142
|
+
```js
|
|
143
|
+
import { createFxTerminal, xtermAdapter } from "libfx/browser";
|
|
344
144
|
|
|
345
|
-
|
|
145
|
+
const runtime = await createFxTerminal({
|
|
146
|
+
terminal: xtermAdapter(term),
|
|
147
|
+
env: { AI_GATEWAY_API_KEY: "<short-lived credential>" },
|
|
148
|
+
});
|
|
346
149
|
|
|
347
|
-
|
|
348
|
-
Core debugger: http://localhost:8080/sdk/index.html
|
|
349
|
-
Interactive terminal: http://localhost:8080/sdk/term-demo.html
|
|
150
|
+
await runtime.interactive;
|
|
350
151
|
```
|
|
351
152
|
|
|
352
|
-
|
|
153
|
+
The terminal runtime exposes `interactive`, `exited`, `write`, `resize`, and
|
|
154
|
+
`abort`. Terminal session, config, OAuth, prompt-history, URL, and workspace
|
|
155
|
+
stores remain terminal-only host integrations.
|
|
353
156
|
|
|
354
|
-
|
|
157
|
+
## Security
|
|
355
158
|
|
|
356
|
-
|
|
357
|
-
|
|
159
|
+
Treat `nativeAddon` and `env.FX_GATEWAY_CHAT_URL` as trusted host
|
|
160
|
+
configuration. Do not embed long-lived credentials in public browser code.
|
|
161
|
+
Host tool functions, MCP clients, and skill loaders retain their own authority;
|
|
162
|
+
libfx validates and sequences them but does not grant operating-system access.
|
package/fx-core.wasm
CHANGED
|
Binary file
|
package/fx-sdk.js
CHANGED
|
@@ -4,6 +4,7 @@ const strictDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
|
4
4
|
const workspaceInfoLimit = 4 * 1024;
|
|
5
5
|
const workspaceCommandLimit = 64 * 1024;
|
|
6
6
|
const workspaceOutputLimit = 64 * 1024;
|
|
7
|
+
const maxInstructionsBytes = 64 * 1024;
|
|
7
8
|
const streamReadsPerTaskYield = 32;
|
|
8
9
|
|
|
9
10
|
function validWorkspacePath(path) {
|
|
@@ -50,7 +51,7 @@ function utf8Prefix(value, limit) {
|
|
|
50
51
|
return value.subarray(0, end);
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
export const fxSdkApiVersion =
|
|
54
|
+
export const fxSdkApiVersion = 2;
|
|
54
55
|
|
|
55
56
|
export function supportsJspi() {
|
|
56
57
|
return typeof WebAssembly.Suspending === "function" &&
|
|
@@ -92,35 +93,6 @@ export function xtermAdapter(term) {
|
|
|
92
93
|
};
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
function createMemorySessionStore() {
|
|
96
|
-
const records = new Map();
|
|
97
|
-
let nextRevision = 1;
|
|
98
|
-
return {
|
|
99
|
-
async load(id) {
|
|
100
|
-
const record = records.get(id);
|
|
101
|
-
return record ? { bytes: record.bytes.slice(), revision: record.revision } : null;
|
|
102
|
-
},
|
|
103
|
-
async commit(id, bytes, expectedRevision) {
|
|
104
|
-
const current = records.get(id);
|
|
105
|
-
if ((current?.revision) !== expectedRevision) throw revisionConflict();
|
|
106
|
-
const revision = String(nextRevision++);
|
|
107
|
-
records.set(id, { bytes: bytes.slice(), revision, updatedAtMs: Date.now() });
|
|
108
|
-
return { revision };
|
|
109
|
-
},
|
|
110
|
-
async list() {
|
|
111
|
-
return [...records.entries()].map(([id, record]) => ({ id, updatedAtMs: record.updatedAtMs }))
|
|
112
|
-
.sort((a, b) => b.updatedAtMs - a.updatedAtMs);
|
|
113
|
-
},
|
|
114
|
-
async remove(id) { records.delete(id); },
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function revisionConflict() {
|
|
119
|
-
const error = new Error("session revision conflict");
|
|
120
|
-
error.code = "FX_SESSION_REVISION_CONFLICT";
|
|
121
|
-
return error;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
96
|
class ByteQueue {
|
|
125
97
|
chunks = [];
|
|
126
98
|
waiters = [];
|
|
@@ -176,7 +148,10 @@ class ByteQueue {
|
|
|
176
148
|
}
|
|
177
149
|
}
|
|
178
150
|
|
|
179
|
-
|
|
151
|
+
const modulePromisesBySource = new Map();
|
|
152
|
+
const modulePromisesByObject = new WeakMap();
|
|
153
|
+
|
|
154
|
+
async function compileModule(input) {
|
|
180
155
|
if (input instanceof WebAssembly.Module) return input;
|
|
181
156
|
if (typeof input === "string") input = fetch(input);
|
|
182
157
|
if (input instanceof Promise) input = await input;
|
|
@@ -195,6 +170,21 @@ async function loadModule(input) {
|
|
|
195
170
|
throw new TypeError("wasm must be a URL, Response, ArrayBuffer, typed array, or WebAssembly.Module");
|
|
196
171
|
}
|
|
197
172
|
|
|
173
|
+
function loadModule(input) {
|
|
174
|
+
if (input instanceof WebAssembly.Module) return Promise.resolve(input);
|
|
175
|
+
const isString = typeof input === "string";
|
|
176
|
+
if (!isString && (typeof input !== "object" || input === null)) return compileModule(input);
|
|
177
|
+
const cache = isString ? modulePromisesBySource : modulePromisesByObject;
|
|
178
|
+
const cached = cache.get(input);
|
|
179
|
+
if (cached) return cached;
|
|
180
|
+
const pending = compileModule(input);
|
|
181
|
+
cache.set(input, pending);
|
|
182
|
+
pending.catch(() => {
|
|
183
|
+
if (cache.get(input) === pending) cache.delete(input);
|
|
184
|
+
});
|
|
185
|
+
return pending;
|
|
186
|
+
}
|
|
187
|
+
|
|
198
188
|
function raceWithTimeout(promise, timeoutMs, timeoutValue) {
|
|
199
189
|
let timer;
|
|
200
190
|
return new Promise((resolve, reject) => {
|
|
@@ -455,6 +445,22 @@ function createRuntime(options) {
|
|
|
455
445
|
}).catch(() => -1);
|
|
456
446
|
}
|
|
457
447
|
|
|
448
|
+
function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr) {
|
|
449
|
+
if (typeof options.hostToolExecutor !== "function") return -1;
|
|
450
|
+
if (options.traceWasi) console.error("fx host tool call start");
|
|
451
|
+
let input;
|
|
452
|
+
try { input = JSON.parse(text(argumentsPtr, argumentsLen)); } catch { return -1; }
|
|
453
|
+
return Promise.resolve(options.hostToolExecutor(text(namePtr, nameLen), input)).then((result) => {
|
|
454
|
+
if (options.traceWasi) console.error("fx host tool call settled", result.cancelled, result.isError);
|
|
455
|
+
if (result.cancelled) return -2;
|
|
456
|
+
const output = encoder.encode(result.content);
|
|
457
|
+
if (output.length > outputCap) return -3;
|
|
458
|
+
bytes(outputPtr, output.length).set(output);
|
|
459
|
+
bytes(statusPtr, 1)[0] = result.isError ? 1 : 0;
|
|
460
|
+
return output.length;
|
|
461
|
+
}).catch(() => -1);
|
|
462
|
+
}
|
|
463
|
+
|
|
458
464
|
function openUrl(urlPtr, urlLen) {
|
|
459
465
|
if (typeof options.openUrl !== "function") return 0;
|
|
460
466
|
return Promise.resolve().then(() => options.openUrl(text(urlPtr, urlLen))).then((accepted) =>
|
|
@@ -774,6 +780,7 @@ function createRuntime(options) {
|
|
|
774
780
|
fx_http_stream_next: new WebAssembly.Suspending(streamNext),
|
|
775
781
|
fx_http_stream_close(handle) { const state = streams.get(handle); state?.controller.abort(); streams.delete(handle); },
|
|
776
782
|
fx_http_request: new WebAssembly.Suspending(httpRequest),
|
|
783
|
+
fx_host_tool_call: new WebAssembly.Suspending(hostToolCall),
|
|
777
784
|
fx_open_url: new WebAssembly.Suspending(openUrl),
|
|
778
785
|
fx_oauth_session_load: new WebAssembly.Suspending(oauthSessionLoad),
|
|
779
786
|
fx_oauth_session_commit: new WebAssembly.Suspending(oauthSessionCommit),
|
|
@@ -930,20 +937,117 @@ function normalizePromptInput(input) {
|
|
|
930
937
|
});
|
|
931
938
|
}
|
|
932
939
|
|
|
933
|
-
|
|
934
|
-
|
|
940
|
+
function normalizeHostTools(value) {
|
|
941
|
+
if (value === undefined) return { descriptors: [], executors: new Map() };
|
|
942
|
+
if (!Array.isArray(value)) throw new TypeError("tools must be an array");
|
|
943
|
+
if (value.length > 64) throw new RangeError("tools cannot contain more than 64 entries");
|
|
944
|
+
const descriptors = [];
|
|
945
|
+
const executors = new Map();
|
|
946
|
+
for (const [index, tool] of value.entries()) {
|
|
947
|
+
if (!tool || typeof tool !== "object") throw new TypeError(`tool ${index} must be an object`);
|
|
948
|
+
const { name, description, inputSchema, execute } = tool;
|
|
949
|
+
if (typeof name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
|
|
950
|
+
throw new TypeError(`tool ${index} has an invalid name`);
|
|
951
|
+
}
|
|
952
|
+
if (executors.has(name)) throw new TypeError(`duplicate tool name: ${name}`);
|
|
953
|
+
if (typeof description !== "string") throw new TypeError(`tool ${name} requires a description`);
|
|
954
|
+
if (typeof execute !== "function") throw new TypeError(`tool ${name} requires execute()`);
|
|
955
|
+
if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
|
|
956
|
+
throw new TypeError(`tool ${name} requires an object inputSchema`);
|
|
957
|
+
}
|
|
958
|
+
let schema;
|
|
959
|
+
try { schema = JSON.parse(JSON.stringify(inputSchema)); } catch {
|
|
960
|
+
throw new TypeError(`tool ${name} inputSchema must be JSON-serializable`);
|
|
961
|
+
}
|
|
962
|
+
descriptors.push({ name, description, inputSchema: schema });
|
|
963
|
+
executors.set(name, execute);
|
|
964
|
+
}
|
|
965
|
+
return { descriptors, executors };
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function normalizeInstructions(value) {
|
|
969
|
+
let instructions;
|
|
970
|
+
if (value === undefined) instructions = "";
|
|
971
|
+
else if (typeof value === "string") instructions = value;
|
|
972
|
+
if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
|
|
973
|
+
instructions = value.filter(Boolean).join("\n\n");
|
|
974
|
+
}
|
|
975
|
+
if (instructions === undefined) {
|
|
976
|
+
throw new TypeError("instructions must be a string or an array of strings");
|
|
977
|
+
}
|
|
978
|
+
if (encoder.encode(instructions).length > maxInstructionsBytes) {
|
|
979
|
+
throw new RangeError(`instructions exceed the ${maxInstructionsBytes} byte libfx limit`);
|
|
980
|
+
}
|
|
981
|
+
return instructions;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function hostToolContent(value) {
|
|
985
|
+
if (typeof value === "string") return value;
|
|
986
|
+
if (value === undefined) return "null";
|
|
987
|
+
const encoded = JSON.stringify(value);
|
|
988
|
+
return encoded === undefined ? "null" : encoded;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function checkpointBytes(value) {
|
|
992
|
+
if (value === undefined) return null;
|
|
993
|
+
if (value instanceof Uint8Array) return value.slice();
|
|
994
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0));
|
|
995
|
+
if (ArrayBuffer.isView(value)) {
|
|
996
|
+
return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
|
|
997
|
+
}
|
|
998
|
+
throw new TypeError("checkpoint must be an ArrayBuffer or typed array");
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function bytesToBase64(value) {
|
|
1002
|
+
let binary = "";
|
|
1003
|
+
for (let offset = 0; offset < value.length; offset += 0x8000) {
|
|
1004
|
+
binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000));
|
|
1005
|
+
}
|
|
1006
|
+
return btoa(binary);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function base64ToBytes(value) {
|
|
1010
|
+
const binary = atob(value);
|
|
1011
|
+
const bytes = new Uint8Array(binary.length);
|
|
1012
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
1013
|
+
return bytes;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
export async function createFxAgent(options = {}) {
|
|
1017
|
+
options = { ...options };
|
|
1018
|
+
const hostTools = normalizeHostTools(options.tools);
|
|
1019
|
+
const instructions = normalizeInstructions(options.instructions);
|
|
1020
|
+
const initialCheckpoint = checkpointBytes(options.checkpoint);
|
|
935
1021
|
const pending = new Map();
|
|
936
|
-
const turns = new Map();
|
|
937
1022
|
let nextId = 1;
|
|
938
|
-
let
|
|
939
|
-
let
|
|
940
|
-
let loadingUpdates = [];
|
|
1023
|
+
let sessionId = null;
|
|
1024
|
+
let activeTurn = null;
|
|
941
1025
|
let closing = false;
|
|
942
1026
|
const emit = (type, detail = {}) => {
|
|
943
1027
|
try { options.onEvent?.({ type, timestamp: performance.now(), ...detail }); } catch {}
|
|
944
1028
|
};
|
|
1029
|
+
const executeHostTool = async (name, input, requestedSessionId) => {
|
|
1030
|
+
const execute = hostTools.executors.get(name);
|
|
1031
|
+
const turn = requestedSessionId === undefined || requestedSessionId === sessionId
|
|
1032
|
+
? activeTurn
|
|
1033
|
+
: null;
|
|
1034
|
+
const controller = new AbortController();
|
|
1035
|
+
turn?.toolControllers.add(controller);
|
|
1036
|
+
let content;
|
|
1037
|
+
let isError = false;
|
|
1038
|
+
try {
|
|
1039
|
+
if (!execute) throw new Error(`unknown host tool: ${String(name)}`);
|
|
1040
|
+
content = hostToolContent(await execute(input, { signal: controller.signal }));
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
isError = true;
|
|
1043
|
+
content = error instanceof Error ? error.message : String(error);
|
|
1044
|
+
} finally {
|
|
1045
|
+
turn?.toolControllers.delete(controller);
|
|
1046
|
+
}
|
|
1047
|
+
return { content, isError, cancelled: controller.signal.aborted };
|
|
1048
|
+
};
|
|
945
1049
|
emit("runtime.start");
|
|
946
|
-
const runtimeOptions = { ...options, args: ["acp"] };
|
|
1050
|
+
const runtimeOptions = { ...options, args: ["acp"], hostToolExecutor: executeHostTool };
|
|
947
1051
|
const runtime = options.runtimeFactory
|
|
948
1052
|
? await options.runtimeFactory(runtimeOptions)
|
|
949
1053
|
: await instantiate(runtimeOptions);
|
|
@@ -967,9 +1071,7 @@ export async function createFxAgent(options) {
|
|
|
967
1071
|
runtime.setLineHandler(async (message) => {
|
|
968
1072
|
emit("acp.receive", { message });
|
|
969
1073
|
if (message.method === "session/update") {
|
|
970
|
-
|
|
971
|
-
if (turn) turn.push(message.params.update);
|
|
972
|
-
else if (loadingSessionId === message.params.sessionId) loadingUpdates.push(message.params.update);
|
|
1074
|
+
if (message.params.sessionId === sessionId) activeTurn?.push(message.params.update);
|
|
973
1075
|
return;
|
|
974
1076
|
}
|
|
975
1077
|
if (message.method === "session/request_permission") {
|
|
@@ -980,150 +1082,169 @@ export async function createFxAgent(options) {
|
|
|
980
1082
|
send({ jsonrpc: "2.0", id: message.id, result: optionId ? { outcome: { outcome: "selected", optionId } } : { outcome: { outcome: "cancelled" } } });
|
|
981
1083
|
return;
|
|
982
1084
|
}
|
|
1085
|
+
if (message.method === "libfx/tool_call") {
|
|
1086
|
+
const { content, isError, cancelled } = await executeHostTool(
|
|
1087
|
+
message.params?.name,
|
|
1088
|
+
message.params?.input,
|
|
1089
|
+
message.params?.sessionId,
|
|
1090
|
+
);
|
|
1091
|
+
if (cancelled || closing) return;
|
|
1092
|
+
send({ jsonrpc: "2.0", id: message.id, result: { content, isError } });
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
983
1095
|
const waiter = pending.get(message.id); if (!waiter) return; pending.delete(message.id);
|
|
984
1096
|
if (message.error) waiter.reject(new Error(message.error.message)); else waiter.resolve(message.result);
|
|
985
1097
|
});
|
|
986
|
-
|
|
1098
|
+
try {
|
|
1099
|
+
await request("initialize", {
|
|
1100
|
+
protocolVersion: 1,
|
|
1101
|
+
clientCapabilities: {
|
|
1102
|
+
...(hostTools.descriptors.length || instructions
|
|
1103
|
+
? { libfx: { tools: hostTools.descriptors, instructions } }
|
|
1104
|
+
: {}),
|
|
1105
|
+
},
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
const sessionResult = await request("libfx/new");
|
|
1109
|
+
sessionId = sessionResult.sessionId;
|
|
1110
|
+
if (initialCheckpoint) {
|
|
1111
|
+
await request("libfx/restore", {
|
|
1112
|
+
sessionId,
|
|
1113
|
+
checkpoint: bytesToBase64(initialCheckpoint),
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
closing = true;
|
|
1118
|
+
try { runtime.abortHostEffects(); } catch {}
|
|
1119
|
+
try { runtime.closeStdin(); } catch {}
|
|
1120
|
+
try { await runtime.exited; } catch {}
|
|
1121
|
+
throw error;
|
|
1122
|
+
}
|
|
987
1123
|
|
|
988
1124
|
const agent = {
|
|
989
|
-
|
|
990
|
-
|
|
1125
|
+
prompt(input, promptOptions = {}) {
|
|
1126
|
+
if (closing) throw new Error("fx agent is closed");
|
|
1127
|
+
if (activeTurn) throw new Error("a prompt is already in progress for this session");
|
|
1128
|
+
return normalizeTurn(startTurn(input, promptOptions));
|
|
1129
|
+
},
|
|
1130
|
+
async checkpoint() {
|
|
1131
|
+
if (closing) throw new Error("fx agent is closed");
|
|
1132
|
+
if (activeTurn) throw new Error("cannot checkpoint while a prompt is active");
|
|
1133
|
+
const response = await request("libfx/checkpoint", { sessionId });
|
|
1134
|
+
if (typeof response?.checkpoint !== "string") throw new Error("fx returned an invalid checkpoint");
|
|
1135
|
+
return base64ToBytes(response.checkpoint);
|
|
1136
|
+
},
|
|
991
1137
|
async close() {
|
|
992
|
-
if (closing)
|
|
993
|
-
|
|
1138
|
+
if (closing) { await runtime.exited; return; }
|
|
1139
|
+
const turn = activeTurn;
|
|
1140
|
+
turn?.cancel();
|
|
1141
|
+
if (turn) await turn.result.catch(() => {});
|
|
994
1142
|
closing = true;
|
|
995
1143
|
runtime.closeStdin();
|
|
996
|
-
|
|
997
|
-
},
|
|
998
|
-
async createSession() {
|
|
999
|
-
if (activeSession) await activeSession.close();
|
|
1000
|
-
const result = await request("session/new");
|
|
1001
|
-
activeSession = await makeSession(result);
|
|
1002
|
-
return activeSession;
|
|
1003
|
-
},
|
|
1004
|
-
async listSessions() {
|
|
1005
|
-
return (await request("session/list")).sessions || [];
|
|
1006
|
-
},
|
|
1007
|
-
async openSession(id) {
|
|
1008
|
-
if (activeSession) await activeSession.close();
|
|
1009
|
-
loadingSessionId = id;
|
|
1010
|
-
loadingUpdates = [];
|
|
1011
|
-
try {
|
|
1012
|
-
const result = await request("session/load", { sessionId: id });
|
|
1013
|
-
activeSession = await makeSession({ sessionId: id, history: loadingUpdates, ...result });
|
|
1014
|
-
return activeSession;
|
|
1015
|
-
} finally {
|
|
1016
|
-
loadingSessionId = null;
|
|
1017
|
-
loadingUpdates = [];
|
|
1018
|
-
}
|
|
1144
|
+
await runtime.exited;
|
|
1019
1145
|
},
|
|
1020
1146
|
};
|
|
1021
1147
|
return agent;
|
|
1022
1148
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1149
|
+
function normalizeTurn(rawTurn) {
|
|
1150
|
+
const toolNames = new Map();
|
|
1151
|
+
const started = new Set();
|
|
1152
|
+
const eventFor = (update) => {
|
|
1153
|
+
if (update.sessionUpdate === "agent_message_chunk") {
|
|
1154
|
+
const delta = update.content?.text;
|
|
1155
|
+
if (!delta || delta.startsWith("[context]")) return null;
|
|
1156
|
+
return { type: "text_delta", delta };
|
|
1157
|
+
}
|
|
1158
|
+
if (update.sessionUpdate === "agent_thought_chunk") {
|
|
1159
|
+
const delta = update.content?.text;
|
|
1160
|
+
return delta ? { type: "reasoning_delta", delta } : null;
|
|
1161
|
+
}
|
|
1162
|
+
if (update.sessionUpdate === "tool_call") {
|
|
1163
|
+
toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool");
|
|
1164
|
+
if (started.has(update.toolCallId)) return null;
|
|
1165
|
+
started.add(update.toolCallId);
|
|
1166
|
+
return {
|
|
1167
|
+
type: "tool_start",
|
|
1168
|
+
id: update.toolCallId,
|
|
1169
|
+
name: toolNames.get(update.toolCallId),
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
if (update.sessionUpdate === "tool_call_update" &&
|
|
1173
|
+
(update.status === "completed" || update.status === "failed")) {
|
|
1174
|
+
const content = update.content?.find((entry) => entry.content?.type === "text")?.content?.text;
|
|
1175
|
+
return {
|
|
1176
|
+
type: "tool_end",
|
|
1177
|
+
id: update.toolCallId,
|
|
1178
|
+
name: toolNames.get(update.toolCallId) || "tool",
|
|
1179
|
+
...(content === undefined ? {} : { content }),
|
|
1180
|
+
isError: update.status === "failed",
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
return null;
|
|
1034
1184
|
};
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
assertOpen();
|
|
1042
|
-
const previousValue = configOptions.find((option) => option.id === configId)?.currentValue;
|
|
1043
|
-
const updated = updateConfig(await request("session/set_config_option", { sessionId: result.sessionId, configId, value }));
|
|
1044
|
-
const accepted = updated.find((option) => option.id === configId)?.currentValue;
|
|
1045
|
-
if (configId === "mode" && accepted) this.modes.currentModeId = accepted;
|
|
1046
|
-
if (accepted === value) {
|
|
1047
|
-
if (options.configStore?.set) {
|
|
1048
|
-
try { await options.configStore.set(configId, value); } catch (error) { emit("config.persist_error", { configId, error }); }
|
|
1049
|
-
}
|
|
1050
|
-
emit("config.changed", { configId, previousValue, value: accepted, source });
|
|
1185
|
+
return {
|
|
1186
|
+
cancel() { rawTurn.cancel(); },
|
|
1187
|
+
async *[Symbol.asyncIterator]() {
|
|
1188
|
+
for await (const update of rawTurn) {
|
|
1189
|
+
const event = eventFor(update);
|
|
1190
|
+
if (event) yield event;
|
|
1051
1191
|
}
|
|
1052
|
-
return updated;
|
|
1053
|
-
},
|
|
1054
|
-
setModel(value) { return this.setConfigOption("model", value); },
|
|
1055
|
-
setMode(value) { return this.setConfigOption("mode", value); },
|
|
1056
|
-
async setConfig(config) {
|
|
1057
|
-
for (const [key, value] of Object.entries(config)) await this.setConfigOption(key, value);
|
|
1058
|
-
return configOptions;
|
|
1059
|
-
},
|
|
1060
|
-
async close() {
|
|
1061
|
-
if (closed) return;
|
|
1062
|
-
activeTurn?.cancel();
|
|
1063
|
-
if (activeTurn) await activeTurn.result.catch(() => {});
|
|
1064
|
-
closed = true;
|
|
1065
|
-
activeTurn = null;
|
|
1066
|
-
if (activeSession === session) activeSession = null;
|
|
1067
|
-
},
|
|
1068
|
-
async remove() {
|
|
1069
|
-
if (activeTurn) throw new Error("cannot remove a session while a prompt is active");
|
|
1070
|
-
await request("session/remove", { sessionId: result.sessionId });
|
|
1071
|
-
closed = true;
|
|
1072
|
-
if (activeSession === session) activeSession = null;
|
|
1073
1192
|
},
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
waiters.splice(0).forEach((resolve) => resolve({ done: true }));
|
|
1110
|
-
});
|
|
1111
|
-
turn.stopReason = turn.result.then((turnResult) => turnResult.stopReason);
|
|
1112
|
-
void turn.stopReason.catch(() => {});
|
|
1113
|
-
if (signal?.aborted) turn.cancel();
|
|
1114
|
-
return turn;
|
|
1193
|
+
result: rawTurn.result.then((result) => ({
|
|
1194
|
+
stopReason: result.stopReason,
|
|
1195
|
+
usage: normalizeTurnUsage(result.usage),
|
|
1196
|
+
})),
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function normalizeTurnUsage(usage) {
|
|
1201
|
+
const result = {};
|
|
1202
|
+
if (Number.isSafeInteger(usage?.inputTokens)) result.inputTokens = usage.inputTokens;
|
|
1203
|
+
if (Number.isSafeInteger(usage?.outputTokens)) result.outputTokens = usage.outputTokens;
|
|
1204
|
+
if (Number.isSafeInteger(usage?.cacheReadTokens)) result.cacheReadTokens = usage.cacheReadTokens;
|
|
1205
|
+
if (Number.isSafeInteger(usage?.cacheWriteTokens)) result.cacheWriteTokens = usage.cacheWriteTokens;
|
|
1206
|
+
if (Number.isSafeInteger(usage?.reasoningTokens)) result.reasoningTokens = usage.reasoningTokens;
|
|
1207
|
+
return result;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function startTurn(input, promptOptions) {
|
|
1211
|
+
const prompt = normalizePromptInput(input);
|
|
1212
|
+
const signal = promptOptions.signal;
|
|
1213
|
+
if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal");
|
|
1214
|
+
const queue = [];
|
|
1215
|
+
const waiters = [];
|
|
1216
|
+
const toolControllers = new Set();
|
|
1217
|
+
let finished = false;
|
|
1218
|
+
let cancelled = false;
|
|
1219
|
+
const turn = {
|
|
1220
|
+
push(update) { const waiter = waiters.shift(); if (waiter) waiter({ value: update, done: false }); else queue.push(update); },
|
|
1221
|
+
toolControllers,
|
|
1222
|
+
cancel() {
|
|
1223
|
+
if (finished || cancelled) return;
|
|
1224
|
+
cancelled = true;
|
|
1225
|
+
send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
|
|
1226
|
+
for (const controller of toolControllers) controller.abort();
|
|
1227
|
+
runtime.abortHostEffects();
|
|
1115
1228
|
},
|
|
1229
|
+
[Symbol.asyncIterator]() { return { next() { if (queue.length) return Promise.resolve({ value: queue.shift(), done: false }); if (finished) return Promise.resolve({ done: true }); return new Promise((resolve) => waiters.push(resolve)); } }; },
|
|
1116
1230
|
};
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1231
|
+
activeTurn = turn;
|
|
1232
|
+
const abort = () => turn.cancel();
|
|
1233
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1234
|
+
turn.result = request("session/prompt", { sessionId, prompt })
|
|
1235
|
+
.then((response) => ({ stopReason: response.stopReason, usage: response.usage }))
|
|
1236
|
+
.catch((error) => {
|
|
1237
|
+
if (error.message === "Cancelled") return { stopReason: "cancelled" };
|
|
1238
|
+
throw error;
|
|
1239
|
+
})
|
|
1240
|
+
.finally(() => {
|
|
1241
|
+
finished = true;
|
|
1242
|
+
signal?.removeEventListener("abort", abort);
|
|
1243
|
+
if (activeTurn === turn) activeTurn = null;
|
|
1244
|
+
toolControllers.clear();
|
|
1245
|
+
waiters.splice(0).forEach((resolve) => resolve({ done: true }));
|
|
1246
|
+
});
|
|
1247
|
+
if (signal?.aborted) turn.cancel();
|
|
1248
|
+
return turn;
|
|
1128
1249
|
}
|
|
1129
1250
|
}
|
package/fx-term.wasm
CHANGED
|
Binary file
|
package/libfx.darwin-arm64.node
CHANGED
|
Binary file
|
package/libfx.darwin-x64.node
CHANGED
|
Binary file
|
package/libfx.linux-arm64.node
CHANGED
|
Binary file
|
package/libfx.linux-x64.node
CHANGED
|
Binary file
|
package/mcp.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const maxTools = 64;
|
|
2
|
+
const maxInstructionsBytes = 64 * 1024;
|
|
3
|
+
|
|
4
|
+
function contentText(content) {
|
|
5
|
+
if (typeof content === "string") return content;
|
|
6
|
+
if (!Array.isArray(content)) return "";
|
|
7
|
+
return content.map((item) => {
|
|
8
|
+
if (item?.type === "text" && typeof item.text === "string") return item.text;
|
|
9
|
+
if (item?.type === "resource" && typeof item.resource?.text === "string") return item.resource.text;
|
|
10
|
+
if (typeof item?.text === "string") return item.text;
|
|
11
|
+
return "";
|
|
12
|
+
}).filter(Boolean).join("\n");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resultText(result) {
|
|
16
|
+
const text = contentText(result?.content);
|
|
17
|
+
if (text) return text;
|
|
18
|
+
if (result?.structuredContent !== undefined) return JSON.stringify(result.structuredContent);
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function appendInstruction(parts, label, text) {
|
|
23
|
+
if (!text) return;
|
|
24
|
+
parts.push(`<${label}>\n${text}\n</${label}>`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function createMcpAdapter(client, options = {}) {
|
|
28
|
+
if (!client || typeof client.listTools !== "function" || typeof client.callTool !== "function") {
|
|
29
|
+
throw new TypeError("MCP client must provide listTools() and callTool()");
|
|
30
|
+
}
|
|
31
|
+
const prefix = options.prefix ?? "";
|
|
32
|
+
if (typeof prefix !== "string" || !/^[A-Za-z0-9_-]*$/.test(prefix)) {
|
|
33
|
+
throw new TypeError("MCP prefix must contain only letters, digits, underscore, or hyphen");
|
|
34
|
+
}
|
|
35
|
+
const listed = await client.listTools();
|
|
36
|
+
const catalog = Array.isArray(listed) ? listed : listed?.tools;
|
|
37
|
+
if (!Array.isArray(catalog) || catalog.length > maxTools) {
|
|
38
|
+
throw new TypeError("MCP listTools() returned an invalid tool catalog");
|
|
39
|
+
}
|
|
40
|
+
const tools = catalog.map((tool, index) => {
|
|
41
|
+
if (!tool || typeof tool.name !== "string" || typeof tool.description !== "string") {
|
|
42
|
+
throw new TypeError(`MCP tool ${index} is invalid`);
|
|
43
|
+
}
|
|
44
|
+
const name = `${prefix}${tool.name}`;
|
|
45
|
+
return {
|
|
46
|
+
name,
|
|
47
|
+
description: tool.description,
|
|
48
|
+
inputSchema: tool.inputSchema ?? { type: "object", properties: {} },
|
|
49
|
+
async execute(input, { signal }) {
|
|
50
|
+
const result = await client.callTool({ name: tool.name, arguments: input }, { signal });
|
|
51
|
+
const text = resultText(result);
|
|
52
|
+
if (result?.isError) throw new Error(text || `MCP tool ${tool.name} failed`);
|
|
53
|
+
return text;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const instructions = [];
|
|
59
|
+
for (const uri of options.resources ?? []) {
|
|
60
|
+
if (typeof client.readResource !== "function") throw new TypeError("MCP client does not provide readResource()");
|
|
61
|
+
const result = await client.readResource({ uri });
|
|
62
|
+
appendInstruction(instructions, "mcp_resource", contentText(result?.contents ?? result?.content));
|
|
63
|
+
}
|
|
64
|
+
for (const prompt of options.prompts ?? []) {
|
|
65
|
+
if (typeof client.getPrompt !== "function") throw new TypeError("MCP client does not provide getPrompt()");
|
|
66
|
+
const request = typeof prompt === "string" ? { name: prompt } : prompt;
|
|
67
|
+
const result = await client.getPrompt(request);
|
|
68
|
+
appendInstruction(
|
|
69
|
+
instructions,
|
|
70
|
+
"mcp_prompt",
|
|
71
|
+
(result?.messages ?? []).map((message) => contentText(message.content)).filter(Boolean).join("\n"),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const instructionText = instructions.join("\n\n");
|
|
75
|
+
if (new TextEncoder().encode(instructionText).length > maxInstructionsBytes) {
|
|
76
|
+
throw new RangeError(`MCP instructions exceed the ${maxInstructionsBytes} byte libfx limit`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let closed = false;
|
|
80
|
+
return {
|
|
81
|
+
tools,
|
|
82
|
+
instructions: instructionText,
|
|
83
|
+
async close() {
|
|
84
|
+
if (closed) return;
|
|
85
|
+
closed = true;
|
|
86
|
+
await client.close?.();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
package/node.js
CHANGED
|
@@ -27,6 +27,7 @@ const defaultNativeCandidates = [
|
|
|
27
27
|
`./libfx.${process.platform}-${process.arch}.node`,
|
|
28
28
|
];
|
|
29
29
|
let nativeBackendPromise;
|
|
30
|
+
const wasmFilePromises = new Map();
|
|
30
31
|
|
|
31
32
|
function jspiFallbackError(surface, nativeError) {
|
|
32
33
|
const nativeDetail = nativeError ? ` Native loading failed: ${nativeError.message}.` : " No compatible native addon was found.";
|
|
@@ -104,10 +105,19 @@ async function resolveNativeBackend(nativeAddon) {
|
|
|
104
105
|
return nativeBackendPromise;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
|
|
108
|
+
function wasmBytes(input) {
|
|
109
|
+
let path;
|
|
110
|
+
if (input instanceof URL && input.protocol === "file:") path = fileURLToPath(input);
|
|
111
|
+
else if (typeof input === "string" && !URL.canParse(input)) path = resolve(input);
|
|
112
|
+
else return input;
|
|
113
|
+
const cached = wasmFilePromises.get(path);
|
|
114
|
+
if (cached) return cached;
|
|
115
|
+
const pending = readFile(path);
|
|
116
|
+
wasmFilePromises.set(path, pending);
|
|
117
|
+
pending.catch(() => {
|
|
118
|
+
if (wasmFilePromises.get(path) === pending) wasmFilePromises.delete(path);
|
|
119
|
+
});
|
|
120
|
+
return pending;
|
|
111
121
|
}
|
|
112
122
|
|
|
113
123
|
function validateGatewayChatUrl(value) {
|
|
@@ -218,7 +228,7 @@ function createNativeCoreRuntime(addon, options) {
|
|
|
218
228
|
if (newline < 0) break;
|
|
219
229
|
const line = lineBuffer.slice(0, newline);
|
|
220
230
|
lineBuffer = lineBuffer.slice(newline + 1);
|
|
221
|
-
if (line) lineHandler(JSON.parse(line));
|
|
231
|
+
if (line) void Promise.resolve(lineHandler(JSON.parse(line))).catch(() => finish(1));
|
|
222
232
|
}
|
|
223
233
|
}
|
|
224
234
|
if (addon.coreExited(core)) finish(addon.coreExitCode(core));
|
|
@@ -254,11 +264,13 @@ async function createWithFallback(surface, nativeMethod, wasmFactory, defaultWas
|
|
|
254
264
|
}
|
|
255
265
|
|
|
256
266
|
let nativeError;
|
|
267
|
+
let nativeAttempted = false;
|
|
257
268
|
if (backend !== "wasm") {
|
|
258
269
|
const native = await resolveNativeBackend(nativeAddon);
|
|
259
270
|
nativeError = native.error;
|
|
260
271
|
if (typeof native.backend?.[nativeMethod] === "function" ||
|
|
261
272
|
(surface === "agent" && typeof native.backend?.createCore === "function")) {
|
|
273
|
+
nativeAttempted = true;
|
|
262
274
|
try {
|
|
263
275
|
if (typeof native.backend?.[nativeMethod] === "function") {
|
|
264
276
|
return await native.backend[nativeMethod](runtimeOptions);
|
|
@@ -276,7 +288,10 @@ async function createWithFallback(surface, nativeMethod, wasmFactory, defaultWas
|
|
|
276
288
|
}
|
|
277
289
|
}
|
|
278
290
|
|
|
279
|
-
if (!supportsJspi())
|
|
291
|
+
if (!supportsJspi()) {
|
|
292
|
+
if (nativeAttempted) throw nativeError;
|
|
293
|
+
throw jspiFallbackError(surface, nativeError);
|
|
294
|
+
}
|
|
280
295
|
return wasmFactory({
|
|
281
296
|
...runtimeOptions,
|
|
282
297
|
wasm: await wasmBytes(runtimeOptions.wasm ?? defaultWasm),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "libfx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7-dev.607.ga90fc3585568",
|
|
4
4
|
"description": "Embed fx agents and terminals in JavaScript hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
},
|
|
24
24
|
"./node": "./node.js",
|
|
25
25
|
"./browser": "./browser.js",
|
|
26
|
-
"./wasm": "./fx-sdk.js"
|
|
26
|
+
"./wasm": "./fx-sdk.js",
|
|
27
|
+
"./mcp": "./mcp.js",
|
|
28
|
+
"./skills": "./skills.js",
|
|
29
|
+
"./skills/node": "./skills-node.js"
|
|
27
30
|
},
|
|
28
31
|
"engines": {
|
|
29
32
|
"node": ">=20"
|
package/skills-node.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
function parseFrontmatter(source) {
|
|
5
|
+
if (!source.startsWith("---\n")) return { metadata: {}, body: source };
|
|
6
|
+
const end = source.indexOf("\n---\n", 4);
|
|
7
|
+
if (end < 0) throw new Error("SKILL.md has unterminated frontmatter");
|
|
8
|
+
const metadata = {};
|
|
9
|
+
for (const line of source.slice(4, end).split("\n")) {
|
|
10
|
+
const separator = line.indexOf(":");
|
|
11
|
+
if (separator < 0) continue;
|
|
12
|
+
metadata[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
|
|
13
|
+
}
|
|
14
|
+
return { metadata, body: source.slice(end + 5).trim() };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function loadSkillFile(path, options = {}) {
|
|
18
|
+
const source = await (options.readFile ?? readFile)(path, "utf8");
|
|
19
|
+
const { metadata, body } = parseFrontmatter(source);
|
|
20
|
+
return {
|
|
21
|
+
name: metadata.name || basename(path).replace(/\.md$/i, ""),
|
|
22
|
+
description: metadata.description || "",
|
|
23
|
+
instructions: body,
|
|
24
|
+
resources: options.resources ?? [],
|
|
25
|
+
tools: options.tools ?? [],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { createSkillsAdapter } from "./skills.js";
|
package/skills.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const maxSkills = 64;
|
|
2
|
+
const maxInstructionsBytes = 64 * 1024;
|
|
3
|
+
|
|
4
|
+
function escapeAttribute(value) {
|
|
5
|
+
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createSkillsAdapter(records) {
|
|
9
|
+
if (!Array.isArray(records) || records.length > maxSkills) {
|
|
10
|
+
throw new TypeError("skills must be an array with at most 64 records");
|
|
11
|
+
}
|
|
12
|
+
const names = new Set();
|
|
13
|
+
const sections = [];
|
|
14
|
+
const tools = [];
|
|
15
|
+
for (const [index, record] of records.entries()) {
|
|
16
|
+
if (!record || typeof record.name !== "string" || typeof record.instructions !== "string") {
|
|
17
|
+
throw new TypeError(`skill ${index} requires name and instructions`);
|
|
18
|
+
}
|
|
19
|
+
if (names.has(record.name)) throw new TypeError(`duplicate skill name: ${record.name}`);
|
|
20
|
+
names.add(record.name);
|
|
21
|
+
const resources = (record.resources ?? []).map((resource) => {
|
|
22
|
+
if (typeof resource?.uri !== "string" || typeof resource?.text !== "string") {
|
|
23
|
+
throw new TypeError(`skill ${record.name} has an invalid resource`);
|
|
24
|
+
}
|
|
25
|
+
return `<resource uri="${escapeAttribute(resource.uri)}">\n${resource.text}\n</resource>`;
|
|
26
|
+
}).join("\n");
|
|
27
|
+
sections.push([
|
|
28
|
+
`<skill name="${escapeAttribute(record.name)}">`,
|
|
29
|
+
record.description ? `<description>${record.description}</description>` : "",
|
|
30
|
+
record.instructions,
|
|
31
|
+
resources,
|
|
32
|
+
"</skill>",
|
|
33
|
+
].filter(Boolean).join("\n"));
|
|
34
|
+
if (record.tools !== undefined) {
|
|
35
|
+
if (!Array.isArray(record.tools)) throw new TypeError(`skill ${record.name} tools must be an array`);
|
|
36
|
+
tools.push(...record.tools);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const instructions = sections.join("\n\n");
|
|
40
|
+
if (new TextEncoder().encode(instructions).length > maxInstructionsBytes) {
|
|
41
|
+
throw new RangeError(`skill instructions exceed the ${maxInstructionsBytes} byte libfx limit`);
|
|
42
|
+
}
|
|
43
|
+
return { instructions, tools };
|
|
44
|
+
}
|