Chương 17Phần 5: Chord và Pi Durable
Pi Durable: mô hình khái niệm
Conversation, entry, document, commit, submission, và lần theo một input được trả lời.
Trong chương này17 mục
- Vai trò: vì sao có Pi Durable
- Mô hình tư duy
- Conversation, entry và các kind dựng sẵn
- Document: trạng thái JSON có kiểu
- Commit: bề mặt giao dịch
- Submission và inbox
- Extension và Registry
- Agent của từng conversation
- System prompt: section và entry hệ thống theo vị trí
- Harness.open, HarnessOptions và Settings
- Quan sát: viewState, watch, watchEvents
- Lần theo một input được trả lời
- API công khai và các ví dụ
- Quyết định thiết kế
- Cạm bẫy và trường hợp biên
- Đọc code ở đâu
- Tóm tắt
Chương này dựng mô hình khái niệm của @earendil-works/pi-durable (gọi tắt Pi Durable): harness bền (durable harness) mà Earendil phát hành cùng Pi 1.0. Ta đi từ bài toán, qua các bản ghi và kiểu dữ liệu thật, tới một lần theo dấu (trace) đầy đủ từ lúc gửi một câu hỏi đến lúc nhận câu trả lời. Phần bên dưới mặt nước (Session, transaction, các backend lưu trữ) nằm ở chương Durable: session, commit, document và storage; task, scheduler, generation và tool nằm ở chương Durable: task, scheduler, generation và tool.
Vai trò: vì sao có Pi Durable#
Coding agent của Pi (CLI pi, xem chương AgentSession) chạy trong một terminal, cho một người. Nếu tiến trình chết giữa một lượt, người dùng xem lại chuyện đã xảy ra rồi bảo nó làm tiếp. Bài "Pi Durable" của Earendil (2026-10-01) đặt ra một bài toán khác: một harness chạy được ở bất cứ đâu có runtime JavaScript, được truy cập từ nhiều bề mặt (giao diện terminal, web, bot chat), chứa rất nhiều hội thoại dài, sống sót qua lỗi bên trong lẫn bên ngoài, và cho nhiều người cùng điều khiển một agent.
Bài viết đưa ra vài tình huống cụ thể. Một agent bị cắt ngang giữa lời gọi tool; một tiến trình mới mở lại cùng storage và chạy tiếp. Request tới mô hình bị đứt được gửi lại, phần trả lời dở được giữ và đánh dấu là bị huỷ (aborted). Một tool không an toàn khi chạy lại, như deploy, được báo cho mô hình là bị gián đoạn thay vì chạy lần thứ hai. Một kênh Slack là một hội thoại, một thread mở từ một câu trả lời trong kênh là một fork (tách nhánh), và cả hai chạy song song.
README của package tóm lời hứa đó trong hai câu: hội thoại, lượt mô hình, lời gọi tool và state riêng của ứng dụng đều được commit vào storage trước khi hiển thị bất cứ thứ gì; nếu tiến trình chết giữa lượt, mở lại storage là công việc tiếp tục từ chỗ dừng (README.md:5). Nó dựng trên pi-ai để gọi mô hình và trên Chord để giữ state của document (README.md:7).
Quan hệ với phần còn lại của Pi cần nói rõ, vì tên gọi dễ gây nhầm:
- Không dùng
AgentSession, không dùngpi-agent-core. Phụ thuộc trực tiếp của package là Chord, pi-ai, pi-codemode và pi-telemetry (package.json:152–156). Vòng lặp agent ở đây là các task bền: taskpi.generationtự gọimodels.streamSimple()của pi-ai (generation.ts:484; spec.md:3953–3960). Coding agent cũng không phụ thuộc Pi Durable; chương Bản đồ monorepo vẽ hai chồng package này cạnh nhau. - Không thay thế coding agent. Bài công bố viết "It does not replace the Pi coding agent": Pi Durable là framework để xây ứng dụng có agent, coding agent chỉ là một loại, và bài học rút ra sẽ chảy ngược về coding agent khi chứng minh được giá trị.
- Ai gọi nó. Một ứng dụng host do bạn viết: tiến trình Node, server, Durable Object của Cloudflare, hay trang web. Host mở
Harness.open(storage, options, context), cài extension vào một registry, rồi gửi input và quan sát state đã commit.
Mô hình tư duy#
Toàn bộ thiết kế xoay quanh một quy tắc, ghi ngay đầu đặc tả:
Nghĩa là: mọi thay đổi (một entry mới, một task đổi trạng thái, một document được sửa) đi qua cùng một dòng commit nguyên tử; giao diện, sự kiện và mọi thứ bên ngoài chỉ thấy những gì đã nằm trong storage. Không có kênh hiển thị nào "nhanh hơn storage". Danh sách bất biến ngay sau đó cấm hiệu ứng bên ngoài (gọi mô hình, chạy tiến trình) chạy bên trong transaction, và coi lỗi storage là lỗi chết của Session (spec.md:63–78).
Ứng dụng host (tiến trình Node, server, Durable Object, trình duyệt)
│ submit · configure · reset · compact · abort · viewState · watch · watchEvents
▼
Harness ──────────── Registry: extension (tool, section, hook, wrap, task)
│ chỉ sống trong bộ nhớ tiến trình, không được lưu
├─ Submissions nhận input/write, inbox, wait()
├─ TaskScheduler chạy task: pi.generation, pi.tool, pi.compaction, task riêng
├─ ConversationViews mount view cho UI, dẫn xuất từ các commit
▼
Session một dòng commit nguyên tử; cache document dưới dạng tracker của Chord
▼
Storage Memory · SQLite · JSONL · IndexedDB · SQLite của Durable Object
Trong storage: conversation ── entry (bất biến) task (bản ghi thay thế trọn)
└──── document (pi.agent, pi.live, pi.inbox, pi.usage, pi.provider, của bạn)
submission (input hoặc write đang chờ, đã đặt, đã xong)
Trong code, quan hệ "Harness là một Session" là quan hệ kế thừa thật: HarnessImpl mở rộng SessionImpl, và mọi thành phần bên trong đọc ghi qua this.storage, tức storage đã được bọc lớp chặn lỗi của Session.
/** Session kernel extended with conversation handles and a registry. */class HarnessImpl<Tool extends ToolRegistration> extends SessionImpl implements HarnessType { readonly #options: HarnessOptions<Tool>; readonly #hostSettings: () => HarnessSettings | undefined; readonly #report: (error: unknown, info: ReportInfo, context?: Context) => void; readonly #host: ConversationHost<Tool>; readonly #tasks: TaskScheduler; readonly #submissions: Submissions; readonly #taskGraph: TaskGraphView; constructor(rawStorage: Storage, options: HarnessOptions<Tool>, context: Context) { const report = safeReport(options.onReport, options.telemetry ?? NOOP_TELEMETRY_CONTEXT); const now = safeNow(options.now, report); const hostSettings = currentSettings(options.settings); // Read at each use, as all settings are, but only this field: it is read on every commit. super(rawStorage, now, () => hostSettings()?.documentRetentionMs, options.setTimer); // Every component reads and writes through the Session's failure guard: `rawStorage` serves only `super()`. const storage = this.storage;TaskScheduler, Submissions, TaskGraphView và ConversationViews trên cùng storage đó (harness.ts:228–263).Từ vựng dùng trong phần còn lại của chương (định nghĩa gốc ở README.md:92–104 và spec.md:46–61):
| Thuật ngữ | Nghĩa | Kiểu trong code |
|---|---|---|
| Harness | Một storage đang mở cộng bộ máy chạy agent trên đó | Harness, harness/types.ts:783 |
| Session | Chủ của dòng commit, các bản ghi và document | Session, types.ts:1070 |
| Hội thoại (conversation) | Một phạm vi transcript; có thể fork từ hội thoại khác. Handle không giữ state, so sánh bằng id | ConversationRecord, Conversation |
| Entry | Một bản ghi bất biến của transcript | EntryRecord |
| Document | State JSON có kiểu, đặt cạnh transcript, chỉ đổi qua commit | defineDoc(), DocToken |
| Commit | Một lần ghi nguyên tử: entry, document, task cùng lưu hoặc cùng không | Tx, commit() |
| Task | Máy trạng thái bền, lưu checkpoint ở mỗi bước; luôn có chủ (hội thoại hoặc task khác) | TaskRecord, defineTask() |
| Submission | Thứ host gửi vào hội thoại: input của người dùng hoặc một entry cần ghi; có thể chờ | SubmissionRecord, Submission |
| Lượt (turn) / lần chạy (run) | Một câu trả lời của mô hình và các lời gọi tool của nó / chuỗi lượt từ một input tới câu trả lời cuối. Hội thoại bận (busy) khi có run | pi.live.run |
| Extension / Registry | Gói code có tên (tool, section, hook, wrap, task) / tập extension tiến trình này đã cài | Extension, Registry |
| Agent | Thứ một hội thoại chạy cùng: mô hình, mức suy nghĩ (thinking level), extension, tool, instructions, thư mục làm việc. Lưu theo tên, giải lại mỗi lần dùng | AgentState, Agent |
Conversation, entry và các kind dựng sẵn#
ID: một không gian số duy nhất#
Mọi bản ghi (hội thoại, entry, task, submission, document) lấy ID từ cùng một dãy số nguyên do storage cấp. Kiểu Id chỉ là number có gắn "nhãn" (brand) lúc biên dịch để TypeScript không cho truyền nhầm TaskId vào chỗ cần EntryId; trong JSON, JSONL và SQLite chúng là số thường (spec.md:242–246).
declare const idBrand: unique symbol;/** Erased nominal number identifying one durable record kind. */export type Id<Kind extends string, Type = unknown> = number & { readonly [idBrand]: { readonly kind: Kind; readonly type: Type; };};export type ConversationId = Id<"conversation">;export type EntryId = Id<"entry">;export type TaskId<Result = unknown> = Id<"task", Result>;export type SubmissionId = Id<"submission">;export type DocumentId = Id<"document">;declare const seqBrand: unique symbol;/** Strictly increasing sequence assigned to one atomic storage commit; gaps are permitted. */export type Seq = number & { readonly [seqBrand]: "sequence" };/** The root conversation always uses this reserved ID. */export const ROOT_CONVERSATION_ID = 1 as ConversationId;Seq là số thứ tự của một commit trong storage, tăng nghiêm ngặt và được phép có khoảng trống. Nó mang nhãn riêng để một ID thực thể không bao giờ bị dùng làm điểm commit của document.Vì dùng chung một dãy và storage hứa cấp số lớn hơn mọi số đã cấp trước đó (types.ts:1185–1189), thứ tự ID cũng là thứ tự tạo: quét entry theo ID là quét theo thời gian. Hội thoại gốc luôn có ID 1, nên mọi backend bắt đầu cấp từ 2; bộ kiểm thử tuân thủ có hẳn một ca cho việc này (storage-conformance.ts:98).
ConversationRecord: cha để thừa kế, chủ để quy trách nhiệm#
/** Ownership selected explicitly whenever a conversation is created. */export type ConversationOwnership = { readonly kind: "ownerless" } | { readonly kind: "task"; readonly taskId: TaskId };/** Immutable identity, history ancestry, and task ownership of a transcript scope. */export type ConversationRecord = { readonly id: ConversationId; /** Fork source and inclusive parent entry through which history is inherited. */ readonly parent?: { readonly conversationId: ConversationId; readonly at: EntryId; }; /** Creator edge used for attribution, subtree abort, and subtree idle waits. */ readonly owner?: { readonly conversationId: ConversationId; readonly taskId: TaskId; };};Hai trường tuỳ chọn có hai vai khác nhau (spec.md:247–255). parent nói hội thoại này là fork của hội thoại nào, tại entry nào: nó thấy các entry của cha tới parent.at. owner nói task nào đã tạo ra nó, ví dụ một subagent do tool subagent tạo; cạnh này dùng để huỷ cả cây và chờ cả cây rảnh, không phải để phân quyền. Khi tạo, người gọi luôn chọn quyền sở hữu tường minh (ownerless hoặc task); Session tự suy ra hội thoại của task chủ. Bản ghi bất biến: một hội thoại không bao giờ đổi cha hay đổi chủ.
EntryRecord: thứ mô hình thấy và thứ ứng dụng giữ#
/** Immutable transcript event with separate model-facing and application-facing payloads. */export type EntryRecord = { readonly id: EntryId; readonly conversationId: ConversationId; /** Application-defined entry discriminator. */ readonly kind: string; /** Messages contributed to model context; absent for display or bookkeeping entries. */ readonly model?: readonly Message[]; /** JSON payload consumed by views, extensions, or bookkeeping logic. */ readonly data?: JsonValue; /** First entry in the active context selected by this entry. */ readonly head?: EntryId; /** Context-only overrides of earlier visible entries. */ readonly edits?: readonly ContextEdit[]; /** Task that appended this entry, when it was produced by durable work. */ readonly byTaskId?: TaskId;};/** Entry content supplied before the Session assigns identity and task attribution. */export type EntryDraft = Omit<EntryRecord, "id" | "conversationId" | "byTaskId" | "head"> & { /** `"self"` starts active context at the newly assigned entry ID. */ readonly head?: EntryId | "self";};ContextEdit ngay phía trên (types.ts:411–425) là một chỉnh sửa chỉ áp vào ngữ cảnh: omit bỏ phần đóng góp của entry đích, replace thay nó bằng các message khác.Một entry tách hai luồng dữ liệu. model là các message (theo kiểu Message của pi-ai) mà entry đóng góp vào request kế tiếp gửi mô hình; data là JSON tuỳ ý cho view, extension hay sổ sách. Một entry không có model chỉ để hiển thị. head đánh dấu điểm bắt đầu của ngữ cảnh đang hoạt động: entry mang head là một "head marker", và khi viết nháp, head: "self" nghĩa là ngữ cảnh mới bắt đầu ngay tại entry này. edits sửa đóng góp của các entry cũ hơn mà không đụng vào chúng. byTaskId ghi task nào đã append entry, do Session đóng dấu từ phạm vi của commit.
Entry không bao giờ bị sửa hay xoá. Reset, compaction (nén ngữ cảnh) và chỉnh sửa đều là entry mới. Đây là khác biệt lớn so với cây JSONL của coding agent (chương Session): ở đó nhánh là cấu trúc của file; ở đây "nhánh" là một hội thoại khác có parent, và "quên bớt ngữ cảnh" là một head marker.
Các kind dựng sẵn#
/** Define a typed entry kind whose `is()` guard narrows by `EntryRecord.kind`. */export function defineEntry<D extends JsonValue = never>(kind: string): Entry<D> { if (typeof kind !== "string" || kind.length === 0) throw new TypeError("Entry kind must be a non-empty string"); return { kind, is: (entry: EntryRecord | undefined): entry is TypedEntry<D> => entry !== undefined && entry.kind === kind, };}/** User input: `model` is `[UserMessage]`. Written by submissions. */export const UserEntry = defineEntry("pi.user");/** Provider result with any stop reason: `model` is `[AssistantMessage]`. Written by generation. */export const AssistantEntry = defineEntry("pi.assistant");/** Positional prompt and tool change: `model` is `[SystemMessage]` with empty `content`. */export const SystemEntry = defineEntry("pi.system");/** * Tool result: `model` is `[ToolResultMessage]`, whose content ends with the rendered diagnostics block; `data` holds * the structured diagnostics, possibly none. Written by tool tasks, and by generation for calls it did not offer. */export const ToolResultEntry = defineEntry<{ diagnostics: ToolDiagnostic[] }>("pi.tool-result");/** * Start of a new context: always `head: "self"`, with `model` absent for a plain reset or `[UserMessage]` carrying the * handoff text. Written by `Conversation.reset()` and the `handoff` tool control. */export const ResetEntry = defineEntry("pi.reset");/** * Compaction summary: `model` is `[UserMessage]` with the wrapped summary, `head` the first kept entry. Written by * compaction tasks, directly or through a write submission. */export const CompactionEntry = defineEntry<{ reason: CompactionReason }>("pi.compaction");kind và hàm thu hẹp kiểu is(). Entry<D> mang kiểu của data: ToolResultEntry luôn có data.diagnostics, CompactionEntry có data.reason, các kind còn lại không có data (spec.md:3975–3979).| Kind | model | Ai ghi |
|---|---|---|
pi.user | [UserMessage], timestamp lấy từ đồng hồ Harness lúc đặt vào transcript | submission, continuation của hook onYield |
pi.assistant | [AssistantMessage] với mọi stop reason, kể cả lỗi và bị huỷ | generation |
pi.system | [SystemMessage] với content: ""; chỉ chứa section và thay đổi tool | bước chuẩn bị của generation |
pi.tool-result | [ToolResultMessage] | task tool; generation, cho lời gọi tới tool không được mời |
pi.reset | không có, hoặc [UserMessage] chứa ghi chú bàn giao; luôn head: "self" | reset(), control handoff của tool |
pi.compaction | [UserMessage] chứa bản tóm tắt; head trỏ vào entry đầu tiên được giữ | task compaction |
Bảng trên tóm spec.md:3981–3988. Mọi câu trả lời của mô hình, kể cả lần thử lỗi, đều thành một entry pi.assistant; vì vậy không cần kind riêng cho "usage" hay "thông báo lỗi". Ứng dụng tự định nghĩa kind của mình bằng defineEntry<D>("app.note"); ví dụ 08-harness-conversations.ts:19 tạo kind message có data.from. Tiền tố pi. thuộc về các kind dựng sẵn theo quy ước; đặc tả nói rõ không có gì cưỡng chế điều đó (spec.md:5565–5568), trừ một chỗ: input gửi kèm entry.kind riêng bị từ chối nếu kind bắt đầu bằng pi. (submissions.ts:182–189).
Từ transcript tới ngữ cảnh của mô hình#
"Mô hình thấy các entry từ lần reset mới nhất trở đi" (README.md:96). Chính xác hơn, việc dẫn xuất ngữ cảnh lấy head marker mới nhất H, quét các entry nhìn thấy được từ H.head tới cuối, áp chỉnh sửa (chỉnh sửa mới nhất của mỗi đích thắng), rồi chuẩn hoá cho giao thức của provider:
/** Derive the context view from the entries scanned within `bounds`. */function deriveRange(bounds: ContextBounds, entries: readonly EntryRecord[]): ContextRange { const edits = new Map<EntryId, ContextEdit>(); // Edits of every entry in the range count, including older head markers that `selectActive()` drops. for (const entry of entries) for (const edit of entry.edits ?? []) edits.set(edit.target, edit); const active = selectActive(bounds.head, entries); const contributions = active.map((entry) => contribute(entry, edits.get(entry.id))); const { settled, open } = settle([], contributions.flat()); const baseline = findBaseline(bounds.head, active); const messages = leadWithSystem([...settled, ...orderToolResults(open)], baseline); const view = { head: bounds.head, entries: active, contributions, messages }; return { bounds, entries, view, edited: new Set(edits.keys()), settled, open, baseline };}contribute() loại các message assistant có stop reason aborted, error hoặc deferred (context.ts:10); orderToolResults() đặt kết quả tool ngay sau lời gọi của nó và tự tổng hợp kết quả lỗi cho lời gọi không có kết quả (context.ts:299–321); leadWithSystem() đưa system message nền (baseline) lên đầu (context.ts:212–220).Mười quy tắc đầy đủ nằm ở spec.md:267–302. Hai hệ quả đáng nhớ: một fork cắt ngay sau một lời gọi tool vẫn gửi được request hợp lệ, vì kết quả thiếu được tổng hợp; và view cho UI giữ entry thô, không phải message đã chuẩn hoá (spec.md:304–306). Ví dụ 09-context.ts dựng đủ các trường hợp: message bị huỷ, kết quả về lệch thứ tự, chỉnh sửa, fork ở lời gọi tool, và một head "self".
Document: trạng thái JSON có kiểu#
Bài toán: ứng dụng có state riêng, như danh sách việc cần làm của agent, lựa chọn mô hình của một hội thoại, hay hàng đợi tin nhắn chưa xử lý. State này phải sống sót qua crash cùng lúc với transcript, và khi fork hội thoại thì nhánh mới phải nhận state "đúng lúc" với entry nó tách ra. Nếu state nằm ở một kho riêng, sẽ có lúc transcript đã ghi mà state chưa ghi. Pi Durable giải bằng document: một object JSON có kiểu, gắn vào session, một hội thoại hoặc một task, và chỉ thay đổi bên trong commit, cùng commit với entry.
/** Conversation document that retains only its current state. */export type LatestConversationSemantics = { readonly scope: "conversation"; readonly history: "latest"; readonly fork: "current" | "initial";};/** Conversation document whose history remains addressable for as-of reads. */export type RewindableConversationSemantics = { readonly scope: "conversation"; readonly history: "rewindable"; readonly fork: "asOf" | "current" | "initial";};/** Ownership and lifetime of a document; only conversation documents declare history and fork behavior. */export type DocumentSemantics = | { readonly scope: "session"; readonly history?: never; readonly fork?: never } | LatestConversationSemantics | RewindableConversationSemantics | { readonly scope: "task"; readonly history?: never; readonly fork?: never };/** Stored replay state supplied to a document's checkpoint predicate. */export type CheckpointInfo = { /** Deltas already stored after the newest base, excluding the change being evaluated. */ readonly deltasSinceBase: number;};/** Definition fields shared by singleton documents and document families. */export type CommonDocDefinition<T extends JsonObject> = { /** Stable persisted kind; part of the public protocol. */ readonly kind: string; /** Positive integer version of the stored value shape. */ readonly version: number; initial(): T; migrate?(value: JsonObject, fromVersion: number): T; /** Required policy: return true to store this ordinary change as a complete base instead of a delta. */ checkpointWhen(value: Readonly<T>, ops: readonly Op[], info: CheckpointInfo): boolean;};Các trường của một định nghĩa:
kind,versionkindlà tên được lưu, thuộc giao thức công khai, không đổi được bằng migration;versionlà số nguyên dương của hình dạng giá trị. Giá trị lưu ở version cũ hơn được chuyển quamigrate(value, fromVersion)khi truy cập (spec.md:1596–1601).scopesession(một bản cho cả session),conversation(một bản cho mỗi hội thoại) hoặctask(sống và tự nghỉ hưu cùng task).history,fork(chỉ document hội thoại)latestchỉ giữ giá trị hiện tại;rewindablegiữ lịch sử để đọc "giá trị lúc entry X" bằngsnapshotAsOf().forknói nhánh mới bắt đầu bằng gì:initial(giá trị khởi tạo),current(giá trị hiện tại của cha khi fork) hoặcasOf(giá trị của cha tại entry fork, nên đòirewindable). Ràng buộc này nằm ở kiểu:LatestConversationSemanticskhông chofork: "asOf".initial(),checkpointWhen()initial()chạy khi document chưa có và được tạo lần đầu.checkpointWhen(value, ops, info)quyết định một thay đổi được lưu như một base (giá trị đầy đủ) hay một delta (các phép toán Chord), để chặn độ dài chuỗi phát lại (replay). Nó bắt buộc, không có mặc định (spec.md:1222–1227).
/** Define a Session-scoped singleton document. */export function defineDoc<T extends JsonObject>( definition: CommonDocDefinition<T> & { readonly scope: "session" },): SessionDocToken<T>;/** Define a latest-only conversation singleton document. */export function defineDoc<T extends JsonObject>( definition: CommonDocDefinition<T> & LatestConversationSemantics,): ConversationDocToken<T>;/** Define a rewindable conversation singleton document. */export function defineDoc<T extends JsonObject>( definition: CommonDocDefinition<T> & RewindableConversationSemantics,): RewindableConversationDocToken<T>;/** Define a task-scoped singleton document. */export function defineDoc<T extends JsonObject>( definition: CommonDocDefinition<T> & { readonly scope: "task" },): TaskDocToken<T>;export function defineDoc<T extends JsonObject>(definition: DocDefinition<T>): DocToken<T, DocDefinition<T>> { validateDefinition(definition); return { definition };}function validateDefinition(definition: AnyDocDefinition): void { if (!Number.isSafeInteger(definition.version) || definition.version < 1) { throw new TypeError(`Document ${definition.kind} version must be a positive integer`); }}defineDoc() chỉ kiểm tra version rồi trả về { definition }. Token không được đăng ký ở đâu: nó được truyền tường minh vào mọi lần truy cập (tx.doc(token, id), snapshot(token, id)), và Session không quét document lúc mở.defineDocFamily() làm tương tự cho một họ document cùng phạm vi, phân biệt bằng một khoá chuỗi, với initial(seed). Task tool dùng một họ như vậy, pi.tool.nested-call, để lưu kết quả của từng lời gọi lồng (tool.ts:106–114). Cách ghi, đọc lịch sử, base, delta và fork bên trong được mổ xẻ ở chương storage.
Năm document dựng sẵn#
Mọi hội thoại do Harness tạo đều có năm document, tạo ngay trong commit tạo hội thoại bởi hook dựng sẵn:
/** * The built-in creation hook, in every commit that creates or forks a conversation: empty `pi.live`, `pi.inbox`, and * `pi.usage`, a fresh `pi.provider`, the conversation's `pi.agent` (see `createAgent()`), then * `HarnessOptions.conversationCreated`. */protected override async conversationCreated(tx: Transaction, record: ConversationRecord): Promise<void> { await tx.doc(LiveDoc, record.id); await tx.doc(InboxDoc, record.id); await tx.doc(UsageDoc, record.id); await tx.doc(ProviderDoc, record.id); await createAgent(tx, record); await this.#options.conversationCreated?.(tx, record);}tx.createConversation() thô do một tool gọi (spec.md:768–783). Một Session trần (createSession()) không tạo document nào.| Kind | Lịch sử / fork | Chứa gì | Lưu base khi | Định nghĩa |
|---|---|---|---|---|
pi.agent | rewindable / asOf | Lựa chọn của hội thoại, lưu theo tên: mô hình, mức suy nghĩ, extension, tool, instructions, cwd | mọi thay đổi | agent.ts:42–51 |
pi.provider | latest / initial | { sessionId }: UUIDv7 mà pi-ai nhận làm sessionId cho prompt cache và session affinity | mọi thay đổi | provider.ts:11–20 |
pi.live | latest / initial | Quyền điều khiển run, câu trả lời đang stream, các tool đang chạy, compaction đang chạy | không còn gì đang chạy | live.ts:111–127 |
pi.inbox | latest / initial | Các submission đang xếp hàng, theo thứ tự ID | hàng đợi rỗng | inbox.ts:21–29 |
pi.usage | latest / initial | Tổng token và chi phí theo provider/model và theo tên tool | mọi thay đổi | usage.ts:14–22 |
Chính sách fork của từng document là quyết định sản phẩm. pi.agent là asOf để một fork chạy với đúng agent mà cha có tại entry fork. Bốn document còn lại là initial: một fork bắt đầu với inbox rỗng (hàng đợi thuộc về hội thoại gốc), không có run, chi phí bằng không (để không đếm hai lần, spec.md:4433–4436) và một sessionId mới (spec.md:866–872). Riêng pi.agent của hội thoại do task sở hữu được sao chép từ hội thoại của task chủ, một lần, lúc tạo:
/** * Built-in part of every Harness commit that creates or forks a conversation, for `pi.agent`: a fork keeps its `asOf` * copy; a new task-owned conversation copies the stored agent of its owner task's conversation; a new ownerless one * starts empty. */export async function createAgent(tx: Tx, conversation: ConversationRecord): Promise<void> { if (conversation.parent !== undefined) return; const agent = await tx.doc(AgentDoc, conversation.id); if (conversation.owner === undefined) return; const owner = await tx.doc(AgentDoc, conversation.owner.conversationId); Object.assign(agent, copyJson(owner) as AgentState);}pi.live là document bận rộn nhất và cũng là định nghĩa của "đang bận":
/** Built-in live conversation state: run control and presentation of the current generation and tool round. */export type LiveState = { /** Run control: the task that settles the run's inputs, and those inputs; present exactly while busy. */ run?: { taskId: TaskId; inputs: SubmissionId[] }; /** Presentation of the current generation attempt. */ generation?: { attempt: number; /** Committed throttled partial of the in-flight response. */ message?: JsonRepresentation<AssistantMessage>; /** Durable backoff before the next attempt. */ retry?: { at: number; error: string }; /** Provider-side deferred response being polled. */ deferred?: { pollAt: number }; }; /** The current tool round in call order, from the tool-calling answer until the generation's `tools` phase ends it. */ tools?: ToolSlot[]; /** * Nested calls of running tool calls, in creation order, so a parent precedes its children and task IDs ascend. A * call's nested slots are removed when the call settles. */ nestedTools?: NestedToolSlot[]; /** Live compaction tasks in task ID order; absent when none. */ compactions?: CompactionStatus[]; /** * The newest compaction failure, so a client can show it after the task ended. A compaction that produces a summary * removes it; one that is aborted or finds nothing to compact leaves it. */ compactionFailure?: CompactionFailure;};run có mặt đúng khi hội thoại bận; taskId là generation chịu trách nhiệm trả lời các inputs, và giá trị này được chuyển từ generation này sang generation kế tiếp suốt một run (spec.md:2528–2537).Chính sách checkpoint của nó cho thấy cách chọn checkpointWhen có chủ đích: lưu base mỗi khi không còn gì đang chạy, tức lúc rảnh, lúc generation giao cho vòng tool, và giữa các tool. Vì một slot chỉ giữ output khi đang chạy, mọi base đều nhỏ, và chuỗi delta không bao giờ dài hơn một generation (live.ts:118–126; spec.md:4092–4101).
Ứng dụng định nghĩa document của mình theo cùng cách. README cho ví dụ app.todos sửa trong một commit rồi đọc lại bằng harness.snapshot() (README.md:728–747); HarnessOptions.conversationCreated cho phép tạo document của ứng dụng trong mọi hội thoại, và extension đọc document của mình qua api hoặc input.read, coi document vắng mặt là giá trị mặc định (11-extension-state.ts:32–56).
Commit: bề mặt giao dịch#
Mọi thay đổi đi qua một callback nhận tx (kiểu Tx). Những gì callback ghi được lưu cùng nhau khi nó trả về, hoặc bị bỏ hết nếu nó ném lỗi (README.md:97). Ví dụ 01-documents.ts:28–32 append một entry và sửa một document trong cùng một commit. Phần ghi của Tx:
/** Create a conversation with explicitly selected ownership. */createConversation(options: { readonly ownership: ConversationOwnership }): Promise<ConversationRecord>;/** Create a history fork at one concrete visible entry with explicitly selected ownership. */forkConversation( parentConversationId: ConversationId, at: EntryId, options: { readonly ownership: ConversationOwnership },): Promise<ConversationRecord>;/** Returned records are Session-owned immutable values and may be shared with commit listeners. */appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord>;/** The token supplies `kind` and types `data`. */appendEntry<D extends JsonValue>( token: Entry<D>, conversationId: ConversationId, value: TypedEntryDraft<NoInfer<D>>,): Promise<TypedEntry<D>>;createTask<I, S extends { phase: string }, R, H extends object>( task: Task<I, S, R, H>, input: I, options: TaskOptions,): Promise<TaskId<R>>;createSubmission, settleSubmission, placeSubmission) và document (doc, retireDoc) nằm ngay dưới (types.ts:953–1005).Có ba cửa vào cùng một cơ chế, khác nhau ở phạm vi mặc định:
harness.commit(tx => ...): commit của Session, không gắn với hội thoại nào;tx.createTask()khi đó phải được choconversationId.conversation.commit(tx => ...): cùng commit nhưng gắn với hội thoại đó, nêntx.createTask()mặc định thuộc hội thoại này (harness.ts:141–143).runtime.commit((tx, current) => next)trong phase của task vàapi.commit()trong tool: commit còn đóng dấubyTaskIdlên entry, và với task thì giá trị trả về là trạng thái kế tiếp của task, ghi trong cùng commit (types.ts:266–278; xem chương task).
Bốn quy tắc cần thuộc trước khi viết commit (chi tiết thực thi ở chương storage):
- Đọc bảng trước, ghi bảng sau. Bảng là hội thoại, entry, task và submission. Sau lần ghi bảng đầu tiên, mọi lần đọc bảng ném
ReadAfterWrite; document vẫn đọc ghi được (spec.md:1728–1734; errors.ts:16–22). - Không làm việc chậm bên trong. Callback giữ dòng commit của cả Session; không được chờ mô hình, tool, mạng, con người hay một commit lồng (spec.md:5521–5523).
- Đọc qua
tx, không qua Session.snapshot(),getTask()và các lần đọc khác có thể xếp hàng sau chính commit đang giữ dòng; gọi chúng bên trong callback có thể treo vĩnh viễn, và chỉ treo khi trượt cache nên lỗi lúc có lúc không (spec.md:5524–5528). - Draft chết khi callback xong.
tx.doc()trả về một draft của Chord; giá trị gán vào được sao chép ngay và phải là JSON nghiêm ngặt; đọc hay ghi draft sau khi callback xong sẽ ném lỗi (spec.md:1519–1535).
Submission và inbox#
Host không append entry trực tiếp vào một hội thoại có thể đang bận. Nó gửi một submission: hoặc input của người dùng (có thể khởi động một run), hoặc một entry cần ghi (write). submit() trả về ngay sau khi submission được ghi bền vào storage (admission), không đợi câu trả lời; đối tượng Submission trả về là thứ để chờ (spec.md:1002–1011).
/** * The entry an input is placed as, instead of `pi.user`: an application kind, and data the transcript shows, such as its * author. Its model message is still the input's user message. */export type InputEntry = { readonly kind: string; readonly data?: JsonValue };/** Host submission: user input that may start a run, or a passive entry write. */export type SubmissionDraft = { readonly requestId?: string;} & ( | { readonly type: "input"; readonly content: UserInput; readonly whenBusy?: "steer" | "followUp" | "reject"; /** Place the input as this entry; default `pi.user` without data. The kind must not start with `pi.`. */ readonly entry?: InputEntry; } | { readonly type: "write"; readonly entry: EntryDraft; readonly content?: never; readonly whenBusy?: never; });export type InputSubmissionDraft = Extract<SubmissionDraft, { readonly type: "input" }>;export type SettledSubmissionRecord = SubmissionRecord & { readonly status: "done" | "unanswered";};/** Awaitable host object for one durably admitted submission. */export interface Submission { readonly id: SubmissionId; status(context: Context): Promise<SubmissionRecord>; wait(context: Context): Promise<SettledSubmissionRecord>; abort(context: Context): Promise<"aborted" | "already_placed" | "settled">;}Mỗi submission có một bản ghi bền với vòng đời riêng. Với input: queued (đã nhận, chưa vào transcript), placed (đã thành entry, thuộc một run đang chạy), rồi done (kèm answer là ID entry câu trả lời) hoặc unanswered (kèm reason). Write không có trạng thái placed: đặt vào transcript là xong.
export type SubmissionRecord = | (SubmissionRecordBase & { readonly type: "input"; } & ( | { /** Admitted but not yet represented in the transcript. */ readonly status: "queued"; readonly entry?: never; readonly answer?: never; readonly reason?: never; readonly detail?: never; } | { /** Added to the transcript and owned by an active run. */ readonly status: "placed"; readonly entry: EntryId; readonly answer?: never; readonly reason?: never; readonly detail?: never; } | { /** Successfully answered user input. */ readonly status: "done"; readonly entry: EntryId; readonly answer: EntryId; readonly reason?: never; readonly detail?: never; } | { /** Terminal input that can no longer receive an answer. */ readonly status: "unanswered"; readonly entry?: EntryId; readonly answer?: never; readonly reason: string; readonly detail?: JsonValue; } ))id, conversationId, requestId, trace ở types.ts:465–473.Admission: một commit quyết định số phận#
Conversation.submit() chuyển cho Submissions.submit(), vốn bật scheduler (một lời gọi xin tiến triển thì không bao giờ phải chờ một Harness đang tạm dừng) rồi chạy toàn bộ việc nhận trong một commit:
/** Admit a submission in one commit; see `admitSubmission()`. */async submit(conversationId: ConversationId, draft: SubmissionDraft, context: Context): Promise<Submission> { this.#resume(); const parent = draft.type === "input" ? this.#telemetry : context.telemetry; return createTypedSpanStarter(parent, [DURABLE_TELEMETRY_SCHEMA])( "pi.durable.submit", { "submission.type": draft.type, "conversation.id": conversationId, }, async (span) => { if (draft.type === "input") span.addLink({ context: context.telemetry }); try { const { id, status } = await this.#session.commitWith( (tx) => admitSubmission(tx, conversationId, draft, this.#now(), this.#queueModes(), span), withTelemetry(span, context), ); span.setAttributes({ "submission.id": id, result: status }); return new SubmissionHandle(id, this);pi.durable.submit chỉ mang ID và trạng thái, không mang nội dung (README.md:84).admitSubmission() đọc từ trên xuống như một cây quyết định. Đầu tiên là trùng requestId và write chỉ để hiển thị:
if (draft.type === "input" && draft.entry !== undefined) checkInputEntry(draft.entry);if (draft.requestId !== undefined) { const existing = await tx.submissionByRequest(conversationId, draft.requestId); if (existing !== undefined) { if (existing.type !== draft.type) { throw new Error(`Request ${draft.requestId} already identifies a submission of type ${existing.type}`); } return { id: existing.id, status: existing.status === "unanswered" ? "done" : existing.status }; }}const fields = { conversationId, requestId: draft.requestId, trace: span?.propagation?.inject(span) };if ( draft.type === "write" && isDisplayOnly(draft.entry) && !hasQueuedReset((await tx.doc(InboxDoc, conversationId)).items)) { const entry = await tx.appendEntry(conversationId, draft.entry); const write = { ...fields, type: "write", status: "done", entry: entry.id } as const; return { id: (await tx.createSubmission(write)).id, status: "done" };}kind và data, không có model, head hay edits (inbox.ts:131–137). Nó không đổi gì mô hình thấy, nên được append và chốt done ngay, kể cả giữa một run; ngoại lệ duy nhất là khi có một reset đang xếp hàng.Tiếp theo là trường hợp bận, hoặc rảnh nhưng inbox còn mục chờ:
const live = await tx.doc(LiveDoc, conversationId);const busy = live.run !== undefined;if ( draft.type === "input" && draft.whenBusy === "steer" && live.run !== undefined && span?.propagation !== undefined) { const run = await tx.task(live.run.taskId); if (run?.trace !== undefined) span.addLink({ context: span.propagation.extract(run.trace) });}if (busy && draft.type === "input" && draft.whenBusy === "reject") throw new ConversationBusy(conversationId);// A boundary reads the table, so it is prepared before the first table write; a busy one needs none.const boundary = busy ? undefined : await prepareBoundary(tx, conversationId, queueModes);if (boundary === undefined || boundary.inbox.items.length > 0) { const { id } = await tx.createSubmission({ ...fields, type: draft.type, status: "queued", }); // Hosts may leave optional fields `undefined`; drafts take strict JSON. const value = copyJson(draft.type === "write" ? draft.entry : draft.content, { omitUndefinedProperties: true, }); const items = (boundary?.inbox ?? (await tx.doc(InboxDoc, conversationId))).items; if (draft.type === "write") items.push({ id, mode: "write", entry: value as JsonObject }); else { const mode = draft.whenBusy === "steer" ? "steer" : "followUp"; const content = value as JsonRepresentation<UserInput>; const entry = draft.entry === undefined ? {} : { entry: pickInputEntry(draft.entry) }; items.push({ id, mode, content, ...entry }); } if (boundary === undefined) return { id, status: "queued" }; const { users } = await applyBoundary(tx, boundary, "final", now); if (users.length > 0) await startRun(tx, conversationId, live, users); return { id, status: draft.type === "write" ? "done" : users.at(-1) === id ? "placed" : "queued" };}pi.live.run có mặt. whenBusy: "reject" ném ConversationBusy và không ghi gì (errors.ts:48–57). Khi rảnh mà inbox còn mục (ví dụ sau một run thất bại), mục mới xếp sau chúng và một boundary final chạy ngay trong commit này.Cuối cùng là trường hợp phổ biến nhất, hội thoại rảnh và inbox rỗng:
if (draft.type === "write") { if (isStale(boundary, draft.entry)) { const stale = { status: "unanswered", reason: "stale" } as const; return { id: (await tx.createSubmission({ ...fields, type: "write", ...stale })).id, status: "done", }; } const entry = await tx.appendEntry(conversationId, draft.entry); const write = { ...fields, type: "write", status: "done", entry: entry.id } as const; return { id: (await tx.createSubmission(write)).id, status: "done" }; } const entry = await appendInput(tx, conversationId, draft.content, draft.entry, now); const input = { ...fields, type: "input", status: "placed", entry: entry.id } as const; const { id } = await tx.createSubmission(input); await startRun(tx, conversationId, live, [id]); return { id, status: "placed" };}head trỏ về trước phạm vi đang hoạt động là stale: đặt nó sẽ làm sống lại lịch sử mà head mới hơn đã cắt (inbox.ts:144–147). Với input, appendInput() ghi entry pi.user (hoặc kind riêng của input) và startRun() tạo task generation đầu tiên (generation.ts:752–760).Bảng chuyển trạng thái đầy đủ ở spec.md:2539–2552. Tóm tắt các nhánh trên:
| Tình huống | Bản ghi submission | Ghi kèm trong cùng commit |
|---|---|---|
requestId đã có trong hội thoại | không ghi gì, trả về submission cũ | không có |
| Write chỉ để hiển thị, không có reset đang chờ | done, kèm entry | entry |
| Input, rảnh, inbox rỗng | placed, kèm entry | entry pi.user, task pi.generation, pi.live.run |
| Write khác, rảnh, inbox rỗng | done, hoặc unanswered lý do stale | entry (nếu không stale) |
| Bận | queued | mục mới trong pi.inbox |
| Rảnh nhưng inbox còn mục | queued, rồi có thể thành placed hoặc done ngay | mục mới xếp cuối pi.inbox, rồi một boundary final trong cùng commit đặt các mục được chọn và có thể mở run |
Bận và whenBusy: "reject" | không có bản ghi | ném ConversationBusy |
Steer, follow-up và boundary#
Mục trong inbox được đặt vào transcript ở các boundary (ranh giới giữa các lượt). Boundary postTools chạy khi một vòng tool kết thúc mà run còn tiếp; boundary final chạy khi run trả lời xong. Write luôn được đặt; tin nhắn steer (chen vào lượt đang chạy) được đặt ở cả hai; follow-up (xếp hàng sau) chỉ ở final, và khởi động run kế tiếp:
const { conversationId, inbox } = boundary;const items = inbox.items;const reset = hasQueuedReset(items);const final = at === "final" || reset;const pick = (mode: "steer" | "followUp", queueMode: QueueMode): number[] => { const indexes = items.flatMap((item, index) => (item.mode === mode ? [index] : [])); return queueMode === "all" ? indexes : indexes.slice(0, 1);};const writes = items.flatMap((item, index) => (item.mode === "write" ? [index] : []));const users = [...pick("steer", boundary.steeringMode), ...(final ? pick("followUp", boundary.followUpMode) : [])];users.sort((a, b) => a - b);steeringMode và followUpMode mặc định là one-at-a-time: mỗi boundary lấy mục đầu tiên của loại đó; all lấy tất cả (agent.ts:78–79). Write được đặt trước, rồi tới input, đều theo thứ tự ID, nên input xếp trước một reset sẽ chạy trong ngữ cảnh mới (spec.md:2584–2592).Một reset đang chờ biến boundary postTools thành final, và các input của run hiện tại kết thúc unanswered với lý do reset, vì ngữ cảnh của chúng đã bị cắt trước khi có câu trả lời (spec.md:2613–2617). Ví dụ 20-inbox.ts chạy đủ steer, follow-up, write và rút lại khi đang bận.
requestId, wait() và abort()#
requestId làm cho việc gửi lại idempotent (gọi lại nhiều lần vẫn cho một kết quả) trong phạm vi một hội thoại: lần gửi thứ hai với cùng ID, kể cả sau khi tiến trình khởi động lại, trả về đúng submission đã có (README.md:129–138). Dùng lại một requestId cho loại submission khác thì bị từ chối. Đây là cách host đạt "đúng một lần" (exactly-once): nếu commit có thể đã thành công trước khi tiến trình chết, cứ gửi lại với cùng requestId. Bài "Pi Durable on Cloudflare" áp dụng đúng mẫu này cho lệnh tạo hội thoại: client gửi lại các yêu cầu chưa được xác nhận với cùng request ID.
async wait(id: SubmissionId, context: Context): Promise<SettledSubmissionRecord> { this.#resume(); // Check and register on the line so no settling publication falls between them. const found = await this.#session.readOnLine(async () => { const record = await this.#storage.submission(id, context); if (record === undefined) throw new Error(`Submission ${id} does not exist`); if (isSettled(record)) return { promise: Promise.resolve(record) }; // Close rejects registered waiters synchronously and may begin during the read. if (this.#closed) throw closedError(this.#session); return { promise: this.#waiters.add(id, context) }; }); return found.promise;}/** Withdraw a queued submission and remove its inbox item; placed inputs and settled submissions are reported. */abort(id: SubmissionId, context: Context, conversationId?: ConversationId): Promise<AbortResult | "not_found"> { return this.#session.commitWith(async (tx) => { const record = await tx.submission(id); if (record === undefined || (conversationId !== undefined && record.conversationId !== conversationId)) { return "not_found"; } if (record.status === "queued") { tx.settleSubmission(id, { status: "unanswered", reason: "aborted" }); await removeInboxItem(tx, record.conversationId, id); return "aborted"; } return record.status === "placed" ? "already_placed" : "settled"; }, context);}#observe(publication: CommitPublication): void { for (const change of publication.changes) { if (change.type !== "submission" || !isSettled(change.value)) continue; this.#waiters.resolve(change.value.id, change.value); }}wait() kiểm tra và đăng ký người chờ trên dòng commit, nên không thể có commit chốt nào lọt vào giữa. Người chờ được giải phóng bởi một bộ nghe commit nội bộ, tức chỉ khi việc chốt đã nằm trong storage. Huỷ context của wait() chỉ huỷ việc chờ; rút lại submission là việc của abort(), và chỉ rút được khi còn queued.Các lý do unanswered mà code v1.4.0 thực sự ghi:
| Lý do | Khi nào | Nơi ghi |
|---|---|---|
aborted | Rút lại khi còn xếp hàng; Conversation.abort(); task của run bị huỷ | submissions.ts:137, inbox.ts:160, generation.ts:312 |
stale | Write có head trỏ về trước phạm vi đang hoạt động | inbox.ts:93, submissions.ts:268 |
reset | Một reset cắt ngữ cảnh giữa run | generation.ts:726 |
model_error, no_model | Mô hình trả lỗi không thử lại được; hội thoại không có mô hình | generation.ts:584, generation.ts:396 |
run_failed | Run trước thất bại và settings.afterFailedRun là withdraw | generation.ts:424 |
faulted, hoặc lý do orphan | Task của run lỗi lập trình, hoặc mất định nghĩa và bị huỷ | live.ts:263–266 |
Mặc định (afterFailedRun: "keep"), một run thất bại để nguyên các mục xếp hàng sau nó cho tới khi có input mới hoặc write xếp hàng chạy boundary; continue chạy run kế tiếp với chúng; withdraw chốt chúng unanswered (README.md:220; generation.ts:415–428).
Extension và Registry#
Một mục tiêu của Pi Durable là đổi được code mà không dừng agent đang chạy. Nhưng state bền lại tham chiếu tới code: hội thoại chọn tool nào, task thuộc định nghĩa nào. Cách giải: tách hẳn hai thứ. Code (tool, section prompt, hook, wrapper, định nghĩa task) nằm trong extension có tên, cài vào một registry do ứng dụng sở hữu, chỉ sống trong bộ nhớ tiến trình. Storage chỉ lưu tên. Mỗi lần dùng, tên được giải (resolve) lại với registry hiện tại (spec.md:2644–2649).
/** Built by `hook()`; matches tasks by name. */export type HookRegistration = { readonly task: string; readonly handlers: object };/** Built by `wrapTool()` and `wrapSection()`; targets a tool name or a section key. Wrappers are pure. */export type Wrap<Tool extends ToolRegistration = ToolRegistration> = | { readonly tool: string; wrap(tool: Tool): Tool } | { readonly section: string; wrap(section: PromptSection<Tool>): PromptSection<Tool> };/** Named bundle of code; installed in a registry and selected by conversations by name. */export interface Extension<Tool extends ToolRegistration = ToolRegistration> { readonly name: string; readonly tools?: readonly Tool[]; readonly sections?: readonly PromptSection<Tool>[]; readonly hooks?: readonly HookRegistration[]; /** Apply where this extension is selected, in order. */ readonly wraps?: readonly Wrap<Tool>[]; /** Resolved by name for every task, whichever conversations select this extension. */ readonly tasks?: readonly AnyTask[];}tools: tool của extension; tool trùng tên của extension đứng sau thay tool của extension đứng trước, ở hội thoại chọn cả hai.sections: các mảnh system prompt (mục System prompt bên dưới).hooks:hook(GenerationTask, {...})hayhook(ToolTask, {...})đăng ký handler theo tên task; chỉ chạy ở hội thoại chọn extension này. Danh sách hook của các task dựng sẵn: harness/types.ts:851–907.wraps:wrapTool(tool, fn)vàwrapSection(key, fn)bọc một tool hay section theo tên, ở nơi extension bọc được chọn, và áp lên tool nào đang thắng.tasks: định nghĩa task; khác với mọi thứ khác, chúng được giải theo tên cho mọi task, bất kể hội thoại nào chọn extension, vì một task đang chờ phải tìm lại được code của nó sau khởi động lại.
/** Identity function that types an extension. */export function defineExtension<Tool extends ToolRegistration = ToolRegistration>( extension: Extension<Tool>,): Extension<Tool> { return extension;}/** Hook handlers for tasks with `task`'s name. */export function hook<K extends AnyTask>(task: K, handlers: Partial<HooksOf<K>>): HookRegistration { return { task: task.definition.name, handlers };}/** Wrap the tool named like `tool` wherever the wrapping extension is selected. */export function wrapTool<Tool extends ToolRegistration>(tool: Tool, wrapper: (tool: Tool) => Tool): Wrap<Tool> { return { tool: tool.name, wrap: wrapper };}defineExtension() trả về chính đối số của nó, hook() chỉ ghi lại tên task. Mọi kiểm tra dồn về registry.install() (spec.md:2759–2763).class RegistryImpl<Tool extends ToolRegistration> implements Registry<Tool> { #current = new RegistryState<Tool>([]); readonly #listeners = new Set<() => void>(); snapshot(): RegistrySnapshot<Tool> { return this.#current; } subscribe(listener: () => void): () => void { this.#listeners.add(listener); return () => this.#listeners.delete(listener); } install(extension: Extension<Tool>): void { validateExtension(extension); const current = this.#current.installed(); const index = current.findIndex((installed) => installed.name === extension.name); const next = index < 0 ? [...current, extension] : current.map((installed, at) => (at === index ? extension : installed)); this.#publish(next); } uninstall(extension: Extension): void { const current = this.#current.installed(); if (!current.some((installed) => installed.name === extension.name)) return; this.#publish(current.filter((installed) => installed.name !== extension.name)); } /** Build and validate the next state, which throws on a task name collision, then publish it synchronously. */ #publish(extensions: readonly Extension<Tool>[]): void { this.#current = new RegistryState(extensions); for (const listener of [...this.#listeners]) listener(); }}new RegistryState(...) ném lỗi nếu hai task trùng tên (registry.ts:18–31) trước khi #current bị gán, nên một lần cài hỏng không công bố gì.validateExtension() đòi tên tool và khoá section không trùng trong một extension, khoá section khớp ^[a-z][a-z0-9_-]*$, và cấm khoá instructions, vốn dành cho instructions của agent (registry.ts:95–109). Ba task dựng sẵn pi.generation, pi.tool và pi.compaction luôn có trong mọi registry và không phải extension (registry.ts:9–10); Harness.open() từ chối registry thiếu chúng, nên hãy luôn tạo bằng createRegistry().
Thay code khi đang chạy (reload) theo một quy tắc đơn giản: việc đã bắt đầu giữ code nó đã lấy. Một lời gọi tool đang chạy kết thúc với cài đặt cũ; mỗi phase của task giải hook và agent một lần từ ảnh chụp (snapshot) registry lúc phase bắt đầu; phase, request hay lời gọi kế tiếp dùng code mới (README.md:464–472; spec.md:3799–3813). Sau khởi động lại, host cài lại cùng các extension; task đang chờ của một extension chạy tiếp khi extension đó được cài. Ví dụ 10-registry-reload.ts minh hoạ thay tại chỗ, wrapper và gỡ.
Agent của từng conversation#
Mỗi hội thoại có thể chạy với cấu hình riêng: subagent dùng mô hình rẻ hơn và không có tool subagent; một chế độ lập kế hoạch chỉ cho đọc; một hội thoại review có instructions riêng. Cấu hình này phải sống qua khởi động lại và theo đúng fork. Pi Durable lưu nó trong document pi.agent, dưới dạng tên:
/** Stored choices of one conversation; names, not objects. Unset fields follow the host. */export type AgentState = { model?: ModelRef; thinkingLevel?: ModelThinkingLevel; /** An array selects exactly these extensions, in order. An object edits the host default selection. */ extensions?: string[] | { add?: string[]; remove?: string[] }; /** Filters the selected extensions' tools: the enabled tools. An array enables exactly these, in order. */ tools?: string[] | { remove: string[] }; /** * Filters the enabled tools the model may call (`callers` includes `model`): the tools offered to it. Never widens: * other tools can still call the ones it leaves out. * - Unset: every such tool except deferred ones. * - An array: exactly these, in order, deferred ones too. * - An object: every such tool except deferred ones and those in `remove`, in registry order, followed by the * deferred tools in `add` that are not in `remove`, in `add` order. Names of tools that are not deferred or not * registered have no effect in `add`. */ modelTools?: string[] | { remove?: string[]; add?: string[] }; /** Rendered after every extension section, as the section `instructions`. */ instructions?: string; /** Directory within the environment's file system, passed to `HarnessOptions.env`. */ cwd?: string;};Trường nào không đặt thì theo host (cài đặt chung của Harness). Thay đổi đi qua configure(), nhận các đối tượng extension và tool rồi lưu tên của chúng, nên không gõ nhầm tên được: một trường được truyền thì thay trọn trường đã lưu, null xoá nó về mặc định, undefined giữ nguyên (harness/types.ts:435–451).
/** Apply one change to `pi.agent`: a given field replaces the stored one, `null` clears it, `undefined` changes nothing. */export async function configure(tx: Tx, conversationId: ConversationId, change: AgentChange): Promise<void> { const state = await tx.doc(AgentDoc, conversationId); applyChange(state, change);}function applyChange(state: Draft<AgentState>, change: AgentChange): void { const set = <K extends keyof AgentState>(key: K, value: AgentState[K] | null | undefined) => { if (value === undefined) return; if (value === null) delete state[key]; else state[key] = value as Draft<AgentState>[K]; }; set("model", change.model === undefined || change.model === null ? change.model : { ...change.model }); set("thinkingLevel", change.thinkingLevel);tx, configure() chạy được trong cả commit tạo hội thoại (tham số agent của root(), createConversation(), fork()); Conversation.configure() chỉ là bản chạy trong commit riêng (harness.ts:118–120).Kết quả giải là kiểu Agent:
/** A conversation's agent resolved against a registry snapshot and the settings. */export type Agent<Tool extends ToolRegistration = ToolRegistration> = { readonly model?: ModelRef; readonly thinkingLevel: ModelThinkingLevel; readonly extensions: readonly Extension<Tool>[]; /** The tools a request offers, in order: enabled, callable by the model, and selected by `modelTools`. */ readonly tools: readonly Tool[]; /** The tools nested calls (`executeTool()`) resolve among, in order: enabled and callable by tools. */ readonly callable: readonly Tool[]; /** Enabled deferred tools the model may call and is not offered yet, in registry order: what `tool_search` loads. */ readonly deferred: readonly Tool[]; /** Extension sections, then `instructions` when set. */ readonly sections: readonly PromptSection<Tool>[]; readonly instructions?: string; readonly cwd?: string;};conversation.agent() đọc pi.agent đã commit, lấy ảnh chụp registry hiện tại và cài đặt hiện tại, rồi gọi resolveAgent(); nó không bao giờ ghi:
/** Resolve a conversation's committed `pi.agent` against `snapshot`, or the current one, and the current settings. */async resolveAgent( id: ConversationId, snapshot: RegistrySnapshot<Tool> | undefined, context: Context,): Promise<Agent<Tool>> { const registry = snapshot ?? this.#options.registry.snapshot(); const state = await this.snapshot(AgentDoc, id, context); return resolveAgent(state, registry, resolveSettings(this.#hostSettings()), (error, extension) => this.#report(error, { type: "extension_error", conversationId: id, extension }, context), );}const extensions = selectExtensions(state?.extensions, snapshot, settings);const composed = new Map<string, Tool>();for (const extension of extensions) for (const tool of extension.tools ?? []) composed.set(tool.name, tool);const sections = new Map<string, PromptSection<Tool>>();for (const extension of extensions) { for (const section of extension.sections ?? []) sections.set(section.key, section);}const enabled = filterTools([...composed.values()], state?.tools);const callableBy = (caller: "model" | "tools") => (tool: Tool) => tool.callers === undefined || tool.callers.includes(caller);const modelCallable = enabled.filter(callableBy("model"));const tools = offeredTools(modelCallable, state?.modelTools);const offered = new Set(tools);const deferred = modelCallable.filter((tool) => tool.deferred === true && !offered.has(tool));const callable = enabled.filter(callableBy("tools"));const instructions = state?.instructions;const agentSections = [...sections.values()];if (instructions !== undefined) agentSections.push({ key: INSTRUCTIONS_KEY, render: () => instructions });onReport (agent.ts:336–353).Thuật toán đầy đủ có ở spec.md:2827–2843. Bốn danh sách tool dễ lẫn:
| Danh sách | Gồm | Dùng cho |
|---|---|---|
| enabled (nội bộ) | Tool của các extension được chọn (trùng tên thì cái sau thắng tại chỗ), qua wrapper, rồi qua bộ lọc tools | Giới hạn trên của mọi thứ khác |
tools | Enabled, callers có model, qua bộ lọc modelTools; tool deferred chỉ có mặt khi modelTools nêu tên | Tool được mời trong request tới mô hình |
callable | Enabled, callers có tools | Lời gọi lồng qua api.executeTool(), script của code mode |
deferred | Tool deferred mô hình được gọi nhưng chưa được mời | Thứ tool_search có thể nạp |
modelTools chỉ thu hẹp, không bao giờ mở rộng tools (README.md:347): configure({ modelTools: [codemode] }) cho mô hình chỉ thấy codemode trong khi script của nó vẫn gọi được read và bash. Phần cách tool chạy thuộc chương task.
Ai giải agent, và khi nào, quyết định một thay đổi có hiệu lực lúc nào (spec.md:2881–2893). Bước chuẩn bị của generation giải một lần cho mỗi request, rồi cố định mô hình, tùy chọn stream và tool được mời trong checkpoint của request. Mỗi phase của task giải nhiều nhất một lần. Task tool giải tên tool khi chạy; nếu tool không còn trong tools của agent (bộ lọc, lựa chọn hay registry đã đổi), lời gọi nhận kết quả lỗi tool_unavailable và run tiếp tục. Một cạm bẫy từ chính thiết kế "lưu tên": { add, remove } trong extensions luôn sửa lựa chọn mặc định của host, không sửa một lựa chọn đã chép từ hội thoại chủ (spec.md:837–841). Ví dụ 07-configuration.ts chạy qua mọi trường; 27-plan-mode.ts và 28-reviewer.ts dựng chế độ lập kế hoạch và một hội thoại review từ đó.
System prompt: section và entry hệ thống theo vị trí#
Prompt cache của provider là một tiền tố token khớp chính xác; đổi system prompt hay danh sách tool ở đầu request là trượt cache toàn bộ (bài "Prompt Caching In Agents"). Pi Durable xử lý việc này bằng cách không có state prompt bền nào. Trước mỗi request, các section của extension được chọn render ra prompt mong muốn; transcript chỉ lưu entry pi.system ở đúng vị trí của chúng, mỗi entry là một bản vá (patch) theo kiểu SystemMessage của pi-ai, và chỉ phần thay đổi mới được append (spec.md:3658–3662). Khái niệm system message giữa hội thoại của pi-ai được bàn ở chương Pi 1.0.
/** Input to system prompt section rendering for one request preparation. */export type PromptInput<Tool extends ToolRegistration = ToolRegistration> = { readonly conversationId: ConversationId; /** The request's resolution; `agent.tools` are the tools offered in this request. */ readonly agent: Agent<Tool>; /** Built by `HarnessOptions.env` for this preparation; `undefined` without an environment. */ readonly env: ExecutionEnv | undefined; /** Sections already in effect after replaying the active transcript. */ readonly shown: Readonly<Record<string, string>>; /** Committed document reads. */ readonly read: DocumentReader;};/** One system prompt section; the agent's sections render in order before each request. */export type PromptSection<Tool extends ToolRegistration = ToolRegistration> = { readonly key: string; render(input: PromptInput<Tool>, context: Context): string | undefined | Promise<string | undefined>; /** Default true: wrap the text as `<key>\n...\n</key>`. */ readonly tag?: boolean;};undefined thì section bị bỏ./** * Render the agent's sections in order. `undefined` omits a section; tagged text is wrapped as `<key>\n...\n</key>`. A * section that throws keeps its shown text, if any, and is reported; errors after `context` is aborted propagate. */export async function renderSections<Tool extends ToolRegistration>( sections: readonly PromptSection<Tool>[], input: PromptInput<Tool>, shown: ReadonlyMap<string, string>, report: (error: unknown, section: PromptSection<Tool>) => void, context: Context,): Promise<Map<string, string>> { const desired = new Map<string, string>(); for (const section of sections) { let text: string | undefined; try { text = await section.render(input, context); } catch (error) { if (context.abortSignal?.aborted) throw error; report(error, section); const kept = shown.get(section.key); if (kept !== undefined) desired.set(section.key, kept); continue; } if (text === undefined) continue; desired.set(section.key, section.tag === false ? text : `<${section.key}>\n${text}\n</${section.key}>`); } return desired;}instructions (agent.ts:268–270).Rồi planSystemEntries() so prompt mong muốn với những gì phát lại (replay) từ các system message đang có trong ngữ cảnh:
/** * Plan the `pi.system` entries that make the replayed sections and tools of `view` equal `desired` and `tools` in values * and order. * * - A head marker with no later `pi.system` entry in context: one complete baseline that omits every retained earlier * `pi.system` entry, written even when it restates the replayed values. * - Otherwise, when a minimal section patch would leave a different order: remove every shown section, then re-add * every desired section in order. * - Otherwise the minimal patch of changed values and `null` removals, or nothing. * * Tool changes ride on the last planned entry, or on one entry of their own. */export function planSystemEntries( view: ContextView, desired: ReadonlyMap<string, string>, tools: readonly Tool[], timestamp: number,): SystemDraft[] { const head = view.head; if (head !== undefined && !view.entries.some((entry) => SystemEntry.is(entry) && entry.id > head.id)) { const edits: ContextEdit[] = view.entries .filter((entry) => SystemEntry.is(entry)) .map((entry) => ({ target: entry.id, action: "omit" })); const baseline = systemEntry( Object.fromEntries(desired), { toolsRemoved: [], toolsAdded: tools.map(toToolDeclaration) }, timestamp, ); return [edits.length === 0 ? baseline : { ...baseline, edits }]; } const sections = planSections(replaySections(view.messages), desired); const changes = planTools(getCurrentTools(view.messages), tools); if (changes.toolsRemoved.length === 0 && changes.toolsAdded.length === 0) { return sections.map((patch) => systemEntry(patch, undefined, timestamp)); } if (sections.length === 0) return [systemEntry(undefined, changes, timestamp)]; return sections.map((patch, index) => systemEntry(patch, index === sections.length - 1 ? changes : undefined, timestamp), );}content: "": mọi phần của prompt, kể cả lời mở đầu, là một section có tên (prompt.ts:144–158; spec.md:140–141).Ba nhánh, theo thứ tự ưu tiên:
- Sau một head marker mà chưa có
pi.systemnào sau nó (vừa reset hay compaction): ghi một baseline đầy đủ mọi section và mọi tool, kèm chỉnh sửaomitcho mọipi.systemcũ còn sót trong phạm vi. Lý do: mộtSystemMessageluôn là bản vá, không thể tự xoá các bản vá trước nó (spec.md:3755–3766). - Thứ tự section phải đổi: hai entry trong một commit, entry đầu xoá mọi section bằng
null, entry sau thêm lại theo thứ tự mong muốn; nếu chỉ khẳng định lại giá trị thì phát lại sẽ không ra đúng thứ tự (spec.md:3748–3753). - Còn lại: bản vá tối thiểu gồm giá trị đổi và
nullcho section bị bỏ; không đổi gì thì không có entry nào.
Thay đổi tool đi kèm entry cuối cùng (hoặc một entry riêng): khai báo được so bằng declarationsEqual() của pi-ai và ghi bằng toToolDeclaration(), nên metadata riêng của ứng dụng gắn trên tool không bao giờ lọt vào transcript (spec.md:3771–3778). Một tool thôi resolve được sẽ xuất hiện trong toolsRemoved của delta kế tiếp, và quay lại khi nó resolve được lần nữa.
Vì input của run được commit trước khi generation render prompt, entry pi.system đầu tiên nằm sau entry pi.user đầu tiên trong transcript. Bước dẫn xuất ngữ cảnh đưa baseline đó lên đầu request, vì provider chỉ coi system message đứng đầu là prompt và bộ tool ban đầu (spec.md:284–302); hai bản sửa lỗi ở 1.1.0 và 1.3.0 là về đúng chuyện này (CHANGELOG.md:123, CHANGELOG.md:56). Ví dụ 15-system-prompt.ts in ra các entry pi.system khi section, instructions và wrapper thay đổi.
Harness.open, HarnessOptions và Settings#
export const Harness = { /** Open a Harness over storage. The registry may keep changing while the Harness runs. */ async open<Tool extends ToolRegistration>( storage: Storage, options: HarnessOptions<Tool>, context: Context, ): Promise<Harness> { context.abortSignal?.throwIfAborted(); const snapshot = options.registry.snapshot(); const missing = BUILTIN_TASKS.filter((task) => snapshot.task(task.definition.name) === undefined); if (missing.length > 0) { const names = missing.map((task) => task.definition.name).join(", "); throw new Error(`Registry lacks built-in tasks ${names}; create it with createRegistry()`); } const harness = new HarnessImpl(storage, options, context); try { await harness.openTasks(context); } catch (error) { // The caller's context may be what failed open: close without it, and rethrow the open error. // A failing Storage close fails the Session, which reports it. await harness.close(withoutAbortSignal(context)).catch(() => {}); throw error; } return harness; },};openTasks() đổi các task còn ở running (tiến trình trước chết giữa chừng) về pending và đếm số lần khởi động lại; nó không chạy task nào. Scheduler chỉ bật khi host gọi resume() hoặc một lời gọi xin tiến triển như submit(), wait(), waitForTask(); bên xem chỉ đọc không bao giờ bật nó (spec.md:744–755). Nhờ vậy host có thể cài extension, gieo dữ liệu, rồi mới cho chạy.Hội thoại gốc được tạo lười. Lần gọi root() đầu tiên tạo hội thoại ID 1, chạy hook tạo hội thoại, áp agent rồi chạy init, tất cả trong một commit; các lần sau, kể cả sau khi mở lại, chỉ trả về nó và bỏ qua cả hai tham số (spec.md:736–741):
async #create(target: CreateTarget, options: CreateOptions, context: Context): Promise<Conversation> { this.assertUsable(); const id = await this.commitWith(async (tx) => { if (target.kind === "root" && (await tx.conversation(ROOT_CONVERSATION_ID)) !== undefined) { return ROOT_CONVERSATION_ID; } const record = target.kind === "root" ? await tx.createRootConversation() : target.kind === "fork" ? await tx.forkConversation(target.parentId, target.at, { ownership: target.ownership }) : await tx.createConversation({ ownership: target.ownership }); if (options.agent !== undefined) await configure(tx, record.id, options.agent); if (options.init !== undefined) await options.init(tx, record.id); return record.id; }, context); return new ConversationImpl(id, this.#host);}ConversationImpl, chỉ giữ id và tham chiếu tới Harness (harness.ts:105–112). Handle thuộc về Harness đang mở; sau khi mở lại phải lấy lại (spec.md:1059–1060).HarnessOptions#
export type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = { /** Host-owned adapter for content-free admission/phase telemetry; propagation and metrics are optional. */ readonly telemetry?: TelemetryContext; /** * pi-ai model access used by generation, compaction, hooks and tools: a `Models`, or a function returning the current * one, called at each use, so a host can replace its model configuration without reopening the Harness. */ readonly models: Models | (() => Models); /** * How model requests identify the app to providers, merged field by field over `DEFAULT_HARNESS_IDENTITY` * (`{ attribution: PI_ATTRIBUTION }`, provider attribution to pi). It applies to every request through the Harness's * models, including hooks and tools, and overrides the identity the `Models` was created with; a request's own * `identity` overrides it. Pass `{ attribution: null }` to send no attribution headers. */ readonly identity?: ClientIdentity; readonly registry: RegistryReader<Tool>; /** Run settings, or a function returning the current ones; read at each use either way. */ readonly settings?: HarnessSettings | (() => HarnessSettings | undefined); /** Builds a conversation's environment at each use. Never called on the Session line; may be async. */ readonly env?: (target: EnvTarget, context: Context) => ExecutionEnv | undefined | Promise<ExecutionEnv | undefined>; /** * Runs in every commit that creates or forks a conversation, raw `tx.createConversation()` included, after the * built-in `pi.*` documents and before the conveniences apply `agent` and run `init`. A fork already has its copies. * Table reads throw `ReadAfterWrite`, as in `init`; a throw fails the creating commit. */ readonly conversationCreated?: (tx: Tx, conversation: ConversationRecord) => void | Promise<void>;models,identityModelscủa pi-ai (chương pi-ai), hoặc một hàm trả về nó, được gọi mỗi lần dùng.identitylà mộtClientIdentityđược trộn lênDEFAULT_HARNESS_IDENTITY(ghi nhận nguồn là pi với OpenRouter, NVIDIA NIM, OpenCode) và áp cho mọi request đi qua Harness, kể cả từ hook và tool;{ attribution: null }để không gửi gì (README.md:80; harness.ts:187–199).telemetry- Một
TelemetryContextcủapi-telemetrydo host sở hữu; bỏ trống thì không làm gì. SchemaDURABLE_TELEMETRY_SCHEMAchỉ có ID và metadata điều khiển, không bao giờ có message, đối số hay kết quả (README.md:82–90). Chi tiết ở chương pi-env và telemetry. env- Hàm dựng môi trường thực thi (
ExecutionEnv) cho một hội thoại từ ID,cwdtrongpi.agentvà một bộ đọc document đã commit; không bao giờ được gọi trên dòng commit, được gọi lại mỗi lần dùng (harness.ts:279–285; spec.md:918–935). Môi trường Node và pi-env ở chương pi-env; cách tool dùng nó ở chương task. conversationCreated- Chạy trong mọi commit tạo hoặc fork hội thoại, sau năm document dựng sẵn, để host tạo document của mình ở mọi hội thoại; đọc bảng ở đây ném
ReadAfterWritevì việc tạo hội thoại đã là một lần ghi bảng. now,setTimer,onWake,onReport- Đồng hồ và bộ hẹn giờ của host (chạy được trên đồng hồ giả);
onWakethay bộ hẹn giờ cho các task đang ngủ bằng báo thức của host, như alarm của Durable Object (mục Cloudflare ở chương storage);onReportnhận các lỗi không làm hỏng thao tác gọi, kèmReportInfocho biết chuyện gì xảy ra (types.ts:148–188).
Settings: đọc trực tiếp, không bao giờ lưu#
Chính sách chạy dùng chung cho mọi hội thoại (lựa chọn extension mặc định, tùy chọn stream, thử lại, ngưỡng compaction, nhịp commit tiến độ, cách chạy tool, chế độ hàng đợi, thời gian giữ bộ nhớ, giới hạn khởi động lại) đi qua settings. Nó được đọc lại ở mỗi lần dùng và không bao giờ được chép hay lưu, nên một object có getter hoặc một hàm làm cho cài đặt "sống" theo file cài đặt của người dùng mà không cần commit nào (README.md:403–425). Settings phải đồng bộ, vì chế độ hàng đợi được đọc ngay trên dòng commit.
/** Resolve the host settings: every field over its built-in default, object fields merged. */export function resolveSettings(settings: HarnessSettings | undefined): Settings { const extensions = settings?.extensions; return { ...(extensions === undefined ? {} : { extensions }), stream: { ...settings?.stream }, retry: { ...DEFAULT_RETRY_POLICY, ...settings?.retry }, compaction: { ...DEFAULT_COMPACTION_POLICY, ...settings?.compaction }, // Field by field, so an explicitly undefined interval keeps its default. progress: { partialIntervalMs: settings?.progress?.partialIntervalMs ?? DEFAULT_PROGRESS_POLICY.partialIntervalMs, outputIntervalMs: settings?.progress?.outputIntervalMs ?? DEFAULT_PROGRESS_POLICY.outputIntervalMs, }, toolExecution: settings?.toolExecution ?? "parallel", steeringMode: settings?.steeringMode ?? "one-at-a-time", followUpMode: settings?.followUpMode ?? "one-at-a-time", contextRetentionMs: settings?.contextRetentionMs ?? DEFAULT_CONTEXT_RETENTION_MS, documentRetentionMs: documentRetention(settings?.documentRetentionMs), afterFailedRun: settings?.afterFailedRun ?? "keep", restartLimit: restartLimit(settings?.restartLimit), };}afterFailedRun: "keep"; restartLimit 5 (agent.ts:20–37).Hàm currentSettings() biến cả hai dạng thành một hàm gọi ở mỗi lần dùng (harness.ts:201–204); riêng documentRetentionMs được đọc ở mỗi commit (harness.ts:220–221). Đổi cài đặt không tạo commit nào và không phát sự kiện nào; một UI hiển thị mô hình hay tool đã giải phải gọi lại conversation.agent().
Quan sát: viewState, watch, watchEvents#
Mọi thứ một UI cần đều là state đã commit (README.md:476). Câu trả lời đang stream nằm trong pi.live.generation.message, output của tool đang chạy nằm trong slot của nó, hàng đợi nằm trong pi.inbox. Không có "kênh sự kiện" riêng nào chạy song song với storage. Vì vậy một client vào muộn, hay kết nối lại, chỉ cần lấy state hiện tại rồi nhận các thay đổi sau đó; không có gì phải phát lại. Đổi lại, tiến độ chỉ hiện ra theo nhịp commit: câu trả lời dở và output tool được commit nhiều nhất mỗi 100 ms theo mặc định, nên crash mất tối đa khoảng đó (README.md:502).
Có ba cách quan sát một hội thoại, cùng dựa trên một "mount" (bản dựng view):
/** Structural mount of one conversation's active transcript and built-in documents (spec §9.3). */export type ConversationView = { readonly conversation: ConversationRecord; /** Raw active entries, as `ContextView.entries`: the head marker, then the non-head entries from its head. */ readonly entries: readonly EntryRecord[]; /** Built-in conversation documents keyed by kind; absent documents are absent. */ readonly docs: Readonly<Record<string, JsonObject>>;};documentState() hay watchDoc() riêng (spec.md:4785–4786).viewState()Một state Chord chỉ đọc, có thể huỷ (AttachedReplicatedState<ConversationView>), cập nhật sau mỗi commit chạm tới hội thoại. Hợp cho UI cục bộ:view.subscribe(render).watch()Cùng view đó, giao từng frame một: giá trị bất biến cộng đúng các phép toán Chord của commit đã tạo ra nó, callback bất đồng bộ, không chồng nhau. Hợp để gửi ops cho một client từ xa áp lại (README.md:489–498).watchEvents()Bộ chuyển đổi thử nghiệm dịch các commit thành sự kiện có hình dạng như sự kiện của coding agent (message_start,tool_execution_update, ...), bắt đầu bằng một sự kiệnsnapshot.
Harness giữ nhiều nhất một mount cho mỗi hội thoại. Observer đầu tiên dựng nó trên dòng commit từ transcript và document đã commit; observer cuối cùng rời đi thì nó bị bỏ (view.ts:142–166). Mỗi revision tiếp theo được dẫn xuất từ publication của commit, không đọc lại storage:
const docOps: Op[] = [];const entryOps: Op[] = [];let entries = mount.value.entries;// Entry writes are published in ID order.for (const change of publication.changes) { if (change.type === "entry" && change.value.conversationId === id) { const entry = change.value; const value = entry as unknown as JsonValue; if (entry.head === undefined) { entryOps.push(["p", ["entries"], entries.length, 0, [value]]); entries = [...entries, entry]; continue; } // A head marker keeps the non-head entries from its head, which are always a suffix, and goes in front. const target = entry.head; let kept = entries.findIndex((candidate) => candidate.head === undefined && candidate.id >= target); if (kept < 0) kept = entries.length; entryOps.push(["p", ["entries"], 0, kept, [value]]); entries = [entry, ...entries.slice(kept)]; continue; } if (change.type !== "document" || change.conversationId !== id) continue; const kind = change.record.kind; if (!MOUNTED_KINDS.has(kind) || change.record.key !== undefined) continue; const path: NonEmptyPath = ["docs", kind]; const mounted = mount.docs.get(kind); if (change.value === null) { if (mounted?.id !== change.record.id) continue; mount.docs.delete(kind); docOps.push(["d", path]); } else if (mounted?.id === change.record.id && mounted.version === change.version) { for (const op of change.ops) docOps.push(prefixed(op, path)); } else { mount.docs.set(kind, { id: change.record.id, version: change.version! }); docOps.push(["s", path, change.value]); }}entries. Một head marker được đặt lên đầu và các entry trước head bị cắt. Phép toán của document được dời xuống dưới ["docs", kind]; document mới tạo hay đổi version thì được đặt cả giá trị. Spec cho ví dụ ["s", ["generation", "message"], v] trở thành ["s", ["docs", "pi.live", "generation", "message"], v] (spec.md:4796–4799). Về bảy loại phép toán của Chord, xem chương Chord.Áp lực ngược: quy tắc 100 frame#
Commit không bao giờ chờ người xem. Một watch chậm (hoặc chưa start()) giữ tối đa 100 frame chưa giao; frame thứ 101 thay toàn bộ phần chưa giao bằng một frame duy nhất chứa trọn view mới nhất dưới dạng phép thay gốc ["r", value]:
advance(value: T, ops: readonly Op[], context: Context): void { if (this.#end !== undefined || this.#retired) return; if (value === null) this.#retired = true; if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) { this.#pending.length = 0; const replacement = this.#replace?.() ?? value; this.#pending.push({ value: replacement, ops: [["r", replacement as JsonValue]], context }); } else { this.#pending.push({ value, ops, context }); } if (this.#started) this.#schedule();}MAX_PENDING_WATCH_FRAMES = 100 (observation.ts:16). Không đo byte, không sao chép, không diff lại. Đây là quan sát hội tụ (convergent), không phải nhật ký kiểm toán: ai cần mọi chuyển trạng thái phải tự lưu chúng vào entry (spec.md:4742–4749).watchEvents: sự kiện kiểu coding agent#
/** Experimental agent event, shaped like the coding agent's session events (spec §9.4). */export type AgentEvent = | SnapshotEvent | { type: "run_start"; inputs: readonly SubmissionId[] } | { type: "run_end"; inputs: readonly SubmissionId[] } | { type: "turn_start" } | { type: "turn_end" } | { type: "message_start"; message: Message } /** `usage` is the partial's current usage, as in the coding agent's JSON mode. */ | { type: "message_update"; usage: Usage; changes: readonly MessageChange[] } | { type: "message_end"; entry: EntryRecord } | ({ type: "tool_execution_start"; args: JsonObject } & ToolEventCall) | ({ type: "tool_execution_update"; /** A front trim and then an append of the retained window, or its replacement. */ output?: { trimStart?: number; append?: string } | { set: string }; details?: JsonValue; diagnostics?: readonly ToolDiagnostic[]; } & ToolEventCall) /** * A model-issued call ends with its result `entry`, a nested call with its `result`. Both are absent when the tool * task faulted or was orphaned, or when the call's slot left `pi.live` unfinished: its run ended, or its parent * settled first. */ | ({ type: "tool_execution_end"; entry?: EntryRecord; result?: NestedToolExecutionResult } & ToolEventCall) | { type: "inbox_update"; items: readonly QueuedItem[] } | { type: "submission"; record: SubmissionRecord } | { type: "auto_retry_start"; attempt: number; at: number; errorMessage: string } | { type: "auto_retry_end"; attempt: number } | { type: "deferred_poll"; pollAt: number } | { type: "entry_appended"; entry: EntryRecord } | { type: "agent_changed"; agent: AgentState } | { type: "usage_changed"; usage: UsageState } | { type: "task_failed"; taskId: TaskId; kind: string; message: string } | { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean } /** * The task's receipt tells whether it produced a summary; the summary entry has its own events. `error` is set when * the compaction failed, faulted, or was orphaned, as in `pi.live.compactionFailure`. */ | { type: "compaction_end"; taskId: TaskId; reason: CompactionReason; error?: string };Mỗi commit sinh một lô (batch) sự kiện, theo một thứ tự cố định: bắt đầu tool, message dở, cập nhật tool, thử lại; rồi entry theo thứ tự append (kết thúc của một tool đứng ngay trước message_start của kết quả của nó, như coding agent); rồi kết thúc compaction, lỗi task, turn_end, run_end; rồi submission; rồi inbox, agent, usage; cuối cùng compaction_start, run_start, turn_start (spec.md:4970–4983). message_update chỉ mang delta: phần text nối thêm, phần đối số tool nối thêm, nên partial được commit dày cũng không nhân dữ liệu trên đường truyền.
const views = conversationViews(harness);let watch!: CommittedWatch<readonly AgentEvent[]>;let snapshot!: SnapshotEvent;await views.attach( conversationId, async (initial, release, storage) => { // Generations whose held outcome already ended their turn, read on the line with the snapshot. const query = { conversationId, kind: "pi.generation", status: "completing" } as const; const completing = await scanAll((cursor) => storage.scanTasks(query, 100, cursor, context)); const held = new Set<TaskId>(completing.map((record) => record.id)); let current = initial; snapshot = snapshotOf(initial); // Batches are the watch's values; an overflow delivers a snapshot of the newest view instead. watch = new CommittedWatch<readonly AgentEvent[]>( [], release, (error) => views.report(error), () => [snapshotOf(current)], ); return { publication: (before, after, ops, publication, commitContext) => { current = after; const events = translate(conversationId, before, after, ops, publication, held); if (events.length > 0) watch.advance(events, [], commitContext); }, closeSession: (failure) => watch.closeSession(failure), }; }, context,CommittedWatch thay các lô chưa giao bằng một snapshot của state mới nhất. Spec gọi bộ chuyển đổi này là thử nghiệm, chỉ phủ một hội thoại (không phủ cây hội thoại nó sở hữu), và giao thức có thể đổi không báo trước (spec.md:4823–4828).Ví dụ 21-late-join.ts gắn một view và một luồng sự kiện vào giữa một run; 19-json.ts in toàn bộ luồng của một run dưới dạng sự kiện (--events) hoặc phép toán thô (--ops), như chế độ JSON của coding agent (chương Các chế độ chạy). Để quan sát một document riêng lẻ, dùng harness.documentState() hoặc harness.watchDoc(); cơ chế bên dưới được mổ xẻ ở chương storage.
Lần theo một input được trả lời#
README tóm vòng đời của một input bằng năm dòng:
One answered input, as entries and tasks:```textsubmit(input) → pi.user pi.generation → pi.system (only if the prompt or tools changed), pi.assistant (tool calls) pi.tool × n → pi.tool-result × n (owned by the generation, which waits for them) pi.generation → pi.assistant (answer) → submission done```Phần này mở rộng năm dòng đó thành từng commit, với code thật ở mỗi bước. Kịch bản: hội thoại gốc đang rảnh, đã cấu hình mô hình, registry có CodingTools (bốn tool read, write, edit, bash, không có section, tools/index.ts:31–39) và một extension có section preamble như ví dụ Coding của README (README.md:232–236). Người dùng gửi "Đọc notes.txt rồi tóm tắt". Mô hình gọi read một lần, nhận nội dung file, rồi trả lời. Phần bên trong task (reservation, wait, thử lại) chỉ được lướt qua; chương task đi sâu vào chúng.
Ví dụ minh hoạ: ID giả định storage mới, root() vừa tạo hội thoại 1 và năm document 2–6
(pi.live = 2, pi.inbox = 3, pi.usage = 4, pi.provider = 5, pi.agent = 6).
# ai commit ghi gì sự kiện watchEvents
C1 Submissions entry 7 pi.user · submission 8 placed message_start/end(7)
task 9 pi.generation pending {prepare, attempt 1} submission(8)
pi.live.run = {taskId 9, inputs [8]} run_start([8]) turn_start
C2 scheduler task 9 pending → running (không có)
C3 generation 9 entry 10 pi.system (baseline: section + toolsAdded) message_start/end(10)
(prepare) task 9 checkpoint {request, model, cutoff 10}
C4 generation 9 pi.live.generation = {attempt 1} (không có)
(request) ... các commit partial, mỗi ~100 ms ... message_start, message_update...
C5 generation 9 entry 11 pi.assistant (toolCall read) · pi.usage message_end(11) usage_changed
task 12 pi.tool pending, chủ là task 9
pi.live.tools = [read: pending] · bỏ generation
task 9 waiting on [12] (allSettled)
C6 scheduler task 12 pending → running (không có)
C7 tool 12 (call) pi.live.tools[0] running · checkpoint {execute, args} tool_execution_start
... execute(), output dở được commit theo nhịp ... tool_execution_update...
C8 tool 12 entry 13 pi.tool-result · slot done, entry 13 tool_execution_end
task 12 terminal {kind model, entryId 13} message_start/end(13)
C9 scheduler task 9 waiting → running (phase tools) (không có)
C10 generation 9 bỏ pi.live.tools · task 14 pi.generation pending turn_end turn_start
(tools) run.taskId 9 → 14 · task 9 terminal {entryId 11}
C11 scheduler task 14 pending → running (không có)
C12 generation 14 prompt và tool không đổi: không có pi.system mới (không có)
(prepare) task 14 checkpoint {request, cutoff 13}
C13 generation 14 pi.live.generation = {attempt 1}, rồi các partial message_start, message_update...
C14 generation 14 entry 15 pi.assistant (câu trả lời) · pi.usage message_end(15) turn_end
(answer) submission 8 done {entry 7, answer 15} run_end([8]) submission(8)
bỏ pi.live.run, generation · task 14 terminal usage_changed
→ Submission.wait() trả về {status "done", entry 7, answer 15}
C1: admission tạo input, run và generation đầu tiên#
Đã xem ở mục Submission: hội thoại rảnh và inbox rỗng nên admitSubmission() append entry pi.user, tạo submission placed rồi gọi startRun(), vốn tạo task pi.generation thuộc hội thoại và đặt pi.live.run (generation.ts:752–769). Bốn bản ghi này lưu cùng nhau hoặc không lưu gì. Nếu tiến trình chết ngay sau C1, lần mở sau thấy một task pending và chạy nó; host gửi lại với cùng requestId thì nhận lại submission 8.
C2: scheduler giữ chỗ cho task#
Scheduler theo dõi các commit, thấy task 9 có thể chạy, và trong một commit của chính nó đổi trạng thái sang running rồi mới gọi handler của phase (scheduler.ts:1540–1547). Trạng thái running được lưu là để lần mở sau biết task này đã bị cắt ngang và đếm số lần khởi động lại.
C3: prepare ghi system prompt theo vị trí#
const view = await runtime.context(conversationId, context);const shown = replaySections(view.messages);let env: ExecutionEnv | undefined;try { env = await runtime.env(context);} catch (error) { if (context.abortSignal?.aborted) throw error; runtime.report(error, { type: "host", callback: "env" });}const input: PromptInput = { conversationId, agent, env, shown: Object.fromEntries(shown), read: runtime };const desired = await renderSections( agent.sections, input, shown, (error, section) => runtime.report(error, { type: "extension", ...optionalExtension(sectionExtension(agent, section.key)), }), context,);const entries = planSystemEntries(view, desired, agent.tools, runtime.now());await runtime.commit(async (tx) => { let cutoff = (await tx.scanEntries({ conversationId }, 1)).items[0]?.id; for (const entry of entries) cutoff = (await tx.appendEntry(SystemEntry, conversationId, entry)).id; if (cutoff === undefined) throw new Error(`Conversation ${conversationId} has no entries to send`); // Checked in this commit, so a compaction admitted during preparation counts. if (threshold === "background" && (await tx.doc(LiveDoc, conversationId)).compactions === undefined) { await createCompaction(tx, conversationId, { reason: "threshold" }); } const request = { attempt, ...(compacted === undefined ? {} : { compacted }), model, thinkingLevel, streamOptions: settings.stream, cutoff, }; return { status: "running", checkpoint: { phase: "request", ...request } };}, context);cutoff là entry mới nhất mà request sẽ thấy. Mô hình, mức suy nghĩ và tùy chọn stream được cố định trong checkpoint request, nên một lần gửi lại sau khôi phục dùng đúng chúng. Phần bị lược là kiểm tra ngưỡng compaction (generation.ts:177–189).C4: request, stream và partial#
Phase request commit pi.live.generation = { attempt }, lấy ngữ cảnh tại cutoff, chạy hook beforeRequest, rồi gọi mô hình với sessionId lấy từ pi.provider (generation.ts:209–236). Trong lúc stream, partial được commit vào pi.live.generation.message với nhiều nhất một commit đang bay (generation.ts:441–498). Đầu phase, convertPartial() biến một partial còn sót từ lần thử bị cắt ngang thành entry pi.assistant có stop reason aborted (generation.ts:430–439): đó chính là "phần trả lời dở được giữ và đánh dấu bị huỷ" trong bài công bố.
C5: câu trả lời gọi tool mở một vòng tool#
await runtime.commit(async (tx): Promise<Next> => { const live = await tx.doc(LiveDoc, conversationId); const entry = await appendAssistant(tx, conversationId, message); const slots: ToolSlot[] = []; const tools: TaskId<ToolTaskResult>[] = []; const pending: string[] = []; for (const call of calls) { if (!offered.has(call.name)) { const unavailable = harnessError("tool_unavailable", `Tool ${call.name} is not available`); const result = await appendToolResult(tx, conversationId, call, unavailable, { timestamp: runtime.now() }); slots.push({ callId: call.id, name: call.name, status: "done", entry: result.id }); continue; } if (sequential && tools.length > 0) { pending.push(call.id); slots.push({ callId: call.id, name: call.name, status: "pending" }); continue; } const taskId = await createToolTask(tx, runtime, entry.id, call.id); tools.push(taskId); slots.push({ callId: call.id, name: call.name, taskId, status: "pending" }); } delete live.generation; live.tools = slots; const checkpoint = { phase: "tools", assistant: entry.id, tools, pending } as const; return { status: "waiting", checkpoint, on: tools, policy: "allSettled" };}, context);tool_unavailable. Mọi lời gọi khác có một task pi.tool mà chủ là generation (ownership: { kind: "task" }, generation.ts:330–337). Generation không chạy code nào trong lúc waiting, và vẫn giữ pi.live.run.C7 và C8: tool ghi ý định, chạy, rồi ghi kết quả#
await runtime.commit(async (tx) => { const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); if (slot !== undefined) slot.status = "running"; // A nested slot shows the arguments the call runs with, which repair, hooks, and coercion may have changed. // Compared with the input, which the slot got at admission: reading the draft would track every leaf. const changed = task.input.kind === "nested" && !jsonEqual(task.input.call.arguments as JsonValue, final); if (changed && slot !== undefined && "parentTaskId" in slot) slot.arguments = final; const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" } as const; return { status: "running", checkpoint: intent };}, context);await run(runtime, task.input, call, tool, final, context);execute: tool chạy lại chỉ khi cả chính sách đã lưu lẫn chính sách hiện tại là safe; nếu không, mô hình nhận kết quả lỗi interrupted kèm output đã commit (tool.ts:180–198). const timing = { timestamp: runtime.now(), durationMs }; const entry = await appendToolResult(tx, conversationId, call, result, timing); if (slot !== undefined && !("parentTaskId" in slot)) { finishSlot(slot); slot.entry = entry.id; } // Tools build control objects freely; drop keys set to undefined so the task result is strict JSON. const control = result.control === undefined || ending.status !== "completed" ? {} : { control: copyJson(result.control as JsonValue, { omitUndefinedProperties: true }) as ToolControl }; settled = { kind: "model", entryId: entry.id, ...control };return { status: "terminal", outcome: { status: "completed", result: settled } };appendToolResult() ghi entry có data.diagnostics và cộng usage của tool vào pi.usage trong cùng commit (tool.ts:885–913).C10: hết vòng tool, giao run cho generation kế tiếp#
await runtime.commit(async (tx): Promise<Next> => { const boundary = await prepareBoundary(tx, conversationId, runtime.settings); if (added.length > 0) await addTools(tx, conversationId, added); const live = await tx.doc(LiveDoc, conversationId); const now = runtime.now(); if (terminate || handoff !== undefined) { } else { const { users, reset } = await applyBoundary(tx, boundary, "postTools", now); if (reset) { // The queued reset cut the run's context before an answer. endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "reset" }); if (users.length > 0) await startRun(tx, conversationId, live, users); } else { delete live.tools; delete live.nestedTools; if (live.run?.taskId === runtime.taskId) live.run.inputs.push(...users); handOver(live, runtime.taskId, await createGeneration(tx, conversationId)); } } return { status: "terminal", outcome: { status: "completed", result: { entryId: assistant } } };}, context);postTools là nơi tin nhắn steer đang chờ được đặt vào transcript và nhập vào run hiện tại (live.run.inputs.push). Generation 9 kết thúc completed; quyền điều khiển run chuyển sang generation 14 mà không có lúc nào hội thoại "rảnh" (generation.ts:771–774).Generation 14 lặp lại prepare; lần này prompt và tool không đổi nên planSystemEntries() trả về mảng rỗng và không có entry pi.system mới: prompt cache của provider vẫn ấm.
C14: câu trả lời cuối chốt submission#
await runtime.commit(async (tx): Promise<Next> => { // Queue modes are read on the Session line, when the boundary is decided. const boundary = await prepareBoundary(tx, conversationId, runtime.settings); const live = await tx.doc(LiveDoc, conversationId); const entry = await appendAssistant(tx, conversationId, message); const result: Next = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } }; const { users, reset } = await applyBoundary(tx, boundary, "final", runtime.now()); if (continuation !== undefined && users.length === 0 && !reset) { const user = { role: "user", content: continuation, timestamp: runtime.now() } as const; await tx.appendEntry(UserEntry, conversationId, { model: [user] }); handOver(live, runtime.taskId, await createGeneration(tx, conversationId)); delete live.generation; return result; } endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id }); if (users.length > 0) await startRun(tx, conversationId, live, users); return result;}, context);applyBoundary(..., "final") đặt nó và startRun() mở run kế tiếp ngay trong commit này./** * End the run owned by `taskId`: settle each of its inputs and remove `run`. Always removes `generation`, `tools`, and * `nestedTools`, whose presentation belongs to the ending run. */export function endRun(tx: Tx, live: Draft<LiveState>, taskId: TaskId, settlement: SubmissionSettlement): void { if (live.run?.taskId === taskId) { for (const id of live.run.inputs) tx.settleSubmission(id, settlement); delete live.run; } delete live.generation; delete live.tools; delete live.nestedTools;}Commit C14 chứa bản ghi submission 8 ở trạng thái done. Bộ quan sát commit của Submissions thấy nó trong publication và giải phóng mọi người đang wait() (submissions.ts:145–150). Host đọc câu trả lời bằng tx.entry(AssistantEntry, settled.answer), như Quick Start (README.md:62–67). Ví dụ chạy được gần nhất với trace này là 17-coding-tools.ts: một lượt dùng tool trên JSONL, để lại thư mục storage để bạn mở ra xem.
API công khai và các ví dụ#
Gốc package (@earendil-works/pi-durable) xuất mọi thứ cần để dựng một harness; phần đầu của index.ts:
export { defineDoc, defineDocFamily } from "./documents.ts";export { AssistantEntry, CompactionEntry, defineEntry, ResetEntry, SystemEntry, ToolResultEntry, UserEntry,} from "./entries.ts";export { ConversationBusy, ReadAfterWrite, SessionFailed, StorageRequestError } from "./errors.ts";export { AgentDoc, configure, DEFAULT_COMPACTION_POLICY, DEFAULT_PROGRESS_POLICY, DEFAULT_RESTART_LIMIT, DEFAULT_RETRY_POLICY,} from "./harness/agent.ts";export { type CompactionCheckpoint, type CompactionInput, CompactionTask, type SummaryRequest,} from "./harness/compaction.ts";export { defineExtension, defineTool, hook, section, wrapSection, wrapTool } from "./harness/define.ts";| Nhóm | Giá trị xuất ra | Ở đâu |
|---|---|---|
| Mở và chạy | Harness, DEFAULT_HARNESS_IDENTITY, createRegistry, createSession, MemoryStorage, ROOT_CONVERSATION_ID | index.ts:41, :53, :126–127, :216 |
| Định nghĩa | defineDoc, defineDocFamily, defineEntry, defineExtension, defineTool, hook, section, wrapTool, wrapSection, defineTask | index.ts:26, :128 |
| Entry dựng sẵn | UserEntry, AssistantEntry, SystemEntry, ToolResultEntry, ResetEntry, CompactionEntry | index.ts:2–10 |
| Document dựng sẵn | AgentDoc, LiveDoc, InboxDoc, ProviderDoc, UsageDoc, NestedCallDoc | index.ts:42–52, :124 |
| Task dựng sẵn (để đăng ký hook) | GenerationTask, ToolTask, CompactionTask | index.ts:35–40, :57–64 |
| Cấu hình | configure, DEFAULT_RETRY_POLICY, DEFAULT_COMPACTION_POLICY, DEFAULT_PROGRESS_POLICY, DEFAULT_RESTART_LIMIT | index.ts:12–19 |
| Quan sát, telemetry | watchEvents (thử nghiệm), DURABLE_TELEMETRY_SCHEMA | index.ts:27–34, :56 |
| Lỗi | ConversationBusy, ReadAfterWrite, SessionFailed, StorageRequestError | index.ts:11 |
| Kiểu | Bề mặt Harness (Conversation, Submission, Agent, HarnessOptions, ...) và bề mặt Session/storage (Tx, Storage, EntryRecord, TaskRecord, ...) | index.ts:65–123, :129–215 |
Phần còn lại tách thành các subpath để không kéo phụ thuộc không cần (package.json:8–111): /tools (read, write, edit, bash, CodingTools), /codemode, /tool-search, /env và /env/node, /images (và /images/node, /images/cloudflare), các backend /storage/memory, /storage/indexeddb, /storage/jsonl(/node), /storage/sqlite(/node, /cloudflare), và /testing. Gốc package tải TypeBox vì task tool kiểm tra đối số; README ước khoảng 23 MB RSS đỉnh khi không bundle, khoảng 4 MB khi bundle có tree-shaking (README.md:817).
Các ví dụ trong test/examples#
Ba mươi hai ví dụ chạy được bằng node --conditions=source --experimental-strip-types test/examples/<file> từ thư mục package (README.md:839–843); đa số dùng provider giả (faux) của pi-ai, vài ví dụ dùng OpenAI khi có OPENAI_API_KEY. Mười bốn ví dụ đầu dạy từng tầng, từ dưới lên:
| Ví dụ | Dạy gì |
|---|---|
| 00-conversation | Session trần trên MemoryStorage; mọi lần ghi nằm trong commit(); hội thoại ownerless |
| 01-documents | Document rewindable; snapshot() và snapshotAsOf() tại từng entry |
| 02-forks | Fork tại một entry: thấy entry của cha tới đó; document asOf được sao chép độc lập |
| 03-owned-conversations | Task nền sở hữu một hội thoại con; task, hội thoại và document registry trong một commit |
| 04-chord-state | documentState(): state Chord chỉ đọc, không bao giờ tạo document |
| 05-watches | watchDoc(): revision gốc rồi frame tuần tự, quy tắc 100 frame |
| 06-harness | Harness với registry; root() với agent và init; agent lưu tên, giải khi đọc |
| 07-configuration | configure(), settings có getter, metadata riêng trên tool, gỡ rồi cài lại extension |
| 08-harness-conversations | Handle không giữ state, entry có kiểu, createConversation() và fork() với agent |
| 09-context | Dẫn xuất ngữ cảnh: head, edit, message bị loại, thứ tự kết quả tool, kết quả tổng hợp |
| 10-registry-reload | Thay extension tại chỗ, wrapper theo tên, gỡ |
| 11-extension-state | Extension có document riêng: tool ghi, section hiển thị cho mô hình |
| 12-tasks | Task tự định nghĩa: lưu ý định, làm, lưu kết quả |
| 13-recovery | Đóng giữa chừng trên SQLite, mở lại, task chạy tiếp; memo chống lặp hiệu ứng; sleep bền |
Mười tám ví dụ sau (14 đến 31) là code kiểu sản phẩm, có bảng mô tả riêng trong README (README.md:845–864): một câu hỏi và câu trả lời (14), system prompt (15), mô hình thật (16), tool lập trình trên JSONL (17), chế độ in (18) và JSON (19) như pi -p và pi --mode json, inbox (20), vào muộn (21), subagent tiền cảnh (22) và subagent nền bền qua khởi động lại (23), task con (24), compaction (25), một coding agent nhỏ (26), plan mode (27), reviewer (28), sandbox theo hội thoại (29), thay tool cho một số hội thoại (30), và reload rồi khởi động lại (31).
Quyết định thiết kế#
Lưu trước, hiển thị sau, không có ngoại lệ#
Bất biến số 3 của đặc tả: "All visible progress is durable. There is no volatile publication path" (spec.md:67), và "visible-undurable publication" đứng trong danh sách không làm (spec.md:5606–5618). Lý do là các mục tiêu nhiều người dùng và vào muộn: nếu UI được phép thấy thứ chưa lưu, một client kết nối lại sau crash có thể thấy "quá khứ bị viết lại". Khi mọi thứ hiển thị đều đã lưu, kết nối lại chỉ là lấy state hiện tại. Cái giá là tiến độ đi theo nhịp commit, và mỗi token stream ra đều thành một lần ghi; Pi Durable trả giá đó bằng throttle 100 ms, delta của Chord và chính sách checkpoint của pi.live để chuỗi delta luôn ngắn.
Mọi thứ harness chạy là một task#
Bài công bố viết: "Everything the harness runs, from calling the model to executing a tool, is a task." Vòng lặp của pi-agent-core (chương agent-core) là một hàm async giữ state trong bộ nhớ: chết là mất. Ở đây mỗi bước là một phase của một máy trạng thái, và state cần để làm tiếp nằm trong checkpoint đã commit. Generation sở hữu các task tool của vòng nó; hội thoại sở hữu generation; huỷ chạy từ dưới lên. Thiết kế này cho khôi phục từ checkpoint cuối, nhưng buộc mọi hiệu ứng ngoài phải nghĩ về chạy lại: tool phải khai báo replay, task phải "lưu ý định, làm, lưu kết quả" (12-tasks.ts:10–14). Tên mã cũ của harness, Pico, vẫn còn trong vài chú thích (sqlite/storage.ts:151).
Lưu tên, không lưu code#
pi.agent chỉ chứa tên extension và tên tool; registry nói tiến trình này chạy được gì lúc này (spec.md:843–848). Nhờ vậy reload không cần migration và một hội thoại cũ mở được trong một tiến trình thiếu vài extension: tên chưa cài bị bỏ qua khi giải, và có hiệu lực lại khi được cài. Cái giá: không có gì báo lỗi khi một extension được chọn mà không có mặt, nên một extension bảo vệ (permission hook) vắng mặt nghĩa là chạy không có bảo vệ.
Prompt là bản vá theo vị trí#
Thay vì dựng lại system prompt mỗi request, transcript giữ đúng những gì mô hình đã thấy, ở đúng vị trí, và chỉ append phần khác. Lựa chọn này phục vụ prompt cache và tính di chuyển giữa provider: chuỗi section đã render là bản ghi có thẩm quyền, kể cả khi renderer sau này thay đổi (spec.md:3780–3784). Cái giá: section phải tất định, và đổi thứ tự tốn hai entry.
Checkpoint là chính sách của định nghĩa, không phải heuristic của backend#
Đặc tả cấm storage tự đếm byte hay tự chèn checkpoint, và coi một định nghĩa không bao giờ checkpoint là lỗi của định nghĩa (spec.md:1569–1576); "automatic checkpoint heuristic" nằm trong danh sách không làm (spec.md:5612). Người định nghĩa document biết nó thay đổi thế nào: pi.live chọn "khi rảnh", pi.inbox chọn "khi rỗng". Bản 1.4.0 bỏ giá trị mặc định để buộc mỗi định nghĩa trả lời câu hỏi đó. Cơ chế base và delta được mổ xẻ ở chương storage.
Sự kiện là bộ chuyển đổi, không phải nguồn sự thật#
Không có nhật ký sự kiện ngữ nghĩa trong lõi Session (spec.md:5610). watchEvents() dẫn xuất sự kiện từ commit và có thể bỏ qua chuyển trạng thái trung gian khi tràn; kết quả chính xác của một input luôn đến từ Submission.wait(). Một TUI vẽ từ ConversationView, sự kiện chỉ dùng cho hiệu ứng chuyển cảnh (spec.md:4986–4987).
Một tiến trình sở hữu một storage#
Không có khoá liên tiến trình (README.md:790), và trộn CRDT hay nhiều người ghi ngoại tuyến không nằm trong phạm vi. "Nhiều người cùng điều khiển một agent" nghĩa là nhiều client nói chuyện với một tiến trình sở hữu storage, như một Durable Object cho mỗi session trong bài "Pi Durable on Cloudflare".
Cạm bẫy và trường hợp biên#
- Append entry thô vào hội thoại đang bận. Chỉ Harness được ghi ngữ cảnh mô hình vào một hội thoại có run; một entry chen vào lúc generation đang chuẩn bị có thể làm lệch entry
pi.systemcủa nó. Dùng write submission (spec.md:5470–5473). - Input gửi giữa run mặc định là follow-up. Nó chờ tới khi run trả lời mới được đặt. Muốn chen vào vòng tool hiện tại thì
whenBusy: "steer". - Write chỉ để hiển thị vượt lên trước. Nó được append ngay, nên có thể đứng trước các mục gửi sớm hơn còn đang chờ, trừ khi có reset đang chờ (README.md:517).
- Hàng đợi sau một run thất bại. Mặc định các follow-up nằm lại trong
pi.inbox, vàwait()của chúng không trả về, cho tới khi có input mới hoặc write xếp hàng chạy boundary (spec.md:5474–5480). - Huỷ
wait()không rút submission. Rút làsubmission.abort(), và chỉ khi cònqueued. root()chỉ ápagentvàinitmột lần. Lần gọi sau, kể cả sau khi đổi code, bỏ qua chúng; muốn đổi thìconfigure().- Lựa chọn và bản sao.
{ add, remove }luôn sửa lựa chọn mặc định của host; ghi nó vào một subagent đã chép lựa chọn từ chủ sẽ thay lựa chọn đã chép. Bản saopi.agentcủa hội thoại do task sở hữu là bản sao một lần (spec.md:5571–5581). - Watch hội tụ, không kiểm toán. Quá 100 frame thì các trạng thái trung gian bị gộp. Cần mọi chuyển trạng thái thì ghi chúng thành entry (spec.md:5551–5555).
- Handle không sống qua lần mở lại.
Conversation,Submissionthuộc Harness đã trả chúng; sau khi mở lại, lấy lại bằngharness.conversation(id)vàharness.submission(id). - Đặt tên với tiền tố
pi.Không bị chặn (trừ kind của input), nhưng va chạm với hành vi dựng sẵn, ví dụ một entry tự đặt kindpi.systemsẽ bị phát lại như system message.
Đọc code ở đâu#
Mọi đường dẫn tính từ packages/durable/. Thứ tự dưới đây đi từ khái niệm tới hiện thực.
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | README.md | Quick Start, Concepts, rồi các mục Busy Conversations, Watching, Errors |
| 2 | docs/spec.md §1–2 | Bất biến, bản ghi lõi, bề mặt công khai của Harness (đặc tả là chuẩn, code thắng khi lệch) |
| 3 | src/types.ts | Mọi bản ghi: ID, hội thoại, entry, submission, task, document, Tx, Session, Storage |
| 4 | src/entries.ts, src/documents.ts | Token entry và document |
| 5 | src/harness/types.ts | Bề mặt Harness: Conversation, Submission, Extension, AgentState, HarnessOptions, hook |
| 6 | src/harness/harness.ts | Harness.open, #create, hook tạo hội thoại, handle |
| 7 | submissions.ts, inbox.ts | Admission, boundary, wait, abort |
| 8 | live.ts, provider.ts, usage.ts | Document dựng sẵn và hàm tiện ích của chúng (endRun, recordUsage) |
| 9 | registry.ts, define.ts, agent.ts | Registry, giải agent, settings |
| 10 | prompt.ts, context.ts | Entry pi.system theo vị trí, dẫn xuất ngữ cảnh |
| 11 | view.ts, events.ts | Mount view và bộ chuyển đổi sự kiện |
| 12 | generation.ts, tool.ts | Chỉ các commit trong trace ở trên; phần còn lại ở chương task |
| 13 | src/index.ts, package.json | Bề mặt xuất ra và các subpath |
| 14 | test/examples/ | 00 tới 13 theo thứ tự, rồi 14, 17, 20, 21 |
Tóm tắt#
- Pi Durable là một harness agent bền, thử nghiệm, phát hành cùng Pi 1.0; nó không dùng
AgentSessionhaypi-agent-core, và không thay thế coding agent. - Quy tắc lõi: entry bất biến, bản ghi task và document Chord được commit nguyên tử; chỉ state đã commit là quan sát được.
- Hội thoại là transcript entry bất biến;
parentcho fork,ownercho subagent; head marker định nghĩa ngữ cảnh đang hoạt động. - Document là JSON có kiểu với phạm vi, lịch sử, chính sách fork và chính sách checkpoint tường minh; năm document dựng sẵn mang agent, danh tính provider, state đang chạy, inbox và chi phí.
submit()nhận input hoặc write bền rồi trả về;requestIdcho exactly-once; bận thì xếp hàng thành steer hoặc follow-up, đặt vào ở boundary.- Code nằm trong extension của registry tiến trình; hội thoại lưu tên, agent được giải lại mỗi lần dùng; system prompt là các entry
pi.systemtheo vị trí, chỉ gửi phần thay đổi. - UI quan sát bằng
viewState(),watch()hoặcwatchEvents(), tất cả dẫn xuất từ commit, hội tụ, giới hạn 100 frame.