写在前面系列是为了帮助大家更好的去理解Agent Harness基础设施并不是想重复造轮子真实开发建议选择一个成熟的SDK或Harness框架才是最合适的选择~1. 模型只会说真正干活的是工具上一篇我用一个约一百行的最小 loop 跑通了「会思考的循环」模型想、模型说仅此而已。这个循环是空壳——它能思考但没有手去碰文件、没有眼睛去看目录。现在我们给 loop 装手和眼睛。2. 工具定义ToolDefinition 的四个字段先给代码。下面两个是真实工具read_file读文件、list_dir列目录constread_file{name:read_file,description:读取指定路径的文本文件内容。当用户想查看文件内容时调用。,parameters:{type:object,properties:{path:{type:string,description:要读取的文件路径},},required:[path],},tier:read-only,// 安全等级015 的审批会用它先埋个字段asyncexecute(args){constfsawaitimport(node:fs/promises)returnawaitfs.readFile(args.path,utf8)},}constlist_dir{name:list_dir,description:列出指定目录下的条目名。当用户想查看目录内容时调用。,parameters:{type:object,properties:{path:{type:string,description:要列出的目录路径},},required:[path],},tier:read-only,asyncexecute(args){constfsawaitimport(node:fs/promises)constentriesawaitfs.readdir(args.path,{withFileTypes:true})returnentries.map((e)(e.isDirectory()?${e.name}/:e.name)).join(\n)},}一个 ToolDefinition 就四个关键字段模型和你的分工各占一半name工具名。模型在回复里用这个名字发起调用同一注册表里不能重名。description模型看到的说明决定模型「何时」选它。它写得好不好直接决定工具被用的频率——把「何时用」讲清楚的 description比含糊的强一个数量级。parameters入参的 JSON Schema。发给模型做参数声明同时被流水线 pre 阶段用来校验。execute真正干活的函数。模型永远不直接碰它——这是这套设计的命门下面展开。tier: read-only是给【安全边界】预留的字段本篇先不展开介绍。第二、三、四个字段合起来就是我 011 里讲透过的 dshdefineTool心智schema render 两层。dsh 的defineToolschema.ts:545有五个字段比我们多一个output而output又拆schemarender两层——schema 声明 execute 返回的「规范值」render 把规范值投影成模型可见的内容。极简版没有output字段但两层心智没丢只是挪了位置schema 层parameters发给模型做声明pre 阶段校验render 层 post 阶段的结果规范化第 4 节非字符串转 JSONdsh 里 output 的validate → freeze → render → snapshot是完整实现index.ts:1793。我们先把口子立起来细节后面补。3. 注册表模型怎么看到工具工具定义好了接下来是注册表——「模型能看到什么」的控制器。classToolRegistry{constructor(){this.toolsnewMap()// name - ToolDefinition}register(tool){this.tools.set(tool.name,tool)returntool}// 模型可见视图把 execute 藏起来只暴露 schemaschemas(){return[...this.tools.values()].map((t)({name:t.name,description:t.description,parameters:t.parameters,}))}get(name){returnthis.tools.get(name)}}注册表干两件事对应同一个 Map 的两个视图执行视图get(name)从name → ToolDefinition映射里取出 handler流水线用它调execute。模型可见视图schemas()把每个工具压成{ name, description, parameters }藏起 execute注入给模型。第二件是命门模型只通过 description 决定「何时」调用永远不直接碰 execute。如果模型能看到 execute 的函数体它就「有手」不再需要注册表这一层。而 harness 的整个安全思路恰恰建立在「模型只有嘴工具才有手」这个边界上下一篇来讨论。注入发生在step()asyncstep(){constmessages[systemMessage(SYSTEM_PROMPT),...this.history]constoutawaitthis.llm.complete(messages,this.registry.schemas())this.history.push(assistantMessage(out))returnout}每次请求前把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真正做事的是工具。工具被调用的每一步都走这条管线classToolPipeline{constructor(registry){this.registryregistry}asyncrun(toolCall){consttoolthis.registry.get(toolCall.name)if(!tool)return{ok:false,error:unknown tool:${toolCall.name}}// pre校验参数 —— 缺必填参数直接拒绝工具 body 不碰非法输入if(tool.parameters?.required){for(constkeyoftool.parameters.required){if(toolCall.arguments?.[key]undefined){return{ok:false,error:missing required argument:${key}}}}}// execute真正干活带超时防工具挂死拖垮整个 loopconsttimeoutMs5000consttimeoutnewPromise((_,reject)setTimeout(()reject(newError(tool${tool.name}timeout after${timeoutMs}ms)),timeoutMs),)letvaluetry{valueawaitPromise.race([tool.execute(toolCall.arguments),timeout])}catch(err){return{ok:false,error:${tool.name}execute failed:${err.message}}}// post结果规范化 —— 非字符串转 JSON保证回写上下文的是稳定形态constcontenttypeofvaluestring?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 只占中间一环模型输出 tool-callpre校验参数缺必填直接拒绝execute真正干活timeout 包在外面你的工具 bodytool.execute 这一行post结果规范化非字符串转 JSONtoolResult 回写历史模型下一轮能读到注意一个心态流水线可以短不能没有。我在 012 拆三家时讲过这条公共要素公共要素③ 工具流水线——harness 最少要有 pre 和 post 两道口pre 管「工具不碰非法输入」post 管「模型读到稳定形态」。你写的 execute 只是中间一行前后全是策略的站位。dsh 的完整版在这两道口之间塞进审批、守卫、瀑布本节的流水线是它的最小版——「最小」不是砍功能是把必经之口立起来。5. 跑通它给你的 loop 装第一个真工具代码都齐了跑一遍。把配套工程拉下来进目录直接cdexamples/first-agentnodestep2-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.mdturn()收到指令push 进 history进入循环。[tool:read_file] - okrunTool()调流水线pre → execute → post 走完打执行标记。[assistant] 工具 read_file 返回了…工具结果作为toolResult回写历史模型下一轮step()读到它按 system prompt 总结成一句话。第 3 行背后那层看不见的回写就是tools/result的极简版代码就一行asyncrunTool(toolCall){constresultawaitthis.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})returnresult}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.mdmock 把工具结果截到 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.jsOpenAI 兼容接口零依赖 fetchloop 主体零改动OPENAI_BASE_URLhttps://api.deepseek.com\OPENAI_API_KEYsk-xxx\OPENAI_MODELdeepseek-chat\nodestep2-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:1569timeout / retry / metrics 包在 execute 外面③ 你的工具 bodydispatchToolBody:1532tool.execute(exec.arguments, exec)这一行④ 结果规范化createSuccessResult:1793validate → freeze → render011 讲透的 schemarender 两层⑤ post-execute waterfallpostExecute:1742accept / block / replace / 附加上下文⑥ finalizeContent tools/resultapplyFinalContent:1649 /notifyResult:1657定义自带内容变换冻结的权威结果通知画出来对照你第 4 节的最小版模型发起工具调用prepareExecution :1463pre-execute waterfall 审批 ask 单调守卫tools/execute 分发 :1569timeout / retry 包在外面你的工具 bodydispatchToolBody :1532createSuccessResult :1793validate - freeze - renderpostExecute :1742post-execute waterfallapplyFinalContent :1649定义自带内容变换tools/result :1657冻结的权威结果现在兑现那个关子你的代码在③那一环——dispatchToolBody里的tool.execute(exec.arguments, exec)源码index.ts:1532。紫色高亮的这一行就是你写的 execute。前后全是策略包裹层。对照表一拉dsh 比你的最小版多做了什么我的最小版dsh 六阶段dsh 多出什么pre校验必填参数① prepareExecution审批 askfail-closed、单调守卫、瀑布可改写executetool.execute timeout②③ tools/execute 你的 bodytimeout/retry/metrics、取消信号融合无④ createSuccessResultoutput schema 校验 render 投影两层完整版post非字符串转 JSON⑤ postExecutepost-execute 瀑布可改写、附加上下文无⑥ finalizeContent tools/result定义自带内容变换、结果通知 活跃批 FIFOtoolResult 回写历史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 事故现场——错误不崩溃、进上下文、模型能读到。源码下载https://download.csdn.net/download/houwenjin/93287753