Pi DurablePi Durable

Today Earendil and the Pi community shipped Pi 1.0. This reflects our belief that after countless hours of hardening, maintenance, and active development, Pi is now a solid foundation on which to build. Pi also continues to evolve. Together with Pi 1.0, we are shipping an experimental new package called Pi Durable. Pi Durable was built specifically for long-running, durable, and malleable agents that can run anywhere. We would like you to join in the fun and help us make it the best durable harness there is.今天,Earendil 和 Pi 社区正式发布了 Pi 1.0。这反映了我们的信念:经过无数小时的强化、维护和积极开发,Pi 现已成为一个稳固的构建基础。同时,Pi 也在不断演进。随 Pi 1.0 一起,我们还发布了一个名为 Pi Durable 的实验性新包。Pi Durable 专为长期运行、持久且易于修改的智能体(agent)而构建,可在任何地方运行。我们希望你加入并参与其中,帮助我们将它打造成为目前最出色的持久化框架。

Why Pi Durable?为什么选择 Pi Durable?

Pi the coding agent is built to run on your (remote) machine, inside a terminal, driven by one person. If the process dies, you look at what happened and tell it to continue. That is what Pi 1.0 focuses on and excels at, and that is not changing.编码智能体 Pi 旨在由个人驱动,在你的(远程)机器终端内运行。如果进程挂掉,你会查看发生了什么,并指示它继续运行。这就是 Pi 1.0 的重点所在及其优势,这一点不会改变。

At Earendil, we want to bring this technology to everyone, in whatever form fits their needs best. For that, we need a harness that runs anywhere, can be reached from different surfaces, supports infinitely long conversations, survives catastrophic internal and external failures, and lets multiple humans steer the same agents.在 Earendil,我们希望以最适合用户需求的形式,将这项技术带给每个人。为此,我们需要一个可以在任何地方运行、能从不同界面访问、支持无限长对话、能从灾难性的内部和外部故障中恢复,并允许多人共同操控同一智能体的框架。

Pi Durable is that harness. It does not replace the Pi coding agent. It is a framework for building any agentic application, coding agents included. It shares not only code with the Pi coding agent, like pi-ai, but also its principles: minimalism and malleability.Pi Durable 就是这样一个框架。它不会取代编码智能体 Pi。它是一个用于构建任何智能体应用的框架,包括编码智能体。它不仅与编码智能体 Pi(如 pi-ai)共享代码,还遵循其核心原则:极简主义和易修改性。

It also lets us explore designs in this space without disrupting Pi the coding agent. Lessons we learn building agentic applications on Pi Durable will flow back into Pi the coding agent as they prove themselves valuable.它还让我们能够在不干扰编码智能体 Pi 的前提下,探索该领域的各种设计。我们在基于 Pi Durable 构建智能体应用过程中获得的经验,一旦证明具有价值,就会反馈到编码智能体 Pi 中。

What is a harness?什么是框架(harness)?

Everybody has their own definition of a harness. We wrote about this previously, but let us reintroduce the concept of the harness for Pi Durable.每个人对框架都有自己的定义。我们之前曾写过相关内容,但在此让我们重新介绍一下 Pi Durable 中“框架”的概念。

A harness is storage plus the machinery needed to run one or more conversations with large language models in parallel. It provides the tools those models call, and the execution environments the tools run in.框架由存储以及运行一个或多个大语言模型并行对话所需的机制组成。它提供了这些模型调用的工具,以及工具运行所需的执行环境。

A conversation is an interaction between you and an agent, recorded as a transcript. The agent is the large language model together with its settings, like the thinking level, and the tools it can call.对话是你与智能体之间以记录(transcript)形式保存的交互。智能体由大语言模型及其设置(如思维深度)以及它可调用的工具组成。

Tools do their work through an execution environment, which can be your laptop, a remote VM, or an in-memory sandbox. Which tools and which execution environment an agent gets is up to each conversation.工具通过执行环境完成工作,该环境可以是你的笔记本电脑、远程虚拟机或内存沙箱。智能体获得哪些工具和执行环境取决于具体的对话。

Everything the harness runs, from calling the model to executing a tool, is a task.框架运行的一切,从调用模型到执行工具,都被视为一个任务(task)。

Like everything in Pi, Pi Durable is built so your agent can understand it. The entire source code, without tests, is about 15,000 lines, which comes out to about 150,000 tokens with GPT and about 250,000 with Claude. That's the worst case. To build on Pi Durable, your agent rarely needs all of it; the storage backends alone are 3,000 lines it can usually skip.像 Pi 中的一切一样,Pi Durable 的构建方式也让你的智能体能够理解它。除去测试代码,整个源代码约为 15,000 行,换算成 Token 大约是 GPT 模型的 150,000 个,Claude 模型的 250,000 个。这是最坏的情况。要基于 Pi Durable 构建,你的智能体通常不需要用到全部代码;仅存储后端就占了 3,000 行,通常可以忽略。

Now let us give you a little tour of Pi Durable, to illustrate what we built and why we built it.现在,让我们带你简单了解一下 Pi Durable,以此说明我们构建了什么以及为何构建它。

Long runs anywhere随处运行,长期运行

We want agents to run for a long time and to be able to run anywhere, where anywhere currently means anywhere there is a JavaScript runtime.我们希望智能体能够长时间运行,并且能够在任何地方运行——目前,“任何地方”指的是任何具备 JavaScript 运行时的地方。

In Pi Durable, a harness opens over a storage backend. Pi Durable ships memory, SQLite, and JSONL storage, plus a conformance suite and benchmarks for your own backend. The SQLite and JSONL storage code uses no Node APIs, so with a small adapter it runs on Bun or inside a Cloudflare Durable Object. The storage interface is small and easy to implement on top of whatever you have, like a key-value store or Postgres. One process owns a storage at a time, and other clients attach to that process.在 Pi Durable 中,框架在存储后端之上开启。Pi Durable 自带内存、SQLite 和 JSONL 存储,以及用于你自定义后端的合规性套件和基准测试。SQLite 和 JSONL 存储代码不使用任何 Node API,因此通过一个小适配器,它可以在 Bun 或 Cloudflare Durable Object 中运行。存储接口简洁且易于在现有系统(如键值存储或 Postgres)之上实现。同一时间只有一个进程拥有存储权限,其他客户端则连接到该进程。

On SQLite, the harness only keeps the working set in memory: the active transcripts, live tasks, and pending submissions. Everything else stays on disk until it is needed. Active transcripts are naturally bounded by the model's context window, because compaction summarizes older messages before they overflow it. So even a conversation with tens of thousands of messages fits snugly into memory.在 SQLite 上,框架仅将工作集保持在内存中:活跃的记录、运行中的任务和待处理的提交。其他所有内容在需要之前都保留在磁盘上。活跃记录自然受限于模型的上下文窗口,因为压缩功能会在溢出前汇总旧消息。因此,即使是包含数万条消息的对话也能轻松放入内存。

Tools that need files or a shell get them from an execution environment. Pi Durable ships a Node execution environment, which gives tools access to your local files. Like storage, the execution environment interface is small and easy to implement, so you can also expose remote execution environments to your tools. That allows the harness to run on one machine while its tools run on another. Your env function builds the environment for every tool call, from the conversation's working directory, so each conversation can run in a different place.需要文件或 Shell 的工具从执行环境中获取资源。Pi Durable 自带一个 Node 执行环境,使工具能够访问你的本地文件。与存储一样,执行环境接口简洁且易于实现,因此你也可以向工具公开远程执行环境。这使得框架可以在一台机器上运行,而其工具在另一台机器上运行。你的 env 函数会根据对话的工作目录为每次工具调用构建环境,因此每个对话都可以在不同的地方运行。

import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { createRegistry, Harness } from "@earendil-works/pi-durable";
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
import {
    openNodeSqliteStorage,
} from "@earendil-works/pi-durable/storage/sqlite/node";
import { CodingTools } from "@earendil-works/pi-durable/tools";

const context = BACKGROUND_CONTEXT; // every call takes a context for cancellation
const models = createModels();
models.setProvider(openaiProvider());

const registry = createRegistry();
registry.install(CodingTools); // read, write, edit, bash

const env = ({ cwd }: { cwd?: string }) =>
    new NodeExecutionEnv({ cwd: cwd ?? process.cwd() });
const harness = await Harness.open(
    await openNodeSqliteStorage("./agent.sqlite"),
    { models, registry, env },
    context,
);
// The root conversation: created on first use, and the same one after every
// restart.
const root = await harness.root(context, {
    agent: {
        model: { provider: "openai", modelId: "gpt-6.1-sol" },
        cwd: "/work/repo",
    },
});

Survives crashes故障恢复

We want an agent to survive its process dying, whether the laptop sleeps, the container is redeployed, or the machine runs out of memory, and to pick up where it left off.我们希望智能体在进程意外终止时(无论是笔记本电脑休眠、容器重新部署还是机器内存耗尽)能够存活,并从中断处继续运行。

In Pi Durable, every step of a run is a task that stores a checkpoint before it moves on. If the process dies, a new process opens the same storage, finds the unfinished tasks, and continues each one from its last checkpoint. A model request that was cut off is sent again; the partial answer stays in the transcript, marked as aborted. A tool call that was cut off reruns if it is safe to; otherwise the model is told it was interrupted. Pi Durable has no built-in subagents, but they take a few lines of code to build, as the triage tool below shows. A subagent runs in a conversation of its own, so it continues from where it left off too, and a subagent tool that is safe to rerun finds its subagent again and waits for its answer. Queued messages are still queued. A requestId makes a submission exactly-once, so a client that retries after a crash gets the original submission back instead of asking twice.在 Pi Durable 中,运行的每一步都是一个任务,在进行下一步之前会存储一个检查点。如果进程终止,新进程会打开相同的存储,找到未完成的任务,并从上一个检查点继续执行。被截断的模型请求会重新发送;部分答案保留在记录中,并标记为已中止。如果工具调用是安全的,则会重新运行,否则模型会被告知该调用已中断。Pi Durable 没有内置子智能体,但正如底下的分流工具所示,只需几行代码即可构建。子智能体在自己的对话中运行,因此它也能从中断处继续;如果子智能体工具可以安全重试,它会再次找到其子智能体并等待回答。队列中的消息依然保持排队状态。requestId 确保了提交的幂等性,因此在崩溃后重试的客户端会收到原始提交结果,而不是重复询问。

const job = {
    type: "input",
    content: "Fix the flaky login test",
    requestId: "job-42",
} as const;
await root.submit(job, context);
// The process dies here, in the middle of a tool call.

// A new process opens the same storage.
const harness = await Harness.open(
    await openNodeSqliteStorage("./agent.sqlite"),
    { models, registry, env },
    context,
);
harness.resume(); // continue the interrupted run
const root = await harness.root(context);
// the same submission, answered
const settled = await (await root.submit(job, context)).wait(context);

Many conversations at once同时处理多个对话

We want one harness to run many conversations at the same time, without one blocking another.我们希望一个框架能够同时运行多个对话,且互不阻塞。

In Pi Durable, one harness runs as many conversations as you need, concurrently, all with the same guarantees. A conversation starts fresh or forks another one at any point in its transcript, and sees the parent's history up to that point without copying it.在 Pi Durable 中,一个框架可以根据需要并发运行任意数量的对话,且都具备相同的保证。对话可以在记录的任何点从头开始或分支(fork)出另一个对话,并能查看父对话在该点之前的历史记录,而无需复制。

Think of a Slack channel where your agent answers mentions from anyone. Then somebody opens a thread. The channel can be one conversation, and the thread a fork of it at the message the thread replies to. Both run at the same time and neither blocks the other.想象一个 Slack 频道,你的智能体在其中回答任何人的提及。然后有人开启了一个话题(thread)。该频道可以是一个对话,而话题则是该对话在回复消息处的一个分支。两者同时运行,互不干扰。

const channel = await harness.root(context);
const question = await channel.submit(
    { type: "input", content: "@agent why did the deploy fail?" },
    context,
);
const answered = await question.wait(context);

// Someone replies to the agent's answer in a thread. Every conversation names
// its owner, which decides what an abort reaches (more on that under Tasks).
// The thread has none.
const thread = await channel.fork(
    answered.answer!,
    { ownership: { kind: "ownerless" } },
    context,
);

// Both conversations work at the same time.
const inThread = await thread.submit(
    { type: "input", content: "@agent can we roll it back?" },
    context,
);
const inChannel = await channel.submit(
    { type: "input", content: "@agent who is on call today?" },
    context,
);
await Promise.all([inThread.wait(context), inChannel.wait(context)]);

Each conversation also stores its own agent: the model, the thinking level, the selected extensions and which of their tools are active, extra instructions, and the working directory in its execution environment. A reviewer next to the main agent can use a cheaper model, read-only tools, and its own checkout.每个对话还存储其自身的智能体信息:模型、思维深度、所选扩展及其启用的工具、额外指令以及执行环境中的工作目录。主智能体旁的审查者可以使用更廉价的模型、只读工具以及它自己的签出(checkout)环境。

Extensions扩展

We want everything an agent can do to be pluggable, and every plugged-in piece to take part in durability.我们希望智能体的每项功能都是可插拔的,且每个插入的组件都能参与到持久化过程中。

In Pi Durable, an extension is a named bundle of system prompt sections, tools, hooks, and tasks. The application installs extensions in a registry. Each conversation selects which extensions and tools it uses, and stores only their names.在 Pi Durable 中,扩展是一个包含系统提示词部分、工具、钩子(hooks)和任务的命名包。应用程序在注册表中安装扩展。每个对话选择其使用的扩展和工具,并仅存储它们的名称。

System prompt sections系统提示词部分

The system prompt is rebuilt from the sections of the conversation's extensions before every request, so a changed section is picked up by the next request. Pi Durable records what changed in the transcript, at the position where it changed, so a restart or a fork sees exactly what the model saw. On models that support system prompt and tool changes in the middle of a conversation, only the change is sent, so the prompt cache stays valid.系统提示词会在每次请求前根据对话扩展的部分重新构建,因此更改的部分会在下一次请求中生效。Pi Durable 会在记录中更改的位置记录变更,因此重启或分支后,模型看到的与之前完全一致。对于支持在对话中更改系统提示词和工具的模型,仅发送变更部分,从而保持提示词缓存的有效性。

import { defineExtension, section } from "@earendil-works/pi-durable";

const ProjectContext = defineExtension({
    name: "project-context",
    sections: [
        // Read from the conversation's execution environment. The files can be
        // loaded and watched in the background; every request renders the
        // latest state.
        section("agents_md", (input) => agentsMd.latest(input.env)),
        section("skills", (input) => skills.latest(input.env)),
    ],
});

Tools工具

Every tool call runs as its own durable task, and its intent is stored before it runs. After a crash, a tool reruns only if it says that is safe. Otherwise the model is told the call was interrupted, with the output stored so far, and decides what to do. Each conversation can also get its own set of tools, like the Slack thread from earlier, which may search but not deploy.每个工具调用作为其自身的持久化任务运行,其意图在运行前会被存储。崩溃后,工具仅在声明安全的情况下才会重试。否则,模型会被告知调用已中断,并附带已存储的输出,由模型决定如何处理。每个对话也可以拥有自己的一套工具,例如前面提到的 Slack 话题,它可能只能搜索而不能部署。

import { Type } from "@earendil-works/pi-ai";
import { defineTool } from "@earendil-works/pi-durable";

const searchIssues = defineTool({
    name: "search_issues",
    description: "Search the issue tracker",
    parameters: Type.Object({ query: Type.String() }),
    replay: "safe", // only reads, so a rerun after a crash is fine
    execute: async (args, api) => {
        // streamed to every client watching
        api.output(`searching for ${args.query}\n`);
        return {
            content: [{ type: "text", text: await tracker.search(args.query) }],
        };
    },
});

const deploy = defineTool({
    name: "deploy",
    description: "Deploy a version to production",
    parameters: Type.Object({ version: Type.String() }),
    // No replay: a deploy interrupted by a crash is reported to the model,
    // never repeated.
    execute: async (args) => ({
        content: [{ type: "text", text: await ci.deploy(args.version) }],
    }),
});

registry.install(defineExtension({ name: "ops", tools: [searchIssues, deploy] }));

// The thread may search, but not deploy.
await thread.configure({ tools: { remove: [deploy] } }, context);

A tool gets the harness API for its call: it can commit entries and documents, start tasks and conversations, and talk to other conversations. That makes a subagent a few lines of code. A tool creates a conversation it owns, gives it a smaller model and its own instructions, and waits for its answer. The subagent is a conversation like any other, so it survives a crash, counts its own cost, and a UI can show it under the call.工具在调用时可获得框架 API:它可以提交条目和文档、启动任务和对话,并与其他对话通信。这使得构建子智能体只需几行代码。工具创建一个它拥有的对话,为其分配一个更小的模型和自己的指令,并等待其回答。子智能体与任何其他对话一样,因此它能从崩溃中恢复,计算自己的成本,且 UI 可以在调用下方展示它。

import type { AssistantMessage } from "@earendil-works/pi-ai";
import { AssistantEntry, configure } from "@earendil-works/pi-durable";

const triage = defineTool({
    name: "triage",
    description: "Label an incoming issue as bug, feature, or question",
    parameters: Type.Object({ issue: Type.String() }),
    // a rerun after a crash finds the same subagent and the same submission
    replay: "safe",
    execute: async (args, api, context) => {
        const child = await api.commit(async (tx) => {
            const existing = (
                await tx.scanConversations({ ownerTaskId: api.taskId }, 1)
            ).items[0];
            if (existing !== undefined) return existing.id;
            // Owned by this call, so aborting the call aborts the subagent.
            const created = await tx.createConversation({
                ownership: { kind: "task", taskId: api.taskId },
            });
            // It starts as a copy of this conversation's agent. Make it a small
            // model without tools.
            await configure(tx, created.id, {
                model: { provider: "openai", modelId: "gpt-6-luna" },
                tools: [],
                instructions: "Answer with one word: bug, feature, or question.",
            });
            return created.id;
        }, context);
        // lets a UI show the subagent under the call
        await api.details({ conversationId: child }, context);
        const subagent = await api.conversation(child, context);
        const request = {
            type: "input",
            content: args.issue,
            requestId: `triage:${api.taskId}`,
        } as const;
        const settled = await (
            await subagent!.submit(request, context)
        ).wait(context);
        // The answer is an entry in the subagent's transcript. Read it and take
        // its text.
        const entry = await api.commit(
            (tx) => tx.entry(AssistantEntry, settled.answer!),
            context,
        );
        const message = entry?.model?.[0] as AssistantMessage;
        const text = message.content
            .flatMap((content) => (content.type === "text" ? [content.text] : []))
            .join("");
        return { content: [{ type: "text", text }] };
    },
});

Extensions can also change other extensions' tools. A tool with the same name in a later extension replaces the earlier one, for example a bash that runs inside a Python virtualenv. A wrap decorates whichever tool won, wherever the wrapping extension is selected.扩展还可以更改其他扩展的工具。后续扩展中同名的工具会替换掉之前的工具,例如在 Python 虚拟环境中运行的 bash。包装(wrap)可以装饰任何胜出的工具,无论包装扩展在何处被选中。

import { wrapTool } from "@earendil-works/pi-durable";
import { createBashTool } from "@earendil-works/pi-durable/tools";

// Times every bash call, whichever bash the conversation ends up with.
const Timing = defineExtension({
    name: "timing",
    wraps: [
        wrapTool(createBashTool(), (bash) => ({
            ...bash,
            execute: async (args, api, context) => {
                const start = Date.now();
                try {
                    return await bash.execute(args, api, context);
                } finally {
                    metrics.record("bash", Date.now() - start);
                }
            },
        })),
    ],
});

Hooks钩子

Hooks let extensions step into tasks, including the built-in tasks for generating a model response, invoking a tool, or performing compaction. They can rewrite a request before it goes to the model, block or rewrite a tool call, replace a result, keep a run going, or write a summary themselves. A hook can run again after a crash, so a hook that makes a decision stores it in a memo: a small value stored with the task, where the first write wins.钩子允许扩展介入任务,包括用于生成模型响应、调用工具或执行压缩的内置任务。它们可以在请求发送给模型前重写请求、阻止或重写工具调用、替换结果、保持运行或自行编写摘要。钩子可以在崩溃后再次运行,因此做出决策的钩子会将其存储在备忘录(memo)中:这是一个随任务存储的小值,写入优先。

import { hook, ToolTask } from "@earendil-works/pi-durable";

const Approval = defineExtension({
    name: "approval",
    hooks: [
        hook(ToolTask, {
            beforeTool: async (call, api, context) => {
                if (call.name !== "deploy") return undefined;
                // After a restart, the hook finds the stored answer instead of
                // asking again.
                let approved = await api.memo<boolean>(
                    "approval:deploy",
                    context,
                );
                approved ??= await api.memo(
                    "approval:deploy",
                    await askInSlack(call),
                    context,
                );
                return approved
                    ? undefined
                    : { block: "Nobody approved the deploy." };
            },
        }),
    ],
});

Several extensions can hook the same thing. Their hooks run as a chain, in the order the conversation selects the extensions, and each hook defines how its chain runs. beforeTool passes rewritten arguments down the chain, and the first block stops it. afterTool passes the result down the chain. onYield stops at the first hook that keeps the run going. Observers like afterResponse always run them all. A hook that throws is reported, and the chain continues, except in beforeTool, where a throw blocks the call.多个扩展可以挂钩同一事物。它们的钩子按对话选择扩展的顺序链式运行,每个钩子定义其链的运行方式。beforeTool 将重写后的参数传递给链条,第一个阻塞操作会停止它。afterTool 将结果传递给链条。onYield 在第一个保持运行的钩子处停止。像 afterResponse 这样的观察者总是运行所有钩子。抛出错误的钩子会被报告,链条继续执行,但在 beforeTool 中,抛出错误会阻塞调用。

Tasks任务

The harness runs conversations with built-in tasks: one for each model request, one for each tool call, and one for compaction. Extensions bring their own tasks and get the same machinery: a checkpoint after every step, timers that survive restarts, and waiting on other tasks.框架通过内置任务运行对话:每个模型请求一个,每个工具调用一个,以及一个用于压缩的任务。扩展可以引入自己的任务并使用相同的机制:每一步后的检查点、重启后依然有效的计时器以及等待其他任务。

A checkout that splits the bill across several cards charges every card at once. If one card is declined, the other payments are aborted and refund themselves:一个将账单分摊到多张卡的结账操作会同时扣除所有费用。如果其中一张卡被拒绝,其他支付会被中止并自动退款:

import { defineTask, type TaskId } from "@earendil-works/pi-durable";

const Payment = defineTask<{ card: string }, { phase: "charge" }, string>({
    name: "shop.payment",
    version: 1,
    initial: () => ({ phase: "charge" }),
    phases: {
        charge: async (task, runtime, context) => {
            // The key makes the charge idempotent: if a crash reruns this
            // phase, the card is only charged once.
            const charge = await bank.charge(
                task.input.card,
                `payment-${task.id}`,
            );
            await runtime.commit(
                () => ({
                    status: "terminal",
                    outcome: charge.ok
                        ? { status: "completed", result: charge.receipt }
                        : { status: "failed", error: { message: charge.error } },
                }),
                context,
            );
        },
    },
    // Another payment failed, or the checkout was cancelled: undo this one.
    abort: async (task, runtime, context) => {
        await bank.refund(`payment-${task.id}`);
        await runtime.commit(
            () => ({ status: "terminal", outcome: { status: "aborted" } }),
            context,
        );
    },
});

type CheckoutState =
    | { phase: "pay" }
    | { phase: "decide"; payments: TaskId<string>[] };
const Checkout = defineTask<{ cards: string[] }, CheckoutState, string>({
    name: "shop.checkout",
    version: 1,
    initial: () => ({ phase: "pay" }),
    phases: {
        pay: async (task, runtime, context) => {
            await runtime.commit(async (tx) => {
                const payments: TaskId<string>[] = [];
                for (const card of task.input.cards) {
                    payments.push(
                        await tx.createTask(Payment, { card }, {
                            ownership: { kind: "task", taskId: task.id },
                        }),
                    );
                }
                // Run no code until every payment is done. The first failed
                // payment aborts the others.
                return {
                    status: "waiting",
                    checkpoint: { phase: "decide", payments },
                    on: payments,
                    policy: "failFast",
                };
            }, context);
        },
        decide: async (task, runtime, context) => {
            const outcomes = await runtime.outcomes(
                task.state.checkpoint.payments,
                context,
            );
            const paid = outcomes.every(
                (outcome) => outcome.status === "completed",
            );
            await runtime.commit(
                () => ({
                    status: "terminal",
                    outcome: paid
                        ? { status: "completed", result: "Order placed." }
                        : {
                            status: "failed",
                            error: { message: "A payment failed." },
                        },
                }),
                context,
            );
        },
    },
    abort: (_task, runtime, context) =>
        runtime.commit(
            () => ({ status: "terminal", outcome: { status: "aborted" } }),
            context,
        ),
});

// The agent starts a checkout with a tool.
const checkout = defineTool({
    name: "checkout",
    description: "Pay for the cart, split across several cards",
    parameters: Type.Object({ cards: Type.Array(Type.String()) }),
    execute: async (args, api, context) => {
        // Owned by this call: aborting the call aborts the checkout and refunds
        // its payments.
        const owner = {
            ownership: { kind: "task", taskId: api.taskId },
        } as const;
        const id = await api.createTask(
            Checkout,
            { cards: args.cards },
            owner,
            context,
        );
        const { outcome } = (await api.waitForTask(id, context)).state;
        const text =
            outcome.status === "completed" ? outcome.result : outcome.status;
        return { content: [{ type: "text", text }] };
    },
});

registry.install(defineExtension({
    name: "shop",
    tools: [checkout],
    tasks: [Payment, Checkout],
}));

Tasks and conversations form one ownership tree. Aborting a task aborts what it owns, bottom-up, so every task cleans up its own effects first, and a task only finishes once the work it owns has finished. A subagent is the same pattern: a conversation owned by the tool call that started it.任务和对话构成了一个所有权树。中止一个任务会自底向上中止它拥有的所有任务,因此每个任务首先清理自己的影响,只有当它拥有的工作完成后,任务才会结束。子智能体是相同的模式:由启动它的工具调用所拥有的对话。

Tasks are foreground by default: they are part of the conversation's current work. The conversation is idle only once they are done, and aborting the conversation, for example when the user presses Esc, aborts them and everything they own. A background task belongs to the conversation, but not to its current work. The conversation goes idle while it runs, and an ordinary abort leaves it and everything it owns alone. That fits a subagent that should outlive the turn that started it, or a reminder that fires tomorrow. Aborting the task itself, or the conversation with { background: true }, still stops it.任务默认是前台的:它们是对话当前工作的一部分。只有当它们完成后,对话才会空闲;而中止对话(例如用户按下 Esc 时)会中止它们及其拥有的所有内容。后台任务属于对话,但不属于其当前工作。对话在后台任务运行时保持空闲,普通的中止操作不会影响它及其拥有的内容。这适用于应该在启动它的轮次结束后依然存活的子智能体,或者明天触发的提醒。中止任务本身,或使用 { background: true } 中止对话,仍然会停止它。

// Part of the current work: Esc aborts it, and the conversation waits for it.
await api.createTask(
    Checkout,
    input,
    { ownership: { kind: "task", taskId: api.taskId } },
    context,
);

// Side work: the conversation goes idle while it runs, and Esc leaves it alone.
await api.createTask(
    Reminder,
    input,
    { ownership: { kind: "conversation" }, background: true },
    context,
);

Compaction压缩

We want long conversations to keep going without the agent stopping to summarize.我们希望长对话能够持续进行,而无需智能体停下来进行总结。

In Pi Durable, compaction is a task like any other, and it runs while the conversation keeps going. When the context gets close to the model's limit, a background compaction summarizes the older messages, and the summary is placed at the next turn boundary. The conversation only waits for a summary when the next request would not fit otherwise. If the provider still rejects a request as too long, the harness compacts and retries once. You can also compact manually at any time, with your own instructions. The older messages always stay in storage.在 Pi Durable 中,压缩是像其他任务一样的任务,它在对话持续进行时运行。当上下文接近模型的限制时,后台压缩会汇总较旧的消息,并将摘要放置在下一个轮次边界。对话仅在下一个请求无法容纳时才会等待摘要。如果提供商仍然因请求过长而拒绝,框架会压缩并重试一次。你也可以随时使用自己的指令手动进行压缩。较旧的消息始终保留在存储中。

const harness = await Harness.open(storage, {
    models,
    registry,
    settings: {
        compaction: {
            // past contextWindow - reserveTokens, the next request waits for a
            // summary
            reserveTokens: 16384,
            // this far before that, a summary starts in the background
            backgroundTokens: 32768,
        },
    },
}, context);

// Manual, also while the agent is working.
await root.compact("Keep the names of the failing tests", context);

reset() goes further: it starts a new context, optionally from a handoff note, and a tool can ask for the same by returning control: { handoff }. Because nothing is deleted, a second tool can still search everything before the handoff. That is all it takes to build an agent that hands off to itself and looks things up later.reset() 更进一步:它启动一个新的上下文,可选择从移交说明开始,工具可以通过返回 { handoff } 来请求同样的操作。由于没有任何内容被删除,第二个工具仍然可以在移交前搜索所有内容。构建一个能移交给自己并稍后查找信息的智能体,只需这些步骤。

const handoff = defineTool({
    name: "handoff",
    description:
        "Start over from a handoff note. " +
        "Older messages stay searchable with search_history.",
    parameters: Type.Object({ note: Type.String() }),
    execute: async (args, api, context) => {
        // Queued behind the handoff, so it starts the next run in the new
        // context.
        const self = await api.conversation(api.conversationId, context);
        await self!.submit(
            {
                type: "input",
                content: "Continue.",
                requestId: `handoff:${api.taskId}`,
            },
            context,
        );
        // Ends this run and starts a new context from the note, like
        // reset(note).
        return {
            content: [{ type: "text", text: "Handing off." }],
            control: { handoff: args.note },
        };
    },
});

const searchHistory = defineTool({
    name: "search_history",
    description: "Search older messages, including those before a handoff",
    parameters: Type.Object({ text: Type.String() }),
    replay: "safe",
    execute: async (args, api, context) => {
        // Tools read records through a transaction too. One that writes
        // nothing stores nothing.
        const page = await api.commit(
            (tx) => tx.scanEntries({ conversationId: api.conversationId }, 200),
            context,
        );
        const hits = page.items.filter((entry) =>
            JSON.stringify(entry.model ?? []).includes(args.text),
        );
        const text = hits.map((entry) => JSON.stringify(entry.model)).join("\n");
        return { content: [{ type: "text", text }] };
    },
});

Durable application state持久化应用状态

We want the state of the application built on the agent to be as durable as the conversation itself.我们希望构建在智能体之上的应用状态,能像对话本身一样持久。

In Pi Durable, application state, like a todo list, a plan, a ticket, or the sandbox a conversation runs in, lives in documents. Documents are typed JSON stored next to the transcript and changed in the same atomic commits, so the state never disagrees with the transcript that produced it. Each document says what a fork starts with: the parent's value at the fork point, its current value, or a fresh one.在 Pi Durable 中,应用状态(如待办事项列表、计划、工单或对话运行的沙箱)存在于文档中。文档是与记录一起存储的类型化 JSON,并在相同的原子提交中变更,因此状态永远不会与生成它的记录产生冲突。每个文档都说明了分支的起始内容:分支点的父级值、当前值或一个全新的值。

import { defineDoc } from "@earendil-works/pi-durable";

const Todos = defineDoc<{ items: string[] }>({
    kind: "app.todos",
    version: 1,
    scope: "conversation",
    history: "rewindable",
    fork: "asOf", // a fork starts with the todos its parent had at the fork entry
    initial: () => ({ items: [] }),
});

const Todo = defineExtension({
    name: "todo",
    tools: [
        defineTool({
            name: "todo",
            description: "Add an item to your todo list",
            parameters: Type.Object({ item: Type.String() }),
            execute: async (args, api, context) => {
                await api.commit(async (tx) => {
                    const todos = await tx.doc(Todos, api.conversationId);
                    todos.items.push(args.item);
                }, context);
                const text = `Added ${args.item}`;
                return { content: [{ type: "text", text }] };
            },
        }),
    ],
    // The model sees the list before every request.
    sections: [
        section("todos", async (input, context) => {
            const todos = await input.read.snapshot(
                Todos,
                input.conversationId,
                context,
            );
            return todos?.items.join("\n") || undefined;
        }),
    ],
});

// A UI subscribes to the committed value.
const todos = await harness.documentState(Todos, channel.id, context);
todos?.subscribe((value) => renderTodos(value?.items ?? []));

Malleable易修改性

We want to change the code of a running agent without stopping it.我们希望在不停止运行的情况下更改智能体的代码。

In Pi Durable, the registry can change while conversations run. Installing an extension under a name that is already installed replaces it in one step. A tool call that is already running finishes on the code it started with; the next call uses the new code. Conversations store extension and tool names, never code, so after a restart they pick up whatever the new process installs.在 Pi Durable 中,注册表可以在对话运行时更改。以已安装的名称安装扩展会一步完成替换。已经在运行的工具调用会使用其启动时的代码完成;下一次调用则使用新代码。对话存储的是扩展和工具名称,而非代码,因此重启后它们会加载新进程所安装的任何内容。

// The extension's file changed on disk.
// same name "ops": replaces the installed one
registry.install(await loadExtension("./ops.ts"));

Multiplayer多人协作

We want many people and clients to work with the same conversations at once: watch them, join late, and steer them.我们希望多人和多个客户端能够同时处理同一个对话:观看、中途加入并进行操控。

In Pi Durable, everything a UI needs is committed state, so any number of clients can attach to any conversation in the harness. A client gets the current view first: the transcript, the answer being streamed, running tools and their output, queued messages, the agent, and usage. After that it only gets what changes. A client that joins late or reconnects starts from the current view. Any client can steer a running conversation or queue a follow-up.在 Pi Durable 中,UI 所需的一切都是已提交的状态,因此任意数量的客户端都可以连接到框架中的任何对话。客户端首先获得当前视图:记录、正在流式传输的答案、运行中的工具及其输出、排队的消息、智能体和使用情况。之后,它仅接收变更部分。中途加入或重新连接的客户端从当前视图开始。任何客户端都可以操控正在运行的对话或排队后续请求。

// A second client joins the thread while the agent is working.
const view = await thread.viewState(context);
render(view.value);
view.subscribe((value) => render(value));

// And steers it. The message joins the running work after the current tool
// calls.
await thread.submit(
    { type: "input", content: "Check the staging logs first", whenBusy: "steer" },
    context,
);

For a remote client, thread.watch() delivers the exact operations of every commit, small enough to send over a socket. If you prefer the coding agent's familiar events, watchEvents() turns the commits into those, at the cost of more bytes on the wire.对于远程客户端,thread.watch() 提供每次提交的确切操作,足够小,可以通过 socket 发送。如果你更喜欢编码智能体熟悉的事件,watchEvents() 会将提交转换为这些事件,代价是传输更多字节。

Try it试用

You can try Pi Durable today. Pi Durable is experimental, and the API might still change. Point your agent at packages/durable in a Pi checkout, have it read the README, the over thirty examples, the small coding agent on Pi Durable, or this beautiful vacation planning agent, and get building.你可以立即试用 Pi Durable。Pi Durable 目前处于实验阶段,API 可能仍会发生变化。将你的智能体指向 Pi 检出目录中的 packages/durable,阅读 README、三十多个示例、Pi Durable 上的小型编码智能体,或者这个精美的假期规划智能体,然后开始构建吧。

The vacation planner is about 1,300 lines of TypeScript, most of them the TUI. If it looks like a coding agent, that's only because it borrows its TUI components from the Pi coding agent.这个假期规划器大约有 1,300 行 TypeScript 代码,大部分是 TUI。如果它看起来像编码智能体,那只是因为它从编码智能体 Pi 中借用了 TUI 组件。

00:0000:00-01:2700:00-01:2700:17 - A subagent runs three searches in parallel, each a durable task.00:17 - 子智能体并行运行三次搜索,每次都是一个持久任务。00:23 - Meanwhile, the main agent is free to chat.00:23 - 同时,主智能体可以自由聊天。00:38 - The process dies. Weather and museums are done, trains is not.00:38 - 进程终止。天气和博物馆搜索已完成,火车搜索未完成。00:44 - Restarted. search is safe to rerun: only trains runs again.00:44 - 重启。搜索可安全重试:仅火车搜索再次运行。00:48 - Switch to the subagent and steer it.00:48 - 切换到子智能体并进行操控。01:02 - Back in main: ask, compact, and steer, all while it works.01:02 - 回到主智能体:询问、压缩并操控,所有操作都在运行中。01:27 - The report arrives as a message; main turns it into a plan.01:27 - 报告以消息形式送达;主智能体将其转化为计划。00:17 - 子智能体并行运行三次搜索,每次都是一个持久任务。00:23 - 同时,主智能体可以自由聊天。00:38 - 进程终止。天气和博物馆搜索已完成,火车搜索未完成。00:44 - 重启。搜索可安全重试:仅火车搜索再次运行。00:48 - 切换到子智能体并进行操控。01:02 - 回到主智能体:询问、压缩并操控,所有操作都在运行中。01:27 - 报告以消息形式送达;主智能体将其转化为计划。
  1. A vacation planner with a TUI, built on Pi Durable.
  2. A subagent runs three searches in parallel, each a durable task.
  3. Meanwhile, the main agent is free to chat.
  4. The process dies. Weather and museums are done, trains is not.
  5. Restarted. search is safe to rerun: only trains runs again.
  6. Switch to the subagent and steer it.
  7. Back in main: ask, compact, and steer, all while it works.
  8. The report arrives as a message; main turns it into a plan.
A vacation planner with a TUI, built on Pi Durable.一个基于 Pi Durable 构建的带有 TUI 的假期规划器。

To run both demos from a Pi checkout:从 Pi 检出目录运行这两个演示:

npm install && npm run build
node packages/coding-agent/src/experimental/durable/main.ts
node packages/coding-agent/src/experimental/vacation/main.ts

To build on Pi Durable in your own project:在你的项目中基于 Pi Durable 进行构建:

npm install @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord

In the coming weeks, we will talk more about Pi Durable and show you the small agentic tools we build with it to help us work, like a Slack bot or a GitHub triage bot. We don't want to spill the beans yet. There is more coming as we use Pi Durable ourselves, just like we use Pi.在接下来的几周里,我们将讨论更多关于 Pi Durable 的内容,并展示我们用它构建的各种小型智能体工具,以帮助我们的工作,例如 Slack 机器人或 GitHub 分流机器人。我们还不想现在就泄露秘密。随着我们自己使用 Pi Durable,就像我们使用 Pi 一样,更多功能即将到来。

FAQ

Why TypeScript again?为什么又是 TypeScript?

Because it is the easiest way to bootstrap this. But as everybody knows by now, it's very easy to port everything to Rust or assembler. We're not ruling this out in the future, but at the moment we are focusing on TypeScript.因为这是引导该项目的最简单方法。但正如大家所知,将一切移植到 Rust 或汇编语言非常容易。我们不排除未来会这样做,但目前我们专注于 TypeScript。