libfx 0.0.13-dev.1470.g5ec2519d98b9 → 0.0.13-dev.1506.gbc968ad65622
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 +238 -107
- package/browser.js +1 -1
- package/durable/local.mjs +55 -55
- package/durable/vercel.mjs +64 -64
- package/durable/world.mjs +1 -1
- package/durable.js +504 -104
- package/fx-core.wasm +0 -0
- package/fx-harness.js +20 -4
- package/fx-sdk.js +92 -36
- 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 +3 -0
- package/node.cjs +446 -125
- package/node.js +22 -6
- package/package.json +1 -1
- package/types/browser.d.ts +5 -1
- package/types/durable-vercel.d.ts +8 -2
- package/types/durable-world.d.ts +16 -0
- package/types/libfx.d.cts +62 -8
- package/types/mcp.d.ts +3 -2
- package/types/node.d.cts +6 -1
- package/types/skills-node.d.ts +3 -3
- package/types/skills.d.ts +4 -3
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# libfx
|
|
2
2
|
|
|
3
|
-
libfx is the agent from [fx](https://github.com/vercel-labs/fx), the open source coding agent from Vercel Labs, as a JavaScript library. You give it a model, instructions and your own tools, and it runs the conversation: streaming, tool calls, retries, steering and images. It's the same Zig engine that runs fx in
|
|
3
|
+
libfx is the agent from [fx](https://github.com/vercel-labs/fx), the open source coding agent from Vercel Labs, as a JavaScript library. You give it a model, instructions and your own tools, and it runs the conversation: the model loop, streaming, tool calls, retries, steering and images. It's the same Zig engine that runs fx in a terminal, built as a Node.js addon and as WebAssembly.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The hard part of running an agent on a server is that the server goes away. A function reaches its time limit in the middle of a reply, a deploy replaces the process, or it crashes during a tool call. libfx stores every prompt before it accepts it and every step of a turn as the turn runs, so the next process that picks up the session continues the turn where it stopped. On Vercel, that takes one route and one line of `vercel.json`.
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
npm install libfx
|
|
@@ -25,43 +25,40 @@ for await (const event of turn) {
|
|
|
25
25
|
await agent.close();
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
Model calls go through [AI Gateway](https://vercel.com/docs/ai-gateway), so any model it lists works
|
|
28
|
+
Set `AI_GATEWAY_API_KEY` and run it with Node.js 20 or newer. Model calls go through [AI Gateway](https://vercel.com/docs/ai-gateway), so any model it lists works, and on Vercel libfx uses the deployment's OIDC token instead of a key. libfx can also run on a user's own ChatGPT or Grok plan, or on another server through the OpenAI Chat Completions API. See [Providers](#providers).
|
|
29
29
|
|
|
30
|
-
Importing libfx
|
|
30
|
+
Importing libfx and calling `createFxAgent()` touch no network, files or processes. The engine loads when a session runs its first turn.
|
|
31
31
|
|
|
32
32
|
The full documentation is at [fx.sh/docs/lib](https://fx.sh/docs/lib).
|
|
33
33
|
|
|
34
|
-
##
|
|
34
|
+
## How it fits together
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
- Takes [images](https://fx.sh/docs/lib/images) in prompts and tool results
|
|
42
|
-
- Gives each conversation its own model, instructions and context
|
|
43
|
-
- Runs on AI Gateway, on a user's own [ChatGPT](#chatgpt-plans) or [Grok](#grok-plans) plan, or on [any OpenAI-compatible server](#custom-providers)
|
|
44
|
-
- Uses a native addon on macOS and Linux, and WebAssembly everywhere else, including browsers
|
|
45
|
-
- Puts the [fx terminal](https://fx.sh/docs/lib/webassembly#embed-the-terminal) in a web page
|
|
46
|
-
- Ships TypeScript types for every entry point, with no runtime dependencies
|
|
36
|
+
| Object | What it is | How long it lives |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| Agent | Configuration: model, instructions, tools, provider and where sessions live | As long as the server process |
|
|
39
|
+
| Session | One conversation | As long as its store keeps it |
|
|
40
|
+
| Turn | One prompt and everything the agent does to answer it | Until it ends with a stop reason |
|
|
47
41
|
|
|
48
|
-
|
|
42
|
+
An agent is configuration, and its conversations live in its sessions. Create each agent once per process with the same options on every server, and open a session for each conversation. Any process with that agent can run, continue or stream any of its sessions. An app can have [several agents](#several-agents), each with its own name.
|
|
43
|
+
|
|
44
|
+
Underneath the agent is `createFxEngine()`, a single conversation with no store, for when you don't want sessions. See [Without sessions](#without-sessions).
|
|
49
45
|
|
|
50
|
-
|
|
46
|
+
## Sessions
|
|
51
47
|
|
|
52
48
|
```js
|
|
53
49
|
const turn = agent.session().prompt("Plan the migration.");
|
|
54
|
-
const { sessionId } = await turn.accepted;
|
|
50
|
+
const { sessionId } = await turn.accepted; // the prompt is stored
|
|
55
51
|
|
|
56
|
-
// Later,
|
|
52
|
+
// Later, in any process that creates the same agent:
|
|
57
53
|
agent.session(sessionId).prompt("Start with the schema.");
|
|
58
54
|
```
|
|
59
55
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
- **One turn at a time.** A prompt sent while a turn runs waits behind it, even when it reaches a different server.
|
|
57
|
+
- **Turns finish on their own.** A turn runs to the end whether or not anyone reads its events, and a turn nobody reads holds no connection open: its view starts reading only once you use its events, `readable` or `result`. Stop it with `session.cancel()`, or with the `signal` you passed to `prompt()`.
|
|
58
|
+
- **Steering.** `session.steer(text)` adds guidance to the running turn at its next model request. When no turn is running, the text becomes the next turn.
|
|
59
|
+
- **Retries are safe.** Give a prompt a `messageId`, and a repeated request with the same id runs nothing new. It follows the original turn, and its result has `repeated: true`.
|
|
63
60
|
|
|
64
|
-
Each conversation can bring its own settings
|
|
61
|
+
Each conversation can bring its own settings, which apply to the turns that session object starts:
|
|
65
62
|
|
|
66
63
|
```js
|
|
67
64
|
agent.session(sessionId, {
|
|
@@ -71,9 +68,17 @@ agent.session(sessionId, {
|
|
|
71
68
|
});
|
|
72
69
|
```
|
|
73
70
|
|
|
71
|
+
A turn's result says how it ended:
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
const { stopReason, usage, error } = await turn.result;
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`stopReason` is `end_turn`, `max_output_tokens`, `max_model_turns`, `refused` or `cancelled`. It's `error` when the turn failed, with the reason in `error`, and `unknown` when the process running it stopped before recording how it ended.
|
|
78
|
+
|
|
74
79
|
### Streaming to a browser
|
|
75
80
|
|
|
76
|
-
`turn.readable` is the turn as newline-delimited JSON, ready to return from a route.
|
|
81
|
+
`turn.readable` is the turn as newline-delimited JSON, ready to return from a route. `session.stream(cursor)` returns every line after `cursor` and stays open for new ones, so a client that loses its connection reconnects to any server and carries on.
|
|
77
82
|
|
|
78
83
|
```js
|
|
79
84
|
// app/api/chat/route.js
|
|
@@ -91,22 +96,23 @@ export async function GET(request) {
|
|
|
91
96
|
const sessionId = params.get("sessionId");
|
|
92
97
|
if (!sessionId) return new Response("sessionId is required", { status: 400 });
|
|
93
98
|
// Same check as POST.
|
|
94
|
-
|
|
95
|
-
return new Response(stream);
|
|
99
|
+
return new Response(agent.session(sessionId).stream(Number(params.get("cursor") ?? 0)));
|
|
96
100
|
}
|
|
97
101
|
```
|
|
98
102
|
|
|
103
|
+
Each line is one JSON object with a `type`: the model's events, such as `text_delta` and `tool_start`, and the turn's own, `turn_start` (which carries the `sessionId`), `turn_end`, `turn_resume` and `turn_yield`. Lines from `session.stream()` carry a `cursor`. Store the last one you read and pass it back to resume after it. The `FxStreamLine` type lists every field.
|
|
104
|
+
|
|
99
105
|
## Durability
|
|
100
106
|
|
|
101
|
-
libfx stores a prompt before
|
|
107
|
+
libfx stores a prompt before `turn.accepted` resolves, and stores each step of a turn while the turn runs. If the process running a turn dies, the next process that gets work for the session continues the turn from its last stored step. The model is told the turn was interrupted, so it can check what happened before going on.
|
|
102
108
|
|
|
103
|
-
Where sessions live depends on where your code runs
|
|
109
|
+
Where sessions live depends on where your code runs. libfx picks for you:
|
|
104
110
|
|
|
105
111
|
| Where | Durability | Sessions live in |
|
|
106
112
|
| --- | --- | --- |
|
|
107
|
-
| Vercel | `vercel()` | Vercel's [Workflow World](https://workflow-sdk.dev/worlds) |
|
|
108
|
-
| Node.js | `local()` |
|
|
109
|
-
| Browsers | `memory()` |
|
|
113
|
+
| Vercel | `vercel()` | Vercel's [Workflow World](https://workflow-sdk.dev/worlds), the store and queue behind Vercel Workflow |
|
|
114
|
+
| Node.js | `local()` | Files in `FX_SESSIONS_DIR`, or in `libfx/sessions` under the system's temporary directory |
|
|
115
|
+
| Browsers | `memory()` | The page's memory |
|
|
110
116
|
|
|
111
117
|
To choose yourself, pass `durability`:
|
|
112
118
|
|
|
@@ -115,14 +121,28 @@ import { createFxAgent, memory } from "libfx";
|
|
|
115
121
|
import { local } from "libfx/durable-local";
|
|
116
122
|
|
|
117
123
|
createFxAgent({ durability: local({ dir: ".fx/sessions" }) });
|
|
118
|
-
createFxAgent({ durability: memory() }); // nothing
|
|
124
|
+
createFxAgent({ durability: memory() }); // nothing outlives the process
|
|
119
125
|
```
|
|
120
126
|
|
|
121
|
-
|
|
127
|
+
On `local()`, `vercel()` and `world()`, a session id is a Workflow run id, `wrun_` followed by letters and digits. Take it from `turn.accepted` or `await agent.newSessionId()` instead of making one up. On `memory()`, any 1 to 128 letters, digits, `.`, `_` or `-` work.
|
|
128
|
+
|
|
129
|
+
A session stores the conversation and nothing else. Every process passes its own model, credentials, tools, MCP clients and skills when it creates the agent. If a session's saved checkpoint came from a different libfx version, tool set or model, the session still loads, and `onEvent` gets a `checkpoint.mismatch` event naming what changed.
|
|
122
130
|
|
|
123
|
-
|
|
131
|
+
### Several agents
|
|
124
132
|
|
|
125
|
-
|
|
133
|
+
An app can have more than one durable agent, each with its own model, instructions and tools. Give each one a name:
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
export const support = createFxAgent({ name: "support", instructions: "Answer billing questions." });
|
|
137
|
+
export const coding = createFxAgent({ name: "coding", model: "anthropic/claude-opus-5.5", tools: codingTools });
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A session belongs to the agent that created it, and its turns run only on an agent with that name. A prompt sent through another agent, such as `coding.session(supportSessionId).prompt()`, still runs on `support`, and the sending agent's `onEvent` gets a `session.forwarded` event. An agent created without a name is named `default`.
|
|
141
|
+
|
|
142
|
+
- Agents with the same name are copies of one agent, such as one on each server, and take turns running its sessions.
|
|
143
|
+
- Keep names stable. A session keeps the name it was created with, so renaming an agent leaves its sessions with no agent to run them.
|
|
144
|
+
- A process that gets a turn for a name it has no agent for refuses it with an `FX_NO_AGENT` error naming the agent, and the queue delivers it again.
|
|
145
|
+
- Agents share sessions through their durability. Give agents that share sessions the same durability, or none: agents created without one share the default.
|
|
126
146
|
|
|
127
147
|
### On Vercel
|
|
128
148
|
|
|
@@ -130,30 +150,89 @@ Add a route for libfx's queue deliveries:
|
|
|
130
150
|
|
|
131
151
|
```js
|
|
132
152
|
// app/api/libfx/route.js
|
|
133
|
-
import {
|
|
153
|
+
import { wakeHandler } from "libfx";
|
|
154
|
+
import "@/lib/agents"; // creates the app's agents, so this function has them
|
|
134
155
|
|
|
135
|
-
export const POST =
|
|
156
|
+
export const POST = wakeHandler();
|
|
136
157
|
```
|
|
137
158
|
|
|
159
|
+
The route delivers each turn to the agent it belongs to, so one route serves every agent the module it imports creates. With one agent, `agent.wakeHandler()` does the same.
|
|
160
|
+
|
|
138
161
|
Then subscribe it to libfx's topic in `vercel.json`:
|
|
139
162
|
|
|
140
163
|
```json
|
|
141
164
|
{
|
|
142
165
|
"functions": {
|
|
143
166
|
"app/api/libfx/route.js": {
|
|
144
|
-
"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "__libfx_wkf_workflow_session" }]
|
|
167
|
+
"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "__libfx_wkf_workflow_session", "retryAfterSeconds": 5 }]
|
|
145
168
|
}
|
|
146
169
|
}
|
|
147
170
|
}
|
|
148
171
|
```
|
|
149
172
|
|
|
150
|
-
That's all. `prompt()`
|
|
173
|
+
That's all. When no other invocation holds the session, `prompt()` runs the turn in the request that called it, so the first model request goes out without a queue hop, and the function stays alive for the turn after the route responds. Vercel Queues keeps the prompt until after that request's time limit, and if the request dies mid-turn, that delivery continues the turn. Other turns go through Vercel Queues to this route:
|
|
174
|
+
|
|
175
|
+
- When a turn gets within 30 seconds of the function's time limit, libfx stops it, and the next invocation continues it. `vercel({ reserveMs })` from `libfx/durable-vercel` changes that margin.
|
|
176
|
+
- A function holds its session for as long as Vercel Queues keeps the turn's message for it, so nothing is written while a turn runs to show the function is alive. If the function's process exits mid-turn, Vercel runs the same delivery again about a second later, and that invocation continues the turn.
|
|
177
|
+
- A function that stops without its process exiting holds the message until the queue's hold on it runs out, up to five minutes. A prompt sent to the session meanwhile continues the turn at that function's time limit, if that comes sooner. `vercel({ leaseMs: 15_000 })` bounds that case: the function renews a 15-second lease on its session every 5 seconds, and the session frees about 15 seconds after the last renewal. With it, every prompt goes through the queue instead of running in the request.
|
|
178
|
+
- A function that only stalled and wakes up after that is refused at its next write, so two processes never write one session.
|
|
179
|
+
|
|
180
|
+
### Tools that change things
|
|
181
|
+
|
|
182
|
+
When a process dies during a tool call, libfx can't know whether the call finished. So each tool says whether it's safe to run twice:
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
const byId = { type: "object", properties: { id: { type: "string" } }, required: ["id"] };
|
|
186
|
+
|
|
187
|
+
const tools = [
|
|
188
|
+
{
|
|
189
|
+
name: "get_order",
|
|
190
|
+
description: "Look up an order.",
|
|
191
|
+
inputSchema: byId,
|
|
192
|
+
idempotent: true, // a lookup is harmless to repeat
|
|
193
|
+
execute: ({ id }) => orders.get(id),
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "refund_order",
|
|
197
|
+
description: "Refund an order.",
|
|
198
|
+
inputSchema: byId,
|
|
199
|
+
idempotent: true, // keyed on executionId, so running it again refunds once
|
|
200
|
+
writes: true,
|
|
201
|
+
execute: ({ id }, { executionId }) => payments.refund(id, { idempotencyKey: executionId }),
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
When the turn continues, a call to an `idempotent: true` tool runs again. A call to any other tool never runs twice on its own: the model gets an error saying the call may have partly run, and decides whether to check, try again or ask the user. The turn's events end the call with that error too, as a `tool_end` with `isError: true`. libfx refuses a tool that doesn't set `idempotent`, so each one makes that choice. A call that finished before the process died keeps its result, whichever kind of tool it is: libfx stores each result as the call finishes.
|
|
207
|
+
|
|
208
|
+
Each call's `executionId` stays the same when it runs again. Pass it to the service your tool calls as an idempotency key, and a service that honors idempotency keys won't refund twice. That makes the tool safe to mark `idempotent: true` with `writes: true`.
|
|
209
|
+
|
|
210
|
+
A tool marked `idempotent: true` without `writes: true` starts as soon as the model asks for it, while libfx stores the call. If the process dies before the call is stored, the model asks again and the call gets a new `executionId`, which is harmless for a lookup. Every other tool starts only once its call is stored, so its `executionId` never changes.
|
|
151
211
|
|
|
152
|
-
###
|
|
212
|
+
### Your own store
|
|
153
213
|
|
|
154
|
-
|
|
214
|
+
`world()` from `libfx/durable-world` keeps sessions in a [Workflow World](https://workflow-sdk.dev/worlds) you create, such as Workflow's Postgres World:
|
|
155
215
|
|
|
156
|
-
|
|
216
|
+
```js
|
|
217
|
+
import { createWorld } from "@workflow/world-postgres";
|
|
218
|
+
import { createFxAgent } from "libfx";
|
|
219
|
+
import { world } from "libfx/durable-world";
|
|
220
|
+
|
|
221
|
+
const sessions = createWorld({
|
|
222
|
+
connectionString: process.env.DATABASE_URL,
|
|
223
|
+
namespace: "libfx",
|
|
224
|
+
jobPrefix: "libfx_",
|
|
225
|
+
});
|
|
226
|
+
await sessions.start();
|
|
227
|
+
|
|
228
|
+
export const agent = createFxAgent({
|
|
229
|
+
durability: world(sessions, { name: "postgres", queueDurable: true }),
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
A World that delivers queue messages over HTTP needs `agent.wakeHandler()`, or `wakeHandler()` for several agents, at the address it posts to. Workflow's Postgres World 5.0.2 posts every message to `/.well-known/workflow/v1/flow` on your app. `world()` takes options for what libfx can't learn from the World itself, such as how long a lease lasts and whether the queue keeps a message across crashes. The `WorldOptions` type documents each one.
|
|
234
|
+
|
|
235
|
+
On a World with Workflow's in-band writer fence, libfx writes each claim and record so that the World itself refuses a write from a worker another has replaced, and nothing of it is stored. libfx finds the fence in the World's capabilities, so there's nothing to configure. On a World without it, libfx detects the replaced worker after its write instead, and the session's log ignores what that worker wrote.
|
|
157
236
|
|
|
158
237
|
## Tools
|
|
159
238
|
|
|
@@ -164,16 +243,29 @@ const agent = createFxAgent({
|
|
|
164
243
|
description: "Look up an order by id.",
|
|
165
244
|
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
|
|
166
245
|
idempotent: true,
|
|
167
|
-
async execute({ id }, { signal
|
|
246
|
+
async execute({ id }, { signal }) {
|
|
168
247
|
return orders.get(id, { signal });
|
|
169
248
|
},
|
|
170
249
|
}],
|
|
171
250
|
});
|
|
172
251
|
```
|
|
173
252
|
|
|
174
|
-
`execute`
|
|
253
|
+
`execute` receives the model's input and a context:
|
|
254
|
+
|
|
255
|
+
| Field | What it is |
|
|
256
|
+
| --- | --- |
|
|
257
|
+
| `signal` | Aborts when the turn is cancelled |
|
|
258
|
+
| `executionId` | The call's id, the same if the call runs again after a crash |
|
|
259
|
+
| `sessionId` | The session the call belongs to |
|
|
260
|
+
| `context` | The JSON passed as `context` to `agent.session()` |
|
|
261
|
+
|
|
262
|
+
Return a string to send it as is, or any other value to send it as JSON. A thrown error reaches the model as a failed tool result. To return images, return `{ type: "libfx.tool-result", text, images }`, with base64 image data.
|
|
263
|
+
|
|
264
|
+
Validate the input and authorize the action inside `execute`. libfx orders the calls but grants nothing itself.
|
|
265
|
+
|
|
266
|
+
On the native backend, calls to idempotent tools from one model response run at the same time, up to 16 at once. A tool that isn't idempotent runs alone, and so does one marked `writes: true`. Mark a tool `writes: false` to let it overlap with others anyway. On WebAssembly, calls run one after another.
|
|
175
267
|
|
|
176
|
-
|
|
268
|
+
`tools` can also be an object keyed by tool name. Add `{ name: "web_search", providerExecuted: true }` for AI Gateway's web search, which runs at the provider instead of in your code.
|
|
177
269
|
|
|
178
270
|
### MCP and skills
|
|
179
271
|
|
|
@@ -195,23 +287,29 @@ await agent.close();
|
|
|
195
287
|
await mcp.close();
|
|
196
288
|
```
|
|
197
289
|
|
|
198
|
-
libfx doesn't own the MCP connection. You create the client, handle its auth and close it after the agent.
|
|
290
|
+
libfx doesn't own the MCP connection. You create the client, handle its auth and close it after the agent. `loadSkillFile()` reads one `SKILL.md` and nothing it references.
|
|
291
|
+
|
|
292
|
+
Every MCP tool comes with `idempotent: false`, since a server's hints can't be trusted. Set it to `true` on a tool you know is safe to run twice. A skill's tools set `idempotent` themselves.
|
|
199
293
|
|
|
200
294
|
## Models
|
|
201
295
|
|
|
202
296
|
```js
|
|
203
|
-
createFxAgent({ model: { id: "anthropic/claude-opus-5.5", effort: "
|
|
297
|
+
createFxAgent({ model: { id: "anthropic/claude-opus-5.5", effort: "high", fast: true } });
|
|
204
298
|
```
|
|
205
299
|
|
|
206
300
|
`effort` and `fast` mean the same as the fx CLI's `--effort` and `--fast` flags. `ultrafast: true` asks for the faster, more expensive service tier on models that offer it. A setting the model doesn't support fails with an error whose `code` names it, such as `LIBFX_MODEL_UNSUPPORTED_EFFORT`.
|
|
207
301
|
|
|
208
|
-
`listModels({ apiKey })` returns the Gateway's language models. Behind a proxy that only
|
|
302
|
+
`listModels({ apiKey })` returns the ids of AI Gateway's language models. Behind a proxy that forwards only chat requests, pass the catalog yourself as `modelCatalog`, and the agent won't fetch it.
|
|
303
|
+
|
|
304
|
+
## Providers
|
|
305
|
+
|
|
306
|
+
### AI Gateway
|
|
209
307
|
|
|
210
|
-
|
|
308
|
+
AI Gateway is the default. libfx uses the first credential it finds: the `apiKey` option, then `AI_GATEWAY_API_KEY`, then on Vercel the deployment's OIDC token.
|
|
211
309
|
|
|
212
310
|
### ChatGPT plans
|
|
213
311
|
|
|
214
|
-
An agent can run on a user's ChatGPT plan through Codex
|
|
312
|
+
An agent can run on a user's ChatGPT plan through Codex. Set `provider: "codex"` and give libfx the user's credentials:
|
|
215
313
|
|
|
216
314
|
```js
|
|
217
315
|
const agent = createFxAgent({
|
|
@@ -222,15 +320,7 @@ const agent = createFxAgent({
|
|
|
222
320
|
});
|
|
223
321
|
```
|
|
224
322
|
|
|
225
|
-
libfx calls `credentials()`
|
|
226
|
-
|
|
227
|
-
Sessions in one process share each refresh. If your app runs on several servers, two of them can notice the same expiring credentials at once, and the provider accepts only the first refresh. Pass `refreshLock` so they take turns:
|
|
228
|
-
|
|
229
|
-
```js
|
|
230
|
-
refreshLock: ({ provider, sessionId }, refresh) => locks.withLock(`${provider}:${accountOf(sessionId)}`, refresh),
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
libfx calls it with a function to run while you hold a lock on that user's account, for example in Redis. Inside it, libfx asks `credentials()` again and uses what another server already saved. This works for Grok too.
|
|
323
|
+
libfx calls `credentials()` in whichever process runs the turn. It refreshes the credentials shortly before they expire and once after a rejected request, then passes the new ones to `onCredentials` for you to save. Codex doesn't accept requests from web pages, so these turns run on your server.
|
|
234
324
|
|
|
235
325
|
To sign a user in, use `libfx/chatgpt`:
|
|
236
326
|
|
|
@@ -240,28 +330,17 @@ import { startSignIn, finishSignIn } from "libfx/chatgpt";
|
|
|
240
330
|
// One request: send the user to signIn.url and keep signIn on the server.
|
|
241
331
|
const signIn = await startSignIn();
|
|
242
332
|
|
|
243
|
-
// A later request: the user pastes the address OpenAI opened.
|
|
333
|
+
// A later request: the user pastes back the address OpenAI opened.
|
|
244
334
|
const credentials = await finishSignIn(signIn, pastedAddress);
|
|
245
335
|
```
|
|
246
336
|
|
|
247
|
-
After the user signs in, OpenAI opens a page at `localhost:1455` that doesn't load. That's expected
|
|
337
|
+
After the user signs in, OpenAI opens a page at `localhost:1455` that doesn't load. That's expected: ask the user to copy the page's whole address and paste it back. If their account or ChatGPT workspace allows device code sign-in, `startDeviceSignIn()` returns a short code to enter on OpenAI's site instead, and `pollDeviceSignIn()` or `completeDeviceSignIn()` waits for it. Each step returns plain data, so a later request or function can finish it.
|
|
248
338
|
|
|
249
339
|
The sign-in uses the same OpenAI client as the Codex CLI, and the user's ChatGPT plan and OpenAI's terms apply to the turns.
|
|
250
340
|
|
|
251
341
|
### Grok plans
|
|
252
342
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
```js
|
|
256
|
-
const agent = createFxAgent({
|
|
257
|
-
provider: "grok",
|
|
258
|
-
model: "grok-4.5",
|
|
259
|
-
credentials: ({ provider, sessionId }) => tokens.load(provider, sessionId),
|
|
260
|
-
onCredentials: (credentials, { provider, sessionId }) => tokens.save(provider, sessionId, credentials),
|
|
261
|
-
});
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
libfx handles Grok credentials the same way as Codex's: it adds them to Grok requests itself, refreshes them, and checks that a refreshed token still belongs to the same xAI account. Grok doesn't accept requests from web pages either, so these turns run on your server.
|
|
343
|
+
`provider: "grok"` runs on a user's Grok plan, with the same `credentials` and `onCredentials` callbacks, called with `provider: "grok"`. libfx also checks that refreshed credentials still belong to the same xAI account. These turns run on your server too.
|
|
265
344
|
|
|
266
345
|
To sign a user in, use `libfx/grok`. Device code is the simplest:
|
|
267
346
|
|
|
@@ -271,44 +350,66 @@ import { startDeviceSignIn, completeDeviceSignIn } from "libfx/grok";
|
|
|
271
350
|
// Show device.userCode and a link to device.verificationUrl.
|
|
272
351
|
const device = await startDeviceSignIn();
|
|
273
352
|
|
|
274
|
-
//
|
|
353
|
+
// Waits until the user enters the code. pollDeviceSignIn() checks once, for a later request.
|
|
275
354
|
const credentials = await completeDeviceSignIn(device);
|
|
276
355
|
```
|
|
277
356
|
|
|
278
|
-
`startSignIn()` and `finishSignIn()` work
|
|
357
|
+
`startSignIn()` and `finishSignIn()` work as for ChatGPT, except that xAI opens a page at `127.0.0.1`. The sign-in uses the same xAI client as the fx CLI, and the user's Grok plan and xAI's terms apply to the turns.
|
|
279
358
|
|
|
280
|
-
###
|
|
359
|
+
### Several servers, one account
|
|
281
360
|
|
|
282
|
-
|
|
361
|
+
The sessions in one process share each refresh. Across servers, two can notice the same expiring credentials at once, and the provider accepts only the first refresh. Pass `refreshLock` so they take turns:
|
|
283
362
|
|
|
284
363
|
```js
|
|
285
|
-
|
|
286
|
-
provider: { name: "local", baseUrl: "http://localhost:11434/v1" },
|
|
287
|
-
model: "qwen3:8b",
|
|
288
|
-
});
|
|
364
|
+
refreshLock: ({ provider, sessionId }, refresh) => locks.withLock(`${provider}:${accountOf(sessionId)}`, refresh),
|
|
289
365
|
```
|
|
290
366
|
|
|
291
|
-
|
|
367
|
+
libfx calls it with a function to run while you hold a lock on that user's account, for example in Redis. Inside it, libfx calls `credentials()` again and uses what another server already saved.
|
|
368
|
+
|
|
369
|
+
### Custom providers
|
|
370
|
+
|
|
371
|
+
An agent can run on a server that speaks the OpenAI Chat Completions API, such as a local model server or a router:
|
|
292
372
|
|
|
293
373
|
```js
|
|
374
|
+
const router = {
|
|
375
|
+
name: "router",
|
|
376
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
377
|
+
models: { "qwen/qwen3-coder": { contextWindow: 128000, supportsToolUse: true } },
|
|
378
|
+
};
|
|
379
|
+
|
|
294
380
|
const agent = createFxAgent({
|
|
295
|
-
provider:
|
|
296
|
-
name: "router",
|
|
297
|
-
baseUrl: "https://openrouter.ai/api/v1",
|
|
298
|
-
models: { "qwen/qwen3-coder": { contextWindow: 128000, supportsToolUse: true } },
|
|
299
|
-
},
|
|
381
|
+
provider: router,
|
|
300
382
|
model: "qwen/qwen3-coder",
|
|
301
383
|
credentials: async ({ provider, sessionId }) => ({ apiKey: await keys.load(provider, sessionId) }),
|
|
302
384
|
});
|
|
303
385
|
```
|
|
304
386
|
|
|
305
|
-
|
|
387
|
+
Requests go to `{baseUrl}/chat/completions`, which must use HTTPS, or HTTP on `localhost`. Leave out `credentials` for a server that needs no key. `models` tells libfx what it can't ask the server: each model's context window and output limit, and whether it takes tools and images. A key isn't refreshed, so a rejected one ends the turn with the server's error. In a browser, the server must accept cross-origin requests, and the page can read the key.
|
|
306
388
|
|
|
307
389
|
Saved sessions follow the agent's provider: point an agent at another base URL and its sessions continue there, history included.
|
|
308
390
|
|
|
309
|
-
|
|
391
|
+
### Where credentials go
|
|
392
|
+
|
|
393
|
+
libfx adds ChatGPT, Grok and custom-provider credentials to each request in JavaScript, just before it's sent, and sends them only to that provider. They never reach the engine. No credential is written to a saved session or passed to `onEvent`, and if a server quotes a key or token back in an error, libfx masks it before the error reaches the turn.
|
|
394
|
+
|
|
395
|
+
## Images
|
|
396
|
+
|
|
397
|
+
```js
|
|
398
|
+
agent.session().prompt([
|
|
399
|
+
{ type: "text", text: "What's wrong with this chart?" },
|
|
400
|
+
{ type: "image", mimeType: "image/png", data: base64Png },
|
|
401
|
+
]);
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
A session stores each prompt as JSON before it runs, so a session prompt takes image data as a base64 string. `createFxEngine()` also takes bytes, a `Blob` or a `File`. Each prompt takes up to 8 PNG, JPEG, GIF or WebP images, at most 3.75 MiB each and 6 MiB together. Pass `resizeImage` to downscale or convert prompt images before they're sent.
|
|
405
|
+
|
|
406
|
+
## Diagnostics
|
|
407
|
+
|
|
408
|
+
`onEvent` receives diagnostics apart from the model's output: request timing and retries, and session events such as `session.deadline` when a turn stops for a function's time limit. The `FxAgentEvent` type lists them all.
|
|
409
|
+
|
|
410
|
+
## Without sessions
|
|
310
411
|
|
|
311
|
-
`createFxEngine()` is the engine under the agent: one conversation in the memory of
|
|
412
|
+
`createFxEngine()` is the engine under the agent: one conversation in the memory of one process, with no queue and no session store. Use it for a single request, a subagent inside a tool call, or a conversation you store yourself.
|
|
312
413
|
|
|
313
414
|
```js
|
|
314
415
|
import { createFxEngine } from "libfx";
|
|
@@ -317,23 +418,33 @@ const engine = await createFxEngine({ apiKey: process.env.AI_GATEWAY_API_KEY, mo
|
|
|
317
418
|
|
|
318
419
|
const turn = engine.prompt("Summarize this file.");
|
|
319
420
|
for await (const event of turn) {
|
|
320
|
-
|
|
421
|
+
if (event.type === "text_delta") process.stdout.write(event.delta);
|
|
321
422
|
}
|
|
322
423
|
|
|
323
|
-
const checkpoint = await engine.checkpoint(); // opaque bytes
|
|
324
|
-
|
|
424
|
+
const checkpoint = await engine.checkpoint(); // opaque bytes, up to 4 MiB
|
|
425
|
+
await engine.close();
|
|
426
|
+
|
|
427
|
+
const restored = await createFxEngine({ apiKey: process.env.AI_GATEWAY_API_KEY, model: "anthropic/claude-haiku-5.5", checkpoint });
|
|
325
428
|
```
|
|
326
429
|
|
|
327
|
-
|
|
430
|
+
An engine differs from a session:
|
|
431
|
+
|
|
432
|
+
- On AI Gateway, it needs an `apiKey`.
|
|
433
|
+
- One prompt runs at a time, and breaking out of a turn's events cancels the turn.
|
|
434
|
+
- The turn itself has `cancel()` and `steer()`.
|
|
435
|
+
|
|
436
|
+
To have the engine record the conversation as it runs, pass a `persistence` store instead of a checkpoint. A new engine on the same store can then `resume()` a turn the last process left open. A store needs two methods and can add a third, and the bytes are opaque, so it never has to understand them:
|
|
328
437
|
|
|
329
438
|
```ts
|
|
330
439
|
interface Persistence {
|
|
331
440
|
load(): Promise<{
|
|
332
441
|
checkpoint?: { data: Uint8Array; through: string };
|
|
333
442
|
journal?: Iterable<JournalRecord> | AsyncIterable<JournalRecord>;
|
|
443
|
+
head?: string | null;
|
|
334
444
|
}>;
|
|
335
|
-
append(input: { expected: string | null; idempotencyKey: string; data: Uint8Array }): Promise<{ cursor: string }>;
|
|
445
|
+
append(input: { expected: string | null; idempotencyKey: string; data: Uint8Array; barrier: boolean; marks?: object[] }): Promise<{ cursor: string }>;
|
|
336
446
|
saveCheckpoint?(input: { through: string; data: Uint8Array }): Promise<void>;
|
|
447
|
+
marks?: boolean;
|
|
337
448
|
}
|
|
338
449
|
|
|
339
450
|
interface JournalRecord {
|
|
@@ -342,11 +453,13 @@ interface JournalRecord {
|
|
|
342
453
|
}
|
|
343
454
|
```
|
|
344
455
|
|
|
345
|
-
Reject an `append()` whose `expected` isn't the last stored cursor with `FxFencedError`, so two processes can't write the same conversation
|
|
456
|
+
Reject an `append()` whose `expected` isn't the last stored cursor with `FxFencedError`, so two processes can't write the same conversation. If your store keeps entries of its own after the last record, return the cursor the next `append()` should name as `head`. When a resumed turn left a tool call running, `resume({ onAmbiguous })` lets you return `"rerun"` to run it again under the same `executionId`.
|
|
457
|
+
|
|
458
|
+
The engine sends one `append()` at a time, and everything that happens while one is in flight goes in the next record. `barrier` is `true` when the engine waits for the record before it goes on, such as before a tool call with effects. A store may acknowledge a record with `barrier: false` before it is durable, as long as it keeps records in order and acknowledges a barrier only once it and every record before it are durable. Set `marks: true` to receive each record's `marks`, which say where turns start (`{ start: turnId }`), yield and end, and which inputs a turn took (`{ accepted: id }`), so a store can track turns without reading records.
|
|
346
459
|
|
|
347
|
-
|
|
460
|
+
`prompt()` and `resume()` take `yieldAt`, a time in milliseconds since the epoch: the turn stops before its next model request after that time, with the stop reason `yielded`, and the next `resume()` continues it without telling the model it was interrupted.
|
|
348
461
|
|
|
349
|
-
|
|
462
|
+
A journal or `checkpoint` from a newer libfx fails with `FxJournalVersionError`. `createMemoryPersistence()` is a reference store for tests.
|
|
350
463
|
|
|
351
464
|
## Runtimes
|
|
352
465
|
|
|
@@ -357,7 +470,7 @@ In libfx 0.0.13 and earlier, `createFxAgent()` created this engine. It now creat
|
|
|
357
470
|
| Browsers | WebAssembly with JSPI, from `libfx/browser` |
|
|
358
471
|
| Bun | The same as Node.js. For WebAssembly, use Bun 1.4.2 or newer. |
|
|
359
472
|
|
|
360
|
-
The default `backend: "auto"` tries native first. Pass `"native"` or `"wasm"` to require one, and call `getBackendInfo()` to see what would load without creating anything.
|
|
473
|
+
The default `backend: "auto"` tries the native addon first. Pass `"native"` or `"wasm"` to require one, and call `getBackendInfo()` to see what would load without creating anything.
|
|
361
474
|
|
|
362
475
|
Next.js works in Node.js route handlers, with webpack or Turbopack, and doesn't need `serverExternalPackages`. CommonJS apps can `require("libfx")`. With TypeScript's `moduleResolution: "bundler"`, add `"node"` to `customConditions` to get the Node.js types.
|
|
363
476
|
|
|
@@ -374,25 +487,43 @@ const terminal = await createFxTerminal({
|
|
|
374
487
|
await terminal.interactive;
|
|
375
488
|
```
|
|
376
489
|
|
|
377
|
-
This is the fx CLI's interface compiled to WebAssembly. Your page supplies storage for its sessions, settings and sign-in, and can give it a workspace that runs commands. See [
|
|
490
|
+
This is the fx CLI's interface compiled to WebAssembly. Your page supplies storage for its sessions, settings and sign-in, and can give it a workspace that runs commands. See [Terminal](https://fx.sh/docs/lib/terminal).
|
|
378
491
|
|
|
379
492
|
## Entry points
|
|
380
493
|
|
|
381
494
|
| Import | What's in it |
|
|
382
495
|
| --- | --- |
|
|
383
|
-
| `libfx` | `createFxAgent`, `createFxEngine`, `
|
|
384
|
-
| `libfx/node` | The same, for Node.js explicitly |
|
|
385
|
-
| `libfx/browser` | The browser build, plus `createFxTerminal` and `xtermAdapter` |
|
|
496
|
+
| `libfx` | `createFxAgent`, `createFxEngine`, `createFxTerminal`, `memory`, `listModels`, `createMemoryPersistence`, `FxFencedError`, `FxJournalVersionError`, `supportsJspi`, `xtermAdapter`, `encodeXtermKeyEvent`, and on Node.js `getBackendInfo` and `wakeHandler` |
|
|
497
|
+
| `libfx/node`, `libfx/browser` | The same, for Node.js or browsers explicitly |
|
|
386
498
|
| `libfx/wasm` | The WebAssembly SDK without packaged asset paths, so you pass `wasm` yourself |
|
|
387
499
|
| `libfx/durable-local`, `libfx/durable-vercel`, `libfx/durable-world` | `local()`, `vercel()` and `world()` |
|
|
388
|
-
| `libfx/chatgpt` |
|
|
389
|
-
| `libfx/grok` | `startSignIn`, `finishSignIn`, `startDeviceSignIn`, `pollDeviceSignIn`, `completeDeviceSignIn`, `refreshCredentials` |
|
|
500
|
+
| `libfx/chatgpt`, `libfx/grok` | Sign-in for ChatGPT and Grok plans, and `refreshCredentials` |
|
|
390
501
|
| `libfx/mcp` | `createMcpAdapter` |
|
|
391
502
|
| `libfx/skills`, `libfx/skills/node` | `createSkillsAdapter`, and `loadSkillFile` for Node.js and Bun |
|
|
392
503
|
|
|
504
|
+
Every entry point ships TypeScript types, and the package has no runtime dependencies.
|
|
505
|
+
|
|
506
|
+
## For coding agents
|
|
507
|
+
|
|
508
|
+
- **Docs as Markdown:** [fx.sh/llms.txt](https://fx.sh/llms.txt) indexes every page, and each page has a `.md` version, such as `https://fx.sh/docs/lib/durability.md`. The declarations in `types/` are the exact API.
|
|
509
|
+
- **Agents at module scope:** create each agent once at module scope, not per request, and open `agent.session(id)` for each conversation.
|
|
510
|
+
- **Session ids:** take them from `turn.accepted` or `agent.newSessionId()`. On every durability except `memory()`, an id that isn't a Workflow run id throws.
|
|
511
|
+
- **Side effects:** every tool sets `idempotent`. Set it to `true` only on tools that are safe to run twice, and pass `executionId` to services that take an idempotency key.
|
|
512
|
+
- **Deployments:** on Vercel, the `wakeHandler()` route and its `vercel.json` trigger are both required, or prompts are stored and never run. The route must import the module that creates the agents.
|
|
513
|
+
- **Several agents:** give each durable agent its own `name`, and never rename one that has sessions.
|
|
514
|
+
- **Credentials:** keep keys and ChatGPT or Grok credentials on the server, and the object `startSignIn()` returns too.
|
|
515
|
+
- **Checking a setup:** `getBackendInfo()` reports which backend would load without creating anything or making requests.
|
|
516
|
+
- **Changing libfx:** the source is in the [fx repository](https://github.com/vercel-labs/fx) under `sdk/`. Its `AGENTS.md` files name the tests each change needs.
|
|
517
|
+
|
|
518
|
+
## Upgrading from 0.0.13
|
|
519
|
+
|
|
520
|
+
In libfx 0.0.13 and earlier, `createFxAgent()` created the engine. It now creates the durable agent, so rename those calls to `createFxEngine()`. Old code runs against the agent once its tools set `idempotent`, with a few differences: breaking out of a turn's events no longer cancels it, turns have no `cancel()` or `steer()` (the session does), and `persistence` and `sessionId` throw because the durability holds the session.
|
|
521
|
+
|
|
522
|
+
`libfxApiVersion` is now 3. Every tool given to `createFxAgent()` must set `idempotent`, and a tool's `writes` now defaults to the opposite of `idempotent`, in the engine too, so a tool that isn't marked idempotent runs alone. A journal written by this libfx can't be loaded by an older one, which fails with `FxJournalVersionError`.
|
|
523
|
+
|
|
393
524
|
## Security
|
|
394
525
|
|
|
395
|
-
Your tools, MCP clients and skill loaders have whatever access your code gives them. libfx
|
|
526
|
+
Your tools, MCP clients and skill loaders have whatever access your code gives them. libfx orders their calls but grants nothing itself, and the native backend doesn't turn on fx's built-in shell or file tools. Don't put long-lived keys in browser code, and treat the `nativeAddon` and `gatewayChatUrl` options as trusted configuration.
|
|
396
527
|
|
|
397
528
|
## Contributing
|
|
398
529
|
|
package/browser.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "./fx-sdk.js";
|
|
15
15
|
|
|
16
16
|
export { createMemoryPersistence, encodeXtermKeyEvent, FxFencedError, FxJournalVersionError, fxSdkApiVersion, listModels, memory, supportsJspi, xtermAdapter };
|
|
17
|
-
export const libfxApiVersion =
|
|
17
|
+
export const libfxApiVersion = 3;
|
|
18
18
|
|
|
19
19
|
const defaultCoreWasm = new URL("./fx-core.wasm", import.meta.url).href;
|
|
20
20
|
const defaultTermWasm = new URL("./fx-term.wasm", import.meta.url).href;
|