libfx 0.0.13-dev.1472.g2c7a4f83f06f → 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 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 | One per server process |
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 it 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.
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. Stop it with `session.cancel()`, or with the `signal` you passed to `prompt()`.
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 { agent } from "@/lib/agent";
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 = agent.wakeHandler();
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()` sends each turn to Vercel Queues, and this route runs it:
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
- - The function running a turn renews a 15-second lease on its session. If the function dies, the session frees when the lease runs out, and the turn continues in a new invocation.
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. `createMemoryPersistence()` is a reference store for tests. When a resumed turn left a tool call running, `resume({ onAmbiguous })` lets you return `"rerun"` to run it again under the same `executionId`.
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
- - **One agent per process:** create it once at module scope, not per request, and open `agent.session(id)` for each conversation.
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:** set `idempotent: true` only on tools that are safe to run twice, and pass `executionId` to services that take an idempotency key.
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 keeps running against the agent, 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.
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 = 2;
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;