上一篇:Day 03 - LLM Chat Loop / 最小对话循环
下一篇:Day 05 - Local File Tools / 本地文件工具
English version: Day 04 - Tool Registry
文章介绍
Day 03 我们把最小 chat loop 接起来了:CLI 把用户任务组织成 system/user messages,交给 model provider,再拿回 assistant message。
但只有 chat loop 的 agent 仍然停留在“会说”的阶段。Coding agent 真正有用,是因为它能读文件、搜索代码、执行命令、生成 diff、申请审批、写 patch。所有这些能力,在 harness 里都要先被建模成 tools。
Day 04 不急着做真实文件工具,也不让模型自动决定调用哪个工具。今天只打好工具层的第一块地基:Tool Registry。
Tool Registry 要解决的是一个很朴素的问题:
- 有哪些工具?
- 每个工具需要什么输入?
- 模型应该看到什么 schema?
- runtime 收到工具名和参数后,如何找到 handler?
- 未知工具、错误参数、handler 失败时,错误应该在哪里出现?
今天要解决什么
今天要完成四个交付:
- 定义工具的公开 schema:
name、description、inputSchema。 - 定义 runtime 内部的 registered tool:公开 schema + handler。
- 实现
register()、list()、dispatch()。 - 加入第一个 mock tool:
echo。
同时给 CLI 增加一个手动工具调用入口:
npm run dev -- --tool echo --tool-input '{"text":"hello"}'
这条命令不是 agent loop。它只是证明 registry 可以从工具名分发到 handler,并返回结构化结果。
它在 Cursor/Codex 里对应哪一层
今天实现的是 tool definition layer 和最小 tool runtime。
在 Cursor、Codex、Claude Code 这类系统里,工具层通常同时服务两方:
Model side
-> sees tool name, description, JSON schema
Runtime side
-> owns handler, validation, dispatch, errors, result logging
模型不应该看到 handler。handler 可能会读文件、执行命令、写 patch,这些都是 runtime 里的真实能力。
runtime 也不能只靠自然语言描述工具。它需要稳定的 schema 和分发表,否则后面加入几十个工具时,所有调用都会变成脆弱的 if/else。
Day 04 的位置如下:
User
-> CLI surface
-> chat loop
-> tool registry
-> tool schema list
-> manual dispatch
-> echo handler
-> context report / transcript

上图是 Day 04 的工具注册表流程:模型侧只看到 ToolDefinition 和 schema list;runtime 侧保存 RegisteredTool,也就是公开定义加上真实 handler。手动 dispatch 会先按 name 找工具,再校验 input,最后执行 handler 并返回结构化 ToolResult。
Day 06 才会把这条线接进 agent loop,让模型决定是否调用工具。今天先让 runtime 具备“能调用”的能力。
设计思路
1. ToolDefinition 是给模型看的
共享包里已有 ToolDefinition:
export type ToolDefinition = {
name: string;
description: string;
inputSchema: JsonSchema;
};
这部分会进入 context report,也会在后续作为模型可用工具的描述。
对于 echo,公开定义是:
{
name: "echo",
description: "Return the provided input. Used as the first mock tool before real file tools exist.",
inputSchema: {
type: "object",
properties: {
text: { type: "string" },
},
required: ["text"],
},
}
这里故意用 JSON schema 风格,而不是 TypeScript 类型。原因是模型 API、MCP、插件系统和很多 agent runtime 都倾向于用 JSON schema 描述工具输入。
2. RegisteredTool 是 runtime 内部结构
runtime 需要比模型多知道一件事:handler。
export type ToolHandler = (input: unknown) => Promise<unknown> | unknown;
export type RegisteredTool = ToolDefinition & {
handler: ToolHandler;
};
handler 不会暴露给模型。list() 返回工具定义时会把 handler 去掉:
list(): ToolDefinition[] {
return [...this.tools.values()].map(({ handler: _handler, ...definition }) => definition);
}
这条边界很重要。模型只负责提出“我要调用什么工具,用什么参数”。runtime 才负责真的执行。
3. dispatch() 返回结构化结果
Day 04 的 dispatch() 不再只返回 handler output,而是返回 ToolResult:
export type ToolResult = {
name: string;
input: unknown;
output: unknown;
durationMs: number;
};
这样做是为了给后面的 transcript logging 留位置。一个工具调用至少应该知道:
- 调了哪个工具。
- 输入是什么。
- 输出是什么。
- 花了多久。
Day 07 会把这些结果进一步拆成 JSONL event。
4. 先做很小的 schema validation
完整 JSON Schema validator 可以很复杂。Day 04 没有引入额外依赖,只做最小校验:
- object
- array
- string
- number
- boolean
- null
- required fields
- nested properties
这足够支撑 echo,也足够让 Day 05 的 list_files、read_file、search_text 继续扩展。
如果输入不符合 schema,runtime 会抛出明确错误,例如:
[mini-harness] Error: Invalid input for echo: missing required property "text"
[mini-harness] Error: Invalid input for echo.text: expected string
实现步骤
1. 扩展 shared types
文件:packages/shared/src/types.ts
Day 04 扩展了 JsonSchema 的类型枚举,并新增 ToolCall / ToolResult:
export type ToolCall = {
name: string;
input: unknown;
};
export type ToolResult = {
name: string;
input: unknown;
output: unknown;
durationMs: number;
};
现在 shared package 里同时有:
- 模型消息类型:
ChatMessage - 工具定义类型:
ToolDefinition - 工具调用/结果类型:
ToolCall、ToolResult
2. 实现 ToolRegistry
文件:apps/mini-harness/src/tools/registry.ts
核心结构:
export class ToolRegistry {
private readonly tools = new Map<string, RegisteredTool>();
register(tool: RegisteredTool): void {
if (this.tools.has(tool.name)) {
throw new Error(`Tool already registered: ${tool.name}`);
}
this.tools.set(tool.name, tool);
}
list(): ToolDefinition[] {
return [...this.tools.values()].map(({ handler: _handler, ...definition }) => definition);
}
async dispatch(name: string, input: unknown): Promise<ToolResult> {
const tool = this.tools.get(name);
if (!tool) {
throw new Error(`Unknown tool: ${name}`);
}
validateInput(tool.inputSchema, input, name);
const startedAt = performance.now();
const output = await tool.handler(input);
return {
name,
input,
output,
durationMs: Math.round(performance.now() - startedAt),
};
}
}
这里有三个小决定:
- 重复注册同名工具直接报错。
list()永远不泄露 handler。dispatch()先校验输入,再执行 handler。
3. 注册 echo mock tool
createDefaultToolRegistry() 现在注册一个 echo 工具:
registry.register({
name: "echo",
description: "Return the provided input. Used as the first mock tool before real file tools exist.",
inputSchema: {
type: "object",
properties: {
text: { type: "string" },
},
required: ["text"],
},
handler: (input: unknown) => input,
});
这个工具没有业务价值,但非常适合验证工具链路。它能暴露所有关键路径:
- schema 是否能进入上下文
- JSON input 是否能解析
- registry 是否能找到工具
- handler 是否能执行
- result 是否能进入 transcript/context report
4. 给 CLI 增加手动工具调用入口
文件:apps/mini-harness/src/cli.ts
新增两个参数:
--tool echo
--tool-input '{"text":"hello"}'
命令:
npm run dev -- --tool echo --tool-input '{"text":"hello"}'
输出会包含:
[mini-harness] Tool result: {"name":"echo","input":{"text":"hello"},"output":{"text":"hello"},"durationMs":0}
如果同时打开 --context-report,工具结果也会作为 tool-results context part 加进去。因为这是手动工具 dispatch demo,不需要调用模型,所以 tool-only run 的 provider 会显示为 none。
Demo
完整 demo 记录见项目里的 demos/day-04/README.md。
手动调用 echo:
npm run dev -- --tool echo --tool-input '{"text":"hello"}'
带 context report:
npm run dev -- --context-report --tool echo --tool-input '{"text":"hello"}'
错误输入示例:
npm run dev -- --tool echo --tool-input '{"text":123}'
会得到类似错误:
[mini-harness] Error: Invalid input for echo.text: expected string
当前系统能力变化
Day 04 之后,系统多了这些能力:
- CLI run summary 会继续把工具定义放进上下文。
- runtime 有一个可扩展的
ToolRegistry。 - 工具注册和工具公开定义分离。
- 手动 CLI 参数可以触发工具 dispatch。
- 工具输入会经过最小 schema validation。
- 工具执行结果有结构化
ToolResult。 - transcript 可以包含工具执行结果。
还没有实现的能力:
- 模型自动选择工具。
- 多步 agent loop。
- 工具调用失败的结构化 event log。
- 真实文件工具。
- approval/sandbox。
遇到的问题
1. 不要把 Day 04 做成假的 agent loop
很容易在今天就写一个“如果用户说 echo,就调用 echo”的自然语言匹配。但这会把 harness 带到错误方向。
真正的 agent loop 应该由模型输出工具调用意图,runtime 再执行工具。Day 04 还没到这一步,所以 CLI 只提供显式 --tool 参数作为 demo 入口。
2. JSON Schema 要小心控制范围
完整 JSON Schema 能力很多:oneOf、enum、additionalProperties、minimum、format 等。今天不需要这些。
过早实现完整 schema validator 会把文章重点从 agent harness 拉到 schema 细节上。现在只做 Day 04 需要的最小子集。
3. 工具结果要从一开始就结构化
如果 dispatch() 只返回 handler output,后面做 transcript 时还要重新包装调用信息。今天直接返回 ToolResult,后续扩展更自然。
明天做什么
Day 05 会实现本地文件工具:
list_filesread_filesearch_text
到那时,echo 会退回到测试/示例位置,registry 会开始承载真正有用的 coding agent 工具。
Comments