Coding Agent Harness 拆解 · 工具系统:从工具定义到执行管线与并发(五)
本文是《Coding Agent Harness 拆解》系列的一篇。系列逐个拆开一个真实在用的终端 AI 编程 agent(codebot),以及它底下的执行内核 agentcore。上一层《架构总览》立了两条边界,其中一条是副作用边界:模型自己不能读写文件、不能跑命令,它只能提出一个 tool call,副作用发生在本地工具里。本篇就站在这条边界上,把三件事讲透:一个工具到底是什么、内核如何用极小接口承载可叠加的能力,以及一次工具调用从模型吐出到结果回炉,中间那条有序管线怎么走、多个调用又怎么并发调度。至于那道审批门背后"批不批"的决策逻辑,本篇只讲它在管线里的位置,完整引擎让给《权限与审批门》。
一个 Tool 的最小契约
在 agentcore 里,一个工具就是实现了四个方法的对象。看 agentcore/tool.go:
// Tool defines the minimal tool interface.
type Tool interface {
Name() string // model calls the tool by this name
Description() string // usage doc shown to the model
Schema() map[string]any // JSON Schema of the arguments
Execute(ctx, args) (json.RawMessage, error) // where the work happens
}
就这么四个。Name / Description / Schema 三者合起来是给模型的广告:模型读到这份清单,才知道有哪些工具、参数长什么形状;Execute 才是副作用发生的地方。schema 一物两用:既让模型生成合法参数,也用于运行时校验。
agentcore 自带一组开箱即用的工具,覆盖日常编程的读、搜、写、跑:
| 工具 | 文件 | 能力 |
| --- | --- | --- |
| read | agentcore/tools/read.go | 读文件(含图片 / 目录) |
| glob | agentcore/tools/glob.go | 按 glob 找文件 |
| grep | agentcore/tools/grep.go | 内容检索(ripgrep 语义) |
| ls | agentcore/tools/ls.go | 列目录 |
| edit | agentcore/tools/edit.go | 精确字符串替换 |
| write | agentcore/tools/write.go | 写 / 覆盖整文件 |
| bash | agentcore/tools/bash.go | 执行 shell 命令 |
前四个只读,edit / write 会改盘,bash 什么都能干:它们在审批门与并发调度里的待遇天差地别,这是后面要展开的。
核心接口 + 可选标记接口
四个方法只够描述"怎么调用、怎么执行",但循环还想知道更多:这个工具是只读的吗?能不能并发跑?要不要先给用户看个 diff 预览?UI 上该显示什么忙碌文案?
agentcore 没有把这些统统塞进 Tool 接口,那样每写一个新工具都得实现十几个用不上的方法。它用的是 Go 惯用法:一个极小的核心接口,加上一组可选的标记接口(marker interface)。工具想要哪个能力,就多实现一个方法;循环在需要时用类型断言探一下"你实现了吗",没实现就走默认行为。
agentcore/tool.go 里定义的这组可选接口:
| 标记接口 | 方法 | 让工具能够…… |
| --- | --- | --- |
| ToolLabeler | Label() | 给出人类可读的显示名("Edit File") |
| Validator | Validate(ctx, args) | 执行前短路:结构合法但语义错误时直接返回 |
| Previewer | Preview(ctx, args) | 执行前算出预览(如 diff),供 UI 与审批门展示 |
| ReadOnlyTool | ReadOnly(args) | 声明只读(可参与并发) |
| ConcurrencySafeTool | ConcurrencySafe(args) | 声明能否并发执行(优先级高于 ReadOnly) |
| ContentTool | ExecuteContent(ctx, args) | 返回富内容(图片等多块结果) |
| ActivityDescriber | ActivityDescription(args) | 给 UI 一句忙碌描述("Running: go test") |
| InterruptBehaviorTool | InterruptBehavior(args) | 声明被打断时该取消还是跑完 |
注意 ReadOnly / ConcurrencySafe / InterruptBehavior 都吃 args,因为同一个工具的"脾气"可能随输入而变。bash 就是典型:ReadOnly 恒返回 false,因为一条命令是 ls 还是 rm -rf 内核无从静态判断,只能一律当作有副作用来对待。
::: tip 为什么要拆成可选接口
如果把这些方法都写进 Tool,每个新工具都得实现一堆返回默认值的样板。拆成标记接口后,Tool 保持四个方法的极简表面,能力按需叠加:内核对每个可选接口做一次 tool.(SomeInterface) 断言,命中就用、不命中就走默认。这就是"核心小、能力可叠加",也是 agentcore 到处在用的扩展手法。
:::
还有一个特殊成员:PermissionMetadata()。它不在 agentcore/tool.go 里,内核刻意不认识 permission 概念。它是 harness 侧的约定:codebot 在 internal/approval/gate.go 里用一个匿名接口去断言工具有没有这个方法,从而拿到工具自报的能力(read / write / exec……)。内核对此一无所知,这条边界卡得很干净。
先读后写:被 Validator 守住的不变量
Validator 值得单独说,因为它守着一条对写类工具至关重要的不变量:先读后写。
想象没有这条约束会怎样:模型凭想象中的文件内容直接 write 或 edit,很可能覆盖掉它根本没看过的代码,或基于一个早已被用户改动过的旧版本去打补丁,静默地毁坏工作区。
agentcore 用 agentcore/tools/file_state.go 里的 FileReadState 解决这个问题。它是一份会话级的登记表:read 每成功读一个文件,就记下一枚 FileReadStamp(读取时刻 + 文件 mtime + 内容令牌 + 是否只读了片段)。write 和 edit 则实现 Validator,在动手前查这张表:
flowchart TB
W["write / edit called"] --> V{"Validate: check FileReadState"}
V -->|"never read / partial read"| R1["deny: read first<br/>(ErrorCode 2)"]
V -->|"read, but file changed since"| R2["deny: stale, re-read<br/>(ErrorCode 3)"]
V -->|"read and unchanged"| OK["pass → Preview / Execute"]
R1 -.self-correct.-> W
R2 -.self-correct.-> W
关键在于校验失败不会崩溃,只是给模型的一条反馈。ValidationResult{OK:false, Message:...} 会作为一个 IsError=true 的普通 tool_result 回给模型,意思是"你输入结构没错,但语义不对,先去读文件再来"。模型读到消息,自己补一个 read 再重试即可,全程不打扰用户。三种内置工具(read / write / edit)共享同一个 FileReadState 实例,这条链才闭合。
::: tip Validator 的三条铁律
agentcore/tool.go 对 Validator 立了规矩:不得询问用户、不得改动持久状态、要足够廉价(只做只读查表 / stat)。它是执行前的一道"便宜的自查",不是审批。审批是管线更下游那道更重的门。
:::
一次工具调用的有序管线
概念铺完,现在把真实细节铺开。主角是内核里一个函数:agentcore/loop.go 的 executeSingleToolCall。它是"一次工具调用要走完的有序管线":从模型吐出一个 ToolCall,到一条结果消息落回上下文,中间每一步都在这里。先把它的完整流程画成一张竖流程图,这是本篇的地基:
flowchart TB
A{"ctx cancelled?"} -->|"yes"| X1["emit Start+End<br/>cancelled · no ToolName · not counted"]
A -->|"no"| B{"breaker<br/>failCount ≥ MaxToolErrors?"}
B -->|"yes"| X2["emit Start+End<br/>disabled after N errors · not counted"]
B -->|"no"| C["emit EventToolExecStart"]
C --> D{"findTool hit?"}
D -->|"no"| X3["tool not found · counted(+1)"]
D -->|"yes"| E["validateToolArgs<br/>schema check + coerceArg"]
E -->|"fail"| X4["schema error · counted(+1)"]
E -->|"pass (Args may be rewritten)"| F{"Validator<br/>semantic check"}
F -->|"OK=false"| X5["semantic error · counted(+1)"]
F -->|"OK / not impl"| G["Previewer → emit preview<br/>(EventToolExecUpdate)"]
G --> H{"ToolGate"}
H -->|"deny / error"| X6["Reason as error result · not counted"]
H -->|"allow / no gate"| I["WithToolProgress injects progressCtx"]
I --> J["Execute / ExecuteContent<br/>(through middleware chain)"]
J --> K["emit EventToolExecEnd<br/>result.ToolName = call.Name · counted"]
X3 --> K
X4 --> K
先记下三个观察点,后面逐一兑现:
- 管线是"早退式"的。 任何一格失败都不会抛异常,只是就地生成一条
IsError=true 的 ToolResult 返回,模型能读懂并自纠,循环不中断。这和 Validator、ToolGate 一脉相承。
- 每条 tool call 严格产生一对
EventToolExecStart / EventToolExecEnd。 连两道前置守卫短路时,也各自补齐一对(带同一个 ToolID)。消费者(TUI)因此永远能靠 ToolID 把"头 + 结果"配平,不会有孤儿事件。
- 不是每次失败都记账。 只有
result.ToolName 非空的结局才会计入断路器,这决定了"谁能把工具打残",是下文一整节的主题。
两道前置守卫:取消与断路器
emit EventToolExecStart 之前,管线先过两道"连活都不开始干"的检查。
取消检查。 进函数第一件事就是 ctx.Err()。如果这次 run 已被用户 Abort,工具一步都不该跑,直接补一对 Start/End,结果是 "Tool execution cancelled.":
// Fast exit: context already cancelled — don't start any tool work.
if ctx.Err() != nil {
content, _ := json.Marshal("Tool execution cancelled.")
result := ToolResult{ToolCallID: call.ID, Content: content, IsError: true}
// ... emit Start + End, return (ToolName empty → not counted)
}
断路器。 第二道是"连续失败熔断"。内核给每个工具名维护一个连续失败计数 toolErrors[name];executeSingleToolCall 收到的 failCount 就是这次调用发起时的快照。一旦它撞上阈值,工具被暂时禁用:
// Circuit breaker: skip if tool exceeded consecutive failure threshold.
if config.MaxToolErrors > 0 && failCount >= config.MaxToolErrors {
// emit Start, then End with "disabled after N consecutive errors"
// return (ToolName empty → not counted)
}
MaxToolErrors 默认 3。它防的是一种典型死循环:模型对同一个工具连发三次都失败,就把这工具短暂拉黑、逼它换条路,不让它继续撞墙。注意计数是连续的:只要成功一次就清零(见记账规则一节)。
Start 之后:找工具、schema 校验与参数纠偏
过了两道守卫,管线正式 emit EventToolExecStart,然后 findTool 按名字线性查表。查不到就是 "tool %q not found",模型幻觉出一个不存在的工具时走这里。
命中后进入 schema 校验 validateToolArgs。它不只做"合法/非法"二值判断,还会一次收集所有问题(缺哪个 required、哪个字段类型不对),让弱模型在一个回合里全改对。最精巧的是参数纠偏 coerceArg:弱模型和某些 provider 有个顽固毛病,把数字、数组序列化成字符串再塞进参数("119",本应是 119)。一律判死会让模型反复重发同一个错调用、卡死循环。coerceArg 专治这类"无歧义可救"的错位:
func coerceArg(val any, expected string) (any, bool) {
s, ok := val.(string)
if !ok {
return nil, false
}
t := strings.TrimSpace(s)
switch expected {
case "integer":
if n, err := strconv.ParseInt(t, 10, 64); err == nil {
return float64(n), true // "119" → 119
}
case "array":
var a []any
if json.Unmarshal([]byte(t), &a) == nil {
return a, true // `["a","b"]` → []any
}
// number / object likewise
}
return nil, false // ambiguous → leave it, report a validation error
}
被纠偏的调用,校验会把修正后的参数重新 marshal 返回,管线随即改写 call.Args:下游的 Validator 和工具本身看到的都是干净、well-typed 的输入;而原始(错位的)参数仍留在先前那条 EventToolExecStart 里,纠偏过程对 UI 依然可见。彻底无法纠偏才返回校验错误,作为一条 IsError 结果喂回模型。校验通过后,就轮到上一节讲过的 Validator 做语义自查(在 schema 校验之后、Preview 之前)。
Preview 与 ToolGate:预览先算,喂给门
Validator 放行后,管线依次做两件对审批 UI 至关重要的事。
Preview(预览)。 若工具实现了 Previewer,先把预览(如 diff)算出来,emit 成一条 EventToolExecUpdate。注意时序:预览在参数校验之后才算,所以审批 UI 拿到的一定是纠偏后、语义已通过的参数对应的 diff。
ToolGate(审批门)。 紧接着就是那道门。它在管线里的位置被 agentcore/tool.go 的文档钉死:参数校验之后、Preview 之后、执行之前,每个 tool call 调用一次。关键在于门收到的 GateRequest 把工具实例和刚算好的预览一起递了过去:
if config.ToolGate != nil {
gateReq := GateRequest{
Tool: tool, // gate can typeswitch any marker interface (e.g. PermissionMetadata)
Call: call,
ToolLabel: label,
Preview: preview, // the diff from the previous step, for the approval prompt
}
decision, err := config.ToolGate(ctx, gateReq)
if err != nil {
decision = &GateDecision{Allowed: false, Reason: err.Error()}
}
if decision != nil && !decision.Allowed {
// return Reason as an IsError result, emit End, do NOT execute
}
}
被拒不算异常,把 Reason 当错误结果喂回模型,和前面所有早退一样。ToolGate 返回 error 一律当作"拒绝"。门是 nil 就等于全放行:内核不做任何权限推理,装不装门、怎么判,全是 harness 的事。codebot 用 Engine.AsToolGate()(internal/approval/gate.go)把审批引擎接成这个函数。至于引擎内部怎么决策、四种模式怎么分档、危险路径怎么强制询问,那是《权限与审批门》的全部内容,本篇到此为止。
注入进度、穿过 middleware、执行
放行后,副作用即将发生,管线先注入进度回调:WithToolProgress(ctx, fn) 把一个闭包塞进 context,工具在长操作中随时 ReportToolProgress,闭包就把它翻译成一条 EventToolExecUpdate,子 agent 工具就是靠这条通道,把内层的流式增量一路冒泡到外层 TUI。
得到 progressCtx 后开始执行:普通工具走 tool.Execute(配了 Middlewares 就先用 buildMiddlewareChain 套成洋葱,最外层先执行、最内层才是真工具,日志 / 审计 / 短路挂在这层);ContentTool(返回图片等富内容)走 executeContentTool,同样穿过 middleware 链。执行完(成功或报错),管线 emit EventToolExecEnd,并把 result.ToolName = call.Name 补上,这一步决定它是否计入断路器。
谁能把工具打残:ToolName 记账规则
"计不计数"是整条管线一个容易忽略但很关键的设计。计数发生在上层调度器的回调里,result.ToolName 空不空就是记账开关:
if result.ToolName != "" {
if result.IsError {
e.toolErrors[result.ToolName]++ // consecutive failure +1
} else {
delete(e.toolErrors, result.ToolName) // success → reset
}
}
而 ToolName 何时非空,取决于结局是"提前返回"还是"落到函数底部"。函数底部那句 result.ToolName = call.Name 会给所有穿到底的结局补上名字。把各种结局摊开看:
| 结局 | 是否提前返回 | result.ToolName | 对断路器 |
| --- | --- | --- | --- |
| ctx 取消早退 | 是 | 空 | 不计 |
| 断路器已熔断 | 是 | 空 | 不计 |
| ToolGate 拒绝 | 是 | 空 | 不计 |
| tool not found | 否,落到底部 | call.Name | +1 |
| schema 校验失败 | 否,落到底部 | call.Name | +1 |
| Validator 失败 | 是(结果已带 ToolName) | call.Name | +1 |
| Execute 报错 | 否,落到底部 | call.Name | +1 |
| Execute 成功 | 否,落到底部 | call.Name | 清零 |
::: tip 审批拒绝为什么不算"工具的错"
ToolGate 拒绝走的是提前返回、且结果里不带 ToolName,不该往断路器账上记。理由:拒绝表示“策略不允许”,不是“这个工具坏了”。若让它计数,会导致一个荒唐结果:连续三次被审批门否决,就把工具永久拉黑。反过来,tool not found 和 schema 校验失败虽然也没在中途 return,却会穿到函数底部被补名并计数:模型连喊三次不存在的工具名、连发三次结构错乱的参数,同样会被熔断掉,逼它停手。区别就在"带不带 ToolName"这一念之间。
:::
turnToolExecutor:一轮里的并发调度
单条管线讲完,上一层是 agentcore/turn_tool_executor.go。一次助手回合可能一口气发好几个 tool call,谁能并发、谁必须独占,全由它裁决。
能不能并发,看工具自报。 每个 call 进来时判一次 safe,优先级写死在 isToolConcurrencySafe:ConcurrencySafeTool > ReadOnlyTool > false。对照 agentcore/tools/:read / glob / grep / ls 全是并发安全,可以并排跑;write / edit / bash 全是 false,写盘会互相踩,bash 内核根本无法静态判断一条命令是 ls 还是 rm -rf,只能一律当独占。
调度规则:安全的并发、不安全的独占。 核心就是 canStartLocked:
func (e *turnToolExecutor) canStartLocked(safe bool) bool {
if e.running == 0 {
return true // idle: anything can start
}
if !safe || e.runningUnsafe {
return false // unsafe must run alone; an unsafe in flight blocks all
}
return e.running < e.maxConcurrency // safe tools fan out up to MaxToolConcurrency
}
配合按队列顺序放行:碰到不安全的工具就停下,它只能在"空场"独自启动,且一旦在跑就用 runningUnsafe 挡住后来者。于是同一轮里,三个 read 会并排飞,而一个 write 一定等前面清空、独自执行完再让位。MaxToolConcurrency ≤ 1 时退化为纯串行。
被打断时,各凭脾气。 工具执行到一半,用户插话(steering)来了怎么办?调度器会停止启动新工具,并只取消声明了 InterruptBehaviorCancel 的在飞工具,Block(默认)的则跑完。剩下还没开跑的,收尾时统一标记为 skip("Skipped due to queued user message.",或 ctx 整体取消时的 "Tool execution cancelled.")。这样"中断"既不粗暴腰斩正在写盘的工具,也不会让排队的工具偷偷溜出去。
结果如何变回一条消息
管线的终点是把 ToolResult 变成上下文里的一条 RoleTool 消息,这样下一轮 LLM 调用才看得到工具干了什么。转换在 toolResultToMessage:富内容(图片等)走多块路径,把 ContentBlocks 装进 Content;纯文本结果走 ToolResultMsg。两条路都会带上 tool_call_id,它让 provider 能把这条结果和先前那个 tool_use 配对。回到 runLoop:拿到本轮所有工具结果后,逐条转成消息、emit 一对 EventMessageStart / EventMessageEnd、落进上下文。至此,一条 tool call 从模型吐出到结果回炉,闭环。这些结果最后打包进 EventModelResponse.ToolResults 一起交给上层,那就是 Agent 主循环接手的地方。
三问回顾
- 这个机制解决了什么痛点? 让工具能力可声明、可叠加(核心接口 + 标记接口),让副作用有边界、可审计、可自纠(Validator 守语义、ToolGate 守授权、断路器掐死循环);把"一次工具调用"拆成一条有序、可早退、全程 emit 事件的管线,每一步失败都变成模型能自纠的反馈,而非崩溃。
- 如果没有它,会在哪里崩? 没有
Validator,模型会基于臆想的旧内容覆盖文件;没有 ToolGate,模型一句话就能 rm -rf 且无人过问;并发调度若不区分安全/独占,两个 write 会同时踩同一个文件;记账若不看 ToolName,一次审批拒绝就能把工具误伤拉黑。
- 它和前后怎么接上? 向上,工具清单以 schema 形式进入模型请求;管线由
turnToolExecutor 调度、结果经 EventModelResponse 交回主循环;向内,ToolGate 这道门只是个 nil 可插拔钩子,策略由 harness 通过 AsToolGate 注入。
延伸阅读