Pi Durable:用於長期運行、多用戶代理應用程式的框架

TL;DR – 什麼是 Pi Durable 以及它為何重要

Pi Durable 是隨 Pi 1.0 發布的一個全新實驗性框架,它使代理(agents)能夠在任何地方運行、在進程崩潰後存活、處理無限長的對話,並支援多用戶和擴充功能。它提供了構建持久、靈活的代理應用程式所需的儲存、任務檢查點和執行環境抽象。


核心問題:Pi 1.0 是一個單用戶、綁定終端的代理

Pi 1.0 旨在讓單個人類在終端內驅動編碼代理。如果進程終止,用戶必須手動重啟它並告訴代理繼續工作。這種模式適用於互動式編碼,但無法支援:

  • 全天候運行的代理:無需人工監控,可運行數天或數週。
  • 多用戶協作:多個客戶端可以同時查看或引導同一個對話。
  • 並發對話:例如 Slack 執行緒或平行的子代理。
  • 強大的恢復能力:從容器重啟、虛擬機崩潰或記憶體不足導致的終止中恢復。

Pi Durable 在不更改原始 Pi 編碼代理的情況下解決了這些缺口。


框架定義 – 儲存 + 執行機制

Pi Durable 中的「框架」(harness)是以下內容的組合:

  1. 儲存後端 – 持久化保存對話記錄、任務和類型化的 JSON 文件。
  2. 執行環境 – 為代理提供工具(文件存取、shell 等)。
  3. 任務引擎 – 將模型請求、工具調用和壓縮作為持久化任務運行。

該框架公開了一個簡單的 API(Harness.open、harness.root、conversation.submit 等),代理可以理解並操作這些 API。


在任何地方運行長期代理

Pi Durable 開箱即用提供三種儲存適配器:

  • Memory – 用於測試的純記憶體儲存。
  • SQLite – 基於磁碟的關聯式儲存。
  • JSONL – 行分隔的 JSON 日誌。

所有適配器都公開相同的最小介面,因此實現自定義後端(例如 Postgres、DynamoDB 或 Cloudflare Durable Objects)非常簡單。同一時間只有一個進程擁有儲存實例;其他客戶端則以讀取者/寫入者身份連接。

框架僅將「工作集」(活動對話記錄、即時任務、待處理提交)保留在記憶體中。較舊的訊息會被壓縮成摘要,因此即使是擁有數萬條訊息的對話,也能舒適地適應模型的上下文視窗。

範例:開啟 SQLite 框架

import { Harness, openNodeSqliteStorage } from "@earendil-works/pi-durable";
import { createModels, openaiProvider } from "@earendil-works/pi-ai";
import { createRegistry, CodingTools } from "@earendil-works/pi-durable/tools";
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";

const models = createModels();
models.setProvider(openaiProvider());
const registry = createRegistry();
registry.install(CodingTools);

const env = ({ cwd }: { cwd?: string }) =>
  new NodeExecutionEnv({ cwd: cwd ?? process.cwd() });

const harness = await Harness.open(
  await openNodeSqliteStorage("./agent.sqlite"),
  { models, registry, env },
  BACKGROUND_CONTEXT,
);

const root = await harness.root(BACKGROUND_CONTEXT, {
  agent: { model: { provider: "openai", modelId: "gpt-6.1-sol" }, cwd: "/work/repo" },
});

具備崩潰恢復能力的執行

每一個步驟——模型請求、工具調用或壓縮——都是一個「任務」,在繼續執行之前會對其輸入和輸出進行檢查點記錄。如果進程崩潰,新進程會重新開啟相同的儲存,發現未完成的任務,並從最後一個檢查點恢復執行。

  • 被中斷的模型調用會被重新發送;部分答案在對話記錄中被標記為「已中止」(aborted)。
  • 工具調用會聲明一個 replay 策略("safe" 或省略)。安全調用會自動重試;不安全調用會通知模型它們已被中斷。
  • requestId 保證了提交的「精確一次」(exact-once)語義:崩潰後的重試會返回原始結果,而不是發出重複請求。

崩潰恢復範例

const job = { type: "input", content: "Fix the flaky login test", requestId: "job-42" } as const;
await root.submit(job, ctx); // process dies during tool call

// New process resumes
const harness2 = await Harness.open(await openNodeSqliteStorage("./agent.sqlite"), { models, registry, env }, ctx);
harness2.resume();
const root2 = await harness2.root(ctx);
const settled = await (await root2.submit(job, ctx)).wait(ctx); // returns the same answer

並發對話與分支(Forking)

單個框架可以託管「許多獨立的對話」,這些對話可以並行運行而不會互相阻塞。對話可以在任何對話記錄條目處進行「分支」,繼承父級直到分支點的歷史記錄,但隨後會分道揚鑣。

分支模擬了 Slack 頻道和執行緒: 主頻道是一個對話;每個執行緒都是一個並發運行的分支。

分支範例

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

const thread = await channel.fork(answered.answer!, { ownership: { kind: "ownerless" } }, ctx);
await Promise.all([
  thread.submit({ type: "input", content: "@agent can we roll it back?" }, ctx).then(t => t.wait(ctx)),
  channel.submit({ type: "input", content: "@agent who is on call today?" }, ctx).then(t => t.wait(ctx)),
]);

每個對話都儲存自己的代理配置(模型、工具、工作目錄),從而實現廉價的審閱者或專業的子代理。


可擴充架構 – 擴充功能、工具、鉤子和任務

擴充功能

一個「擴充功能」(extension)捆綁了:

  • 系統提示詞部分(在每次模型請求前重新渲染)。
  • 工具(模型可以調用的函數)。
  • 鉤子(攔截或修改任務執行)。
  • 自定義任務(用戶定義的持久化工作流)。

對話按名稱選擇擴充功能;只有名稱被持久化,因此重啟後它們會自動載入最新的程式碼。

系統提示詞部分

部分(Sections)是從對話執行環境讀取並返回插入系統提示詞的文字的函數。變更在對話記錄中進行版本控制,因此重啟後模型看到的內容與之前完全一致。

import { defineExtension, section } from "@earendil-works/pi-durable";
const ProjectContext = defineExtension({
  name: "project-context",
  sections: [
    section("agents_md", (input) => agentsMd.latest(input.env)),
    section("skills", (input) => skills.latest(input.env)),
  ],
});

工具與重放語義

工具是持久化任務。replay 標記告訴 Pi Durable 工具在崩潰後是否可以安全地重新運行。

const searchIssues = defineTool({
  name: "search_issues",
  description: "Search the issue tracker",
  parameters: Type.Object({ query: Type.String() }),
  replay: "safe",
  execute: async (args, api) => {
    api.output(`searching for ${args.query}\n`);
    return { content: [{ type: "text", text: await tracker.search(args.query) }] };
  },
});

像 deploy 這樣的不安全工具會省略 replay;如果被中斷,模型會收到通知,且操作不會重複。

鉤子 – 攔截任務

鉤子可以修改或阻塞模型請求、工具調用或壓縮。它們按照對話所選擴充功能的順序鏈式運行。

import { hook, ToolTask } from "@earendil-works/pi-durable";
const Approval = defineExtension({
  name: "approval",
  hooks: [
    hook(ToolTask, {
      beforeTool: async (call, api, ctx) => {
        if (call.name !== "deploy") return undefined;
        let approved = await api.memo<boolean>("approval:deploy", ctx);
        approved ??= await api.memo("approval:deploy", await askInSlack(call), ctx);
        return approved ? undefined : { block: "Nobody approved the deploy." };
      },
    }),
  ],
});

鉤子在崩潰後依然有效,因為它們將決策儲存在附加到任務的備忘錄(memo)中。

自定義任務 – 持久化工作流

任務是具有明確階段、檢查點和中止邏輯的一等持久化單元。它們可以擁有其他任務,形成一個所有權樹,保證中止訊號能乾淨地傳播。

const Payment = defineTask<{ card: string }, { phase: "charge" }, string>({
  name: "shop.payment",
  version: 1,
  initial: () => ({ phase: "charge" }),
  phases: {
    charge: async (task, rt, ctx) => {
      const charge = await bank.charge(task.input.card, `payment-${task.id}`);
      await rt.commit(() => ({
        status: "terminal",
        outcome: charge.ok ? { status: "completed", result: charge.receipt }
                           : { status: "failed", error: { message: charge.error } },
      }), ctx);
    },
  },
  abort: async (task, rt, ctx) => {
    await bank.refund(`payment-${task.id}`);
    await rt.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx);
  },
});

更高級別的 Checkout 任務協調多個 Payment 任務,展示了「快速失敗」(fail-fast)編排和中止時的自動退款。


無限上下文的自動壓縮

當對話接近模型的上下文限制時,Pi Durable 會運行一個「後台壓縮任務」,總結較舊的訊息。摘要會插入到下一個轉折邊界,將活動視窗保持在 reserveTokens(預設 16,384)內。如果提供商仍然拒絕請求,框架會再次壓縮並重試。

await harness.root(ctx).compact("Keep the names of the failing tests", ctx);

reset() 操作可以啟動一個全新的上下文,同時保留舊訊息以供日後搜尋,從而實現交接模式。


通過類型化文件實現持久化應用狀態

應用程式狀態(例如待辦事項清單)存在於「文件」(documents)中——與對話記錄原子化儲存的類型化 JSON 物件。文件支援:

  • 作用域 (conversation 或 global)。
  • 歷史模式 (rewindable 用於版本化讀取)。
  • 分支行為 (asOf 在分支點繼承父級狀態)。
const Todos = defineDoc<{ items: string[] }>({
  kind: "app.todos",
  version: 1,
  scope: "conversation",
  history: "rewindable",
  fork: "asOf",
  initial: () => ({ items: [] }),
});

工具可以在事務內讀寫文件,保證對話記錄和狀態永遠不會分歧。


靈活性 – 熱插拔擴充功能

註冊表可以在運行時更新。以現有名稱安裝擴充功能會立即替換它。正在進行的工具調用會使用它們開始時的程式碼完成;後續調用則使用新的實現。由於對話僅儲存名稱,重啟會自動獲取最新版本。

registry.install(await loadExtension("./ops.ts")); // replaces previous "ops" extension

多人協作

所有 UI 狀態均源自已提交的儲存,因此任意數量的客戶端都可以連接到對話,接收當前視圖,然後訂閱增量更新。

const view = await thread.viewState(ctx);
view.subscribe(v => render(v));
await thread.submit({ type: "input", content: "Check the staging logs first", whenBusy: "steer" }, ctx);

客戶端還可以觀察低級提交流(thread.watch())或更高級別的事件流(watchEvents())。


社群反應(精選 HN 評論)

「很高興看到 Pi 也構建了一個持久化代理框架。我自己在這個領域也構建了很長一段時間……主要參與者正在這個領域構建產品:LangChain Deep Agents、Vercel Eve、OpenAI Agents API、Anthropic Managed Agents 等。」 – lukebuehler

「整個原始碼(不含測試)大約有 15,000 行,對於 GPT 來說大約是 150,000 個 token,對於 Claude 來說大約是 250,000 個 token。」 – ireadmevs(強調了程式碼庫的大小和 token 數量的差異)。

「我最喜歡多用戶的部分。這應該能讓我更容易構建我的遠端控制工具……」 – skeledrew

「太棒了。但我希望持久化應用狀態不僅限於 JSON 文件。應該有一種整合的方式來實現發件箱模式(outbox pattern)……」 – vmg12

「來自 1.0 執行緒的轉發。我正在為 Slack 構建一個框架……處理 JSONL 會話文件和 Pod 中斷增加了複雜性。我現在正在為此使用 DBOS。」 – azuanrb

這些評論強調了社群對持久性、多用戶支援和靈活儲存後端的需求。


入門指南

Pi Durable 是「實驗性」的;API 可能會演變。要在本地嘗試:

npm install @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord
# Clone the Pi repo and run the examples
npm install && npm run build
node packages/coding-agent/src/experimental/durable/main.ts
node packages/coding-agent/src/experimental/vacation/main.ts

探索 packages/durable/test/examples 中的 30 多個範例腳本,以及度假規劃 TUI 以獲取具體用例。


展望

Pi Durable 為構建全天候、協作式代理提供了一個堅實且可擴充的基礎。通過將框架與編碼代理分離,Earendil 可以在不干擾核心 Pi 體驗的情況下,對持久性、儲存和多用戶功能進行迭代。未來幾週將會看到更多生產級的擴充功能(例如 Slack 機器人、GitHub 分流代理)以及與外部狀態儲存的更深層整合。

Sources

相關

  • Dispatch
  • 專案
  • 專案
  • 專案
  • Dispatch