s02: Tool Dispatch — 一个注册表就是工具边界
“模型只提出调用意图,Harness 决定它能否以及如何成为一次本地执行。”
Harness 层:工具分发 — 从模型协议跨入真实 Python 代码的边界。
代码架构图
flowchart LR
A["provider tool_use"] --> B["ToolCall"]
B --> C["ToolRegistry"]
C --> D["名称查找"]
D --> E["Schema 参数校验"]
E --> F["Python handler"]
F --> G["ToolDispatchResult"]
D -. "unknown_tool" .-> G
E -. "invalid_arguments" .-> G
F -. "execution_error" .-> G
G --> H["provider tool_result"]
C --- S["ToolSpec = schema + handler + policy"]学习前置知识
- s01 已经把一轮模型响应归一成
AgentTurn,并用工具内容块决定循环是否继续。 - 工具定义存在两个方向:向模型描述可用能力,以及在本地执行 Python callable。
- 模型产生的是不可信协议输入;即使 schema 已发给模型,Harness 仍要在执行前验证。
- 并发是执行策略,不是所有工具调用的默认属性。
本章抓住的 WorkBuddy-style 机制
- 用一个
ToolRegistry管理注册、模型 schema、名称查找、参数校验和执行。 - 用
ToolCall隔离 provider block,用ToolDispatchResult隔离本地执行结果。 - 未知工具、错误参数和 handler 异常都转换成显式错误结果,不让 Agent Loop 崩溃。
- 只有标记为
concurrent_safe的只读工具批次才能并发;写入和未知调用保守串行。 - 文件工具继续使用
safe_path()限制工作区边界。 - bash handler 为子进程重新构造最小环境,避免把 provider 凭据随工具调用直接传出 Harness。
常见误区
- 分别维护
TOOLS和TOOL_HANDLERS:改了 schema 忘记改 handler,或者反过来。 - 认为模型“看过 schema”就不会传错参数:模型输出仍然可能缺字段、类型错误或包含多余字段。
- 直接执行
handler(**block.input):协议错误和 Python 异常会穿透到循环层。 - 把所有同轮工具都并发:
write_file后紧跟read_file时会产生竞态。 - 把安全策略塞进 Agent Loop:循环会逐渐知道每种工具的细节,新增工具就必须改循环。
- 认为设置
cwd就完成了隔离:工作目录不会自动移除父进程的 API key、SSH agent socket 或其他环境变量。
问题
s01 只有一个 bash 工具,所以循环可以直接写:
output = run_bash(call.arguments["command"])当工具扩展到 read、write、edit 和 glob 后,硬编码分支会同时承担七件事:
- 告诉模型有哪些工具;
- 根据名字找到 Python 函数;
- 判断参数是否完整;
- 把参数传给 handler;
- 决定能否并发;
- 把成功或失败编码回 provider 协议。
- 决定工具子进程可以继承哪些宿主环境变量。
如果这些信息散落在 schema 列表、dispatch 字典和循环分支中,它们迟早会不一致。真正需要新增的不是更多 if/elif,而是一条明确的 dispatch boundary。
解决方案
每个工具只注册一次:
ToolSpec(
name="read_file",
description="Read UTF-8 file contents from the workspace.",
input_schema=object_schema(
{
"path": {"type": "string"},
"limit": {"type": "integer", "minimum": 1},
},
["path"],
),
handler=run_read,
concurrent_safe=True,
)这一个对象同时回答四个问题:
| 字段 | 面向谁 | 作用 |
|---|---|---|
name | 模型协议和 Harness | 稳定查找键 |
description + input_schema | 模型 | 描述调用方式 |
handler | 本地执行器 | 实际 Python 实现 |
concurrent_safe | Harness | 批量执行策略 |
ToolRegistry 再从这些 ToolSpec 派生模型工具目录,并使用同一批对象完成运行时查找。项目中不再存在一份独立的 TOOLS 列表和另一份 TOOL_HANDLERS 字典。
工作原理
1. 归一化 provider 输入
模型 SDK 的 block 是供应商对象。dispatch 层先只提取需要的字段:
@dataclass(frozen=True)
class ToolCall:
tool_use_id: str
name: str
arguments: objectarguments 故意只声明为不可信 object,因为“它是不是可用的参数映射”本身就是 Harness 要验证的协议条件。校验通过后,dispatch 才把它收窄为 Mapping 并展开给 handler;这里不提前假装输入可信。
2. 从注册表生成模型 schema
def model_schemas(self):
return [spec.model_schema() for spec in self._specs.values()]model_schema() 返回深拷贝。调用方即使修改发送给 SDK 的字典,也不会污染注册表中的执行契约。
3. 先查找,再校验,最后执行
def dispatch(self, call):
spec = self.get(call.name)
if spec is None:
return unknown_tool_result(call)
error = validate(spec.input_schema, call.arguments)
if error:
return invalid_arguments_result(call, error)
try:
return success_result(call, spec.handler(**dict(call.arguments)))
except Exception as exc:
return execution_error_result(call, exc)顺序很重要:
- 查找失败说明模型调用了未注册能力;
- 校验失败说明协议参数无法安全绑定;
- 执行失败说明已经进入 handler,但本地 I/O 或实现报错。
三者对排查问题的意义不同,所以使用稳定的 ToolErrorCode 区分,而不是全部返回模糊的 Error: ...。
4. 参数校验属于 Harness
本章实现教学所需的 JSON Schema 子集:
- 输入必须是 object;
- required 字段必须存在;
additionalProperties: false拒绝拼错或多余参数;- 检查 string、integer、number、boolean、object、array;
- 支持本章
limit使用的minimum。
这不是要重写完整 JSON Schema 标准,而是展示关键职责:schema 不只用于提示模型,也用于执行前验证。生产实现可以在相同边界替换成成熟 validator,而 Agent Loop 无需改变。
5. 错误也是正常协议结果
@dataclass(frozen=True)
class ToolDispatchResult:
call: ToolCall
content: str
error_code: ToolErrorCode | None = None失败时编码为:
{
"type": "tool_result",
"tool_use_id": "call_123",
"content": "Error [invalid_arguments]: ...",
"is_error": True,
}Agent Loop 因此可以把失败反馈给模型,让模型修正参数或选择其他工具,而不是因为一个 KeyError 或 TypeError 整体退出。
6. 并发由工具元数据决定
当前五个工具的策略是:
| 工具 | concurrent_safe | 原因 |
|---|---|---|
read_file | 是 | 只读文件 |
glob | 是 | 只读目录匹配 |
bash | 否 | 命令语义未知 |
write_file | 否 | 修改文件 |
edit_file | 否 | 读取后再写入 |
只有整个批次都明确安全时才进入线程池;只要包含一个未知、bash 或写工具,就按模型给出的顺序串行执行。并发结果仍按原调用顺序返回,保证 tool_use_id 对齐。
7. 文件边界仍由 handler 保证
def safe_path(path_text: str) -> Path:
path = (WORKDIR / path_text).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"path escapes workspace: {path_text}")
return path注册表负责通用协议边界,handler 仍负责自己的领域约束。路径越界异常会被 dispatch 捕获并转换成 execution_error。s04 会把更完整的 allow / ask / deny 决策提升到权限层;s23 再讲系统沙盒。
8. bash 子进程不继承 provider 凭据
真实模型客户端通常需要从宿主环境读取 API key,但执行工具的 shell 不应自动得到同一批凭据。run_bash() 因此显式传入重新构造的环境:
completed = subprocess.run(
command,
cwd=WORKDIR,
env=build_subprocess_env(WORKDIR),
shell=True,
# ...
)build_subprocess_env() 只保留命令查找、语言区域和临时文件所需的变量;HOME 与 PWD 都指向 WORKDIR。未列入允许列表的 OPENAI_API_KEY、ANTHROPIC_API_KEY、SSH_AUTH_SOCK 和任意宿主变量不会进入子进程。
这是对子进程直接继承环境的默认拒绝,不是完整沙盒。bash 仍可能读取工作区中的 .env、访问当前系统用户有权读取的其他文件或连接网络;这些能力需要 s04 的权限判断、s23 讨论的系统隔离和生产环境的网络策略共同限制。
Agent Loop 为什么几乎不变
s02 继承 s01 的 AgentTurn、停止原因和最大轮次,只把硬编码执行替换成两句:
tools = registry.model_schemas()
dispatch_results = registry.dispatch_many(list(turn.tool_calls))随后统一编码:
messages.append({
"role": "user",
"content": [result.to_protocol_block() for result in dispatch_results],
})这正是边界设计的价值:新增一个工具时,循环不需要知道它的 schema、函数签名、错误类型或并发策略。
相对 s01 的变化
| 组件 | s01 | s02 |
|---|---|---|
| 工具数量 | 1 个 bash | 5 个注册工具 |
| 元数据来源 | 单工具常量和硬编码执行 | ToolSpec 单一真源 |
| 名称查找 | 无 | ToolRegistry.get() |
| 参数处理 | 直接取 command | schema 执行前校验 |
| 错误处理 | bash 文本错误 | 稳定错误码 + is_error |
| 并发 | 串行 | 仅全只读安全批次并发 |
| 路径安全 | bash 自身语义 | 文件 handler 使用 safe_path() |
| 子进程环境 | 继承 provider 进程环境 | 允许列表 + 工作区 HOME / PWD |
| 循环契约 | 显式 turn 与 stop reason | 保持不变,只委托 dispatch |
试一下
无需 API key 的章节演示:
python3 s02_tool_dispatch/code.py --demo连接真实 provider:
python3 s02_tool_dispatch/code.py可以尝试:
Read README.md and s01_agent_loop/README.md, then compare them观察两个只读调用能否并发。Create notes/demo.txt, then read it back观察写后读为何保守串行。Find all Python files under s02_tool_dispatch观察 glob 工具。- 在测试中构造缺少参数或未知工具,观察
is_error结果如何回到模型。
观察重点不是工具函数各自做什么,而是每次调用如何穿过同一条注册、校验、执行和返回路径。
动态工具池仍需要统一注册边界
生产 Harness 的工具可能来自内置工具、MCP 连接器或按需加载的技能:
assemble_tool_pool() = BUILTIN_TOOLS + MCP_TOOLS + SKILL_TOOLS来源可以动态变化,但进入 Agent Loop 前仍应归一成统一的工具描述:稳定名称、模型 schema、执行入口和策略元数据。S02 使用静态注册表,是为了把这条不随来源变化的边界讲清楚;S03 才讨论“先发现、再加载”。
完整生产实现会继续增加什么
- 使用成熟 JSON Schema validator,并输出可定位到字段路径的错误;
- 为工具名称加入命名空间,例如
mcp__server__tool; - 增加权限等级、超时、取消、输出上限和审计标签;
- 根据资源锁或读写集合判断能否并发,而不只使用布尔值;
- 对流式工具参数先组装完整 JSON,再进入同一个 dispatch boundary;
- 记录调用耗时、错误码和重试次数,供 trace 与 eval 使用。
- 将默认拒绝的环境过滤覆盖到 sidecar、MCP server 和其他外部进程,并与 OS 沙盒及网络出口控制组合使用。
这些机制都可以扩展 ToolSpec 或包裹 dispatch(),不需要把工具细节重新塞回 Agent Loop。
下一课
现在所有工具都能通过一个注册表描述和执行,但它们仍在启动时全部可见。工具数量继续增长时,完整 schema 会占用上下文,也会降低模型选对工具的概率。
s03 Deferred Loading → ToolSearch + DeferExecuteTool:先发现,再加载。