Pi Durable: 長期実行・マルチユーザー対応エージェントアプリケーションのためのハーネス

TL;DR – Pi Durableとは何か、なぜ重要なのか

Pi Durableは、Pi 1.0とともにリリースされた新しい実験的なハーネスです。これにより、エージェントはどこでも実行可能になり、プロセスのクラッシュから復旧し、無限に続く会話を処理し、複数のユーザーや拡張機能をサポートできるようになります。耐久性があり、柔軟なエージェントアプリケーションを構築するために必要なストレージ、タスクのチェックポイント機能、実行環境の抽象化を提供します。


核心的な問題:Pi 1.0はシングルユーザーでターミナルに縛られたエージェントである

Pi 1.0は、一人の人間がターミナル内でコーディングエージェントを操作するように設計されています。プロセスが終了した場合、ユーザーは手動で再起動し、エージェントに継続するように指示しなければなりません。このモデルは対話的なコーディングには適していますが、以下のような用途には対応していません:

  • 常時稼働エージェント:数日間または数週間、無人で実行されるもの。
  • マルチユーザーコラボレーション:複数のクライアントが同じ会話を監視または操作するもの。
  • 同時並行の会話:Slackのスレッドや並列サブエージェントなど。
  • 堅牢な復旧:コンテナの再起動、VMのクラッシュ、メモリ不足による強制終了からの復旧。

Pi Durableは、元のPiコーディングエージェントを変更することなく、これらのギャップを埋めます。


ハーネスの定義 – ストレージと実行の仕組み

Pi Durableにおけるハーネスとは、以下の組み合わせを指します:

  1. ストレージバックエンド – トランスクリプト、タスク、型付きJSONドキュメントを永続化します。
  2. 実行環境 – エージェントにツール(ファイルアクセス、シェルなど)を提供します。
  3. タスクエンジン – モデルのリクエスト、ツール呼び出し、圧縮を耐久性のあるタスクとして実行します。

ハーネスは、エージェントが理解し操作できるシンプルなAPI(Harness.open、harness.root、conversation.submitなど)を公開します。


どこでも実行可能な長期稼働エージェント

Pi Durableには、標準で3つのストレージアダプターが同梱されています:

  • Memory – テスト用の純粋なメモリ内ストレージ。
  • SQLite – ディスク上のリレーショナルストア。
  • JSONL – 行区切りのJSONログ。

すべてのアダプターは同じ最小限のインターフェースを公開しているため、カスタムバックエンド(Postgres、DynamoDB、Cloudflare Durable Objectsなど)の実装が容易です。一度にストレージインスタンスを所有できるプロセスは1つだけであり、他のクライアントはリーダー/ライターとして接続します。

ハーネスはワーキングセット(アクティブなトランスクリプト、ライブタスク、保留中の送信)のみをメモリに保持します。古いメッセージは要約に圧縮されるため、数万件のメッセージがある会話でも、モデルのコンテキストウィンドウ内に快適に収まります。

例: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" },
});

クラッシュ耐性のある実行

モデルのリクエスト、ツール呼び出し、圧縮といったすべてのステップは、進む前にその入力と出力をチェックポイントするタスクです。プロセスが終了した場合、新しいプロセスが同じストレージを再オープンし、未完了のタスクを発見して、最後のチェックポイントから再開します。

  • 中断されたモデル呼び出しは再送信されます。部分的な回答はトランスクリプト内で中止としてマークされます。
  • ツール呼び出しはreplayポリシー("safe"または省略)を宣言します。安全な呼び出しは自動的に再試行され、安全でない呼び出しは中断されたことがモデルに通知されます。
  • requestIdは、送信に対して正確に1回のセマンティクスを保証します。クラッシュ後の再試行では、重複したリクエストを発行する代わりに元の結果が返されます。

クラッシュ復旧の例

const job = { type: "input", content: "Fix the flaky login test", requestId: "job-42" } as const;
await root.submit(job, ctx); // ツール呼び出し中にプロセスが終了

// 新しいプロセスが再開
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); // 同じ回答が返る

同時並行の会話とフォーク

単一のハーネスで、互いにブロックすることなく並行して実行される多数の独立した会話をホストできます。会話はトランスクリプトの任意の場所でフォークでき、フォークポイントまでの親の履歴を継承しつつ、その後は分岐します。

フォークはSlackのチャンネルとスレッドをモデル化します: メインチャンネルが1つの会話であり、各スレッドは並行して実行されるフォークです。

フォークの例

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)),
]);

各会話は独自のエージェント設定(モデル、ツール、作業ディレクトリ)を保存するため、安価なレビュー担当者や専門的なサブエージェントを作成できます。


拡張可能なアーキテクチャ – 拡張機能、ツール、フック、タスク

拡張機能

拡張機能は以下をバンドルします:

  • システムプロンプトセクション(各モデルリクエストの前に再レンダリングされます)。
  • ツール(モデルが呼び出せる関数)。
  • フック(タスク実行のインターセプトや変更)。
  • カスタムタスク(ユーザー定義の耐久性のあるワークフロー)。

会話は名前で拡張機能を選択します。名前のみが永続化されるため、再起動後には自動的に最新のコードが読み込まれます。

システムプロンプトセクション

セクションは、会話の実行環境から読み取り、システムプロンプトに挿入されるテキストを返す関数です。変更はトランスクリプト内でバージョン管理されるため、再起動時にもモデルが以前見たものと全く同じ内容を確認できます。

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." };
      },
    }),
  ],
});

フックはタスクに付随するメモに決定を保存するため、クラッシュしても生き残ります。

カスタムタスク – 耐久性のあるワークフロー

タスクは、明示的なフェーズ、チェックポイント、中止ロジックを持つファーストクラスの耐久性ユニットです。他のタスクを所有でき、クリーンな中止の伝播を保証する所有権ツリーを形成します。

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タスクを調整し、フェイルファストなオーケストレーションと中止時の自動返金を実現します。


無限コンテキストのための自動圧縮

会話がモデルのコンテキスト制限に近づくと、Pi Durableは古いメッセージを要約するバックグラウンド圧縮タスクを実行します。要約は次のターンの境界に挿入され、アクティブなウィンドウをreserveTokens(デフォルト16,384)内に保ちます。それでもプロバイダーがリクエストを拒否した場合、ハーネスはもう一度圧縮して再試行します。

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

reset()操作は、後の検索のために古いメッセージを保持しながら、新しいコンテキストを開始でき、ハンドオフパターンを可能にします。


型付きドキュメントによる耐久性のあるアプリケーション状態

アプリケーションの状態(例:ToDoリスト)は、トランスクリプトとともにアトミックに保存されるドキュメント(型付き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")); // 前の "ops" 拡張機能を置き換える

マルチプレイヤーコラボレーション

すべての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トークン、Claudeでは約250,000トークンになる」 – ireadmevs (コードベースのサイズとトークン数の違いを強調).

「マルチユーザーの部分が一番気に入った。リモートコントロールツールを作るのが簡単になりそうだ……」 – skeledrew

「素晴らしい。ただ、耐久性のあるアプリケーション状態がJSONドキュメントだけに制限されていない方が良かった。アウトボックスパターンを実装する統合的な方法があるべきだ……」 – vmg12

「1.0スレッドからのクロスポスト。私はSlack用のハーネスを構築している……JSONLセッションファイルとポッドの中断を扱うのは複雑さが増す。今はそのためにDBOSを使っている」 – azuanrb

これらのコメントは、耐久性、マルチユーザーサポート、柔軟なストレージバックエンドに対するコミュニティの需要を強調しています。


はじめに

Pi Durableは実験的であり、APIは進化する可能性があります。ローカルで試すには:

npm install @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord
# Piリポジトリをクローンして例を実行
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