给 DeepSeek Harness 加一个自定义工具:Cordis 插件实战
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 前校验args(packages/core/tools/src/schema.ts:545的defineTool,validate就是validateJsonSchemaValue)。官方文档原话:defineTool infers and validates args from parameters。 ctx.tools.register校验output必须带schema和render,然后把它插进注册表,返回一个 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 }](源码 createSuccessResult 在 index.ts:1793,流程是 validate → freeze → render → snapshot)。
分两层意味着:工具返回的规范值(value)是给程序用的权威结果,模型看到的(content)是渲染出来的投影。你的工具可以返回一个结构化对象,render 把它压成文本给模型看。换 render 不影响 execute 逻辑,反之亦然——展示和计算解耦了。后面第 7 节反推架构时你会看到,这套"规范值 + 投影"的设计贯穿整个工具执行管线。
4. 挂进 profile:三条路
插件写好了,接下来是挂载。挂进 profile 有三条路。官方文档只把其中一条讲全——而且是在源码仓库的语境里;剩下两条,文档最多在层序图里带过一句。先卖个关子:这三条路的坑,后面逐个踩给你看。
动手前先分清两个概念,这是 010 强调过、我在这里再打一遍的:
- boot profile:
dsh --profile web启动的,只有web/headless两种。 - agent-preset:
standard/code/minimal/cordis四份 YAML,是"人格 + 工具组合",挂进 boot profile 的插件树。
加工具 = 往 web profile 的插件树顶层插一个插件。web profile 默认挂 agent-presets 的 standard preset,standard 不做 tools.restrict 过滤(第 5 节讲,这是源码确认的),所以全局注册的工具,standard 人格的 agent 也能看到。
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,或者用下面的无密钥组合。
验证有三个层次,对应三种手段:
- 加载日志:boot stdout 出现标记行
[my-greet-tool] apply() ran, registering "greet" tool。证明 import 了插件、apply 跑了、执行到了注册那一步。这是"加载"成立的最硬证据。 --dump-config看插件树:dump 里多一个图层、多一条插件行。这只是"配置树里有条目",boot-free,不 import 插件。- 注册工具列表:真正证明"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 那条展开说:standard 的 agent.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:zlibzstd(22.15+)和node:modulestrip-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/dsh和node 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_modules里dsh-my-tool的符号链接还在,还留着.pnpm/、pnpm-lock.yaml这些。要删就整个删node_modules。
7. 从插件反推架构
到这里,工具已经挂上、能注册、能执行了。回头看一眼,你写的这几十行,其实印证了整个 harness 的架构主张。我要立一个判断,它可能有人不同意:工具是插件,不是特例。 这不是口号,是整套架构成立的前提——你加工具时真正要理解的东西。
dsh 里没有"内置工具"和"插件工具"之分。内置的 bash、fs、web 工具和你的 greet 一样,都是往 ctx.tools 注册的 ToolDefinition。区别只在谁写、有没有随发行版打包。这带来一个直接后果:加能力不用等官方,也不用改源码。官方功能是插件,源码就是最好的教材——想抄一个官方工具,直接读它的插件怎么注册的。
你的自定义工具进了注册表之后,就被一条完整的执行管线接管。我在之前的文章中拆过这条流水线,这里结合源码再把"你的工具在哪个环节"标出来:
你的工具只占中间一个小环节(紫色高亮):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
更多推荐


所有评论(0)