跳至内容

官方工具插件完整契约

这篇把“写一个工具”拆成 DSH 真正使用的几层:插件怎样注册工具,模型怎样看到工具,调用怎样经过策略和执行流水线,结果怎样进入模型、Session 和 UI,以及取消、并发、后台任务和卸载怎样收尾。固定提交中的工具 README 和官方教程是本文的事实来源。

证据范围:本文固定到上游提交 aa6c361a972c8369148dea7380bb5c21c24e07ec;官方源码、README 和测试链接都指向这个提交。本学习仓库没有运行上游 DSH、真实模型或第三方插件,因此本文的“测试覆盖”是源码中的测试证据,不是本地运行结果。

如果你还没理解“工具已注册、模型可见、执行允许”的三层关系,先读工具可见性与非侵入扩展。本文进一步说明工具已经进入系统后,schema、呈现、权限、执行和卸载怎样组成完整契约。

先理解工具插件和普通插件的关系

工具插件仍然是 Cordis 插件。它通常导出名称和 inject 声明,在 apply(ctx) 中调用 ctx.tools.register();工具注册表随后把 schema 送进系统提示词,并在模型调用时负责校验、策略、执行、结果规范化和观察。

ts
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'greet-tool'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'greet',
    description: 'Greet someone by name.',
    parameters: {
      name: { type: 'string', required: true },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args, exec) {
      if (exec.signal.aborted) throw new Error('cancelled')
      return 'Hello, ' + args.name + '!'
    },
  }))
}

这段代码有四个关键点:inject 让 Cordis 等待 tools 服务;parameters 描述模型输入并推导 args 类型;output.schema 描述工具主体返回的规范 JSON 值;output.render 把该值转换为面向模型的内容。工具主体返回值,不直接返回内容块,也不自己决定 UI 卡片。

注册返回 disposer,并随所属 Fiber 一起释放。同一作用域内重复工具名会失败;缺失或不支持的 output 声明、非法 timeoutMs 和非工具允许的保留名称也会在注册阶段失败。这个“加载时尽早失败”的设计能把配置错误和执行时错误分开。

工具调用经过哪些阶段

一次调用可以先记成这条链:

text
模型产生 tool call
  -> tools/pre-execute
  -> ctx.tools.guard 单调守卫
  -> tools/execute 环绕分发
  -> 工具 execute 主体
  -> tools/post-execute
  -> finalizeContent
  -> tools/result 实时观察
  -> tool/result 持久化到 Session
  -> UI 的 presentResult

tools/pre-execute、tools/execute 和 tools/post-execute 是可以被插件监听和组合的 waterfall。前置阶段可以允许、拒绝或询问;execute 阶段适合超时、重试和指标等环绕逻辑;后置阶段可以替换结果、阻止结果或增加模型可见上下文。工具定义自己的 finalizeContent 最后只负责面向模型的 content 不变量,tools/result 只观察最终结果。

拒绝或审批失败时,工具主体不会运行;策略、工具主体、包装器或结果规范化抛错时,注册表仍把它们收敛到统一的错误结果路径。这样 UI、Session 和模型都能看到一致的结果分类,而不是由每个工具自定义一套失败协议。

tools/result 是实时事件,适合指标、日志和观测;tool/result 是随后写进 Session 的持久事件,面向会话恢复和下一次模型上下文。名称相似但职责不同,不能把实时监听器当成持久化机制。

schema、规范值和 render 的区别

output.schema 是程序之间交换的规范 JSON 值。它可以是对象、数组、字符串、数字、布尔值或 null;注册表会在结果进入后续阶段前快照、校验和冻结。工具主体只返回这个值,不能依赖 UI 解析一段自然语言来取得 id、路径或状态。

output.render(args, value) 是面向模型的内容投影器。它可以把规范值转成文本内容或其他模型可见内容,但内容替换不是保密边界;如果程序化消费者不应拿到某个值,应阻止调用或替换规范值,而不是只隐藏 render 的文字。

UI 卡片是第三个层次。presentCall(args) 和 presentResult(args, result) 是由工具提供的纯展示意图,返回 generic、terminal、diff、search、read 或 web 等带 card 标签的数据。工具不应导入 React 或绑定某个具体 UI;host/client 根据中性意图决定怎样渲染。文件编辑管线实验用 str_replace_editor 演示这条契约的两端:请求侧生成 diff 卡载荷(oldText/newText 就是请求里的 old_str/new_str),执行侧按唯一匹配规则决定到底有没有东西可呈现——零次与多次匹配连写入都不会发生。search 与 web 两族结果卡同理由 meta 驱动重放:Web 工具管线实验展示 fetch 的有效截断为什么必须随日志携带,以及搜索多查询怎样轮转合并、URL 去重、封顶即停。

presentCall 和 presentResult 必须是纯函数:不能读取文件、Session、时钟或随机数。需要在回放时保留的事实,应通过规范结果或 output.presentationMeta 传递;不能在 UI 展示器里临时访问外部状态。

native、code 和 both

工具运行时的 mode 不是三个不同的执行器,而是工具向模型呈现的方式:

mode模型看到什么需要注意什么
native每个工具的原生 Function Calling 定义模型可以直接发起对应工具调用
code保留的 run_code 传输和生成的 tools:sdk只有 run_code 可以被模型直接调用,其他工具名会被识别为 UNKNOWN_TOOL
both原生定义和 Code Mode 同时可用两种入口都进入同一执行流水线

没有单独声明的 agent 默认采用 tools 配置里的 mode。agent 作用域可以调用 presentAs(mode) 为自己遮蔽默认呈现方式;这只影响该 agent 的提示词和工具列表,不会修改全局注册表,也不会让工具脱离正常执行流水线。

Code Mode 需要当前语言有 SDK renderer。没有相应 renderer 时,提示词组装应明确失败,而不是发送一个模型无法使用的半成品协议。system-prompt/assemble 也可以替换注册表贡献,因此它必须保留当前 mode 所需的 run_code 或 SDK 规则。「run_code 发起的内部调用为什么仍要走完整权限管线」这个问题,Code Mode 权限管线实验用固定 seed 的二维时间轴逐步展示:策略拒绝的调用没有主体区间,但仍结算 post-execute 和 result。

presentAs 只能从 agent context 使用,同一 scope 重复声明会失败;普通根 context 不能用它把整个进程的 mode 偷换掉。工具目录本身仍然可以被 schemas 查询,改变的是该 agent 最终看到的呈现。

restrict 是可见性组合,不是系统权限

agent context 可以调用 restrict(filter) 组合工具可见范围。多个筛选器取交集,再合并该 agent 自己注册的本地工具;被限制掉的全局工具在 get() 中表现为不存在。未知名称、保留名称、本地工具和空筛选器会被拒绝。

restrict 解决的是“这个 agent 的提示词和解析结果里有哪些工具”,不是“操作系统是否允许文件、网络或子进程”。真正的执行权限应由工具策略、审批、沙箱、宿主账号和操作系统能力共同决定;不要用 restrict 代替安全审计。多层筛选叠加时每个工具卡在哪一层,工具可见性实验用一份 12 工具的教学清单逐行给出判定和挡下它的那道收窄。

设计工具插件时,可以先设一个“工具预算”:正常 agent 只保留完成当前任务所需的最小集合,调试 agent 再打开诊断工具。注册表可以继续拥有完整能力,但模型面对的 schema 应由作用域和呈现规则决定;这不是把工具从系统中删除。

guard 为什么是单调拒绝

tools/pre-execute 是可扩展的前置策略,可以允许、拒绝或询问;ctx.tools.guard() 是工具拥有方或部署方注册的同步守卫。guard 返回字符串时形成拒绝,返回 undefined 时保持原决定;后续 waterfall 监听器不能把这个拒绝改回允许。

单调拒绝是为了避免“后注册的普通监听器意外绕过拥有方策略”。guard 应放在能判断真实调用身份、参数和 agent 的位置,并返回能让使用者定位原因的简短说明。它仍然不是操作系统隔离,工具内部的文件、网络和子进程行为还要经过对应能力层。

ask 是 DSH 的审批路径,不是工具自己拿到的 OS 权限。结局是封闭的四个词:allowed-once、rejected、cancelled、unavailable;没有可用 answerer、应答者抛错或返回词表外的值,都归一化为 unavailable。审批通过也只代表这一次 typed tool decision 被允许。

这条规则有一个可以亲手触发的版本:审批流实验把「工具主体 → 审批服务 → 应答者 → Session 日志 → 结果」摆成时间线,策略切到 never 看派发前的确定性拒绝,把应答者拿走看 fail closed 怎么发生,每次询问还会在日志泳道落一对 approval/asked 与 approval/decided 审计事件。

互动组件审批流实验单独打开

正在载入互动组件……

不看组件也能读的说明

组件是固定教学模型:三个参与方、两条裁决路径、一份确定性步骤表。它不弹出真实审批界面,也不连接宿主;真实 UI 形态以宿主实现为准。

取消:工具必须主动配合

每次类型化调用都带有调用方拥有的 AbortSignal,工具主体从 exec.signal 读取它。调用开始前已经取消,会产生 ABORTED_BEFORE_DISPATCH 并跳过策略和主体;主体启动后取消,会把成功结果替换为 ABORTED,除非更具体的拒绝、失败或超时结果已经胜出。

取消是协作式的。异步工具必须把 signal 传给 fetch、readFile、子进程或自己的循环,并且只有在工作真正停止后才结束 Promise。注册表不会因为 signal 触发就提前假装工具停稳;它会等待已启动的同进程 Promise 结算。

tools/execute 包装层可以临时替换 dispatch 视图中的 signal 以施加超时,但注册表仍会保留调用方原始取消。插件不要缓存旧 signal,也不要在取消后继续写 Session、发送通知或创建新资源。

并发:只有严格 true 才安全

工具可以声明 isConcurrencySafe(args)。只有该函数严格返回 true 时,注册表才把这次调用分类为 parallel;返回 false、undefined、非布尔值、抛异常、参数无效或工具不可见时,全部按独占执行。

并发安全不只是“函数没有 await”。并行工具不能不受保护地修改父 agent 状态、共享文件、全局缓存、终端会话或一次性外部资源;如果状态更新不满足交换性,就应该返回 false 或不声明并发安全。测试要同时覆盖 true 和所有保守降级路径。

后台任务和前台调用不是一回事

长时间任务可以通过 producer 和 ctx.jobs.start() 发布后台 job。工具成功返回的是带类型的句柄,例如 { kind: 'background', jobId },而不是让后续代码解析一段自然语言。任务服务负责 id、owner、会话围栏、取消、通知和 owner dispose。

前台工具应使用 exec.signal;后台任务发布成功后,应使用任务自己的取消信号。外层调用被取消,只表示调用方不再等待本次调用,不应自动杀掉已经明确发布的后台工作;job_kill、owner dispose 和服务 teardown 才拥有后台任务生命周期。

producer 还应提供同步 cancel、资源清理后 settle 且不 reject 的 done,以及可选的有界 readOutput。要测试预取消、发布失败、owner dispose、job kill、输出截断和进程退出,不能只测试"返回了一个 jobId"。这套生命周期有一个可交互版本:后台任务生命周期实验用三个剧本(读者流/杀手流/拆除流)逐步推状态迁移与 reported 认领——包括「取消后 producer 迟到地 resolved(completed)」这类先到先得的判定。编排面的另外两块也各有实验:定时与工作流实验覆盖 schedule 三种触发器的追投语义,以及 workflow 引擎的 meta 前置校验、agent 按 seq 配对和有界宽限强结算。凭据解析的可用性检查与 fail-closed 语义由 credential 实验覆盖。

finalizeContent 能做什么,不能做什么

finalizeContent 是工具定义的同步回调,在结果完成规范化后运行。它可以维护最终面向模型的 content,不可以更改规范 value、绕过 schema、改变权限决策或成为秘密数据隔离层。它也必须对成功、失败、后置策略错误和其他结果都有定义。

如果需求是“不让某个程序化消费者看到值”,应在前置策略中拒绝,或在后置阶段替换规范值;只替换 content 会留下原始 value 给代码路径。这个区分是工具插件最容易被误写成安全边界的地方。

最小测试路径

先对 parameters 和 output schema 做单元测试,覆盖必填值、类型错误、枚举、嵌套对象、无效输出和渲染器失败。再用最小 Context 挂载插件,断言工具出现在 schemas/get 中、能执行一次、dispose 后注册消失。

接着使用真实 package manifest、Bundle patch、Profile 和 Loader 做组合测试,确认 inject、裸包解析、工具注册、mode、entry 激活和失败清理。然后构建包,让普通 Node 读取构建后的 exports/lib 入口;不能只在 workspace source alias 或 tsx 下证明成功。

如果工具改变模型可见 schema、工具结果、UI card 或 Session transcript,要增加固定输入的 snapshot 或组装示例。若工具调用外部 API、文件、网络、子进程或真实模型,再单独标记凭据、平台、模型和网络条件;没有 key 时跳过只表示未执行。

每个测试都要包含卸载断言:工具注册、监听器、timer、watcher、socket、worker、子进程和临时文件是否停止;取消发生时是否等待真正停稳;重复安装、启动失败、超时和 HMR 失败是否不会留下半启动资源。

推荐的源码阅读顺序

先读工具包 README,建立 API、流水线和取消的全貌;官方工具教程工具编写参考分别给出最小实现与完整契约清单,适合对照着读。

然后按依赖关系回到源码。从类型定义开始,它是其余文件共用的词汇表;再看注册表入口怎样把插件声明变成运行时里的工具;呈现意图schema 定义解释 UI 呈现和参数校验各自由谁决定;测试辅助展示官方期望的测试组装方式。

测试按同样的主线收尾:工具测试覆盖主流程,执行模式测试取消测试scope 测试分别钉住 mode、取消和作用域三条支线。读完参数 schema 测试,把每一条契约对到实际断言。

最容易犯的错误

  • 只注册 name 和 description,不声明 output.schema/render,导致模型和 UI 没有稳定结果协议。
  • 把 output.render 当成权限过滤,把 content 隐藏误写成 value 已经不可访问。
  • 用 restrict() 声称文件或网络被隔离,用 allow 结果声称取得操作系统权限。
  • 在 execute 主体里忽略 exec.signal,或把已经发布的后台任务错误地绑定到外层调用取消。
  • 让 isConcurrencySafe 在未知情况下默认 true,造成共享资源竞态。
  • 在 post-execute 里试图撤销 monotonic guard 的拒绝。
  • 只在单元测试里调用 apply(ctx),不测试真实 Bundle/Profile/Loader 和构建产物。
  • 把 tools/result 的实时观察写成 tool/result 的持久会话事件,或反过来。

固定版本官方入口