01插件是什么
在 dsh 里,插件就是一个导出 apply 函数的模块。框架加载它时调用 apply,
传进来一个 ctx 上下文,你通过它注册能力。就这些——没有基类要继承,没有注解要写。
import type { Context } from '@deepseek-ai/cordis'
export const name = 'my-plugin'
export function apply(ctx: Context) {
// 在这里注册能力
}
这个模型的力量在于整个产品都是这样拼出来的:模型适配器、工具注册表、会话日志、 甚至 agent 主循环本身,都是和你写的这个东西同等地位的插件行。所以你能挂上去的位置,和官方能挂的位置是同一批。
02五分钟跑通第一个
-
建一个临时项目
仓库根目录mkdir -p scratch-plugin/src -
写插件
scratch-plugin/src/my-plugin.tsimport type { Context } from '@deepseek-ai/cordis' export const name = 'hello-plugin' export function apply(ctx: Context) { console.log('[hello-plugin] plugin loaded!') } -
用一个 overlay 把它插进插件树
scratch-plugin/cordis.yml- insert: - id: hello name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'这里最容易卡住路径必须是绝对路径。patch 文件只贡献配置,不会改变 loader 解析模块路径时所用的目录, 所以相对路径会解析到 profile 目录而不是你的项目。先
pwd拿到真实路径填进去。 -
带着 overlay 启动
终端pnpm dsh web --patch ./scratch-plugin/cordis.yml启动日志里会打印
[hello-plugin] plugin loaded!。跑通了。
03加一个模型能用的工具
这是最常见的需求:让模型多一项本事。把上面的文件换成:
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, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}
重启后在 Web UI 里说「用 greet 工具跟 Ada 打个招呼」,模型就会调用它。四个部分各管一件事:
| 字段 | 作用 |
|---|---|
inject: ['tools'] | 声明依赖。Cordis 会等工具注册表就绪后才加载你的插件,所以 apply 里 ctx.tools 一定可用 |
parameters | 模型看到的参数表。defineTool 据此推导并校验 args 的类型 |
execute | 真正干活的地方,返回 output.schema 声明的规范值 |
output.render | 把那个规范值转成模型看到的内容。纯函数,展示与计算分开 |
更复杂的场景——嵌套 schema、后台任务、策略钩子、Code Mode、UI 卡片——在仓库的
docs/cookbook/adding-a-tool.md 里有完整参考。
04接受配置
导出一个 Config 类型和同名的 Schemastery schema,默认值直接写在 schema 字段上:
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
export const name = 'my-plugin'
export interface Config {
greeting: string
maxRetries: number
}
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default('Hello'),
maxRetries: Schema.number().default(3),
})
export function apply(ctx: Context, config: Config) {
console.log(config.greeting) // 用户配的值,或 schema 默认值
}
然后在 cordis.yml 的插件行里给它配置:
- insert:
- id: hello
name: './src/my-plugin.ts'
config:
greeting: 'Hi there'
maxRetries: 5
Config 必须是 Schemastery schema。导出一个普通 JS 对象不会报错但会失效——
它没有实现 Cordis 要求的 Standard Schema 接口。
仓库自己的一条硬规矩
任何两个部署可能想设成不同值的东西,都必须是配置字段。判据很简单:
能不能只改 cordis.yml 就换掉这个值,而不用动代码。
// 错:硬编码的超时
const TIMEOUT = 30000
// 对:可配置
export interface Config {
timeoutMs: number // 默认 30000
}
另一条:配置错了要在加载时就大声失败。能自洽表达的约束都写进 schema, 这样非法配置在插件加载阶段就带着可操作的错误信息挂掉,而不是等到运行到一半。
05清理与生命周期
通过 ctx 注册的一切都会在插件卸载时自动回收——事件监听、工具、定时器都不用手动
removeListener 或 clearInterval。这是 Cordis 「注册即效果」的直接结果。
需要显式清理的资源(比如一个网络连接),用 ctx.effect() 交出它的销毁函数:
export function apply(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(() => console.log('heartbeat'), 5000)
// 返回的函数在插件卸载时执行
return () => clearInterval(timer)
})
}
这一点在开发时很实用:改配置会触发热替换——框架卸载旧实例、加载新实例。 因为注册都是可回收的效果,替换不会残留旧实例的注册。改完存盘即可,不用重启。
06三种插件形态
| 形态 | 写法 | 什么时候用 |
|---|---|---|
| 函数 | 具名导出 name / inject / Config / apply | 绝大多数情况 |
| 对象 | export default { name, inject, apply } | 想把元信息和实现放在一起时 |
| 类 | export default class X extends Service | 要给别的插件提供服务时 |
import { Service, type Context } from '@deepseek-ai/cordis'
export default class MyService extends Service {
static inject = ['tools']
constructor(ctx: Context) {
super(ctx, 'myService') // 之后别人可以 ctx.myService
// 同步初始化写在构造函数里
}
}
服务包用 default 导出服务类;函数插件用具名导出且不能有 default 导出。 两种形式混在一个模块里,Loader 会丢弃函数插件的命名空间——而且不会明确报错。
07打包成 bundle
前面用 --patch 加载的是本地文件。要让别人能装,得打包成 bundle。
先分清两个概念——它们都用 package.json 描述,但 dsh 字段下的清单不同,回答的问题也不同:
| 概念 | 是什么 | 回答的问题 |
|---|---|---|
bundledsh.bundle | 一个携带配置层的 npm 包 | 「这个包贡献了什么?」——一个插入或覆盖插件行的 patch 文件 |
profiledsh.profile | $DSH_HOME/profiles/<name> 下的一份可运行组合 | 「这套配置由哪些 bundle 按什么顺序组成?」 |
你写的是 bundle,用户启动的是 profile。没有东西同时是两者。profile 的清单不用手写,dsh plugin 会维护。
一个 bundle 的三个文件
hello-plugin/
├── package.json # 声明 dsh.bundle
├── cordis.patch.yml # profile 列入本 bundle 时应用的配置层
└── index.js # patch 行引用的插件模块
{
"name": "dsh-hello-plugin",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"files": ["index.js", "cordis.patch.yml"],
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
- insert:
- id: hello
name: dsh-hello-plugin
注意这里的 name 写的是包名而不是相对路径——这样 Node 的模块解析才能找到安装后的代码。
装进 profile
dsh plugin --profile demo add ./hello-plugin
dsh --profile demo --dump-config # 应能看到 "# == dsh-hello-plugin" 这一层
dsh --profile demo
首次使用会初始化 profile(第一个 bundle 是 @deepseek-ai/dsh-base),
因为你的包声明了 dsh.bundle,dsh 会把它追加进 dsh.profile.bundles。
没有这个声明的包也能装上,但只作为普通依赖——dsh plugin 会警告并且不激活任何层。
08配置层叠顺序
最终配置在一棵空树上依次叠加,后面的层按行 id 覆盖前面的:
| 顺序 | 层 |
|---|---|
| 1 | dsh.profile.bundles 里的各 bundle patch,按列表顺序(dsh-base 最先) |
| 2 | profile 自己的 cordis.patch.yml |
| 3 | home 级 $DSH_HOME/cordis.patch.yml(机器本地偏好,跨 profile 共享) |
| 4 | 命令行 --patch 覆盖,按 argv 顺序 |
一个 patch 替换目标行的整个 config 值,不会按键深合并。对 bundle 作者有两个后果:
- 你可以按
id覆盖前面层的行,但必须把该行需要的每个键都重写一遍,只写改动的那个不行 - 用户也能在自己的 profile patch 里覆盖你的行——所以默认值要选用户大概率愿意保留的,其余交给 schema
09分发上架
三条路,安全含义完全不同:
| 方式 | 用户命令 | 要不要构建许可 |
|---|---|---|
| npm 发布 | dsh plugin add your-package | 不要(装的是预构建产物) |
| tarball | dsh plugin add ./x-0.1.0.tgz | 不要 |
| GitHub 直装 | dsh plugin add github:you/repo | 要——见下 |
GitHub 直装的坑
git 安装拉的是源码而不是构建产物,没有任何东西会跑你的 build 脚本——
一个 TypeScript 包会因为缺 lib/ 而加载失败。两边各要做一件事:
-
作者:提供一个
prepare脚本(pnpm 在 git 安装后会执行它), 从源码构建出发布入口,且必须自足——不能假设旁边有 monorepo 检出。 -
用户:pnpm ≥10 默认拒绝执行 git 依赖的
prepare,首次add会失败, 需要把 pnpm 打印的包名填进 profile 的pnpm-workspace.yaml再重试:profile 的 pnpm-workspace.yamlallowBuilds: dsh-hello-plugin: true
allowBuilds 就是允许该包在安装时于你的机器上执行代码,
而且是在 agent 运行的沙箱之外。只对你信得过源码的包开这个口子,
并且用 github:you/repo#<sha> 钉住 commit,防止之后的推送悄悄改掉被执行的内容。
作为作者,发 npm 或 tarball 能让用户完全不必开这个许可,是更友好的选择。
让别人找到你
给仓库打上 dsh-plugin topic。官方在 CONTRIBUTING.md 里明确不接受外部 PR,
插件生态是唯一的贡献路径,这个 topic 就是它的入口——
目前已有 700 多个仓库,插件目录列出了全部。
10扩展点速查
想做的事和该挂的地方对应关系(完整版见仓库 docs/architecture.md):
| 你想… | 挂在哪 |
|---|---|
| 给模型加一项本事 | 注册到 ctx.tools,它的 schema 会自动进 prompt 组装 |
| 接入一个模型厂商 | 把适配器注册到 ctx.llm |
| 换掉文件读写或加策略 | 注册 ctx.fs provider,或监听 fs/* 事件 |
| 加一种 shell 执行 | 注册 ctx.shell 后端 |
| 加人类命令(不走模型轮次) | 注册到 ctx.commands |
| 加后台任务 | 注册到 ctx.jobs,job_* 工具会统一管控 |
| 拦截请求 / 工具 / 轮次 | 用对应的 agent/* 或 tools/* 事件 |
| 给模型加上下文 | agent.inject(),会进入下一次被接纳的请求 |
| 加持久的会话状态 | 扩展 SessionEventMap,从日志渲染与回放 |
| 加 Web UI 面板 | 注册 ConversationNodeDefinition + 具名渲染器 |
凡是会进入模型请求的东西,都必须能从会话日志重建。 所以如果你的插件给模型加了一种新的可见输入,就需要同时新增一个 session 事件—— 这条规约有运行时断言兜底,详见「可重建性」一章。
11六个坑
都是文档里明确写出来、但容易在实际动手时踩到的:
| 坑 | 后果 | 怎么躲 |
|---|---|---|
--patch 里用相对路径 | 解析到 profile 目录,找不到模块 | 用绝对路径 |
Config 导出普通对象 | 不实现 Standard Schema,配置失效 | 必须用 Schemastery schema |
| 函数插件里混了 default 导出 | Loader 丢弃命名空间,且不明确报错 | 函数插件只用具名导出 |
| patch 只写要改的那个键 | 整块替换,其余键全丢 | 覆盖行时重写该行所有键 |
| TypeScript 包直接挂 GitHub | 用户装完没有 lib/,加载失败 | 提供自足的 prepare,或发 npm / tarball |
| 硬编码超时、路径、模型名 | 违反仓库规约,别的部署改不动 | 做成 Config 字段 |
调试手段
dsh --profile web --dump-config— 打印最终组装出的插件树,并标注每一行来自哪个文件、被哪些层改过。组合出问题时第一个该跑的命令- 改配置存盘即热替换,不用重启
- 想找真实范例,插件目录里 700 多个仓库的源码都在,按分类翻