写在前面:系列是为了帮助大家更好的去理解Agent Harness基础设施,并不是想重复造轮子,真实开发建议选择一个成熟的SDK或Harness框架,才是最合适的选择~
1. 模型只会说,真正干活的是工具
上一篇我用一个约一百行的最小 loop 跑通了「会思考的循环」:模型想、模型说,仅此而已。这个循环是空壳------它能思考,但没有手去碰文件、没有眼睛去看目录。现在我们给 loop 装手和眼睛。
2. 工具定义:ToolDefinition 的四个字段
先给代码。下面两个是真实工具,read_file 读文件、list_dir 列目录:
js
const read_file = {
name: 'read_file',
description: '读取指定路径的文本文件内容。当用户想查看文件内容时调用。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: '要读取的文件路径' },
},
required: ['path'],
},
tier: 'read-only', // 安全等级:015 的审批会用它(先埋个字段)
async execute(args) {
const fs = await import('node:fs/promises')
return await fs.readFile(args.path, 'utf8')
},
}
const list_dir = {
name: 'list_dir',
description: '列出指定目录下的条目名。当用户想查看目录内容时调用。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: '要列出的目录路径' },
},
required: ['path'],
},
tier: 'read-only',
async execute(args) {
const fs = await import('node:fs/promises')
const entries = await fs.readdir(args.path, { withFileTypes: true })
return entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).join('\n')
},
}
一个 ToolDefinition 就四个关键字段,模型和你的分工各占一半:
name:工具名。模型在回复里用这个名字发起调用;同一注册表里不能重名。description:模型看到的说明,决定模型「何时」选它。它写得好不好,直接决定工具被用的频率------把「何时用」讲清楚的 description,比含糊的强一个数量级。parameters:入参的 JSON Schema。发给模型做参数声明,同时被流水线 pre 阶段用来校验。execute:真正干活的函数。模型永远不直接碰它------这是这套设计的命门,下面展开。
tier: 'read-only' 是给【安全边界】预留的字段,本篇先不展开介绍。
第二、三、四个字段合起来,就是我 011 里讲透过的 dsh defineTool 心智:schema + render 两层 。dsh 的 defineTool(schema.ts:545)有五个字段,比我们多一个 output,而 output 又拆 schema + render 两层------schema 声明 execute 返回的「规范值」,render 把规范值投影成模型可见的内容。
极简版没有 output 字段,但两层心智没丢,只是挪了位置:
- schema 层 =
parameters:发给模型做声明,pre 阶段校验 - render 层 = post 阶段的结果规范化(第 4 节,非字符串转 JSON)
dsh 里 output 的 validate → freeze → render → snapshot 是完整实现(index.ts:1793)。我们先把口子立起来,细节后面补。
3. 注册表:模型怎么看到工具
工具定义好了,接下来是注册表------「模型能看到什么」的控制器。
js
class ToolRegistry {
constructor() {
this.tools = new Map() // name -> ToolDefinition
}
register(tool) {
this.tools.set(tool.name, tool)
return tool
}
// 模型可见视图:把 execute 藏起来,只暴露 schema
schemas() {
return [...this.tools.values()].map((t) => ({
name: t.name,
description: t.description,
parameters: t.parameters,
}))
}
get(name) {
return this.tools.get(name)
}
}
注册表干两件事,对应同一个 Map 的两个视图:
- 执行视图 :
get(name)从name → ToolDefinition映射里取出 handler,流水线用它调execute。 - 模型可见视图 :
schemas()把每个工具压成{ name, description, parameters },藏起 execute,注入给模型。
第二件是命门:模型只通过 description 决定「何时」调用,永远不直接碰 execute。如果模型能看到 execute 的函数体,它就「有手」,不再需要注册表这一层。而 harness 的整个安全思路,恰恰建立在「模型只有嘴,工具才有手」这个边界上,下一篇来讨论。
注入发生在 step():
js
async step() {
const messages = [systemMessage(SYSTEM_PROMPT), ...this.history]
const out = await this.llm.complete(messages, this.registry.schemas())
this.history.push(assistantMessage(out))
return out
}
每次请求前,把 schemas() 作为 tools 参数传给模型。llm/real.js 把它转成 OpenAI 格式的 tools 数组;mock 模型拿它判断「该不该调工具」。模型接口约定就一条:complete(messages, tools) -> Promise<{ text } | { toolCall }>。换真实模型时这一层零改动,换 provider 一行改。
一个细节:注册是 effect。register 的本质是 Map.set,撤销就是 Map.delete。dsh 的 ctx.tools.register 返回一个 disposer,插件卸载时自动调用。
4. 最小执行流水线:pre → execute → post
模型只输出 tool-call,真正做事的是工具。工具被调用的每一步,都走这条管线:
js
class ToolPipeline {
constructor(registry) {
this.registry = registry
}
async run(toolCall) {
const tool = this.registry.get(toolCall.name)
if (!tool) return { ok: false, error: `unknown tool: ${toolCall.name}` }
// pre:校验参数 ------ 缺必填参数直接拒绝,工具 body 不碰非法输入
if (tool.parameters?.required) {
for (const key of tool.parameters.required) {
if (toolCall.arguments?.[key] === undefined) {
return { ok: false, error: `missing required argument: ${key}` }
}
}
}
// execute:真正干活,带超时(防工具挂死拖垮整个 loop)
const timeoutMs = 5000
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`tool ${tool.name} timeout after ${timeoutMs}ms`)), timeoutMs),
)
let value
try {
value = await Promise.race([tool.execute(toolCall.arguments), timeout])
} catch (err) {
return { ok: false, error: `${tool.name} execute failed: ${err.message}` }
}
// post:结果规范化 ------ 非字符串转 JSON,保证回写上下文的是稳定形态
const content = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
return { ok: true, content }
}
}
三段各管一件事:
- pre:校验参数。缺必填参数直接拒绝,工具 body 不碰非法输入。这一道口,是你对「模型乱传参」的第一道防线。
- execute:真正干活 。
tool.execute(toolCall.arguments)就这一行,是你的工具 body。外面包了 timeout------防一个挂死的工具拖垮整个 loop。 - post:结果规范化 。非字符串转 JSON,保证回写上下文的是稳定形态。这是 dsh 里
output.render的极简版。
画出来就是这样,你的工具 body 只占中间一环:

注意一个心态:流水线可以短,不能没有。 我在 012 拆三家时讲过这条公共要素(公共要素③ 工具流水线)------harness 最少要有 pre 和 post 两道口,pre 管「工具不碰非法输入」,post 管「模型读到稳定形态」。你写的 execute 只是中间一行,前后全是策略的站位。dsh 的完整版在这两道口之间塞进审批、守卫、瀑布,本节的流水线是它的最小版------「最小」不是砍功能,是把必经之口立起来。
5. 跑通它:给你的 loop 装第一个真工具
代码都齐了,跑一遍。把配套工程拉下来,进目录直接:
bash
cd examples/first-agent
node step2-tools/index.js
不需要 npm install,不需要 API key------默认走 llm/mock.js 确定性 mock 模型。本机真实输出(逐字取自 PRACTICE.md):
$ node step2-tools/index.js
[user] 读文件 README.md
[tool:read_file] -> ok
[assistant] 工具 read_file 返回了:# first-agent ------ 动手开发你的第一个 agent(系列配套工程)
系列文章《动手开发你的第一个 age...
[user] 列目录 ..
[tool:list_dir] -> ok
[assistant] 工具 list_dir 返回了:llm/
package.json
README.md
step1-loop/
step2-tools/
step3-s...
一轮交互,三行关键输出,每一行都能对上代码:
[user] 读文件 README.md:turn()收到指令,push 进 history,进入循环。[tool:read_file] -> ok:runTool()调流水线,pre → execute → post 走完,打执行标记。[assistant] 工具 read_file 返回了:...:工具结果作为toolResult回写历史,模型下一轮step()读到它,按 system prompt 总结成一句话。
第 3 行背后那层看不见的回写,就是 tools/result 的极简版,代码就一行:
js
async runTool(toolCall) {
const result = await this.pipeline.run(toolCall)
console.log(`[tool:${toolCall.name}] -> ${result.ok ? 'ok' : 'ERR'} ${result.ok ? '' : result.error}`)
this.history.push({ role: 'toolResult', toolName: toolCall.name, content: result.ok ? result.content : result.error })
return result
}
history.push({ role: 'toolResult', ... })------工具结果必须回到模型上下文,模型才能继续。
现在看这段最有价值的真实素材。第一次跑 读文件 README.md 时,README.md 还不存在------真实输出是:
[tool:read_file] -> ERR read_file execute failed: ENOENT: no such file or directory
[assistant] 工具 read_file 返回了:read_file execute failed: ENOENT: ...
(记录里省了完整错误带的绝对路径;真实输出是 ERR read_file execute failed: ENOENT: no such file or directory, open 'D:\...\README.md',mock 把工具结果截到 60 字符。)
这段发生了什么,逐帧拆:
execute里fs.readFile抛 ENOENT------try/catch接住,流水线返回{ ok: false, error: 'read_file execute failed: ENOENT: ...' }。runTool打出[tool:read_file] -> ERR ...,但没有 throw、没有崩溃,loop 照常往下走。- 关键一步:
content: result.ok ? result.content : result.error------错误字符串作为 toolResult 回写历史。错误进了模型上下文。 - 模型下一轮总结:「工具 read_file 返回了:read_file execute failed: ENOENT: ...」------模型能读到错误。
后来建了 README.md,同一条命令变成 ok。这证明两件事:
- 工具错误不崩溃 loop,错误进上下文,模型能读到并调整。 换成真实模型,它读到 ENOENT 就知道「文件不存在」,下一步可能去创建文件、或者换路径------这不是 demo 话术,是这条流水线天然给出的能力。
- 真实工程的输出随工程状态变化。 你今天跑
列目录 ..,目录里会多出PRACTICE.md、step3-safety/、step4-session/------真实代码的输出跟着真实工程走。这就是真实代码和示意代码的区别。
到这里,我要重申开头那个判断:工具是插件,不是特例。 你的 read_file 和 dsh 内置的 bash、fs 工具没有本质区别------都是往注册表注册一个 ToolDefinition,都被同一条流水线接管。区别只在谁写的、有没有随发行版打包。加能力不用等官方,官方功能也是插件,源码就是最好的教材。
想接真实模型?照工程 README,设环境变量后自动切 llm/real.js(OpenAI 兼容接口,零依赖 fetch),loop 主体零改动:
bash
OPENAI_BASE_URL=https://api.deepseek.com \
OPENAI_API_KEY=sk-xxx \
OPENAI_MODEL=deepseek-chat \
node step2-tools/index.js
真实 API 调用我本机没跑(无 key),标「待核实」;签名一致由 complete(messages, tools) 约定保证。
6. 对照 dsh:你的注册,被一条六阶段流水线接管
先卖个关子:你写的这个注册,最后会被 dsh 一条六阶段流水线接管------猜猜你的代码在哪个环节?看完整条流水线你就知道答案了。
之前 拆过 dsh 的工具执行流水线,位置在 packages/core/tools/src/index.ts。六个阶段对应源码:
| 阶段 | 源码 | 干什么 |
|---|---|---|
| ① pre-execute waterfall + 审批 + 单调守卫 | prepareExecution :1463 |
钩子 / 权限 / 沙箱;ctx.approval 一次性询问;守卫是不可重排的所有者策略 |
| ② tools/execute 分发 | dispatchScheduledExecution :1569 |
timeout / retry / metrics 包在 execute 外面 |
| ③ 你的工具 body | dispatchToolBody :1532 |
tool.execute(exec.arguments, exec) 这一行 |
| ④ 结果规范化 | createSuccessResult :1793 |
validate → freeze → render(011 讲透的 schema+render 两层) |
| ⑤ post-execute waterfall | postExecute :1742 |
accept / block / replace / 附加上下文 |
| ⑥ finalizeContent + tools/result | applyFinalContent :1649 / notifyResult :1657 |
定义自带内容变换;冻结的权威结果通知 |
画出来,对照你第 4 节的最小版:

现在兑现那个关子:你的代码在③那一环 ------dispatchToolBody 里的 tool.execute(exec.arguments, exec),源码 index.ts:1532。紫色高亮的这一行,就是你写的 execute。前后全是策略包裹层。
对照表一拉,dsh 比你的最小版多做了什么:
| 我的最小版 | dsh 六阶段 | dsh 多出什么 |
|---|---|---|
| pre:校验必填参数 | ① prepareExecution | 审批 ask(fail-closed)、单调守卫、瀑布可改写 |
| execute:tool.execute + timeout | ②③ tools/execute + 你的 body | timeout/retry/metrics、取消信号融合 |
| (无) | ④ createSuccessResult | output schema 校验 + render 投影(两层完整版) |
| post:非字符串转 JSON | ⑤ postExecute | post-execute 瀑布可改写、附加上下文 |
| (无) | ⑥ finalizeContent + tools/result | 定义自带内容变换、结果通知 + 活跃批 FIFO |
| toolResult 回写历史 | tools/result 通知 | 事件广播,观察者可监听不可改 |
dsh 多做的三件事,单独说透:
审批和守卫分开。 审批是一次性的人机询问,缺了回答方一律 deny------fail-closed;守卫是已注册的所有者策略,不能重新排序。它们在 prepare 阶段先于你的 body 执行。010 里我证过这个结论,这里只提醒:守卫 vs 审批是两回事,别混。
三个瀑布都能改写一次调用。 pre-execute、execute、post-execute 都是 waterfall,钩子可以拦截、放行、改写。官方 extension-cookbook 的映射表写得很清楚:权限门禁挂 tools/pre-execute,沙箱走 ctx.sandbox,超时重试包 tools/execute,结果转换挂 tools/post-execute。你的最小版没有瀑布,但它占了「必经之口」的位置------将来要插策略,插的就是这些口子。
finalizeContent + tools/result 是收尾两件套。 finalizeContent 应用定义自带的内容变换(最后一道 content-only 不变式);tools/result 通知冻结的权威结果,观察者能读不能改。你的 runTool 里 console.log + history.push,就是这两件事的最简合体。
一句话收束:你的实现是 dsh 的最小版,dsh 是你的超集。 六阶段里,你真正写的只有 execute(③ 那一行);其余五段是 harness 白给的------不用你写一行,注册进注册表就自动被接管。
7. 结论:流水线可以短,不能没有
今天给 loop 装上了手和眼睛。回顾这一篇做了四件事:定义工具(四字段)、建注册表(模型可见视图)、走最小流水线(pre/execute/post)、结果回写(tools/result)。跑通一次真实调用,还看了一场真实的 ENOENT 事故现场------错误不崩溃、进上下文、模型能读到。