跳到正文
hackernews100· paulsmith·· 7 小時前精選AI 評分74

Pi Durable 推出跨環境 Agent 持久執行框架

Pi Durable

AI 導讀

Pi Durable 讓 Agent 可跨環境長期執行,並支援多方協同調整,提升持久對話的可靠性。

推薦理由

Pi Durable 讓 Agent 可跨環境長期執行,並支援多方協同調整,提升持久對話的可靠性。

正文 · AI 翻譯

今天 Earendil 與 Pi 社群 釋出了 Pi 1.0。這體現了我們的信念:經過無數小時的硬化、維護與積極開發,Pi 現已成為可供構建的堅實基礎。Pi 仍在持續演進。與 Pi 1.0 一同,我們推出了一個實驗性新套件,名為 Pi Durable。Pi Durable 專為可長時間執行、耐用且可塑性代理而設計,能在任何地方執行。我們邀請你加入樂趣,協助我們打造最優秀的耐用工具。

為什麼 Pi Durable?

Pi 這個程式碼代理被設計為在您的(遠端)機器上、在終端機內執行,由一人驅動。如果程序死亡,您會檢視發生了什麼並告訴它繼續。這正是 Pi 1.0 專注並擅長的地方,且這不會改變。

在 Earendil,我們想將這項技術帶給每個人,無論以何種形式最符合他們的需求。為此,我們需要一個能在任何地方執行、可從不同介面訪問、支援無限長對話、能抵禦嚴重內部與外部失敗,並允許多位人類共同驅動同一代理的工具。

Pi Durable 就是那個工具。它並不取代 Pi 程式碼代理。它是一個用於構建任何代理式應用程式的框架,程式碼代理也包含在內。它不僅與 Pi 程式碼代理共享程式碼,例如 pi-ai,還共享其原則:最小化與可塑性。

它還讓我們能在此領域探索設計,而不會幹擾 Pi 程式碼代理。從在 Pi Durable 上構建代理式應用程式中學到的教訓,當證明有價值時,將迴流至 Pi 程式碼代理。

什麼是 harness?

每個人對於 harness 的定義各不相同。我們曾在之前 寫過此事,但讓我們重新介紹 Pi Durable 的 harness 概念。

一個 harness 是儲存與執行一個或多個大型語言模型對話所需機制的結合。它提供模型所呼叫的工具,以及工具執行的執行環境。

對話是您與代理之間的互動,並以文字記錄為轉錄。代理是大型語言模型及其設定,例如思考層級,以及它能呼叫的工具。

工具透過執行環境完成工作,執行環境可以是您的筆電、遠端虛擬機器,或是記憶體中的沙盒。代理所使用的工具與執行環境由每個對話決定。

harness 執行的所有工作,從呼叫模型到執行工具,都屬於一個任務。

與 Pi 的所有內容相似,Pi Durable 也是為了讓您的代理能夠理解而構建。整個原始碼(不含測試)約 15,000 行,使用 GPT 約 150,000 個 token,使用 Claude 約 250,000 個 token,這是最差情況。若要在 Pi Durable 上開發,您的代理很少需要全部程式碼;僅儲存後端就有 3,000 行,通常可以跳過。

現在讓我們帶您簡短參觀 Pi Durable,說明我們所構建的內容與原因。

長期執行於任何地方

我們希望代理能長時間執行,並能在任何地方執行,現在所謂任何地方指的是任何有 JavaScript 執行環境的地方。

在 Pi Durable 中,harness(測試框架)會在儲存後端上啟動。Pi Durable 附帶記憶體、SQLite 和 JSONL 儲存,以及符合性測試套件和基準測試,供您自訂後端使用。SQLite 與 JSONL 儲存程式碼不使用 Node API,因此只需一個小型介面卡即可在 Bun 或 Cloudflare Durable Object 內執行。儲存介面簡潔易實作,您可以在任何您擁有的系統上使用,例如 key-value 儲存或 Postgres。一次只能有一個程序擁有儲存,其他客戶端會連線到該程序。

在 SQLite 上,harness 只將工作集保留在記憶體中:即活躍的對話紀錄、正在執行的任務以及待提交的作業。其餘資料則儲存在磁碟上,直到需要時才載入。活躍對話紀錄自然受限於模型的上下文視窗,因為壓縮功能會在訊息溢位前先摘要舊訊息。因此,即使對話包含數萬條訊息,也能緊湊地放進記憶體。

需要檔案或 shell 的工具會從執行環境取得。Pi Durable 附帶 Node 執行環境,讓工具能存取您的本機檔案。與儲存相似,執行環境介面簡潔易實作,因此您也可以將遠端執行環境暴露給工具。這樣就能讓 harness 在一臺機器上執行,而工具則在另一臺機器上執行。您的 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",
    },
});

能夠在崩潰後存活

我們希望代理在程序終止時能夠存活,無論是筆記型電腦進入睡眠、容器被重新部署,或機器記憶體耗盡,都能從中斷處繼續執行。

在 Pi Durable 中,執行的每一步都是一個任務,並在繼續前儲存一個檢查點。若程序死亡,新的程序會開啟同一個儲存,尋找未完成的任務,並從最後的檢查點繼續。被截斷的模型請求會重新送出;部分答案會保留在對話紀錄中,並標記為中斷。若工具呼叫被截斷,且安全可重試,則會重新執行;否則模型會被告知已被中斷。Pi Durable 沒有內建子代理,但只需幾行程式碼即可構建,以下的 triage 工具示範。子代理在自己的對話中執行,亦能從中斷處繼續;且安全可重試的子代理工具會再次找到其子代理並等待其答案。排隊訊息仍保持排隊。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);

同時處理多個對話

我們希望一個 harness 能同時執行多個對話,互不阻塞。

在 Pi Durable 中,一個 harness 可以同時執行任意數量的對話,並行執行,且享有相同的保證。對話可以從新開始,也可以在任意位置從另一個對話分支,並在不複製的情況下看到父對話到該點的歷史。

想像一個 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)]);

每個對話還會儲存自己的代理設定:模型、思考層級、已選擇的擴充功能以及其工具的啟用狀態、額外指示,以及執行環境中的工作目錄。與主代理並列的審核者可使用較便宜的模型、唯讀工具,並擁有自己的檢出。

擴充功能

我們希望代理能做的所有事情都能被插拔化,並且每個已插入的元件都能參與耐久性。

在 Pi Durable 中,擴充功能是一組命名的系統提示段落、工具、掛鉤和任務。應用程式將擴充功能安裝到登錄檔中。每段對話會選擇使用哪些擴充功能和工具,並僅儲存它們的名稱。

系統提示段落

系統提示會在每次請求前從對話的擴充功能段落重新構建,這樣改動的段落會在下一個請求中被採用。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)),
    ],
});

工具

每一次工具呼叫都作為獨立的耐久任務執行,其意圖會在執行前儲存。若發生崩潰,工具僅在表示安全時才會重新執行。否則模型會被告知該呼叫已被中斷,並附上目前已儲存的輸出,然後決定接下來的行動。每段對話也可擁有自己的工具集合,例如前述的 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);

工具會取得其呼叫所需的 harness 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 }] };
    },
});

擴充功能也能改變其他擴充功能的工具。若後續擴充功能中有相同名稱的工具,將取代先前的,例如在 Python 虛擬環境中執行的 bash。包裝功能會裝飾獲勝的工具,無論包裝擴充功能被選取於何處。

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

掛鉤

掛鉤允許擴充功能介入任務,包括生成模型回覆、呼叫工具或執行壓縮的內建任務。它們能在請求送往模型前重寫請求、阻止或重寫工具呼叫、取代結果、保持執行繼續,或自行撰寫摘要。掛鉤在崩潰後可再次執行,故做出決策的掛鉤會將其存於備忘錄:一個與任務一起儲存的小值,首次寫入勝出。

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

多個擴充功能可掛鉤相同專案。它們的掛鉤以鏈結方式執行,順序為對話所選擇的擴充功能順序,每個掛鉤定義其鏈的運作方式。beforeTool 將重寫後的引數傳遞至鏈下,第一個阻塞即可停止。afterTool 將結果傳遞至鏈下。onYield 在第一個保持執行的掛鉤停止。觀察者如afterResponse 始終執行全部。丟擲例外的掛鉤會被報告,鏈仍繼續,除非beforeTool,此時丟擲會阻止呼叫。

任務

harness 以內建任務執行對話:每次模型請求一個、每次工具呼叫一個、以及一次壓縮任務。擴充功能帶來自己的任務,並使用相同機制:每一步後的檢查點、能在重啟後持續的計時器,以及等待其他任務。

將帳單拆分至多張卡片的結帳一次性扣款所有卡。若其中一張卡被拒,其他付款將被中止並自動退款。

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

任務與對話構成一棵所有權樹。中斷任務會中斷其所擁有的內容,從下往上,因而每個任務會先清理自己的影響,並且只有當其所擁有的工作完成後才算結束。子代理遵循相同模式:由啟動它的工具呼叫所擁有的對話。

任務預設為前景:它們屬於對話的當前工作。對話只有在它們完成後才會變為閒置,並且中斷對話(例如使用者按下 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,
);

壓縮

我們希望長對話能持續進行,而不必讓代理停下來做摘要。

在 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() 更進一步:它會啟動一個新的上下文,可選擇從交接筆記開始,工具亦可透過回傳 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).
        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 }] };
    },
});

耐久應用程式狀態

我們希望建立在代理上的應用程式狀態與對話本身同樣耐久。

在 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 ?? []));

可塑性

我們希望在不停止代理的情況下改變其程式碼。

在 Pi Durable 中,登錄檔可在對話執行時變更。以已安裝的名稱安裝擴充功能會一步驟替換它。已在執行中的工具呼叫會以其啟動時的程式碼完成;下一個呼叫則使用新程式碼。對話只儲存擴充功能和工具名稱,從不儲存程式碼,因此重新啟動後會接續使用新程序安裝的內容。

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

多人模式

我們希望多位使用者和客戶端能同時使用同一對話:觀察、延遲加入並引導它們。

在 Pi Durable 中,UI 所需的一切皆為已提交的狀態,因此任意數量的客戶端都能連線到 harness 中的任何對話。客戶端首先取得目前的畫面:對話紀錄、正在串流的答案、正在執行的工具及其輸出、佇列訊息、代理與使用量。此後它只會接收變更。延遲加入或重新連線的客戶端會從目前畫面開始。任何客戶端都能引導正在執行的對話或排隊後續。

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

對於遠端客戶端,thread.watch() 交付每一次提交的精確操作,足夠小以便透過 socket 傳送。如果你偏好程式碼代理的熟悉事件,watchEvents() 將提交轉換為那些事件,會增加網路傳輸的位元數。

試試看

你可以今天就試用 Pi Durable。Pi Durable 仍為實驗性,API 仍可能變更。將你的代理指向 Pi checkout 中的 packages/durable,讓它閱讀 README、超過三十個 範例、Pi Durable 上的小型程式碼代理,或這個美麗的 度假規劃代理,並開始構建。

度假規劃器大約 1,300 行 TypeScript,主要是 TUI。 若看起來像程式碼代理,只是因為它借用了 Pi 程式碼代理的 TUI 元件。

  1. 一個帶有 TUI 的度假規劃器,建立於 Pi Durable 上。
  2. 一個子代理同時執行三個搜尋,每個都是耐久任務。
  3. 同時,主代理可以自由聊天。
  4. 該流程已結束。天氣與博物館已完成,列車尚未完成。
  5. 重新啟動。search 可以安全重跑:只有列車再次執行。
  6. 切換到子代理並指導它。
  7. 回到主代理:詢問、簡化並指導,同時它仍在運作。
  8. 報告以訊息形式到達;主代理將其轉為計畫。

要從 Pi checkout 執行兩個示範:

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

在自己的專案中使用 Pi Durable 建置:

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

在接下來的幾週,我們將更深入討論 Pi Durable,並展示我們用它構建的小型代理工具,協助我們工作,例如 Slack 機器人或 GitHub triage 機器人。我們暫時不想透露太多。隨著我們像使用 Pi 一樣使用 Pi Durable,還會有更多內容推出。

常見問題

為什麼再次使用 TypeScript?

因為它是啟動此專案最簡單的方法。但正如大家現在已知的,將一切移植到 Rust 或組譯語言都非常容易。我們並未排除未來的可能,但目前我們專注於 TypeScript。

來源:hackernews100 · earendil.com