libfx 0.0.13-dev.1472.g2c7a4f83f06f → 0.0.13-dev.1510.ga878d4900a6a
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 +60 -23
- 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
|
@@ -35,11 +35,11 @@ The full documentation is at [fx.sh/docs/lib](https://fx.sh/docs/lib).
|
|
|
35
35
|
|
|
36
36
|
| Object | What it is | How long it lives |
|
|
37
37
|
| --- | --- | --- |
|
|
38
|
-
| Agent | Configuration: model, instructions, tools, provider and where sessions live |
|
|
38
|
+
| Agent | Configuration: model, instructions, tools, provider and where sessions live | As long as the server process |
|
|
39
39
|
| Session | One conversation | As long as its store keeps it |
|
|
40
40
|
| Turn | One prompt and everything the agent does to answer it | Until it ends with a stop reason |
|
|
41
41
|
|
|
42
|
-
An agent is configuration, and its conversations live in its sessions. Create
|
|
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
43
|
|
|
44
44
|
Underneath the agent is `createFxEngine()`, a single conversation with no store, for when you don't want sessions. See [Without sessions](#without-sessions).
|
|
45
45
|
|
|
@@ -54,7 +54,7 @@ agent.session(sessionId).prompt("Start with the schema.");
|
|
|
54
54
|
```
|
|
55
55
|
|
|
56
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
|
|
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
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
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`.
|
|
60
60
|
|
|
@@ -128,37 +128,55 @@ On `local()`, `vercel()` and `world()`, a session id is a Workflow run id, `wrun
|
|
|
128
128
|
|
|
129
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.
|
|
130
130
|
|
|
131
|
+
### Several agents
|
|
132
|
+
|
|
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.
|
|
146
|
+
|
|
131
147
|
### On Vercel
|
|
132
148
|
|
|
133
149
|
Add a route for libfx's queue deliveries:
|
|
134
150
|
|
|
135
151
|
```js
|
|
136
152
|
// app/api/libfx/route.js
|
|
137
|
-
import {
|
|
153
|
+
import { wakeHandler } from "libfx";
|
|
154
|
+
import "@/lib/agents"; // creates the app's agents, so this function has them
|
|
138
155
|
|
|
139
|
-
export const POST =
|
|
156
|
+
export const POST = wakeHandler();
|
|
140
157
|
```
|
|
141
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
|
+
|
|
142
161
|
Then subscribe it to libfx's topic in `vercel.json`:
|
|
143
162
|
|
|
144
163
|
```json
|
|
145
164
|
{
|
|
146
165
|
"functions": {
|
|
147
166
|
"app/api/libfx/route.js": {
|
|
148
|
-
"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "__libfx_wkf_workflow_session" }]
|
|
167
|
+
"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "__libfx_wkf_workflow_session", "retryAfterSeconds": 5 }]
|
|
149
168
|
}
|
|
150
169
|
}
|
|
151
170
|
}
|
|
152
171
|
```
|
|
153
172
|
|
|
154
|
-
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:
|
|
155
174
|
|
|
156
|
-
- When a turn gets within 30 seconds of the function's time limit, libfx stops it, and the next invocation continues it.
|
|
157
|
-
-
|
|
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.
|
|
158
178
|
- A function that only stalled and wakes up after that is refused at its next write, so two processes never write one session.
|
|
159
179
|
|
|
160
|
-
`vercel({ reserveMs, leaseMs })` from `libfx/durable-vercel` changes both timings.
|
|
161
|
-
|
|
162
180
|
### Tools that change things
|
|
163
181
|
|
|
164
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:
|
|
@@ -178,14 +196,18 @@ const tools = [
|
|
|
178
196
|
name: "refund_order",
|
|
179
197
|
description: "Refund an order.",
|
|
180
198
|
inputSchema: byId,
|
|
199
|
+
idempotent: true, // keyed on executionId, so running it again refunds once
|
|
200
|
+
writes: true,
|
|
181
201
|
execute: ({ id }, { executionId }) => payments.refund(id, { idempotencyKey: executionId }),
|
|
182
202
|
},
|
|
183
203
|
];
|
|
184
204
|
```
|
|
185
205
|
|
|
186
|
-
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.
|
|
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.
|
|
187
207
|
|
|
188
|
-
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.
|
|
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.
|
|
189
211
|
|
|
190
212
|
### Your own store
|
|
191
213
|
|
|
@@ -208,7 +230,9 @@ export const agent = createFxAgent({
|
|
|
208
230
|
});
|
|
209
231
|
```
|
|
210
232
|
|
|
211
|
-
A World that delivers queue messages over HTTP needs `agent.wakeHandler()` 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.
|
|
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.
|
|
212
236
|
|
|
213
237
|
## Tools
|
|
214
238
|
|
|
@@ -237,10 +261,10 @@ const agent = createFxAgent({
|
|
|
237
261
|
|
|
238
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.
|
|
239
263
|
|
|
240
|
-
On the native backend, the calls from one model response run at the same time, up to 16 at once. Mark a tool `writes: true` to keep its calls from overlapping any other. On WebAssembly, calls run one after another.
|
|
241
|
-
|
|
242
264
|
Validate the input and authorize the action inside `execute`. libfx orders the calls but grants nothing itself.
|
|
243
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.
|
|
267
|
+
|
|
244
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.
|
|
245
269
|
|
|
246
270
|
### MCP and skills
|
|
@@ -265,6 +289,8 @@ await mcp.close();
|
|
|
265
289
|
|
|
266
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.
|
|
267
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.
|
|
293
|
+
|
|
268
294
|
## Models
|
|
269
295
|
|
|
270
296
|
```js
|
|
@@ -414,9 +440,11 @@ interface Persistence {
|
|
|
414
440
|
load(): Promise<{
|
|
415
441
|
checkpoint?: { data: Uint8Array; through: string };
|
|
416
442
|
journal?: Iterable<JournalRecord> | AsyncIterable<JournalRecord>;
|
|
443
|
+
head?: string | null;
|
|
417
444
|
}>;
|
|
418
|
-
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 }>;
|
|
419
446
|
saveCheckpoint?(input: { through: string; data: Uint8Array }): Promise<void>;
|
|
447
|
+
marks?: boolean;
|
|
420
448
|
}
|
|
421
449
|
|
|
422
450
|
interface JournalRecord {
|
|
@@ -425,7 +453,13 @@ interface JournalRecord {
|
|
|
425
453
|
}
|
|
426
454
|
```
|
|
427
455
|
|
|
428
|
-
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.
|
|
459
|
+
|
|
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.
|
|
461
|
+
|
|
462
|
+
A journal or `checkpoint` from a newer libfx fails with `FxJournalVersionError`. `createMemoryPersistence()` is a reference store for tests.
|
|
429
463
|
|
|
430
464
|
## Runtimes
|
|
431
465
|
|
|
@@ -459,7 +493,7 @@ This is the fx CLI's interface compiled to WebAssembly. Your page supplies stora
|
|
|
459
493
|
|
|
460
494
|
| Import | What's in it |
|
|
461
495
|
| --- | --- |
|
|
462
|
-
| `libfx` | `createFxAgent`, `createFxEngine`, `createFxTerminal`, `memory`, `listModels`, `createMemoryPersistence`, `FxFencedError`, `FxJournalVersionError`, `supportsJspi`, `xtermAdapter`, `encodeXtermKeyEvent`, and on Node.js `getBackendInfo` |
|
|
496
|
+
| `libfx` | `createFxAgent`, `createFxEngine`, `createFxTerminal`, `memory`, `listModels`, `createMemoryPersistence`, `FxFencedError`, `FxJournalVersionError`, `supportsJspi`, `xtermAdapter`, `encodeXtermKeyEvent`, and on Node.js `getBackendInfo` and `wakeHandler` |
|
|
463
497
|
| `libfx/node`, `libfx/browser` | The same, for Node.js or browsers explicitly |
|
|
464
498
|
| `libfx/wasm` | The WebAssembly SDK without packaged asset paths, so you pass `wasm` yourself |
|
|
465
499
|
| `libfx/durable-local`, `libfx/durable-vercel`, `libfx/durable-world` | `local()`, `vercel()` and `world()` |
|
|
@@ -472,17 +506,20 @@ Every entry point ships TypeScript types, and the package has no runtime depende
|
|
|
472
506
|
## For coding agents
|
|
473
507
|
|
|
474
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.
|
|
475
|
-
- **
|
|
509
|
+
- **Agents at module scope:** create each agent once at module scope, not per request, and open `agent.session(id)` for each conversation.
|
|
476
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.
|
|
477
|
-
- **Side effects:**
|
|
478
|
-
- **Deployments:** on Vercel, the `wakeHandler()` route and its `vercel.json` trigger are both required, or prompts are stored and never run.
|
|
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.
|
|
479
514
|
- **Credentials:** keep keys and ChatGPT or Grok credentials on the server, and the object `startSignIn()` returns too.
|
|
480
515
|
- **Checking a setup:** `getBackendInfo()` reports which backend would load without creating anything or making requests.
|
|
481
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.
|
|
482
517
|
|
|
483
518
|
## Upgrading from 0.0.13
|
|
484
519
|
|
|
485
|
-
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
|
|
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`.
|
|
486
523
|
|
|
487
524
|
## Security
|
|
488
525
|
|
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;
|