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的工作。