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

第一部|最小閉環 · 第 2 章

訊息、Transcript 與 Provider Adapter

理解無狀態模型如何藉由 transcript 延續工作,以及 provider wire format 為何不能直接進入核心。

約 18 分鐘2 / 8

模型是無狀態的。Agent 能延續工作的唯一原因,是 host 保存了可重播的 transcript,並把它重新送給模型。

四種角色

Role 用途 關鍵限制
system Agent 身分、規則、skills metadata 由 trusted host 組裝
user 目標、後續指令、compact summary 不可假裝成 tool result
assistant final text 或 tool calls 每個 tool call 必須有對應 result
tool 外部能力的觀察結果 tool_call_id 必須精確配對
{"role":"assistant","content":null,"tool_calls":[
  {"id":"call_1","type":"function","function":{"name":"read","arguments":"{\"path\":\"README.md\"}"}}
]}
{"role":"tool","tool_call_id":"call_1","content":"..."}

如果只保存 assistant tool call,卻在 crash 或取消時遺失 result,下一次 provider request 可能直接拒絕整段 transcript。這也是 tiny-agent 在 tool 中斷時,會替尚未開始或結果未知的 calls 補上固定 synthetic result 的原因。

Provider Adapter 必須正規化

OpenRouter wire response 可能包含 reasoningrefusalreasoning_details 等 provider-specific 欄位。它們不能原樣寫入 closed Session schema。TypeScript 曾以 choice.message as Message 假裝完成轉型,結果 strict reducer 以 INVALID_FACT 拒絕。

function normalizeAssistantMessage(value: unknown): Message {
    // 驗證 role、content 與每個 tool call
    // 只建立 canonical fields,不回傳 provider object
    return {
        role: "assistant",
        content,
        ...(toolCalls ? { tool_calls: toolCalls } : {}),
    };
}

Type assertion 只影響編譯器,不會移除 runtime 欄位。正確 seam 是:

provider wire shape
→ validate + normalize
→ canonical assistant message
→ Agent / Session

Stop Reason 也是狀態

不能只看「有沒有 tool calls」。Provider 的 finish_reason 至少要映射成:

  • stop:正常 final answer。
  • toolUse:tool calls 已完整產生,可進入 dispatch。
  • length:輸出被截斷;其中 tool arguments 不可執行。
  • provider error:保存 failure,不偽造 assistant completion。

length 伴隨 tool calls,Agent 會寫入固定的 truncated synthetic results;這保護 transcript,也避免執行不完整 JSON arguments。

Usage 是每次 Physical Request 的帳

type Usage = {
    input: number;
    output: number;
    cacheRead: number;
    cacheWrite: number;
};

OpenRouter 的 prompt tokens可能已包含cache tokens,因此 tiny-agent正規化為:

input = prompt_tokens - cacheRead - cacheWrite
cacheHitRate = cacheRead / (input + cacheRead + cacheWrite)

model.completed.cacheHitRate 表示單次 request,可找出某一輪 cache 崩潰;run/session 的 rate 則由累積 counters 重算。不要把「最後一輪 rate」和「整段累積 tokens」混在一起。

親手驗證

用既有的offline regression test重播真實provider wire shape;它會加入reasoningrefusal與額外tool-call欄位,然後確認Session只留下canonical message:

npm --prefix typescript test -- \
  --test-name-pattern="normalizes provider-only"

npm --prefix typescript test -- \
  --test-name-pattern="rejects malformed provider assistant"

第一組應成功完成且可重新開啟Session;第二組應保存一次usage與failed outcome,而不是讓INVALID_FACT洩漏到CLI。接著閱讀test中的mock response,逐欄標出哪些屬於provider wire、哪些能進入canonical transcript。

合法transcript保證了資料「長什麼樣」,但沒保證「誰能把它變成真正的檔案讀寫或指令執行」——這是下一章的問題。