Pi durable
Replace the read, edit, and write tools of the experimental @earendil-works/pi-durable harness with createDurableFsTools() from @better-fs-tools/pi-durable.
@earendil-works/pi-durable is experimental. Its API can change in each release.
@better-fs-tools/pi-durable follows pi-durable 1.2, and its peer range is ~1.2.0. Check this
page again before you update pi-durable.
@earendil-works/pi-durable is a durable agent harness. It is separate from the Pi coding agent. The Harness stores each conversation, each model turn, and each tool call before it shows the result. When the process stops during a turn, the next process opens the same storage and continues the work.
Each tool call is a durable task. The Harness stores the intent of the call before execute() runs, and it stores the result before anyone sees it. Tools come in extensions, and a registry holds the extensions.
createDurableFsTools() from @better-fs-tools/pi-durable is an extension with the Better FS Tools read, edit, and write. It can also add apply_patch and bash. Each call reads and writes through the execution environment of that call. So the tools work on the local machine, over SSH with @earendil-works/pi-env, and in a container.
Install
npm install @better-fs-tools/pi-durable @better-fs-tools/shell @earendil-works/pi-durable@~1.2.0 @earendil-works/chord@~1.2.0 typebox@earendil-works/pi-durable (~1.2.0), @earendil-works/chord (~1.2.0), and typebox (^1.3.16) are required peers. The package imports no node: module. @better-fs-tools/shell gives shellEnv() for the bash tool. The Harness example below also uses @earendil-works/pi-ai for the models.
Recommended configuration
These options suit the durable harness. They are options, not defaults, so each one is visible in your code:
import {
createDurableFsTools,
documentReadStore,
durableEnvFileSystem,
durableEnvRunner,
} from "@better-fs-tools/pi-durable";
import { shellEnv } from "@better-fs-tools/shell";
createDurableFsTools({
fs: durableEnvFileSystem({ allowedRoots: ["."] }), // through the call's ExecutionEnv
state: documentReadStore(), // read records follow the conversation and its forks
bash: { runner: durableEnvRunner(), env: shellEnv() }, // the environment's exec()
});fs: durableEnvFileSystem(): each call reads and writes through the call'sExecutionEnv. So the tools work on the local machine, over SSH with@earendil-works/pi-env, and in a container. See How each call works.state: documentReadStore(): each read record is a document of the conversation. The records stay after a restart, and a fork gets the records of its parent. See Read records.bash: { runner: durableEnvRunner(), env: shellEnv() }: commands run through the environment'sexec(). Leavebashout to keep thebashofCodingTools. See Bash.
Every other option has the default of every integration: sha256Digest(), memoryLocks(), and read returns images. Choose an integration has the full table.
Replace the built-in tools
CodingTools from @earendil-works/pi-durable/tools has read, write, edit, and bash. When two selected extensions have a tool with the same name, the extension installed later wins. Install the Better FS Tools extension after CodingTools. With the recommended configuration, its read, edit, write, and bash then replace those of CodingTools:
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, Harness, MemoryStorage } from "@earendil-works/pi-durable";
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
import { CodingTools } from "@earendil-works/pi-durable/tools";
import {
createDurableFsTools,
documentReadStore,
durableEnvFileSystem,
durableEnvRunner,
} from "@better-fs-tools/pi-durable";
import { shellEnv } from "@better-fs-tools/shell";
const registry = createRegistry();
registry.install(CodingTools);
// Installed later, so its tools replace those of CodingTools.
registry.install(
createDurableFsTools({
fs: durableEnvFileSystem({ allowedRoots: ["."] }),
state: documentReadStore(),
bash: { runner: durableEnvRunner(), env: shellEnv() },
}),
);
const context = BACKGROUND_CONTEXT;
const harness = await Harness.open(
new MemoryStorage(),
{
models: createModels(),
registry,
env: ({ cwd }) => new NodeExecutionEnv({ cwd: cwd ?? process.cwd() }),
},
context,
);
const root = await harness.root(context, { agent: { cwd: "/work/repo" } });
await root.submit({ type: "input", content: "Fix the typo in README.md." }, context);
await harness.close(context);The tools of this conversation are read, write, edit, and bash, all from Better FS Tools. Without bash, the bash of CodingTools stays. To give one conversation an exact tool list, use conversation.configure({ tools: [...] }, context) with the tool objects.
How each call works
A pi-durable tool gets execute(args, api, context). api.env is the execution environment of the call. The env option of Harness.open() builds it from the conversation's cwd. context is a Chord Context, and a cancel of the call stops it.
The host of each call is { api, env, context }. Host functions, for example an authorize function, get it as ctx.call.host. A function fs, state, or runner gets the call. durableEnvFileSystem(), documentReadStore(), and durableEnvRunner() are such functions: each builds its part over the call's environment. A call without an environment fails with an error result.
durableEnvFileSystem() takes the options of executionEnvFileSystem() except context, which comes from the call:
| Option | Default | Meaning |
|---|---|---|
allowedRoots | ["."] | The roots of each call, relative to the environment's cwd |
denyRoots | none | Paths that are refused |
symlinks | "follow-within-roots" | Follow a link when its real path stays inside the roots |
Options
createDurableFsTools() takes the options of createFsTools(), with the same defaults, plus a signature in each tool's options, plus the Harness settings name and outputLimits:
| Option | Default | Meaning |
|---|---|---|
fs | required | A writable filesystem, or a function of the call. durableEnvFileSystem() is the recommended value |
state | null | The read store. See Read records |
digest | sha256Digest() | The digest for read records |
locks | memoryLocks() | One lock manager for every call of the extension. See Locks |
clock | () => new Date() | The clock of the tools and the store |
read, edit, write | {} | The options of one tool, and its signature. See the read and write guides |
applyPatch | off | true, or an options object, adds apply_patch |
bash | off | An options object with runner and env adds bash. See Bash |
name | "better-fs-tools" | The extension name |
outputLimits | 128 KiB and 4,000 lines | The Harness output limits of the tools. See Output limits |
An unknown key, a shared option inside one tool's options, and true as the value of bash throw TypeError.
The default signatures are those of every integration: read({ path, offset?, limit? }), edit({ path, old_string, new_string, replace_all? }), write({ path, content }), apply_patch({ patch }), and bash({ command, timeout?, cwd? }) with the timeout in milliseconds. A signature with a grammar sets constrainedSampling, as in the Pi integrations. read returns images by default.
Alternatives
| To do this | Use |
|---|---|
| Turn read before write off | Leave out state, or set state: null |
| Keep the read records in process memory | state: memoryStore(). Every conversation shares it |
Keep the bash of CodingTools | Leave out bash |
| Give the model Durable's own edit input | edit: { signature: multiEditSignature() }: edits: [{ oldText, newText }] |
| Send the Codex patch grammar | applyPatch: { signature: freeformPatchSignature() } |
| Narrow the roots of each call | durableEnvFileSystem({ allowedRoots, denyRoots, symlinks }) |
multiEditSignature() and freeformPatchSignature() come from @better-fs-tools/write/signature. See Tool definitions.
Read records
With state: documentReadStore(), read before write is on. Each read record is a ReadStateDoc document of the conversation, keyed by the environment id and the path.
- The records stay after a restart. An
editin the next process needs no new read. - A fork starts with the records that its parent had at the fork entry. A model in the fork can change only what that history read.
With no state, read before write is off. A ReadStateStore, such as memoryStore(), keeps the records in process memory, and every conversation shares it.
Replay after a crash
When the process stops during a tool call, the next process finds the stored intent without a result. replay on the tool decides what happens then:
| Tool | replay | Why |
|---|---|---|
read | "safe" | A read changes no file, so the Harness can run it again |
edit, write, apply_patch | "unsafe", the default | The first run may have changed the file. A second run can then fail to find the old text, or make the change twice |
With "unsafe", the model gets an interrupted error result with the output stored so far. The model can read the file and try again.
Output limits
The Harness cuts each tool's output at outputLimits. The Harness default is 50 KiB, 2,000 lines, and retain: "head". The Better FS Tools read already stops its view at 2,000 lines and 50 KiB by default, and it tells the model how to continue. Line numbers and notes make the output longer than the view. With the Harness defaults, the Harness can cut the end of the output a second time, and the model then loses the note that tells it how to continue.
The extension sets outputLimits to 128 KiB and 4,000 lines by default, so the limits of Better FS Tools apply first. The bash tool gets the same limits with retain: "tail", so the end of the output stays. When you raise the read limits, raise outputLimits too. See Read limits.
Results
A Better FS Tools tool does not throw for a tool error. It returns isError: true. A nonzero bash exit, a timeout, and an error are error results too. No tool declares a structuredOutputSchema, so programs get the text. A read with status unsupported, for example of a binary file, is not an error.
Locks
The default lock manager is one memoryLocks() for every call of the extension. Each key has the environment id and the real path. So two conversations that change the same file in one environment wait for each other, and the same path in two environments does not wait.
The tools do not share the queue of the CodingTools edit and write, because pi-durable does not export it. A CodingTools tool that you keep, for example bash, does not wait for a Better FS Tools write.
Bash
bash: { runner: durableEnvRunner(), env: shellEnv() } adds a bash tool over the environment's exec(). Its input is the shared default signature: { command, timeout?, cwd? }, with the timeout in milliseconds. A cwd from the model is relative to the environment's cwd. Install the extension after CodingTools to replace its bash.
- By default, a command keeps the environment's own variables, for example
PATHandHOMEon a remote host. Theenvof the tool goes over them. - A nonzero exit is an error result.
durableEnvRunner()takes the options ofexecutionEnvRunner()exceptcontext, which comes from the call.
For the input of the Durable bash, { command, timeout? } with the timeout in seconds, pass signature: defaultBashSignature({ timeoutUnit: "s", cwd: false }) from @better-fs-tools/shell/signature. See the bash guide for limits and policy.
The parts
The package also exports the parts of the extension, for your own tools:
| Export | What it is |
|---|---|
createDurableFsTools(options) | The extension |
durableEnvFileSystem(options?) | A per-call fs: executionEnvFileSystem() over the call's environment |
durableEnvRunner(options?) | A per-call bash runner: executionEnvRunner() over the call's environment |
documentReadStore() | A per-call state in the conversation's ReadStateDoc documents |
executionEnvFileSystem(env, { context, allowedRoots }) | A WritableFileSystem over any ExecutionEnv, for any tool or bundle |
executionEnvRunner(env, { context }) | A bash CommandRunner over env.exec() |
ReadStateDoc | The document family of the read records |
This example uses the filesystem of one environment with the core bundle:
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
import { executionEnvFileSystem } from "@better-fs-tools/pi-durable";
import { createFsTools } from "@better-fs-tools/write";
// The files of one environment, rooted at its cwd.
const env = new NodeExecutionEnv({ cwd: "/work/repo" });
const fs = executionEnvFileSystem(env, { context: BACKGROUND_CONTEXT, allowedRoots: ["."] });
export const tools = createFsTools({ fs });Paths are POSIX paths in the environment. A path is checked against the roots as written, and again after its symbolic links resolve. A write through a dangling symbolic link is refused. Writes use the environment's writeFile(), which replaces the content in place. The file keeps its mode, but the write is not atomic. Versions are weak: size and modification time.
Limits
- Writes are not atomic. A crash during a write can leave part of the new content in the file.
- No shared queue with
CodingTools. See Locks. - Untested environments. The package tests use
NodeExecutionEnv. A remote environment over SSH and a crash with a replay are not part of the tests.
See also
- Pi: the coding agent extension and SDK
- Pi agent core: the tools for
AgentandagentLoop() - Tool definitions: signatures, names, and descriptions
- Read before write: what the read store does
Pi agent core
Give the read, edit, and write tools to an Agent or agentLoop() from @earendil-works/pi-agent-core with createAgentCoreFsTools().
Tool definitions
Connect the read, edit, write, apply_patch, and bash tools to a framework of your own, and change the tool name, the parameter names, and the descriptions with a signature.