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

第二部|能力邊界 · 第 3 章

Tool 是 Agent 的手

設計小而深的 Tool 介面、runtime validation、錯誤語意與可安全擴充的 plugin bundle。

約 20 分鐘 3 / 8

LLM 只能提出 action;Tool 才真的讀檔、寫檔、查 Sentry 或呼叫 MCP。Tool 介面因此同時是能力 seam、測試表面與安全邊界。

小而深的 Tool 介面

export type Tool = {
    name: string;
    description: string;
    parameters: Record<string, unknown>;
    replay?: "safe" | "never";
    replayKey?: string;
    execute(args: ToolArgs, signal?: AbortSignal): Promise<string>;
};

Agent loop 不知道這是檔案、Sentry 還是 MCP。它只做四件事:把 schema 送給模型、依名稱找到 tool、執行、把結果寫回 transcript。這是 Strategy、Command 與 dependency injection 的最小組合,但 implementation 仍只是一般 object 與 array。

稍後你會再看到這個形狀:一個小而深的介面,把決策留給外面,自己只管一件事。

Schema 與 Execute 必須放在一起

一個 tool 的名稱、描述、參數 schema、runtime guard 與 implementation 應具有 locality。不要把 metadata 放一張表,再用 if (name === "read") 在另一處 dispatch。

const readTool: Tool = Object.freeze({
    name: "read",
    replay: "safe",
    replayKey: "builtin:read:v1",
    description: "Read a UTF-8 text file...",
    parameters: {
        type: "object",
        properties: {
            path: { type: "string" },
            offset: { type: "integer", minimum: 1 },
        },
        required: ["path"],
    },
    async execute(args, signal) {
        const path = requiredString(args.path, "path");
        return trustedReadExecute(path, { offset: args.offset, signal });
    },
});

trustedReadExecute 是概念名稱,代表 host 已套用實際的工作區規則、分頁與輸出上限;它不是 Node.js 標準函式。若直接把 readFile(path) 暴露給模型,模型就能要求讀取任何程序可存取的路徑,不能當成 production-safe Tool。

tiny-agent 的 file tools 會解析 canonical path,拒絕離開目前工作區的路徑與指向外部的 symlink。這是 application-level containment,不是 sandbox,也不是完整的授權系統;路徑檢查與實際開檔之間仍可能存在 TOCTOU race。多租戶或惡意工作負載必須由外層 execution capsule 提供 OS 層隔離。

JSON Schema 不等於 Runtime Validation

Schema 是給模型的介面說明,不是安全檢查。模型或 provider 仍可能回傳:

{"command": 42}
null
[]

Dispatch seam 先要求 arguments 為 non-null JSON object;每個 built-in tool 再驗證自己真正執行的欄位。失敗必須 throw,由 Agent 轉成 error result。不要回傳一般字串 Error: ... 來表示失敗,否則 monitoring 只能猜文字。

Plugin 是具名 Tool Bundle

export type Plugin = {
    name: string;
    tools: readonly Tool[];
};

這不是 dynamic package system。--plugin read,edit 只是從 trusted built-in catalog 選擇本次提供的 capabilities。未指定時啟用四個 built-ins;指定後變成 allowlist。

tiny-ts --plugin read "分析 README"
tiny-ts --plugin read,edit "閱讀並修改 README"

Repository 不能自行安裝 plugin,模型也不能要求下載任意 module。企業工具應由 trusted host 組裝,並透過 closure 持有 tenant scope 與 credential:

function createSentryPlugin(client, trustedScope): Plugin {
    return {
        name: "sentry",
        tools: [createGetIssueTool(client, trustedScope)],
    };
}

MCP 只是另一個 Tool Adapter

MCP tools/list → tiny-agent Tool[]
Tool.execute()  → MCP tools/call

MCP 不會建立第二個 agent loop,也不是 authorization 或 sandbox。Tiny-agent 只接受 trusted named catalog;URL、token、tenant 與 authorization 不能來自 model arguments。遠端名稱內部會編碼避免碰撞,TUI 才顯示成人類可讀的 mcp:sentry/get_issue

目前 TypeScript、Go、Python 會把 MCP 能力適配進 generic Tool seam;Rust 仍使用獨立 McpTool dispatch。這是已知 cross-language difference,不應在教材中假裝完全一致。

Replay Policy 是 Effect Semantics

只有 exact built-in read 宣告 safe replay:

replay = safe
replayKey = builtin:read:v1

同名 custom read 仍是 never。bash/write/edit/MCP 預設 never,因為 crash 後無法證明 effect 沒有發生。Replay 不是由模型選擇,也不能只靠 tool name 判斷。

親手驗證

先跑 injected Tool 與 filesystem containment 的離線測試,不需要 OpenRouter key:

npm --prefix typescript test -- \
  --test-name-pattern="injected tool|filesystem tools contain canonical paths"

go -C go test ./cmd/tiny-go \
  -run 'TestFilesystemToolsContainCanonicalPaths' -count=1

在 test 中追蹤四個步驟:model-facing schema、provider 產生的 call、runtime argument validation、tool result 回到 transcript。Symlink 案例只證明靜態 path containment;它不能取代面對惡意並行 filesystem mutation 的 OS sandbox。完整安全邊界見第 08 章。

Tool 決定能力邊界,但要讓 Agent 一次只看到需要的能力與規則——這是下一章 Context 的工作。