1. 引言:为什么给 harness 加工具?

读完这篇,你能给 DeepSeek Harness(dsh)加一个真实可用的自定义工具。不是改它的源码,是写一个插件、挂一条配置。

先亮个反差:官方教程教你改源码仓库——clone、pnpm install、node --import tsx vendor/cordis/bin.js。但普通人是 npm 装完 @deepseek-ai/dsh 才想加工具,这条路人没教全。我走的就这条路,从头到尾用 npm 安装版实跑。

我之前拆过 dsh 的架构:一切皆插件,模型、工具、会话、沙箱、Agent Loop 全是插件。那篇讲的是"能换什么",这篇把"加一个工具"真正做一遍。

读完你能拿走三样东西:一个能跑的 greet 工具、挂进 profile 的三条路、一张 Windows 踩坑清单。插件用 TypeScript 写,但照抄能跑,前端/TS 背景可有可无;真正拦人的是 Cordis 插件体系和"npm 安装版怎么开发"这件事。下面按顺序走:先看插件长什么样,再注册工具,挂进 profile,验证,踩坑,最后从插件反推架构。

2. 一个插件长什么样

在 Cordis 里,插件就是一个导出 apply 函数的模块。dsh 加载它时,用一个上下文调用 apply——那个参数就是 ctx,你通过它注册能力。最小形态长这样:

// greet-tool.js —— 最小插件骨架(纯 JS / ESM 写法)
export const name = 'my-greet-tool'   // 可选展示元数据,诊断日志用它标注插件
export function apply(ctx) {          // 插件主体,dsh 挂载时调用
  console.log('[my-greet-tool] apply() ran')
}

name 只是展示用,没有它插件照样跑(官方教程 01 章说的)。apply(ctx) 是主体,所有注册都发生在它里面。

Cordis 接受三种插件形态:函数(最常见,就是上面这个)、对象(带 apply 方法的对象字面量)、Service 子类,当你要向别人提供服务时才用)。加工具用函数形态就够了,类形态等你需要公开服务再学。

有两个细节先记住,后面全是它们的分身:

  • inject:声明这个插件依赖哪些服务。比如 export const inject = ['tools'],意思是"等工具注册表就绪再调我的 apply"。没有它,apply 里可能拿不到 ctx.tools
  • 纯 JS(ESM)零配置能跑。官方教程的 .ts 写法也能跑,但 npm 安装版需要 NODE_OPTIONS="--experimental-strip-types",纯 .js 不用(见第 6 节)。

插件目录里先放一个 package.json

{
  "name": "dsh-my-tool",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "main": "greet-tool.js",
  "dsh": { "bundle": { "patch": "./bundle.patch.yml" } }
}

"type": "module" 让同目录 .js 按 ESM 解析;"dsh": { "bundle": ... } 是 bundle 清单,只有走第 4 节路 C(dsh plugin add 装成 bundle)才需要,只用 --patch 或用户层挂载可以没有这段。

3. 给工具注册:defineTool

光有 apply 没用,要注册一个工具给模型用。dsh 提供 defineTool 帮你把工具定义规范化,注册进 ctx.tools。下面是完整的 greet-tool.js,直接抄:

// greet-tool.js —— 一个最小的 dsh 自定义工具插件(纯 JS / ESM 写法)
// --------------------------------------------------------------------
// 目标:挂载进 web profile 后,向 dsh 的工具注册表注册一个名为 greet 的工具。
// 加载方式:cordis-plugin-loader 用 Node 原生 import() 加载本模块,然后从
// 模块导出里找到 apply 函数并调用。

// 从 dsh-tools 引入 defineTool:把参数规格转成模型可见的 JSON Schema,
// 并在 execute 执行前校验模型给出的 args(官方 tool.md 推荐写法)。
import { defineTool } from '@deepseek-ai/dsh-tools'

// name —— 可选的展示元数据,仅用于诊断日志里标注这个插件。
export const name = 'my-greet-tool'

// inject —— 声明本插件依赖 tools 服务(工具注册表)。Cordis 会等 tools
// 就绪后才调用 apply,避免 apply 里 ctx.tools 尚不存在。
export const inject = ['tools']

// apply —— 插件主体。Cordis 挂载本插件时调用,ctx 是共享上下文。
export function apply(ctx) {
  // 标记行:真实运行时的取证证据。dsh 加载本插件且 apply 真正执行时会
  // 打印这行到 stdout——第 5 节的 boot 输出就是靠它证明「插件真被加载」。
  console.log('[my-greet-tool] apply() ran, registering "greet" tool')

  // ctx.tools.register(...) 把工具定义注册进注册表,并返回一个取消注册的
  // disposer(卸载插件时自动调用,无需手动清理)。工具注册进「全局层」,
  // standard preset 不做 restrict 过滤,所以 standard 人格的 agent 也能看到它。
  ctx.tools.register(defineTool({
    // name:工具名。模型在回复里用这个名字发起调用;同一作用域内不能重复。
    name: 'greet',
    // description:模型看到的工具说明,决定模型「何时」选择调用它。越精确越好。
    description: 'Greet someone by name.',
    // parameters:入参 JSON Schema。defineTool 会把它转成发给模型的 schema,
    // 并在 execute 前校验模型给出的参数;name 必填,类型 string。
    parameters: {
      name: { type: 'string', required: true, description: 'The name to greet' },
    },
    // output:输出契约。
    //   schema —— 声明 execute 返回值(「规范值」)的 JSON Schema,注册时校验;
    //   render —— 把规范值转成模型可见的 Native 内容块(这里是纯文本块)。
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    // execute —— 真正干活的函数。args 已被校验过(name 必填且是字符串)。
    // 返回的值必须符合 output.schema(这里是 string),否则抛 INVALID_TOOL_OUTPUT。
    async execute(args) {
      return `Hello, ${args.name}!`
    },
  }))
}

defineTool 做了三件事,对应的源码都在本地仓库:

  • parameters 规约转成发给模型的 JSON Schema,并在 execute 前校验 argspackages/core/tools/src/schema.ts:545defineToolvalidate 就是 validateJsonSchemaValue)。官方文档原话:defineTool infers and validates args from parameters。
  • ctx.tools.register 校验 output 必须带 schemarender,然后把它插进注册表,返回一个 disposer(packages/core/tools/src/index.ts:1037)。注册是 effect,插件卸载时自动撤销,不用手动清理。
  • 工具名冲突会直接抛错,源码里叫 “already registered”。

为什么 output 分 schema + render 两层? 这是我理解这套设计的关键,展开说。

schema 声明的是 execute 的返回值——我叫它"规范值"。注册时校验它合法,execute 返回后再次校验,不符合就抛 INVALID_TOOL_OUTPUT(源码 index.ts:513)。render纯投影:把规范值转成模型真正看到的内容块 [{ type: 'text', text: value }](源码 createSuccessResultindex.ts:1793,流程是 validate → freeze → render → snapshot)。

分两层意味着:工具返回的规范值(value)是给程序用的权威结果,模型看到的(content)是渲染出来的投影。你的工具可以返回一个结构化对象,render 把它压成文本给模型看。换 render 不影响 execute 逻辑,反之亦然——展示和计算解耦了。后面第 7 节反推架构时你会看到,这套"规范值 + 投影"的设计贯穿整个工具执行管线。

4. 挂进 profile:三条路

插件写好了,接下来是挂载。挂进 profile 有三条路。官方文档只把其中一条讲全——而且是在源码仓库的语境里;剩下两条,文档最多在层序图里带过一句。先卖个关子:这三条路的坑,后面逐个踩给你看。

动手前先分清两个概念,这是 010 强调过、我在这里再打一遍的:

  • boot profiledsh --profile web 启动的,只有 web / headless 两种。
  • agent-presetstandard / code / minimal / cordis 四份 YAML,是"人格 + 工具组合",挂进 boot profile 的插件树。

加工具 = 往 web profile 的插件树顶层插一个插件。web profile 默认挂 agent-presetsstandard preset,standard 不做 tools.restrict 过滤(第 5 节讲,这是源码确认的),所以全局注册的工具,standard 人格的 agent 也能看到。

my-tool 插件目录
greet-tool.js + package.json

挂进 web profile 三条路

路 A:--patch 覆盖层
insert-greet.patch.yml

路 B:profile 用户层
$DSH_HOME/profiles/web/cordis.patch.yml

路 C:dsh plugin add
装成 bundle

web profile 插件树顶层
insert 追加一条插件行

standard agent 可见
全局注册,preset 不 restrict 过滤

4.1 路 A:--patch 覆盖层

写一个 patch 文件,指定插入的插件。注意 name 用的是 file:///D:/... 前缀的绝对路径——Windows 上 Node ESM 不接受裸 D:/...,实测会报 ERR_UNSUPPORTED_ESM_URL_SCHEME(protocol ‘d:’)。官方 basic/index.zh.md 只说了"插件路径必须是绝对路径",那是 POSIX 风格,Windows 读者照抄必踩坑。

insert-greet.patch.yml

# --patch 覆盖层:向 web profile 的插件树顶层插入 my-greet-tool 插件。
# 没有 id,insert 直接追加到顶层数组。
- insert:
    - id: my-greet-tool
      name: file:///D:/dsh-practice/my-tool/greet-tool.js

挂载后 dump 配置树(不启动、只看叠加结果):

cd /d/dsh-practice && node node_modules/@deepseek-ai/dsh/lib/bin.js --profile web \
    --patch D:/dsh-practice/my-tool/insert-greet.patch.yml --dump-config

我本机基线 dump 是 490 行,挂载后 493 行,diff 真实结果:

490a491,493
> # == D:\dsh-practice\my-tool\insert-greet.patch.yml
> - id: my-greet-tool
>   name: file:///D:/dsh-practice/my-tool/greet-tool.js

dump 在末尾新增一个以 patch 文件路径为注释的图层,里面是刚插入的插件条目。这证明"插件进了配置树"。

真 boot(启动 profile,会 import 插件):

cd /d/dsh-practice && node node_modules/@deepseek-ai/dsh/lib/bin.js --profile web \
    --patch D:/dsh-practice/my-tool/insert-greet.patch.yml

stdout 真实输出:

[my-greet-tool] apply() ran, registering "greet" tool

这行是 apply 里的 console.log。它出现在 stdout,证明 Cordis 真的 import 了插件、真的执行了 apply、执行到了注册那一步。插件加载成立。

但完整 boot 在我这台机器上是失败的,stderr 真实报错(关键两处):

Error: failed to import loader entry session-persistence-jsonl (@deepseek-ai/dsh-session-persistence-jsonl): The requested module 'node:zlib' does not provide an export named 'createZstdDecompress'
Error: failed to import loader entry code-runtime (@deepseek-ai/dsh-code-runtime-worker-thread): The requested module 'node:module' does not provide an export named 'stripTypeScriptTypes'

原因是我这台 Node 是 v22.12,base 包要用 node:zlib 的 zstd 和 node:module 的 strip-types——前者 Node 22.15+/23.4+ 才有,后者 22.18+/23.6+ 才有。这两个 entry 和 my-greet-tool 并发启动,报错把整棵树拖垮了;而你的插件在它们报错前就 apply 完了。所以:插件加载成立;完整 web boot 在我这台 Node 22.12 上不可行,与插件无关。 写作日(2026-08-14)我 WebSearch 到官方现在要求 ^22.19.0 || >=24.0.0,装新 Node 的读者可以跳过这个坑。

4.2 路 B:profile 用户层 cordis.patch.yml

把同样的内容写进 profile 自己的 patch 层:$DSH_HOME/profiles/web/cordis.patch.yml(我本机是 C:/Users/admin/.dsh/profiles/web/cordis.patch.yml,初始为空 [])。

# Your patch layer for this dsh profile, applied after every bundle layer.
- insert:
    - id: my-greet-tool
      name: file:///D:/dsh-practice/my-tool/greet-tool.js

再 dump,diff 真实结果:

490a491,493
> # == C:\Users\admin\.dsh\profiles\web\cordis.patch.yml
> - id: my-greet-tool
>   name: file:///D:/dsh-practice/my-tool/greet-tool.js

和路 A 唯一区别是图层注释从 --patch 的路径变成 profile 自己的 cordis.patch.yml。boot 行为与路 A 相同——同一条 insert 语义,apply 照样跑。这条路的坑是:它藏在 $DSH_HOME/profiles/<name>/ 下面,官方文档只在层序图里提了一句,很多人根本不知道有这层。

4.3 路 C:dsh plugin add 装成 bundle

这条路把插件装成组合包(bundle),进 profile 的依赖和层栈。dsh plugin 在 profile 目录里转发给 pnpm,所以前提是环境里有 pnpm——我本机 pnpm 10.32.1 实测可用(NodeSoft 版 Node 自带)。

cd /d/dsh-practice && node node_modules/@deepseek-ai/dsh/lib/bin.js plugin --profile web add D:/dsh-practice/my-tool

第一次装(package.json 还没有 dsh.bundle)时,真实输出里有一句关键的警告:

dependencies:
+ dsh-my-tool link:D:/dsh-practice/my-tool

Already up to date
Done in 333ms using pnpm v10.32.1

dsh: warning: dsh-my-tool declares no dsh.bundle — installed as a plain dependency, not a profile layer (a later update that gains one activates it automatically)

没声明 dsh.bundle,它只是普通依赖,不进层栈,dump 里没有 dsh-my-tool 图层。补上 bundle 清单(就是第 2 节 package.json 里那行 "dsh": { "bundle": { "patch": "./bundle.patch.yml" } }),再 remove + add 一次。现在 profile 的 package.json 真实内容:

{
  "name": "dsh-profile-web",
  "private": true,
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "@deepseek-ai/dsh-web-app",
        "dsh-my-tool"
      ]
    }
  },
  "dependencies": {
    "dsh-my-tool": "link:D:/dsh-practice/my-tool"
  }
}

bundle 清单指向的 bundle.patch.yml,插件行按包名引用,不是相对路径:

# dsh.bundle 清单指向的补丁层:安装为 bundle 后,这一层会被叠加到 profile。
# 插件行按包名引用(不是相对路径),Node 从安装位置解析到已安装的代码。
- insert:
    - id: my-greet-tool
      name: dsh-my-tool

dump 真实 diff:

490a491,493
> # == dsh-my-tool
> - id: my-greet-tool
>   name: dsh-my-tool

boot 真实 stdout,bundle 方式同样加载成功:

[my-greet-tool] apply() ran, registering "greet" tool

bundle 的插件行按包名 dsh-my-tool 引用还能解析,靠的是 dsh boot 用 node-addon-require-builtin 拿到 Node 内部 ESM loader,并把 bare 包名锚定在 profile 目录解析。别用普通 import('dsh-my-tool') 的心理模型去推断——在 D:/dsh-practice 下普通 import('dsh-my-tool') 实测是 ERR_MODULE_NOT_FOUND

三条路走完,悬念兑现:路 A 的坑在 Windows 绝对路径必须 file:/// 前缀、Node 版本下限;路 B 的坑在它藏在 profile 目录下、官方没当一条路教;路 C 的坑在 dsh.bundle 声明、pnpm 有无、以及 file: 依赖复制非链接(第 6 节细说)。

5. 验证:它真的被加载了吗

三条路都验证到"配置树里有条目"了。但这里有个最关键的坑,我要单独讲:dsh --dump-config 不等于加载。 dump 是 boot-free 的,不会 import 插件,只能证明"配置树里有条目",不能证明"真的加载"。运行时证据要看 boot 的 stdout,或者用下面的无密钥组合。

验证有三个层次,对应三种手段:

  1. 加载日志:boot stdout 出现标记行 [my-greet-tool] apply() ran, registering "greet" tool。证明 import 了插件、apply 跑了、执行到了注册那一步。这是"加载"成立的最硬证据。
  2. --dump-config 看插件树:dump 里多一个图层、多一条插件行。这只是"配置树里有条目",boot-free,不 import 插件。
  3. 注册工具列表:真正证明"greet 进了注册表",用无密钥的最小组合。这是最扎实的一条,因为它走的是真实执行管线。

最小组合是官方教程第 7 章的手法,无 API key 也能跑。它把 dsh-system-prompt + dsh-tools + greet-tool 拼起来,再加一个观察者插件,用 ctx.tools.execute(...) 代替模型驱动一次真实调用。

cordis.yml

# 无密钥的最小工具运行组合(对应 cordis 教程 07 章):
# dsh-tools 需要 dsh-system-prompt(tools 服务注入 systemPrompt),
# 两者必须先列出,否则 tools 服务会一直 PENDING。
- name: '@deepseek-ai/dsh-system-prompt'
- name: '@deepseek-ai/dsh-tools'
- name: './greet-tool.js'
- name: './tool-logger.js'

launcher.mjs(复制官方 vendor/cordis/bin.js 的最小启动器,纯 JS):

// launcher.mjs — 复制官方 cordis 教程 vendor/cordis/bin.js 的最小启动器。
// 用 npm 安装版的 @deepseek-ai/cordis + @deepseek-ai/cordis-plugin-loader,
// 从 ./cordis.yml 组合插件树。纯 JS,不需要 tsx。
import { Context } from '@deepseek-ai/cordis'
import { pathToFileURL } from 'node:url'
import Loader from '@deepseek-ai/cordis-plugin-loader'

const ctx = new Context()
ctx.baseUrl = pathToFileURL(process.cwd()).href + '/'

await ctx.plugin(Loader)
await ctx.loader.create({
  name: '@deepseek-ai/cordis-plugin-include',
  config: {
    path: process.env.CORDIS_CONFIG ?? './cordis.yml',
  },
})

tool-logger.js(观察者 + 驱动,代替模型发起工具调用):

// tool-logger.js — 观察者插件:为 greet 工具的「注册」和「执行」取证。
// 不依赖 LLM/API key:它直接走 ctx.tools 注册表的真实管线驱动一次调用,
// 代替模型发起工具调用(与 cordis 教程 07 章的手法一致)。
import { CallId } from '@deepseek-ai/dsh-llm'

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

let done = false

export function apply(ctx) {
  // 取证 1:监听 tools/change 事件。每次触发时 dump 当前模型可见的
  // 工具名列表,证明 greet 真的进了注册表。
  ctx.on('tools/change', () => {
    const names = ctx.tools.schemas().map(s => s.name).sort()
    console.log('[tool-logger] tools/change; visible tools =', JSON.stringify(names))
    void maybeRun()
  })

  // 启动时也 dump 一次注册表快照(避免 tools/change 在本监听器挂上之前
  // 就已触发),直接证明 greet 出现在模型可见的工具列表里。
  void (() => {
    const names = ctx.tools.schemas().map(s => s.name).sort()
    console.log('[tool-logger] startup schemas =', JSON.stringify(names))
  })()
  void maybeRun()

  async function maybeRun() {
    if (done) return
    if (!ctx.tools.get('greet')) return
    done = true
    // 取证 2:通过注册表真实执行 greet。这就是模型调用工具时走的同一条
    // 执行管线(tools/execute -> execute body -> tools/result)。
    const result = await ctx.tools.execute({
      callId: CallId('demo-1'),
      name: 'greet',
      arguments: { name: 'Cordis' },
      signal: new AbortController().signal,
    })
    console.log('[tool-logger] execute greet ->', JSON.stringify(result.content))
    process.exit(0)
  }
}

跑它:

cd /d/dsh-practice/my-tool && node launcher.mjs

真实输出三行,exit 0:

[my-greet-tool] apply() ran, registering "greet" tool
[tool-logger] startup schemas = ["greet"]
[tool-logger] execute greet -> [{"type":"text","text":"Hello, Cordis!"}]

三行分别证明:① 插件加载、apply 执行;② 注册表模型可见列表里有 greet,注册成立;③ ctx.tools.execute 通过真实执行管线跑出 Hello, Cordis!,可执行成立。整条验证链闭环,而且没有碰 API key。

顺带一个真实的小现象:输出里没有 tools/change; visible tools = 那行,因为 greet-tool 先于 tool-logger 完成注册,事件在监听器挂上之前就发过了。这正是官方教程说的"entries start concurrently,列表位置不保证加载顺序",所以观察者补了一个启动时快照。

诚实边界我照实列,这是这篇的地基:

状态
路 A/B/C 的 dump diff 本机实跑(490→493 行)
路 A/C boot 的 apply 标记行 本机实跑
最小组合 schemas + execute 本机实跑(三行输出)
.ts 插件 + --experimental-strip-types 本机实跑
npx shim 与 node 直跑一致性(dump) 本机实跑,逐字节一致
路径机制(file:/// 前缀、包名锚定) 本机实跑
dsh plugin remove 符号链接残留 本机实跑
模型真正看到/调用 greet(需 boot + API key) 仅源码依据,未实跑
standard preset 不做 restrict 过滤 仅源码确认(grep preset YAML + ToolRuntime.view
完整 web profile 起 webserver + 正常交互 本机失败(Node 22.12 太旧,base 包报错)
dsh.bundle 官方发布流程(pnpm pack / tarball / git) 仅读 publish.md,未实跑
热重载 HMR 未实跑

standard 那条展开说:standardagent.cordis.yml 我 grep 过 restrict|allow|deny,没有 tools.restrict/allow/deny,只有无关的 allowParallelInProgress: true(子代理配置)。再加上源码 ToolRuntime.view 的逻辑:一个 scope 的可见工具 = 继承的全局层(经 restriction 过滤)+ 自己 scope 注册的层。standard 不对全局层过滤,所以全局注册的 greet 会被 standard 人格的 agent 继承可见。这层是源码确认——模型真正调用它要完整 boot + 一次带 key 的 LLM 调用,本机无 key 且 boot 不了,没实跑。

6. 真实踩坑

把第 4、5 节的坑集中列一遍,每一条都是本机踩出来的或源码确认的:

  • file: / link: 本地依赖改源码不更新dsh plugin add 装本地路径时,profile 的依赖是 link:D:/dsh-practice/my-tool 这种形态,改插件源码后已装的那份不一定跟着更新,最稳的做法是 remove + add 一次。第三方博客园作者 pc2005 也踩过同款(他说 file: 依赖是复制非链接),两条路交叉验证一致。
  • inject 指向无人提供的服务,永远 PENDING 且不报错。最小组合里 dsh-tools 需要 dsh-system-prompt(tools 服务注入 systemPrompt),两者必须先列出;少列一个,工具插件就静默停在 PENDING,不崩溃也不报错。官方教程 03 章说得很透:PENDING 是合法状态,提供方可能稍后才挂载。排查姿势:ctx.registry 里看 fiber 状态。
  • Windows 绝对路径必须 file:///D:/...。裸 D:/... 实测 ERR_UNSUPPORTED_ESM_URL_SCHEME(Received protocol ‘d:’)。官方文档的 /absolute/path/to/... 是 POSIX,Windows 读者不能照抄。
  • Node 版本有硬性下限。base 包要 node:zlib zstd(22.15+)和 node:module strip-types(22.18+)。我实跑这台 22.12 起不来完整 web profile。写作日官方要求 ^22.19.0 || >=24.0.0。别只把失败归给"没 API key"——Node 太旧也 boot 不了。
  • .ts 插件在 npm 安装版需要 NODE_OPTIONS="--experimental-strip-types"(Node 22.12)或 --import tsx(更老 Node)。纯 .js 零配置。另外 loader 会把相对路径 ./greet-tool.ts 改写成 ./greet-tool.js,所以 .ts 插件在 patch 里要用 file:///D:/.../greet-tool.ts 这种 URL 形态。
  • Windows shim--dump-config 这类不启动 profile 的元命令,npx @deepseek-ai/dshnode node_modules/@deepseek-ai/dsh/lib/bin.js 实测逐字节一致;但启动 profile 那一步,建议统一用 node .../lib/bin.js 显式写法绕开 shim(shim 在启动路径是否挂)。
  • pnpm 有无取决于 Node 安装方式。我本机 NodeSoft 版自带 pnpm 10.32.1,dsh plugin add 实测可用;官方 Node 安装包默认不带。没 pnpm 就走路 A/路 B,不依赖 pnpm。
  • dsh plugin remove 不清理符号链接。remove 之后 profile 的 node_modulesdsh-my-tool 的符号链接还在,还留着 .pnpm/pnpm-lock.yaml 这些。要删就整个删 node_modules

7. 从插件反推架构

到这里,工具已经挂上、能注册、能执行了。回头看一眼,你写的这几十行,其实印证了整个 harness 的架构主张。我要立一个判断,它可能有人不同意:工具是插件,不是特例。 这不是口号,是整套架构成立的前提——你加工具时真正要理解的东西。

dsh 里没有"内置工具"和"插件工具"之分。内置的 bash、fs、web 工具和你的 greet 一样,都是往 ctx.tools 注册的 ToolDefinition。区别只在谁写、有没有随发行版打包。这带来一个直接后果:加能力不用等官方,也不用改源码。官方功能是插件,源码就是最好的教材——想抄一个官方工具,直接读它的插件怎么注册的。

你的自定义工具进了注册表之后,就被一条完整的执行管线接管。我在之前的文章中拆过这条流水线,这里结合源码再把"你的工具在哪个环节"标出来:

模型发起工具调用

prepareExecution
pre-execute waterfall + 审批 ask + 单调守卫

tools/execute 分发
timeout / retry 包在外面

你的工具 body
execute 执行,返回规范值

createSuccessResult
validate -> freeze -> render

post-execute waterfall

finalizeContent
materializeFinalResult

tools/result 通知
模型可见即已记录

你的工具只占中间一个小环节(紫色高亮):dispatchToolBody 里那行 tool.execute(exec.arguments, exec)(源码 index.ts:1532)。前后全是策略包裹层——pre-execute 瀑布(审批、权限、plan-mode 都挂在这)、单调守卫、post-execute、finalizeContent、tools/result。这些不用你写一行,注册进注册表就自动被接管。所以 dsh 的工具天然继承沙箱、审批、权限、plan-mode 这些策略——官方 extension-cookbook.zh.md 那张功能→机制映射表写得很清楚:权限门禁挂 tools/pre-execute,沙箱走 ctx.sandbox,Plan mode 也在这条轴上。

最后说 inject 的可逆副作用,这是架构里我最欣赏的一处。inject 不是一次性启动检查。03 章官方文档说得透:如果应用运行期间所需服务消失——提供方被卸载或热替换——每个依赖插件也会随之卸载,服务恢复后再次加载。结合 effect 机制(注册属于 effect,插件卸载时自动撤销),这防止运行中的消费方保留对不可用服务的引用。

对你意味着什么?你加的工具不是一个孤岛。它挂在注册表上,卸载自动撤销;它被流水线接管,天然带审批和守卫;它依赖的服务可替换——换 shell 提供方时,所有注入 shell 的插件会干净地重启,不残留对旧实现的引用。这些能力不是你写的,是架构白给的。

8. 结论:加一个工具的成本与收益

把账算清。收益:加能力不用等官方、不用改源码。写一个插件、挂一条配置,工具就进注册表,模型可见,走的是和内置工具完全一样的执行管线。成本:要懂 Cordis 插件结构(apply / inject / defineTool 三件套),踩一遍挂载、路径、依赖的坑。对 dsh 使用者,这成本不高——今天这篇照走一遍就能跑通。

"一切皆插件"对使用者,我压成一句话:你不用会写插件,但你要知道你随时能加、能换、能卸。 官方功能也是插件,源码就是最好的教材——想加一个和内置工具同级的自定义工具,现在你会的。

今天就把一个工具跑通。不需要 API key,最小组合三行输出就能证明它活了。跑通之后你会发现,“给 harness 加能力"这个动作,从"改源码"降级成了"加一条配置”。

如果你认同"工具是插件不是特例"这个判断,点个赞——这套思路值得被更多人看见。

下一步我打算给 standard 加一个真正有用的工具(不是 greet 这种 demo),或者把插件发布到 npm 走一遍完整流程(dsh.bundle + 发布 + 别人 dsh plugin add 装),看哪边呼声高先写哪边,关注的都算数。

你给 dsh 加过工具吗?卡在哪——是挂载路径的 file:/// 前缀,还是 inject 永远 PENDING,还是 Node 版本起不来?评论区说说,我每条都看。

代码点击这里下载:https://download.csdn.net/download/houwenjin/93278516

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐