어디서나 실행되며 오래 지속되고 유연하게 변경할 수 있는 에이전트를 위한 실험적 프레임워크 Pi Durable을 소개합니다.
오늘 Earendil과 Pi 커뮤니티는 Pi 1.0을 출시했습니다. 이는 수없이 많은 시간의 강화, 유지보수, 활발한 개발을 거쳐 Pi가 이제 구축을 위한 견고한 기반이 되었다는 우리의 믿음을 반영합니다. Pi는 계속 발전하고 있습니다. Pi 1.0과 함께 Pi Durable이라는 실험적인 새 패키지도 출시합니다. Pi Durable은 어디서나 실행할 수 있는, 오래 지속되고 내구성이 있으며 유연하게 변경할 수 있는 에이전트를 위해 특별히 만들어졌습니다. 여러분도 함께 참여하여 이것을 최고의 내구성 하니스로 만드는 데 도움을 주시기를 바랍니다.
코딩 에이전트 Pi는 한 사람이 조작하는 터미널 안에서 여러분의 원격 머신에서 실행되도록 만들어졌습니다. 프로세스가 죽으면 무슨 일이 있었는지 살펴보고 계속하라고 지시합니다. 그것이 Pi 1.0이 집중하고 탁월하게 해내는 일이며, 바뀌지 않습니다.
Earendil에서는 이 기술을 각자의 필요에 가장 잘 맞는 형태로 모든 사람에게 제공하고자 합니다. 그러려면 어디서나 실행되고, 여러 표면에서 접근할 수 있으며, 무한히 긴 대화를 지원하고, 치명적인 내부 및 외부 장애에서도 살아남으며, 여러 사람이 같은 에이전트를 조종할 수 있게 하는 하니스가 필요합니다.
Pi Durable이 바로 그 하니스입니다. 이는 코딩 에이전트 Pi를 대체하지 않습니다. 코딩 에이전트를 포함한 모든 에이전트 애플리케이션을 구축하기 위한 프레임워크입니다. pi-ai 같은 코드를 코딩 에이전트 Pi와 공유할 뿐 아니라, 미니멀리즘과 유연성이라는 원칙도 공유합니다.
또한 코딩 에이전트 Pi를 방해하지 않고 이 영역의 설계를 탐색할 수 있게 해 줍니다. Pi Durable 위에서 에이전트 애플리케이션을 구축하며 얻는 교훈은 그 가치가 입증되는 대로 코딩 에이전트 Pi에 다시 반영될 것입니다.
하니스에 대한 정의는 사람마다 다릅니다. 이전에 이에 관해 글을 쓴 적이 있지만, Pi Durable을 위해 하니스의 개념을 다시 소개하겠습니다.
하니스는 저장소와, 하나 이상의 대규모 언어 모델 대화를 병렬로 실행하는 데 필요한 기계 장치의 결합입니다. 모델이 호출하는 도구와 도구가 실행되는 실행 환경을 제공합니다.
대화는 여러분과 에이전트 간의 상호작용으로, 기록으로 남습니다. 에이전트는 사고 수준 같은 설정 및 호출할 수 있는 도구와 결합된 대규모 언어 모델입니다.
도구는 여러분의 노트북, 원격 VM 또는 메모리 내 샌드박스일 수 있는 실행 환경을 통해 작업합니다. 에이전트가 어떤 도구와 어떤 실행 환경을 갖는지는 각 대화에 따라 결정됩니다.
모델 호출부터 도구 실행까지, 하니스가 실행하는 모든 것은 태스크입니다.
Pi의 모든 것처럼 Pi Durable도 에이전트가 이해할 수 있도록 만들어졌습니다. 테스트를 제외한 전체 소스 코드는 약 15,000줄이며, GPT에서는 약 150,000토큰, Claude에서는 약 250,000토큰에 해당합니다. 이는 최악의 경우입니다. Pi Durable 위에 구축할 때 에이전트가 그 전체를 필요로 하는 일은 드뭅니다. 저장소 백엔드만 해도 보통 건너뛸 수 있는 3,000줄입니다.
이제 우리가 무엇을, 왜 만들었는지 보여 드리기 위해 Pi Durable을 간단히 둘러보겠습니다.
에이전트가 오랫동안 실행되고, 현재로서는 JavaScript 런타임이 있는 곳이라면 어디서든 실행될 수 있기를 원합니다.
Pi Durable에서 하니스는 저장소 백엔드 위에 열립니다. Pi Durable은 메모리, SQLite 및 JSONL 저장소와 자체 백엔드를 위한 적합성 모음 및 벤치마크를 제공합니다. SQLite 및 JSONL 저장소 코드는 Node API를 사용하지 않으므로, 작은 어댑터를 추가하면 Bun 또는 Cloudflare Durable Object 내부에서 실행됩니다. 저장소 인터페이스는 작고 구현하기 쉬워서 키-값 저장소나 Postgres처럼 여러분이 가진 어떤 것 위에도 구현할 수 있습니다. 한 번에 하나의 프로세스가 저장소를 소유하고, 다른 클라이언트는 그 프로세스에 연결합니다.
SQLite에서 하니스는 활성 기록, 실시간 태스크, 대기 중인 제출 등 작업 집합만 메모리에 유지합니다. 그 외 모든 것은 필요할 때까지 디스크에 남아 있습니다. 활성 기록은 모델의 컨텍스트 창에 의해 자연스럽게 제한됩니다. 넘치기 전에 압축이 오래된 메시지를 요약하기 때문입니다. 따라서 수만 개의 메시지가 있는 대화도 메모리에 알맞게 들어갑니다.
파일이나 셸이 필요한 도구는 실행 환경에서 이를 얻습니다. 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",
},
});
노트북이 절전 상태가 되거나, 컨테이너가 재배포되거나, 머신의 메모리가 부족해지는 등 프로세스가 죽어도 에이전트가 살아남아 중단한 곳에서 다시 시작하기를 원합니다.
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);
하나의 하니스가 하나가 다른 하나를 막지 않고 동시에 많은 대화를 실행하기를 원합니다.
Pi Durable에서 하나의 하니스는 같은 보장을 유지하면서 필요한 만큼의 대화를 동시에 실행합니다. 대화는 새로 시작하거나 기록의 어느 지점에서든 다른 대화를 포크할 수 있으며, 복사하지 않고 그 지점까지의 부모 기록을 봅니다.
에이전트가 누구의 멘션에도 응답하는 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);
도구는 호출을 위한 하니스 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 }] };
},
});
확장은 다른 확장의 도구도 바꿀 수 있습니다. 나중 확장에서 같은 이름을 가진 도구는 앞선 도구를 대체합니다. 예를 들어 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은 예외입니다.
하니스는 내장 태스크로 대화를 실행합니다. 각 모델 요청에 하나, 각 도구 호출에 하나, 압축에 하나씩입니다. 확장은 자체 태스크를 가져오며 매 단계 후 체크포인트, 재시작 후에도 살아남는 타이머, 다른 태스크 대기라는 같은 기계 장치를 얻습니다.
여러 카드로 계산을 나누는 체크아웃은 모든 카드에 동시에 청구합니다. 한 카드가 거절되면 다른 결제는 중단되고 자동으로 환불됩니다.
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에 필요한 모든 것은 커밋된 상태이므로, 임의의 수의 클라이언트가 하니스의 임의의 대화에 연결할 수 있습니다. 클라이언트는 먼저 현재 보기를 받습니다. 기록, 스트리밍 중인 응답, 실행 중인 도구와 그 출력, 대기 중인 메시지, 에이전트 및 사용량입니다. 그 후에는 변경된 것만 받습니다. 늦게 참여하거나 다시 연결하는 클라이언트는 현재 보기에서 시작합니다. 모든 클라이언트가 실행 중인 대화를 조종하거나 후속 작업을 대기열에 넣을 수 있습니다.
// 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()는 모든 커밋의 정확한 작업을 소켓으로 보내기에 충분히 작은 크기로 전달합니다. 코딩 에이전트의 익숙한 이벤트를 선호한다면 watchEvents()가 더 많은 전송 바이트를 사용하는 대신 커밋을 그러한 이벤트로 변환합니다.
오늘 Pi Durable을 사용해 볼 수 있습니다. Pi Durable은 실험적이며 API는 여전히 변경될 수 있습니다. 에이전트가 Pi 체크아웃의 packages/durable을 가리키게 하고, README, 30개가 넘는 예제, Pi Durable 위의 작은 코딩 에이전트, 또는 이 멋진 휴가 계획 에이전트를 읽게 한 뒤 구축을 시작하세요.
휴가 플래너는 약 1,300줄의 TypeScript이며, 대부분은 TUI입니다. 코딩 에이전트처럼 보인다면 Pi 코딩 에이전트에서 TUI 구성 요소를 빌려 왔기 때문일 뿐입니다.
Pi Durable 위에 구축된 TUI 휴가 플래너. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ { "task": "두 성인을 위한 비엔나 주말 여행을 조사하세요. 처음 방문하는 사람을 위한 강력한 2일 일정, 현재의 실용적인 방문객 정보, 주목할 만한 레스토랑/카페, 교통 팁 및 예약 고려 사항을 파악하세요. 날짜는 아직 제공되지 않았으므로 날짜 의존 항목을 표시하세요." } 조사가 백그라운드에서 시작되었습니다. 조사가 진행 중입니다. 어느 주말에 방문하시며, 대략적인 예산과 관심사는 무엇인가요? 미술, 역사, 음식, 클래식 음악, 나이트라이프 태스크 (실행 중 3개, 숨기려면 /tasks) vacation.research #12: 실행 중 deliver [백그라운드]가 대화 13 소유 pi.generation #21: 실행 중 요청 pi.generation #22: 실행 중 요청 ── ⠧ 작업 중... (중단하려면 esc) ───────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────────────── ↑243 ↓96 $0.003 0.1%/500k /private/tmp/vacation-trip main · radius/gpt-5.6-sol · thinking:low (shift+tab) · ctrl+l 또는 /model · /agents · /compact · /tasks · option+enter 후속 작업 · ctrl+c 종료
00:00-01:27 00:17 - 하위 에이전트가 각각 내구성 태스크인 세 개의 검색을 병렬로 실행합니다.
00:23 - 그동안 주 에이전트는 자유롭게 대화할 수 있습니다.
00:38 - 프로세스가 죽습니다. 날씨와 박물관은 끝났지만, 기차는 끝나지 않았습니다.
00:44 - 재시작되었습니다. search는 다시 실행해도 안전하므로 기차만 다시 실행됩니다.
00:48 - 하위 에이전트로 전환하여 조종합니다.
01:02 - 주 에이전트로 돌아가 작업 중에도 질문하고, 압축하고, 조종합니다.
01:27 - 보고서가 메시지로 도착하고, 주 에이전트가 이를 계획으로 만듭니다.
search는 다시 실행해도 안전하므로 기차만 다시 실행됩니다.Pi Durable 위에 구축된 TUI 휴가 플래너.
Pi 체크아웃에서 두 데모를 모두 실행하려면 다음을 사용하세요.
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 분류 봇처럼 우리가 업무에 도움을 받기 위해 이로 구축하는 작은 에이전트 도구를 보여 드리겠습니다. 아직은 비밀을 밝히고 싶지 않습니다. 우리 스스로 Pi Durable을 사용하면서, Pi를 사용하는 것처럼 더 많은 것이 찾아올 것입니다.
이것을 시작하기에 가장 쉬운 방법이기 때문입니다. 하지만 이제 모두가 알다시피, 모든 것을 Rust나 어셈블리로 포팅하기는 아주 쉽습니다. 미래에 이를 배제하지는 않지만, 현재는 TypeScript에 집중하고 있습니다.