在 DeepSeek Harness(下文简称 dsh)里做 Agent 开发,真正决定「模型能干什么」的,不是提示词写得多花哨,而是你有没有把工具定义好。工具定义里最容易被低估的三个东西是:参数 schema、审批决策与工具流水线。参数 schema 决定了模型能不能正确地产出调用参数,审批决定了这次调用到底该放行还是拦截,而流水线决定了从「模型发出一次调用」到「结果回到模型」之间到底经过了哪些关卡、每一关谁说了算。本篇作为上下两段中的第 1 段,先把地基打牢:用 defineTool 从零写出第一个可运行工具 greet,逐字段拆解 name、description、parameters、output.schema、output.render、execute 的职责边界;随后进入 dsh 的工具执行流水线,把 tools/pre-execute → 单调守卫 → tools/execute → tools/post-execute → finalizeContent → tools/result 这六段固定顺序一次讲清,并解释 waterfall 式事件分发里 next() 与短路返回的差别。读完之后,你应该能回答一个具体问题:模型发出一次工具调用之后,中间到底发生了什么,而我又能在这条链路的哪一环插手。
工具即函数:defineTool 的 name/description/parameters 三件套如何被模型消费
先建立一个最朴素也最准确的认知:工具就是一个描述清晰的函数。它有名称、有说明、有参数、有输出格式。这句话听起来像废话,但它把工具的定位说清楚了——工具不是「给框架看的插件元信息」,而是「给模型看的函数签名」。在 dsh 里,模型生成回复时会读取你注册的工具定义,然后基于这些信息决定是否调用、调用哪个、传什么参数。所以工具定义的读者其实有两拨人:一拨是运行时,它要拿定义去做校验与分派;另一拨是模型,它要拿定义去做决策。defineTool 的设计目标,就是让同一份定义同时满足这两拨读者的需求。
defineTool 接收一个对象,这个对象描述工具的全部信息。三个最核心的字段是 name、description、parameters。它们看起来平平无奇,但各自承担的信息量与失败模式完全不同,值得逐个拆开。
name 是调用的唯一标识,也是模型发起调用时使用的那个字符串。它的信息量最小,但对唯一性和可读性的要求最高。工程上要注意三点。第一,名称在同一个注册表里必须唯一,重名会直接破坏分派逻辑,因为模型返回的调用里只有名字,没有别的身份信息。第二,名称应当是可读的动宾结构或领域术语,而不是内部缩写,比如 greet 比 g1 好,fs_write 比 w 好,因为模型在选择工具时依赖名字做语义匹配,一个含糊的名字会显著抬高误调用率。第三,名字的大小写与分隔风格要全局统一,混用 greet、Greet、greet_tool 这类风格,在工具数量上去之后会让模型的选择行为变得不稳定。
description 是「什么时候用这个工具」的自然语言说明,它是三件套里信息密度最高、也最该花时间打磨的字段。素材里的示例写的是 'Greet someone by name.',一句话讲清了动作与对象。但在真实项目里,description 应该承担更多职责:说明这个工具做什么、不做什么、在什么场景下应该优先选择它、以及哪些边界情况它不负责。原因是模型的工具选择几乎完全基于 name + description 的语义匹配,parameters 更多影响的是「怎么填参数」而不是「要不要选这个工具」。一个常见坑是 description 写得过于笼统,例如只写「查询数据」,结果模型在三个不同的查询工具之间反复横跳。解决方案是在每个 description 里明确写出「用于 X 场景,不要用于 Y 场景」这类区分性语句,把工具之间的边界显式化。
parameters 是入参 schema,defineTool 会据此推导并校验 args。这是三件套里唯一同时面向模型与运行时的字段:模型看它来理解每个参数的类型与含义,运行时看它来做参数校验与类型推导。它的设计要求是「类型化定义」,也就是每个参数都给出独立的类型与描述,而不是丢一个自由格式的对象。下一节会专门展开 parameters 的细节。
把三个字段放在一起看,它们的分工可以归纳为一张表:
| 字段 | 主要读者 | 承担的信息量 | 常见失败模式 |
|---|---|---|---|
| name | 运行时 + 模型 | 唯一标识、调用入口名 | 重名冲突、命名含糊导致误选 |
| description | 模型为主 | 适用场景、能力边界、优先级 | 过于笼统,多个工具之间不可区分 |
| parameters | 运行时 + 模型 | 参数类型、必填性、参数含义 | 自由格式对象,校验形同虚设 |
这张表值得反复回看。很多「模型乱调工具」的问题,根因不在模型能力,而在这三个字段的信息量供给不足。工具定义本质上是写给模型看的 API 文档,文档写得差,调用自然乱。
ctx.tools.register 与 inject: ['tools']:把 greet 挂进工具注册表的最小前提
定义写好了,下一步是把它挂进 dsh 的工具注册表。在 dsh 里,工具通过 ctx.tools.register 注册。但这里有一个前置条件:你的插件必须声明对 tools 服务的依赖,否则 ctx.tools 这个命名空间根本不存在,调用 register 会直接失败。
素材里的写法是:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
// 需要 tools 服务:注册工具的前提
export const inject = ['tools']
export function apply(ctx: Context) {
// 注册一个名为 greet 的工具
ctx.tools.register(defineTool({
// ...
}))
}这段代码里有三个必须理解的机制。
第一是 inject。export const inject = ['tools'] 声明了这个插件依赖名为 tools 的服务。Cordis 这类插件框架的依赖注入是显式的:声明了 inject,框架才会在 apply 被调用之前把 tools 服务准备好并挂到 ctx 上;没有声明,ctx.tools 就是 undefined。这带来一个工程上的直接后果——你不可能在一个没声明依赖的插件里偷偷注册工具,基础设施层面就把这条路径堵死了。发布插件时如果忘了写 inject,最常见的报错就是 ctx.tools 为空导致的 TypeError,而修复方式就是补上这一行声明。
第二是 apply(ctx)。export function apply(ctx: Context) 是插件被激活时的入口。注册动作放在 apply 内部,而不是模块顶层,原因是:顶层的副作用执行时机早于框架完成服务装配,此时 ctx 还没有 tools 服务,注册必然失败。把所有依赖 ctx 的操作放进 apply,是这类框架里最基本也是最重要的纪律。注册位置决定注册时机,注册时机决定服务是否已经就绪。
第三是 注册的返回语义。ctx.tools.register 接收一个已经由 defineTool 包装好的工具定义,把它登记进注册表。注意调用形态是 ctx.tools.register(defineTool({ ... }))——defineTool 是包装/构造,register 是登记。这个二层结构的好处是:defineTool 产出的对象是纯粹的、可复用的定义,注册动作本身是另一件事,二者解耦。工程上你可以把定义单独抽到一个文件里导出,在多个 apply 里按条件注册,或者在测试里对同一份定义做重复注册的边界测试。
还有一点值得提醒:export const name = 'greet-tool' 这个 name 是插件名,不是工具名。工具名是 defineTool 对象里的 name: 'greet'。两者的命名空间完全不同,初学者很容易把插件名和工具名混为一谈,然后在 debug 时对着日志里出现的 greet-tool 和 greet 两个字符串发呆。插件名用于框架识别与管理这个插件,工具名用于模型发起调用。务必在命名时保持两者语义清晰、互不冲突。
parameters 就是入参 schema:defineTool 如何据此推导并校验 args
parameters 是 defineTool 里最「重」的一个字段,因为它是类型化定义,defineTool 会据此推导出 args 的类型,并在运行时做校验。素材里的例子是:
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
}逐字段看:type 是参数的类型,这里声明为 string,意味着这个参数值必须是一个字符串;required 表示必填,为 true 时模型必须提供这个参数,否则调用无法通过校验;description 是参数说明,它的读者主要是模型——模型要靠这句话理解这个参数应该填什么内容。三者组合起来,就构成了一个完整的参数契约:类型决定能不能通过校验,必填性决定缺省是否合法,描述决定模型会填出什么。
这里最关键的一句话是:「defineTool 会推导并校验 args」。这句话包含两层含义,必须分清。
第一层是推导,发生在开发期(对 TypeScript 而言就是类型层面)。defineTool 从 parameters 的声明推导出 execute 里 args 的静态类型,于是你在写 args.name 时编辑器能给出补全,写错字段名会直接标红。这是把参数定义当作单一事实来源(single source of truth)的典型做法:你只写一遍 parameters,类型就从它推导出来,不需要再手写一遍 interface 然后祈祷两者不漂移。很多团队在工具定义上用「手写 interface + 手写校验函数」的双份维护方式,最终必然出现类型说 required、校验说 optional 这类不一致。
第二层是校验,发生在运行时。模型输出的调用参数本质上是一个不受信的输入——模型完全可能漏字段、传错类型、多塞无关字段。defineTool 基于 parameters 做校验,把不合格的调用挡在 execute 之外。这意味着 execute 里的 args 已经被校验过了,这是一条非常重要的工程保证:你在 execute 里可以放心地按声明使用 args.name,而不必在每个工具内部重复写「if (!args.name) return 错误」这种防御性代码。
关于参数 schema 有几个实战坑值得展开。
- 不要用自由格式对象承载参数。把 parameters 写成「一个任意对象」会让校验形同虚设,模型也会失去填参数的约束,倾向于塞进一堆自由文本。类型化定义的价值恰恰在于约束,放弃约束等于放弃这次定义。
- description 要写「填什么」而不是「是什么」。只写「名字」是不够的,模型需要知道是「用户的全名」还是「用户昵称」还是「系统 ID」,颗粒度差一点,填出来的值就差很多。
- 参数在进入策略层之前就已被冻结。这一点在素材的第二段有明确交代:参数不可被改写,因为历史记录、审计、UI 与执行必须保持一致。也就是说,后面的流水线环节能看到参数,但不能篡改它。涉及参数改写的需求,必须在调用发生之前解决,而不是指望在流水线里「顺手改一下」。
- 必填性是对模型的硬约束。required 为 true 的参数缺失时,这次调用走不到 execute。因此在设计工具时,要把「必须由模型提供的信息」和「可以由工具自己推导的默认值」分清楚:前者设 required,后者不给 required 并在 execute 里用默认值补齐。
output.schema 与 output.render 的分工:规范值、展示值与模型可见内容
很多人写工具时只关心入参,忽略出参,结果就是工具能跑但不能用。dsh 在工具定义里专门用 output 一节来约束出参,分成 output.schema 和 output.render 两块。素材里的示例是:
output: {
// 规范值类型:execute 的返回值
schema: { type: 'string' },
// render:把规范值转成面向模型的内容
render: (_args, value) => [{ type: 'text', text: value }],
},output.schema 声明的是「规范值」(canonical value)的类型,也就是 execute 返回值的类型契约。示例里声明为 string,约束 execute 必须返回一个字符串。规范值是整个工具输出体系的锚点:它是被声明、被校验、被冻结的那个值,后续所有环节都以它为准。类比一下,规范值相当于数据库里的强类型字段,而 render 出来的内容相当于面向展示的视图。分清楚这两个概念,是理解流水线后半段(尤其是 post-execute 的两种 accept 决策)的前提。
output.render 的职责是「把规范值转成面向模型的内容」。它接收 (_args, value),其中 value 就是 execute 返回的规范值,返回的是一组内容块,示例中是 [{ type: 'text', text: value }],也就是一个纯文本块。这里的设计意图很明确:
- 规范值面向程序:类型明确、可校验、可被上层逻辑安全消费,适合做审计、指标、后续自动化处理。
- 渲染内容面向模型:模型最终看到的是 render 的产物,而不是规范值本身。文本块可以带上人类可读的措辞、单位、上下文,而规范值可以保持纯粹。
这个分离带来一个很实际的好处:同一个工具,可以在规范值层面保持严格的结构(例如返回一个数字),同时在 render 层面把它包装成一句自然语言(例如「当前温度是 26 摄氏度」),让模型更好地理解。如果把这两件事混在一起——execute 直接返回给模型看的字符串——那么程序侧就无法可靠地消费这个结果,做统计、做断言、做二次处理都会变成解析自然语言的噩梦。
需要特别记住一条来自流水线的警示:内容替换是展示策略,不是保密策略。这意味着如果你在 render 里对某些内容做了处理,那只是改变了模型看到的样子,并不等于把数据真正藏起来了。要隐藏程序化值,必须替换值本身或直接阻止结果,这一点会在 post-execute 的决策表里体现得非常清楚。
execute 的返回值契约:为什么 greet 只返回一个字符串
execute 是工具的实现,也就是真正执行逻辑的地方。素材里的实现是:
async execute(args) {
// 返回规范值,这里是一个字符串
return `Hello, ${args.name}!`
},它只有一行,却精确地演示了 execute 的三条契约。
第一条契约:execute 返回的是规范值,而不是最终展示内容。greet 返回的是 Hello, ${args.name}! 这个字符串,它必须符合 output.schema 声明的 string 类型。至于模型最终看到什么,那是 render 的事。这正是「实现层与声明层的边界」——实现层只负责产出符合声明层约束的规范值,怎么呈现不由实现层决定。把这两层混在一起,是工具定义里最常见的结构性错误。
第二条契约:args 已经被校验过了。因为 parameters 声明了 name: { type: 'string', required: true },所以 execute 里的 args.name 一定存在且一定是字符串。这就是为什么 grep 一遍真实代码库,你很难在 execute 里看到对 args 的重复校验——那些校验已经在流水线的更早阶段做完了。反过来说,如果你在 execute 里仍然写了大量「参数缺失怎么办」的分支,那大概率是 parameters 没定义好,或者你把业务前置校验和参数校验搞混了。业务逻辑上的校验(比如「这个名字是否存在于数据库中」)当然还是要写在 execute 里,但它和参数校验是两回事。
第三条契约:execute 可以是异步的。示例中用了 async,返回 Promise,这为工具内部的 IO 操作留足了空间。工具的本质是「Agent 用来干活的函数」,而绝大多数真实的活都需要 IO:读文件、发请求、查数据库。把 execute 设计成可异步,是让工具能真正落地的前提。
把三条契约合起来看,execute 的定位非常清楚:它是一个已经被喂了合法参数、只需要专心干活的函数,产出一个类型正确的规范值。参数合法性由 schema 负责,展示形态由 render 负责,execute 只负责中间那段真正的业务逻辑。这种职责分离带来的工程收益是:工具的实现变得极简,而工具的约束变得极强。
把 scratch-plugin/src/my-plugin.ts 换成第一个可运行工具
理论讲完,落地只有一个动作:把 scratch-plugin/src/my-plugin.ts 的内容替换为下面这份完整代码。它就是一个可直接运行的最小工具插件。
// 文件路径:scratch-plugin/src/my-plugin.ts
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
// 需要 tools 服务:注册工具的前提
export const inject = ['tools']
export function apply(ctx: Context) {
// 注册一个名为 greet 的工具
ctx.tools.register(defineTool({
// 工具名:模型会以这个名字发起调用
name: 'greet',
// 工具说明:告诉模型什么时候用
description: 'Greet someone by name.',
// 入参 schema:defineTool 会推导并校验 args
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
// 输出定义
output: {
// 规范值类型:execute 的返回值
schema: { type: 'string' },
// render:把规范值转成面向模型的内容
render: (_args, value) => [{ type: 'text', text: value }],
},
// 工具实现:真正执行逻辑
async execute(args) {
// 返回规范值,这里是一个字符串
return `Hello, ${args.name}!`
},
}))
}落地的步骤可以整理成一份清单,照着做就不会漏:
- 定位文件:打开
scratch-plugin/src/my-plugin.ts,把原有内容整体替换掉。 - 写 import:从
@deepseek-ai/cordis引入Context类型(注意是 import type,因为它只用于类型标注),从@deepseek-ai/dsh-tools引入defineTool。 - 导出 name:
export const name = 'greet-tool',这是插件名,供框架识别插件。 - 声明 inject:
export const inject = ['tools'],这是能拿到 ctx.tools 的前提。 - 导出 apply:在 apply 内部调用
ctx.tools.register(defineTool({ ... })),把完整的工具定义登记进注册表。 - 填满六项定义:name、description、parameters、output.schema、output.render、execute,一个都别省。少填任何一项,要么工具不可见,要么输出不可用。
这份代码的另一层价值是它把本段前面所有概念串成了一条线:inject 与 apply 解决「挂载」,name/description/parameters 解决「模型怎么选、怎么填」,output.schema/output.render 解决「结果怎么给模型看」,execute 解决「真正干活」。一个 30 行的文件,就是一个完整工具的最小闭环。
tools/pre-execute → 单调守卫 → tools/execute → tools/post-execute → finalizeContent → tools/result:一次调用的固定顺序
现在进入本段的下半场:模型发出一次工具调用之后,到工具真正执行、结果回到模型,中间到底走了哪些环节?答案不是「调用一下函数」那么简单,而是一条有固定顺序的流水线。官方文档把顺序概括为:tools/pre-execute → 单调守卫 → tools/execute → tools/post-execute → finalizeContent → tools/result。
这六段各有明确的职责分界,可以这样理解:
| 环节 | 类型 | 职责 | 能否改写这次调用 |
|---|---|---|---|
| tools/pre-execute | waterfall | 承载钩子、权限、沙箱等可重排策略 | 可(allow / deny / ask) |
| 单调守卫 | guard | 只允许缩减、不可撤销的最终防线 | 可拒绝,不可恢复为允许 |
| tools/execute | 环绕分派 | 包装真正的工具主体调用,做超时、重试、指标 | 可替换必需的 exec.signal |
| tools/post-execute | 决策 | 结果归一化前的检查或改写 | 可(accept / block) |
| finalizeContent | 定义自身回调 | 最后的仅内容不变式 | 仅内容层修正 |
| tools/result | 同步通知 | 观测冻结后的权威结果 | 不可改写,仅观测 |
素材里有一句非常关键的总结:前三个 waterfall 可以改写一次调用,而由定义自身控制的 finalizeContent 与 tools/result 在其后运行。这句话划出了流水线的主分界——前半段是策略与分派的地盘,可以在多个插件之间协调甚至推翻彼此的决定;后半段则逐渐收敛到「结果定型」,最终冻结成不可变的权威结果。
把这个顺序记成一句话:「pre-execute 决定能不能做,execute 决定怎么做,post-execute 决定结果怎么呈现,result 只负责看一眼最终结果。」这句话是选型记忆法,遇到「我这个需求该挂哪个扩展点」时,先问自己需求属于哪一类,答案基本就出来了。
再补一个常见的认知纠偏:流水线不是一个「钩子随便插」的散乱集合,而是一条有严格先后与权限递减关系的链路。越靠前的环节权限越大、可塑性越强;越靠后的环节权限越小、约束越硬。这种设计的目的,是让「可协商的策略」和「不可动摇的不变式」在时间上分层,避免出现后注册的插件把前面已经做出的安全决策悄悄推翻的情况。理解了这个梯度,你在设计自己的插件时就不会把强制性的安全约束挂在错误的环节上。
waterfall 与 next():监听器如何委托决定权或短路返回
要真正用好流水线,必须先理解 waterfall(瀑布式事件)这种事件分发模式。它不是普通的事件广播——普通广播是「所有监听器都跑一遍,各干各的」,而 waterfall 的监听器手里握着一个决定权,可以选择把它往下传,也可以选择当场截停。
具体来说,waterfall 的监听器有两种出口:
- 调用 next() 委托下去:监听器做完自己的判断后,调用 next(),把决定权交给流水线中后续的监听器。如果当前监听器没有意见,这是标准做法。多个策略插件通过 next() 串联起来,形成一个可协商的决策链。
- 直接返回决策短路整条链:监听器直接返回一个类型化的决策,不再调用 next(),这条流水线就此终止,后续监听器不会再执行。短路是一种强动作,用得好是效率与安全的保障,用不好就是难以排查的拦截问题。
tools/pre-execute 就是流水线中的第一个 waterfall。它负责承载「钩子、权限、沙箱」这一类可重排的策略。之所以说它「可重排」,是因为监听器可以通过 next() 把决定权传给下一个监听器,多个策略插件的先后顺序可以在配置里调整。这一点和后面要讲的单调守卫形成了鲜明对比——守卫的顺序无法改变结果的方向,因为守卫没有 allow 结果。
pre-execute 返回的是一个类型化决策 PreToolDecision,有三种取值:
| 决策 | 含义 | 后续行为 |
|---|---|---|
{ kind: 'allow' } | 放行这次调用 | 继续走单调守卫与之后的环节 |
{ kind: 'deny'; reason: string } | 拒绝这次调用 | 物化成一个错误结果,工具主体被跳过 |
{ kind: 'ask'; reason?: string } | 询问用户 | 只有审批服务返回 allowed-once 才继续,否则拒绝 |
这三档决策的设计很讲究。allow 是最常见的放行路径;deny 会立即终止这次调用,并把拒绝物化成一个错误结果返回——注意是「错误结果」而不是「什么都不返回」,模型会收到这次调用失败的信息,从而有机会调整策略;ask 则把决定权交给人类,触发 ctx.approval 的一次性询问,只有当审批服务返回 allowed-once 才继续,否则一律拒绝。ask 分支涉及的审批细节会留到下一篇展开,本段先记住它的语义:ask 不是「默认允许」,而是「默认拒绝,除非用户明确一次性放行」。这个默认值的选择非常关键,它决定了审批失败时的安全方向。
在写 pre-execute 监听器时,有几个工程要点必须记住。
- 参数不可被改写。素材明确说明:参数不可被改写,因为历史记录、审计、UI 与执行必须保持一致。这条约束是为了保证「你看到的调用」和「实际执行的调用」是同一个。任何依赖篡改参数才能实现的策略,都是错误设计。
- next() 是委托,不是可选装饰。如果监听器放行却不调用 next(),后续监听器就被静默跳过了,很可能导致本该生效的安全策略没有生效。放行时调用 next() 应当是条件反射级别的习惯。
- 短路时机要谨慎。返回 deny 会立即终止,后续监听器不再执行。也就是说,如果你把一个高优先级的 deny 放在前面,它就不会给别人任何协商机会。这通常是对的(安全优先),但你要清楚这是有意的选择。
- 什么时候用 pre-execute。当策略需要「允许、拒绝或询问」三类动作之一,且希望策略之间可以自由排序时,用它。沙箱、权限、plan-mode 等插件都用这个扩展点。
为了把这段机制落到工程上,下面给一个可直接粘贴的权限门禁插件示例,它完整演示了 waterfall 的两种出口(返回 deny 短路、调用 next() 委托):
// 文件路径:my-plugins/permission-gate/src/index.ts
// 一个基于 tools/pre-execute 的权限门禁插件。
// 它返回类型化的决策:命中黑名单就 deny,否则调用 next() 委托下去。
import type { Context } from '@deepseek-ai/cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
// 黑名单:runoob 项目里禁止直接写文件系统的工具。
// 这里用最简单的集合演示;真实项目里可以查数据库、问审批服务。
const DENY_TOOLS = new Set(['fs_write', 'fs_edit'])
// 策略判定函数:返回这次调用是否被允许。
// exec 携带不可变的调用身份(callId、name、arguments、agent、token、signal)。
async function isAllowed(exec: ToolExecution): Promise<boolean> {
if (DENY_TOOLS.has(exec.name)) return false
// 额外示例:runoob 演示里禁止修改 .env 文件(参数在进入策略前已被冻结)。
const raw = exec.arguments as { path?: string }
if (typeof raw.path === 'string' && raw.path.includes('.env')) return false
return true
}
export const name = 'permission-gate'
export function apply(ctx: Context) {
// tools/pre-execute 是 waterfall:监听器可以返回决策,或调用 next() 委托。
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (!(await isAllowed(exec))) {
// 返回 deny 会立即终止这次调用,后续监听器不再执行。
return { kind: 'deny', reason: 'Denied by policy: this tool is not allowed in the runoob workspace.' }
}
// 放行:把决定权交给流水线中后续的监听器。
return next()
})
}这段代码几乎可以用作「如何写策略插件」的模板。它演示了几个关键点:监听器通过 ctx.on 挂到 tools/pre-execute 上;exec 携带不可变的调用身份,包括 callId、name、arguments、agent、token、signal;策略判定是异步的,因为真实项目里可能要查数据库或问审批服务,所以判定函数声明为 async;命中策略就直接返回 deny 短路,并且给出明确的 reason 字符串,方便模型和开发者定位原因;放行就 return next(),把决定权交下去。这五个点覆盖了策略类插件 90% 的写法。
还值得一提的是 exec.arguments as { path?: string } 这个断言。它说明参数在进入策略前已被冻结——你只能读,不能改。策略能看到 path 的值,也正是基于这个可见值做判断,所以「禁止修改 .env」这类规则可以可靠地实现。这个细节反过来印证了前面那条原则:参数不可改写,既是审计一致性的要求,也让策略判断有了稳定的事实基础。
到这里,第 1 段的地基已经铺好:工具定义是什么、怎么注册、参数与输出怎么声明、执行流水线有哪六段、waterfall 的决定权怎么流转、以及一个真实可用的权限门禁插件长什么样。接下来第 2 段会继续往流水线深处走——处理单调守卫为什么故意没有 allow 结果、tools/execute 如何替换 signal 来施加截止时间、tools/post-execute 两种 accept 决策改写内容与改写值的区别、finalizeContent 这个「最后的仅内容不变式」到底负责什么、以及 tools/result 为什么只能观测不能变换。理解完这些,你才能真正做到在该插手的地方插手,在该放手的地方放手。
上一段我们拆完了 defineTool 的字段结构、参数 schema 的推导机制,以及工具注册进注册表的完整路径。但定义完工具只是第一步:模型吐出一条 tool call 之后,到工具主体真正跑起来、结果再回到模型上下文,中间还有一条被官方称作 工具执行流水线 的固定链路。这一段我们就把这条链路逐环节拆开,并落地一个真正能用的权限门禁插件。
PreToolDecision 三态:allow、deny(reason)、ask(reason?) 的后续行为差异
tools/pre-execute 是整条流水线的第一个 waterfall,也是承载策略的主要位置。它的返回值不是布尔值,而是一个类型化决策 PreToolDecision。这一点很关键:布尔值只有两态,而真实工程里策略往往需要第三态——「我不能替你决定,去问用户」。这正是 ask 存在的理由。
三种决策的后续行为差异如下,请逐条对照,不要记混:
- { kind: 'allow' }:放行这次调用。监听器返回它之后,流水线会继续往下走,进入单调守卫,再进入 tools/execute。注意 allow 并不会绕过守卫——守卫仍然可以在 allow 之后把调用拦下来。
- { kind: 'deny'; reason: string }:拒绝这次调用。注册表会把这次拒绝物化成一个错误结果,也就是说模型最终读到的是一段带 reason 的错误内容,而工具主体被直接跳过,execute 根本不会执行。deny 是立即终止:后续监听器不再有机会执行。
- { kind: 'ask'; reason?: string }:询问用户。此时会触发 ctx.approval 的一次性询问流程。区别于普通的「弹窗确认」,ask 的语义更严格:只有审批服务返回 allowed-once 才继续,其余任何结果(拒绝、超时、无响应)都按拒绝处理。
把这三态放进一张表里对照,差异会更清晰:
| 决策 | 含义 | 后续行为 | 是否触发审批服务 |
|---|---|---|---|
| { kind: 'allow' } | 放行这次调用 | 继续走单调守卫与之后的环节 | 否 |
| { kind: 'deny'; reason } | 拒绝这次调用 | 物化成错误结果,工具主体被跳过 | 否 |
| { kind: 'ask'; reason? } | 询问用户 | 只有审批服务返回 allowed-once 才继续,否则拒绝 | 是,一次性的 |
这里要特别强调 allow 不等于「已执行」。很多初学者在写钩子时会有个直觉误区:以为第一个监听器返回 allow 就万事大吉了。实际上 allow 只是「我不反对」,它把决定权交给了流水线后面的人。如果后面还有守卫、还有别的策略插件,调用依然可能被拦。反过来,deny 的优先级极高,一旦返回就短路,谁都救不回来。
至于 ask,它适合的典型场景是「破坏性但可逆、且用户明确知道自己在做什么」的操作,比如写入用户指定目录、执行一条用户刚刚口述的 shell 命令。这类操作不适合用一刀切的 deny 拦死,也不适合无条件 allow,交给用户一次性确认最合理。要注意 allowed-once 这个命名的分量:它是「这一次调用」的许可,不是「这个工具」的许可,更不是「这个会话」的许可。想做成会话级白名单,你得自己在插件里维护状态,而不能指望审批服务记住。
什么时候该用 pre-execute?一句话判断:当你的策略需要「允许、拒绝或询问」这三类动作之一,且希望策略之间可以自由排序时,用它。官方生态里的沙箱、权限、plan-mode 等插件,用的都是这个扩展点。
参数为什么不可改写:历史记录、审计、UI 与执行必须一致
这是一个容易被忽视、但一旦踩坑就很痛的设计约束:在 tools/pre-execute 阶段,参数不可被改写。你可以读 exec.arguments,但不能改它。
为什么?从一致性约束出发,arguments 是这次调用的「事实」。它同时被至少四个消费者观察:
- 历史记录:会话 transcript 里记录的是模型原始发出的调用参数。如果某个策略偷偷改了参数,历史记录与真实执行就对不上了,回放会失真。
- 审计:审计系统需要回答「模型到底要求做什么」。如果参数在中途被改,审计日志记录的是改后值还是原值?无论记哪个都是错的——记原值则与执行不符,记改后值则掩盖了模型真实意图。
- UI:界面上给用户展示「Agent 想调用 fs_write,路径是 ./a.txt」。如果策略把路径换成了 ./b.txt 而 UI 没同步,用户看到的和实际发生的就不是一回事。
- 执行:工具主体拿到的参数,必须是历史、审计、UI 三方共识的那一份。
把这四点对齐,唯一的解就是冻结 arguments。前文提到 exec 携带的是不可变的调用身份,具体包括 callId、name、arguments、agent、token、signal 这些字段。它们是这次调用的身份证:callId 是唯一编号,name 是被调用的工具名,arguments 是参数快照,agent 标明发起方,token 关联授权上下文,signal 是取消信号的载体。
于是你会遇到一个很典型的工程问题:参数已经在策略运行前被冻结了,那我想做「参数级校验」怎么办?答案是只能读、不能改,通过决策结果表达你的态度。比如你在权限门禁里发现路径里带了 .env,你要做的是 deny,而不是「把路径改掉再放行」。后者会破坏上面那四条一致性约束。
需要「修正参数」的诉求,正确的出口是 tools/post-execute 的 block 分支——工具跑完之后,用 feedback 把纠正意见交回给模型,让模型自己重新发起一次调用。这样参数变更就发生在模型的下一轮决策里,历史记录依然自洽。
ctx.tools.guard() 与 ToolGuard:没有 allow 结果的单调守卫
waterfall 的灵活性是有代价的,这个代价叫「可被推翻」。后注册的监听器一旦返回 allow,前面某个监听器的 deny 就会被绕过。绝大多数策略场景这没问题,因为策略本来就是可排序、可协商的。但有一类需求不吃这一套:不变式(invariant)。
不变式的要求是「最终拒绝,且任何人都不能撤销」。这时候就要请出 ctx.tools.guard(),注册一个 ToolGuard。它的类型签名非常有表达力:
// ToolGuard:感知作用域的最终预分派策略
type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
请把注意力放在返回类型上:string | undefined。返回字符串表示拒绝,字符串本身是拒绝原因;返回 undefined 表示维持现状。这里故意没有 allow 结果。
没有 allow 意味着什么?意味着监听器之间无法互相「翻案」。你不可能通过注册一个新守卫、返回某种「允许」来撤销前面守卫的拒绝——因为那种返回值压根不存在。这就是单调两个字的含义:只减不增,只收权限,不给权限。权限只能沿着守卫链被不断缩窄,永远不可能被放宽。
这里的 Readonly<ToolExecution> 也值得说一句。守卫拿到的是只读视图,它没有能力改参数,也没有能力改调用身份。守卫能做的只有一件事:判断这次调用是否违反不变式,违反就给个理由拒掉。
落到实践原则上,选型规则非常干脆:
- 可重排的策略 → 放 tools/pre-execute。比如业务级的限流、灰度、场景化权限,这些策略的先后顺序可能随配置变化。
- 必须最终生效、不可撤销的不变式 → 放 ctx.tools.guard()。比如「任何情况下不得写入系统目录」「任何情况下不得把密钥外发」这类红线。
工程上常见的坑是把红线写进了 pre-execute。因为 pre-execute 是 waterfall,一个后续插件返回 allow 就能把它绕过去,红线形同虚设。反过来,把可排序的业务策略写进 guard 也不合适——guard 没有 allow,所有守卫都只能「不表态」或「拒绝」,你没法用它表达「这个场景我批准了」。两类机制各司其职,别混用。
tools/execute 的环绕分派:ToolDispatchExecution 与 exec.signal 的替换规则
过了策略层和守卫层,调用终于要真正执行了。tools/execute 负责的是「环绕分派」——把真正调用工具主体这件事包起来。所谓「环绕」,是因为这个扩展点拿到的执行上下文允许你在主体前后各做一层事情。
哪些事情适合在这一层做?超时、重试、指标收集,这三类是标准住户。原因很直观:
- 超时需要给调用施加截止时间,本质是操作 signal,只有这一层能做。
- 重试需要知道主体是否失败、失败了几次,需要在主体外面包一层循环。
- 指标收集需要测量主体耗时、成功失败计数,也必须在主体外面计时。
这一层拿到的视图是 ToolDispatchExecution。只有这一个视图可以替换必需的 exec.signal,用来施加截止时间。替换规则很讲究,请记牢:
可以替换,但不能移除;注册表会在调用工具主体前重新融合调用方的 signal。
这句话要拆成两半理解。前半句「可以替换」意味着你能用一个带 deadline 的 signal 去覆盖当前的 signal,从而实现超时控制。后半句「不能移除」+「注册表会重新融合调用方的 signal」意味着,即使你替换了 signal,注册表在真正调用主体之前,还会把调用方原始的 signal 重新融合进来。
为什么要多此一举?因为取消权属于调用方。用户点了取消、上层 Agent 决定中止、整个会话被 kill——这些信号必须能穿透到工具主体。如果某个插件能通过替换 signal 把调用方的取消信号「替换掉」,那工具就再也停不下来了,这是不可接受的。所以设计上做了双重保险:你可以叠加你的截止时间,但调用方的取消永远保留。
实践中这带来一个值得注意的行为:你注册了超时插件,超时触发后主体的 signal 会 abort;但与此同时,如果调用方也 abort 了,两者谁先到就谁生效。所以工具主体的正确写法是响应 signal,而不是假定「只要我没超时就一定不会被取消」。
接下来是 tools/post-execute。它在工具执行完、结果归一化之前做检查或改写,返回值是类型化的 PostToolDecision。这个扩展点是「结果层」的策略位置,和 pre-execute 的「意图层」正好对称。
PostToolDecision 的四种结果改写:accept(content?)、accept(value)、block(feedback) 与保密边界
PostToolDecision 的取值看起来比 PreToolDecision 多,是因为「接受」这件事有两种粒度:改展示、改规范值。加上拒绝,一共四类:
| 决策 | 含义 | 代价与副作用 |
|---|---|---|
| { kind: 'accept'; content? } | 接受结果,可替换展示内容 | 保留规范值与元数据,只换给模型看的那部分 |
| { kind: 'accept'; value } | 接受结果,可替换规范值 | 会重新校验并重算内容 |
| { kind: 'block'; feedback } | 阻止结果 | 把纠正反馈变成错误结果交回模型 |
| (不返回 / 放行) | 维持原结果 | 调用继续沿流水线往下 |
重点讲前两种 accept 的区别,这是最容易搞混的地方:
- accept(content?):只替换展示内容。规范值(也就是 execute 返回的那个值)和元数据都会原样保留。适合的场景是「我想给模型换一种表述」,比如把一份冗长的 JSON 摘要成一段自然语言。
- accept(value):替换规范值。这是重操作——注册表会拿新的 value 重新走一遍 output.schema 的校验,并且调用 output.render 重新计算面向模型的内容。也就是说,换了 value,content 会自动跟着重算,你不需要(也最好不要)同时手写 content。
这两种的语义差别,决定了它们适用的场景完全不同。如果你只是想改「给模型看的措辞」,用 content;如果你确实要改「这次工具调用产生的权威结果」,用 value,并接受重新校验的成本与失败风险——新 value 不满足 output.schema 的话,这次替换就是错的。
再讲 block(feedback)。它把工具的结果替换成一次错误结果,错误内容就是你的 feedback。这不是「隐藏结果」,而是「告诉模型这次结果不可用,请按 feedback 修正后重来」。所以 feedback 的措辞要像给同事的 code review 意见,而不是一句「invalid」。比如路径越界,feedback 应该写清「路径超出了允许的工作目录,请改用工作目录内的相对路径」。
最后是一个必须敲黑板的保密边界:
内容替换是展示策略,不是保密策略。要隐藏程序化值,必须替换该值或阻止结果。
这句话的含义是:你只改 content,规范值仍然在流水线里流动,任何后续能看到规范值的地方(审计、日志、其他钩子)都能拿到它。如果某个字段是敏感信息,你希望它彻底不出现在结果里,那就必须走 accept(value) 把它从规范值里摘掉,或者干脆 block。指望「改一改展示文本就藏住了」,是不成立的。
这条边界在真实项目里踩坑率极高,尤其是涉及到凭证、内部路径、内部服务名的时候。一个稳妥的做法是:把所有「必须消失」的字段列成清单,在 post-execute 里对规范值做处理;把「只是不好看」的字段交给 content。
finalizeContent 与 tools/result:最后的内容不变式与冻结结果的只读观测
post-execute 之后还有两个环节,它们的分工非常清楚。
第一个是 finalizeContent。它是工具定义(ToolDefinition)自己拥有的回调,注意关键词「自己拥有的」——它不属于钩子插件,而是写 defineTool 时你可以声明的部分。注册表会恰好调用它一次。它的定位是「最后的仅内容不变式」:
- 同步执行:不做异步等待,不引入新的调度点。
- 只做内容层面的最后修正:它能碰的是内容,不能翻案前面已经接受的规范值。
因为这个回调属于工具定义本身,所以它天然适合放「这个工具无论如何都应该满足的内容规则」。比如某类工具的输出必须带一个固定的尾部提示,那就写在这里,一次到位,不依赖任何插件的注册顺序。
finalizeContent 跑完之后,注册表会物化并冻结已接受的结果,然后触发 tools/result。tools/result 是一个同步通知,它的作用是让你观测那个冻结的、不可变的权威结果。三个特征要一起记:
- 同步:通知是同步发出的,不会拖到下一个 tick。
- 只读:观测者无法变换结果。结果已经冻结了,你想改也没有入口。
- 失败隔离:观测者自身出错会被隔离,不会影响主流程。这一点对可观测性插件特别重要——埋点写挂了不该把业务调用带崩。
那什么时候用 tools/result,什么时候用 tools/post-execute?给出选型准则:
- 需要审计、指标、捕获最终结果 → 用 tools/result。它看到的就是最终真相。
- 需要变换结果或附加上下文 → 用 tools/post-execute。因为 result 阶段已经改不动了。
官方文档给过一个很好记的选择口诀,我把这里的流水线顺序重新整理一下:pre-execute 决定「能不能做」,execute 决定「怎么做」,post-execute 决定「结果怎么呈现」,result 只负责「看一眼最终结果」。中间那个 guard 则是一个贯穿始终的红线开关,谁都不能翻案。
把整条链路的顺序完整写一遍,方便对照排查问题:tools/pre-execute → 单调守卫 → tools/execute → tools/post-execute → finalizeContent → tools/result。其中前三个 waterfall 可以改写一次调用,而由定义自身控制的 finalizeContent 与 tools/result 在其后运行。
2026 年 9 月实践:用 pre-execute 写权限门禁与策略排序的当前做法
理论讲完,落地。官方文档以「权限门禁」为例展示钩子插件如何使用 tools/pre-execute,我们把它重写成一个可以直接放进项目里的版本。
先说一个容易被忽略的前提:钩子插件就是普通的 Cordis 插件,并不需要外部协议。这句话有两层意思。第一,你不用为钩子发明什么新格式,export name、export apply、inject 这套 Cordis 插件写法照旧。第二,既然它是普通插件,那它就享受插件生态的一切——可以被装、被卸、被配置排序。
下面这个例子的策略是:命中黑名单的工具直接 deny;另外拦截所有试图触碰 .env 的行为。注意代码里访问参数的方式——参数在进入策略前已经被冻结,所以我们只能读、不能改。
// 文件路径:my-plugins/permission-gate/src/index.ts
// 一个基于 tools/pre-execute 的权限门禁插件。
// 它返回类型化的决策:命中黑名单就 deny,否则调用 next() 委托下去。
import type { Context } from '@deepseek-ai/cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
// 黑名单:runoob 项目里禁止直接写文件系统的工具。
// 这里用最简单的集合演示;真实项目里可以查数据库、问审批服务。
const DENY_TOOLS = new Set(['fs_write', 'fs_edit'])
// 策略判定函数:返回这次调用是否被允许。
// exec 携带不可变的调用身份(callId、name、arguments、agent、token、signal)。
async function isAllowed(exec: ToolExecution): Promise<boolean> {
if (DENY_TOOLS.has(exec.name)) return false
// 额外示例:runoob 演示里禁止修改 .env 文件(参数在进入策略前已被冻结)。
const raw = exec.arguments as { path?: string }
if (typeof raw.path === 'string' && raw.path.includes('.env')) return false
return true
}
export const name = 'permission-gate'
export function apply(ctx: Context) {
// tools/pre-execute 是 waterfall:监听器可以返回决策,或调用 next() 委托。
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (!(await isAllowed(exec))) {
// 返回 deny 会立即终止这次调用,后续监听器不再执行。
return { kind: 'deny', reason: 'Denied by policy: this tool is not allowed in the runoob workspace.' }
}
// 放行:把决定权交给流水线中后续的监听器。
return next()
})
}
这个文件里有几处设计细节值得展开,都是 2026 年 9 月当下写这类门禁插件的标准姿势:
- 判定逻辑与注册逻辑分离。isAllowed 是一个纯函数式的判定,ctx.on 里面只负责把判定结果翻译成决策。这样做的收益是可测试性:你可以对 isAllowed 写单测,构造各种 exec 形态,不需要起插件运行时。
- 命中就 deny,绝不试图「修正后放行」。理由在前面「参数为什么不可改写」已经讲透:参数是冻结的调用身份,改了就破坏一致性。真需要模型换个参数重试,那是 post-execute 的 block 或下一轮对话该干的事。
- 放行必须显式 return next()。这是最容易漏的一行。waterfall 的语义是监听器把决定权往下传,你不调用 next() 也不返回决策,调用链就卡在那里了。空 return、忘记 return、只 return undefined,都是不同形式的「静默挂起」。
- deny 的 reason 写成给人看的完整句子。因为这段 reason 会物化成错误结果交给模型,措辞质量直接决定模型下一轮能不能自我纠正。示例里写的是 Denied by policy: this tool is not allowed in the runoob workspace.,明确说了「在哪个环境里不允许」,模型至少知道换环境或者换工具。
再说 策略排序 的当前做法。pre-execute 是可重排的策略层,多个策略插件的先后顺序可以在配置里调整。这一点在真实项目里非常有用:你可能有「组织级策略」「项目级策略」「会话级策略」三层,逻辑上希望组织级先跑(更早 deny 掉明显违规的),项目级其次,会话级最后做精细判断。实现方式就是通过插件的注册/加载顺序来控制,而不是在代码里硬编码优先级数字。
顺序可调带来一个必须小心的问题:deny 是短路,但 allow 不是。如果你的组织级策略对某次调用返回了 deny,后面所有策略都不再执行——这是想要的行为。但如果组织级策略返回 allow,它并不会让后续策略跳过检查,后续策略仍然可以 deny。所以在设计多层策略时,应该把「最严格的、最希望抢先拒绝的」放在前面,把「更细致、更愿意放行」的放在后面。反过来排布是常见错误。
另外提一句与守卫的配合。如果你的门禁里有真正的红线,比如「无论什么策略排序都不得写系统目录」,那这一段不该写在这个 pre-execute 插件里,而应该用 ctx.tools.guard() 再注册一个 ToolGuard。因为 pre-execute 插件是可以被后面的 allow 覆盖的,guard 不能。一个务实的项目结构是:guard 放红线,pre-execute 放可协商策略,post-execute 放结果改写与反馈,result 放埋点审计。四层各就各位,后面维护起来才不会互相打架。
最后给一个部署与验证的检查清单,帮助你确认门禁真的生效了:
- 调用一个黑名单工具(如 fs_write),确认返回的是 deny 对应的错误结果,而不是工具真实执行后的结果。
- 调用一个参数里含 .env 的允许工具,确认被拦。
- 调用一个完全正常的工具,确认仍然能拿到真实结果——这一步用来排除「忘了 return next() 导致全部挂起」的经典事故。
- 临时用 guard 注册一条红线,确认 pre-execute 里的 allow 无法绕过它。
总结与最佳实践
把整篇文章的要点压缩成一份可执行清单,写工具、接流水线的时候照着过一遍:
- 工具定义用 defineTool,字段齐全:name、description、parameters、output.schema、output.render、execute。output.schema 声明规范值类型,output.render 负责把规范值翻成面向模型的内容,execute 返回规范值。
- 注册走 ctx.tools.register,插件要 export inject = ['tools'],把依赖显式声明出来。
- 记牢流水线顺序:tools/pre-execute → 单调守卫 → tools/execute → tools/post-execute → finalizeContent → tools/result。
- PreToolDecision 三态:allow 放行继续;deny(reason) 物化成错误结果并跳过工具主体;ask(reason?) 触发 ctx.approval 一次性询问,且仅 allowed-once 才继续。
- 绝不改写参数。arguments 在策略前已冻结,为了历史记录、审计、UI 与执行四方一致。需要修正参数,走 post-execute 的 block 反馈给模型重来。
- invariant 用 ctx.tools.guard()。ToolGuard 签名是 (execution: Readonly<ToolExecution>) => string | undefined,返回字符串即拒绝、undefined 即维持现状;没有 allow,所以顺序永远无法把拒绝翻回允许——这就是单调。
- 超时、重试、指标放 tools/execute。它拿 ToolDispatchExecution,只有这里能替换 exec.signal 施加截止时间;规则是「可替换、不可移除」,注册表会在调用主体前重新融合调用方 signal。
- PostToolDecision 分清粒度:accept(content?) 只换展示内容、保留规范值与元数据;accept(value) 换规范值,会重新校验并重算内容;block(feedback) 把纠正反馈变成错误结果。
- 保密别指望 content。内容替换是展示策略不是保密策略;要藏程序化值,必须替换 value 或 block。
- finalizeContent 是工具定义自己的回调,注册表恰好调用一次、同步、只做最后的内容修正;之后结果被物化冻结,触发同步的 tools/result。
- tools/result 是只读观测:观测者无法变换结果,观测者失败被隔离,不影响主流程。审计、指标、捕获最终结果用它;要变换结果或附加上下文用 post-execute。
- 门禁插件就是普通 Cordis 插件。命中黑名单(如 fs_write、fs_edit)或敏感路径(含 .env)就 return deny;放行必须显式 return next(),漏了会把整条链挂住。
- 策略排序靠插件注册顺序,不是硬编码优先级。把最严格、最想抢先拒绝的放前面;记住 deny 短路、allow 不短路,所以顺序设计要围绕「谁有权先拒绝」。
- 分层归位:guard 放红线不变式,pre-execute 放可协商策略,execute 放横切关注点,post-execute 放结果改写与反馈,result 放埋点审计。
- 上线前跑四步验证:黑名单被拦、敏感参数被拦、正常工具仍通、guard 红线不可被 allow 绕过。
写到这里,defineTool 定义工具与工具执行流水线这两段就完整了。工具定义决定模型「能看见什么、怎么调用」,流水线决定每次调用「经过哪些关卡、结果如何回到模型」。把这两层都理清,再去接审批、沙箱、可观测性这些高级主题,路就顺了。