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 被 kill -9 強制關掉。(這只是用來說明問題的情境,不需要你實際執行。)

重開程式之後,你面對一個沒有答案的問題:deploy.sh 到底跑了沒有? 如果它其實跑完了,你重新執行一次可能就是重複部署;如果它其實沒跑,你以為完成了卻什麼都沒發生。兩種猜錯的代價都不小,而系統目前唯一知道的事,就是「process 死掉了」。

這章要解決的就是這個問題:怎麼設計儲存格式,讓重開之後系統不用猜,而是能明確讀出「這件事做到哪一步」。核心規則只有一句:intent 必須早於 effect——先把「我打算做這件事」寫進硬碟,才真的去做。

如果假設 model request 永遠成功、tool 不會失敗、process 也不會突然停止,那麼幾十行就能寫出一個 Agent。真正困難的是失敗之後的狀態:process crash 之後,系統仍必須判斷工作是否已接受、model 是否呼叫過,以及 tool effect 是否可能已經發生。

延伸閱讀:02 章與 05 章其實是同一次 request 的兩個切面(選讀)
  1. 寫入 stepAttempt

    含 configurationDigest

  2. 組 provider request

    ch02 Provider Adapter

  3. OpenRouter 回應

    含 reasoning/refusal 等 wire 欄位

  4. 正規化成 canonical message

    ch02 normalizeAssistantMessage

  5. 寫入 settled assistant entry

    對回同一筆 stepAttempt

圖 3:一次 provider request 如何同時是 ch02 的正規化對象,也是 ch05 的 durable record。

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,也可以是一個非空的 facts array。Reducer 會將同一個 array 內的 facts 視為一個原子單位。

[
  {"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

回到開頭的 deploy.sh:如果系統的習慣是「等 tool 執行完、拿到結果,才把這件事寫進硬碟」,那麼 kill -9 那一刻,硬碟裡什麼都沒有——不是「未執行」,也不是「執行中」,就是完全沒有記錄。重開後你回到最開頭那個沒有答案的問題。

錯誤:execute effect → persist result
正確:persist toolStarted → execute effect → persist result
  1. 寫入 toolStarted

    intent,含 replayKey

    ⚠ 此刻 crash → recovery 已知「打算做這件事」

  2. 執行 effect

    檔案系統/bash

    ⚠ 此刻 crash → recovery 依 replay policy 判斷:safe read 可重播;write/edit/bash/MCP 一律標 interrupted

  3. 寫入 tool result entry

    ⚠ 此刻 crash → 已完成,recovery 什麼都不用做

圖 4:intent 先於 effect 的時間軸。usage 只在 tool 本身會回報用量時才會有(例如部分 MCP tool);多數 built-in tool 的一般 tool result 不會連帶一筆 usage。

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 的責任。下一章也會講一個真的用 kill -9 挖出來、只存在於其中一個語言 port 的真實 bug:intent-before-effect 這個機制本身四個語言都做對了,錯的是它「之後」的下一步判斷。