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

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

Reducer、Planner 與 Recovery

以 pure reducer 重建狀態,再由 planner 區分 retry、replay、interrupted 與 blocked。

約 25 分鐘6 / 8

重開檔案不應該直接執行effect。第一步是用pure reducer回答「durable facts表示什麼狀態」;第二步才由pure planner回答「在目前configuration下,允許做什麼」。

兩個Pure Functions

reduceSession(bytes) → SessionState
planRecovery(state, currentConfiguration) → RecoveryPlan

Reducer不認識目前tools、model或environment,也不執行I/O。Planner不寫Session、不分配ID、不呼叫model/tool,只描述下一項effect或blocked reason。這樣的分離讓每個crash prefix都能用deterministic fixtures驗證。

這是本書第三次看到同一招:把複雜度收進一個小而深的介面。Tool的execute、SessionStore的commit/load,都靠這招把I/O細節藏起來,呼叫方不需要知道內部怎麼做——但它們自己就會真的去讀檔、寫檔、呼叫bash,是會碰I/O的殼。reducer/planner把這招再往前推一步:不只是藏細節,還把「決定要做什麼」和「真的去做」拆成兩個函數——一個不碰I/O的純函數(reducer、planner本身),配一個真的會碰I/O的殼(SessionStore、Agent loop)。

Reducer 重建什麼

type SessionState = {
    transcript: Message[];
    activeContext: Message[];
    usage: Usage;
    operation:
        | { kind: "idle" }
        | { kind: "run"; step?: StepState; toolCalls: ToolCallState[] }
        | { kind: "compaction"; step?: StepState; resultEntryId: string };
};

transcript保留完整model-visible歷史;activeContext反映最新compaction投影;operation說明目前是否有未完成run/compaction。

Retry 與 Replay 完全不同

Retry Model Attempt

重送相同context給無外部副作用的provider request。

  • 只處理crash-unknown attempt
  • configuration digest必須一致
  • 最多attempt 2

Replay Tool Effect

再次執行可能觸碰外部世界的tool。

  • 預設never
  • 只有exact built-in read是safe
  • replayKey與environment必須一致

Live 429、5xx或timeout會寫stepFailed,不會自動retry。這份設計只允許process crash造成的unknown attempt多一次機會,避免無界重複計費。

其實有四種可能結局,不是兩種:retry(同一個model attempt再送一次)、replay(同一個safe tool effect再執行一次)、blocked(configuration或replay宣告不符,需要人為介入,不寫終局)、failedstepFailed是live request已經記錄的失敗,runtime選擇不自動retry)。混淆retry與replay是最常見的誤解;混淆blocked與failed是第二常見——blocked代表「recovery在目前configuration/environment下無法安全自動繼續,必須停下來交給人」,failed代表「這次live request已經寫入stepFailed,runtime選擇不自動retry,避免無界重複計費」;timeout之類的錯誤本來就無法證明server端到底有沒有處理完,failed因此不等於「已經確定這次沒成功」。

核心 Recovery 決策

Durable prefix Planner結果
run已接受,沒有attempt start assistant attempt 1
attempt 1 open,沒有settled response config相同則attempt 2,否則blocked
attempt 2仍open attempts_exhausted
length response含tool calls 補truncated synthetic results,不執行
safe read已started、無result identity相同才replay
never tool已started、無result 補interrupted,不replay
terminal assistant已有、缺finish 只補operationFinished

Configuration Identity 防止錯誤恢復

Attempt會保存model、system prompt digest、ordered tool definitions、adapter identity、routing identity與 output options digest。Planner會比較目前configuration。

Tool intent另外綁定:

  • environmentIdentity:預設是cwd canonical realpath,也可由trusted host覆寫。
  • replayKey:識別exact implementation與effect semantics。
  • definitionDigest:識別model-facing schema。

任何不一致都回傳blocked,不執行effect,也不寫 failed terminal。恢復原本configuration或明確abort,才是合法下一步。

Synthetic Results 維護 Transcript

Recovery不是只改變internal state;它必須補出provider可接受的tool result:

invalidArguments → tool未執行
unknownTool      → tool未執行
truncated        → arguments可能不完整,未執行
aborted          → abort時尚未開始
interrupted      → intent已寫,effect狀態未知,不重播

這些內容是跨語言contract,不能localize或隨意改寫,因為它們同時進入model transcript與shared fixtures。

把所有 Durable Prefix 當測試對象

Repo中的shared corpus包含reducer與planner fixtures。每個語言都必須從相同JSONL bytes得到等價state,從真實prefix與current configuration得到相同plan。它不是測試helper,而是四語言parity的永久merge gate。

make test
# TypeScript / Go / Python / Rust 都會跑各自的 reducer、planner 與 production tests

親手驗證

Reducer 與 planner 都是 pure functions,因此最好的練習是直接對同一 durable prefix 執行離線測試。先跑 safe 與 never 兩條 recovery fixture:

cd typescript
node --import tsx --test --test-name-pattern="replay-safe-tool|interrupted-never-tool" test/session-reducer.test.ts

cat ../schemas/session/planner-fixtures/replay-safe-tool.expected.json
cat ../schemas/session/planner-fixtures/interrupted-never-tool.expected.json

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

比較兩份 plan:safe read 必須包含相同的 replayKey 與 environment identity;never tool 只能 materialize interrupted synthetic result,不能再次執行 effect。再將 fixture 的 current configuration 中任一 digest 改掉,重跑 planner 測試,預期結果應是 blocked,而且原 JSONL bytes 不得改變。

以上都是process crash後被動發現的狀態。如果使用者按下Esc是主動要求中斷,情況又不一樣——下一章處理這條路徑。