[翻译]Pi Durable:持久化智能体运行框架

文章目录
  • 原文地址:Pi Durable
  • 原作者:Earendil Engineering
  • 原文发表于:2026-10-01
  • 原文源码:earendil-works/website · posts/pi-durable.md
  • 原文许可:Apache-2.0,许可来自原文网站源码仓库,全文附于文末。
  • 翻译说明:本文添加中文译文及代码注释译文,原文站内链接改为绝对地址;终端演示保留原站入口及章节说明。代码逻辑和示例字符串保持原样,示例对应原文发布时的版本。

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 专为能够在任何地方运行、支持长期运行、具备持久性且易于改造的智能体而打造。我们希望你也加入进来,一起把它打造成最好的持久化智能体运行框架(harness)。

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.

每个人对 harness 都有自己的定义。我们之前写过这个话题,这里再结合 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.

harness 是存储,以及并行运行一个或多个大语言模型对话所需的运行机制。它提供模型调用的工具,以及这些工具运行所在的执行环境。

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.

运行框架执行的所有工作,从调用模型到执行工具,都是任务。

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 行,使用 GPT 分词约为 150,000 个 token,使用 Claude 分词约为 250,000 个 token。这是最坏的情况。基于 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
// 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 没有内置子智能体,但只需几行代码就能构建,下文的 triage 工具就展示了这一点。子智能体在自己的对话中运行,因此也能从中断的位置继续;如果一个子智能体工具可以安全重跑,它会重新找到自己的子智能体,等待它的回答。已排队的消息仍然留在队列中。requestId 确保一次提交只发生一次(exactly-once),因此客户端在崩溃后重试时会得到原来的提交,而不会重复提问。

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 频道,你的智能体会回应任何人对它的提及。随后有人开启了一个讨论串。频道可以是一个对话,而讨论串则可以从它所回复的那条消息处分支出来。两个对话同时运行,互不阻塞。

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.

每个对话还会保存自己的智能体配置:模型、思考级别、选用的扩展及其中启用的工具、额外指令,以及执行环境中的工作目录。主智能体旁边的审查智能体可以使用更便宜的模型、只读工具,以及自己独立检出的代码。

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 中,扩展是一个具名的组合,包含系统提示词片段、工具、钩子和任务。应用把扩展安装到注册表中。每个对话选择自己使用的扩展和工具,并且只保存它们的名称。

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:它可以提交记录条目和文档、启动任务和对话,也能与其他对话通信。因此,只需几行代码就能实现子智能体。工具创建一个归自己所有的对话,为它配置更小的模型和专门的指令,再等待回答。子智能体与其他对话一样,能够在崩溃后恢复、单独统计自己的费用,界面也可以把它展示在对应的工具调用下面。

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.
// 为每次 bash 调用计时,无论对话最终选用的是哪个 bash。
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.
// 属于当前工作:Esc 会中止它,对话会等待它完成。
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.
// 后台工作:运行期间对话可以空闲,Esc 不会影响它。
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
// 超过 contextWindow - reserveTokens 后,下一次请求会等待摘要。
reserveTokens: 16384,
// this far before that, a summary starts in the background
// 在距离上述阈值还有这么多 token 时,开始在后台生成摘要。
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() 更进一步:它启动一个新的上下文,也可以选择从一份交接说明开始。工具返回 control: { 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).
// 结束本次运行,根据交接说明启动新上下文,相当于 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
// 扩展文件在磁盘上发生了变化;名称同为 "ops",因此替换已安装的扩展。
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 中,界面所需的一切都是已提交的状态,因此任意数量的客户端都能连接到运行框架中的任意对话。客户端首先获得当前视图:对话记录、正在流式生成的回答、运行中的工具及其输出、排队的消息、智能体配置,以及用量。之后只接收发生变化的部分。稍后加入或重新连接的客户端,从当前视图开始。任何客户端都可以引导正在运行的对话,或把后续消息加入队列。

// 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。它仍处于实验阶段,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 组件。

终端演示:在原文中播放假期规划智能体演示。以下保留原演示的章节顺序和时间点。

  1. 0:00 — A vacation planner with a TUI, built on Pi Durable.

    基于 Pi Durable 构建、带有 TUI 的假期规划器。

  2. 0:17.9 — A subagent runs three searches in parallel, each a durable task.

    子智能体并行执行三项搜索,每项都是持久化任务。

  3. 0:23.9 — Meanwhile, the main agent is free to chat.

    与此同时,主智能体仍然可以自由聊天。

  4. 0:38.6 — The process dies. Weather and museums are done, trains is not.

    进程终止。天气和博物馆搜索已完成,火车搜索尚未完成。

  5. 0:44.9 — Restarted. search is safe to rerun: only trains runs again.

    重启后,由于 search 可以安全重跑,只有火车搜索重新执行。

  6. 0:48.9 — Switch to the subagent and steer it.

    切换到子智能体,并引导它。

  7. 1:02.3 — Back in main: ask, compact, and steer, all while it works.

    回到主智能体:在它工作的同时提问、压缩上下文并引导它。

  8. 1:27 — The report arrives as a message; main turns it into a plan.

    报告以消息的形式送达,主智能体把它整理成计划。

To run both demos from a Pi checkout:

要在检出的 Pi 代码库中运行这两个演示:

git checkout v1.0.0
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 一样亲自使用 Pi Durable,还会有更多内容陆续推出。

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。

Apache License 2.0 — 原文许可全文
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/

TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

1. Definitions.

"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.

"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.

"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.

"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.

"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.

"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.

"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work.

"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.

"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."

"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.

2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.

3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.

4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:

(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and

(b) You must cause any modified files to carry prominent notices stating that You changed the files; and

(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and

(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.

You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.

5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.

6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.

7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.

8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.

9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.

END OF TERMS AND CONDITIONS

最后更新: