Pi Durable: 장기 실행, 다중 사용자 에이전트 애플리케이션을 위한 하네스
TL;DR – Pi Durable이란 무엇이며 왜 중요한가
Pi Durable은 Pi 1.0과 함께 출시된 새로운 실험적 하네스로, 에이전트가 어디서나 실행되고, 프로세스 충돌에서 살아남으며, 무한히 긴 대화를 처리하고, 다중 사용자 및 확장을 지원할 수 있게 합니다. 이 하네스는 내구성이 뛰어나고 유연한 에이전트 애플리케이션을 구축하는 데 필요한 스토리지, 작업 체크포인팅 및 실행 환경 추상화를 제공합니다.
핵심 문제: Pi 1.0은 단일 사용자, 터미널 기반 에이전트이다
Pi 1.0은 한 명의 인간이 터미널 내에서 코딩 에이전트를 구동하도록 설계되었습니다. 프로세스가 종료되면 사용자가 수동으로 다시 시작하고 에이전트에게 계속하도록 지시해야 합니다. 이 모델은 대화형 코딩에는 적합하지만 다음을 지원하지 않습니다:
- 며칠 또는 몇 주 동안 무인으로 실행되는 상시 가동 에이전트.
- 여러 클라이언트가 동일한 대화를 지켜보거나 조종하는 다중 사용자 협업.
- Slack 스레드나 병렬 하위 에이전트와 같은 동시 대화.
- 컨테이너 재시작, VM 충돌 또는 메모리 부족으로 인한 종료로부터의 강력한 복구.
Pi Durable은 기존 Pi 코딩 에이전트를 변경하지 않고 이러한 격차를 해결합니다.
하네스 정의 – 스토리지 + 실행 메커니즘
Pi Durable의 하네스는 다음의 조합입니다:
- 스토리지 백엔드 – 트랜스크립트, 작업 및 타입이 지정된 JSON 문서를 영구 저장합니다.
- 실행 환경 – 에이전트에게 도구(파일 액세스, 셸 등)를 제공합니다.
- 작업 엔진 – 모델 요청, 도구 호출 및 압축을 내구성 있는 작업으로 실행합니다.
하네스는 에이전트가 이해하고 조작할 수 있는 간단한 API(Harness.open, harness.root, conversation.submit 등)를 노출합니다.
어디서나 실행되는 장기 실행 에이전트
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" },
});
충돌 복구 실행
모델 요청, 도구 호출 또는 압축과 같은 모든 단계는 진행하기 전에 입력과 출력을 체크포인트로 저장하는 **작업(task)**입니다. 프로세스가 종료되면 새 프로세스가 동일한 스토리지를 다시 열고 완료되지 않은 작업을 발견하여 마지막 체크포인트에서 다시 시작합니다.
- 중단된 모델 호출은 다시 전송되며, 부분적인 답변은 트랜스크립트에서 *중단됨(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); // 도구 호출 중 프로세스 종료
// 새 프로세스 재개
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); // 동일한 답변 반환
동시 대화 및 포킹(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)),
]);
각 대화는 자체 에이전트 구성(모델, 도구, 작업 디렉토리)을 저장하여 저렴한 검토자나 전문 하위 에이전트를 활성화합니다.
확장 가능한 아키텍처 – 확장, 도구, 후크 및 작업
확장(Extensions)
확장은 다음을 번들로 제공합니다:
- 시스템 프롬프트 섹션(각 모델 요청 전에 다시 렌더링됨).
- 도구(모델이 호출할 수 있는 함수).
- 후크(작업 실행을 가로채거나 수정).
- 사용자 지정 작업(사용자 정의 내구성 워크플로우).
대화는 이름으로 확장을 선택합니다. 이름만 유지되므로 재시작 후 자동으로 최신 코드를 로드합니다.
시스템 프롬프트 섹션
섹션은 대화의 실행 환경에서 읽고 시스템 프롬프트에 삽입된 텍스트를 반환하는 함수입니다. 변경 사항은 트랜스크립트에 버전이 지정되므로 재시작 시 모델이 본 내용을 정확히 볼 수 있습니다.
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 작업을 조정하여 빠른 실패(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")); // 이전 "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