tiny-agent從第一性原理打造可靠 Agent

第三部|可靠執行 · 第 5 章

Durable Session:先記 Intent,再做 Effect

用 transactional JSONL 保存接受的工作、model attempt 與 tool intent,讓 crash 後仍可解釋。

約 26 分鐘5 / 8

設想這個情境:Agent剛送出 bash rm old.log && deploy.sh,process在tool執行中被kill -9。重開後,deploy.sh到底跑了沒有?(這只是用來說明問題的情境,不需要你實際執行。)

核心規則:intent必須早於effect。

如果假設model request永遠成功、tool不會失敗、process也不會突然停止,那麼幾十行就能寫出一個Agent。真正困難的是失敗之後的狀態:process crash之後,系統仍必須判斷工作是否已接受、model是否呼叫過,以及tool effect是否可能已經發生。本章要建立一種durable格式,讓上面這個問題有明確答案,而不是猜測。

Durability 的精確定義

Tiny-agent承諾process-crash durability:一筆已接受的LF-terminated transaction已交給OS;重開時會丟棄最後LF之後的torn tail,並從完整prefix恢復。

它不承諾power-loss durability。沒有每筆file sync與directory sync,就不能保證斷電或storage controller failure後仍存在。教材刻意保留這個區別,不用含糊的「永不遺失資料」。

一個檔案,兩種行

第一行是唯一header:

{
  "kind": "header",
  "version": 2,
  "id": "019...",
  "createdAt": 1787371200000,
  "cwd": "/workspace",
  "provider": "openrouter",
  "model": "deepseek/deepseek-v4-flash-0731",
  "environmentIdentity": "/workspace"
}

之後每一行是一個完整transaction:一個fact,或一個non-empty facts array。Array中的facts對reducer原子可見。

[
  {"kind":"entry","seq":1,"id":"...","entry":{"type":"message","message":{"role":"user","content":"修正bug"}}},
  {"kind":"record","seq":2,"id":"...","record":{"type":"runStarted","operationId":"...","operationKind":"run","inputEntryId":"..."}}
]

Intent 必須早於 Effect

舊式session常在tool完成後才寫 result。若process在外部effect成功、result落盤前crash,重開後完全看不出tool是否執行過。

錯誤:execute effect → persist result
正確:persist toolStarted → execute effect → persist result

toolStarted會保存有效arguments、tool identity、replay policy、environment identity與預留的result entry ID。Crash後看到 intent卻沒有result,就能明確得到:

這個effect可能已經發生,結果未知。

系統不猜測。Safe read可以在identity完全一致時重播;write、edit、bash與MCP則寫入interrupted synthetic result,不重播。

Entry、Record 與 Usage

Kind 保存內容 會送給模型?
entry user/assistant/tool message、compaction checkpoint 依active context投影
record run、attempt、tool intent、abort、operation outcome 不會
usage physical model attempt或nested tool用量 不會

Usage獨立成ledger,避免從「最後一則assistant message」猜整個session成本。每筆usage綁定到exact attempt或toolStarted。

Transaction Invariants

  • seq從1開始,每個fact嚴格+1,包含array內部。
  • ID是唯一UUIDv7;reference必須指向已存在且正確ownership的fact。
  • 完整transaction先驗證,成功後才更新memory state。
  • 最後LF後的 bytes是torn tail;完整但非法的一行則是corruption,不能跳過。
  • Session使用single-writer contract;runtime只防同process重複writer,跨process互斥由job runner保證。

深而小的 Storage Interface

interface SessionStore {
    commit(facts: NewFact[]): Promise<CommittedFact[]>;
    load(): Promise<SessionState>;
    close(): Promise<void>;
}

commit隱藏seq、IDs、timestamp、serialization與FIFO queue;load隱藏framing、torn-tail repair與pure reduction。刪除這個module會讓複雜度重新散回agent loop,因此它是真正通過deletion test的deep module。

稍後你會再看到這個形狀:commit/load隱藏了seq、ID、framing,這是全書第二次看到「小而深的介面」。

親手驗證

先用標準函式庫親手建立一個最小 append-only JSONL,再故意留下沒有 LF 的 torn tail。這個練習不需要模型或網路:

tmp=$(mktemp)
printf '%s\n' '{"kind":"header","version":2}' > "$tmp"
printf '%s\n' '[{"kind":"record","seq":1}]' >> "$tmp"
printf '%s' '{"torn":' >> "$tmp"
node -e 'const b=require("fs").readFileSync(process.argv[1]); const i=b.lastIndexOf(10); console.log(b.subarray(0,i+1).toString())' "$tmp"

輸出只應包含兩個 LF-terminated records;最後半截不能被當成已提交的交易。接著執行真正的 Store regression test,確認修復後才能繼續 append:

cd typescript
node --import tsx --test --test-name-pattern="repairs a torn tail" test/session.test.ts

--test-name-pattern必須放在檔案路徑之前,Node.js的test runner才會實際過濾;放在npm test --之後會被接到glob尾端而完全不生效,因此這裡直接呼叫node --test並指定單一檔案,只跑這一個測試。

最後開啟 schemas/session/fixtures/torn-tail.jsonl 與其 expected state,對照手作版本和 canonical contract 的差別。

重建出的SessionState本身不能決定下一步該做什麼——這是下一章Planner的責任。