← 回到源码分析

插件开发指南

从一个五分钟就能跑起来的最小插件,到打包、安装、分发上架。 本页所有代码都来自仓库 docs/user/develop/ 的官方教程与参考,不是示意伪代码。

01插件是什么

在 dsh 里,插件就是一个导出 apply 函数的模块。框架加载它时调用 apply, 传进来一个 ctx 上下文,你通过它注册能力。就这些——没有基类要继承,没有注解要写。

最小骨架
import type { Context } from '@deepseek-ai/cordis'

export const name = 'my-plugin'

export function apply(ctx: Context) {
  // 在这里注册能力
}

这个模型的力量在于整个产品都是这样拼出来的:模型适配器、工具注册表、会话日志、 甚至 agent 主循环本身,都是和你写的这个东西同等地位的插件行。所以你能挂上去的位置,和官方能挂的位置是同一批。

02五分钟跑通第一个

  1. 建一个临时项目

    仓库根目录
    mkdir -p scratch-plugin/src
  2. 写插件

    scratch-plugin/src/my-plugin.ts
    import type { Context } from '@deepseek-ai/cordis'
    
    export const name = 'hello-plugin'
    
    export function apply(ctx: Context) {
      console.log('[hello-plugin] plugin loaded!')
    }
  3. 用一个 overlay 把它插进插件树

    scratch-plugin/cordis.yml
    - insert:
        - id: hello
          name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
    这里最容易卡住

    路径必须是绝对路径。patch 文件只贡献配置,不会改变 loader 解析模块路径时所用的目录, 所以相对路径会解析到 profile 目录而不是你的项目。先 pwd 拿到真实路径填进去。

  4. 带着 overlay 启动

    终端
    pnpm dsh web --patch ./scratch-plugin/cordis.yml

    启动日志里会打印 [hello-plugin] plugin loaded!。跑通了。

03加一个模型能用的工具

这是最常见的需求:让模型多一项本事。把上面的文件换成:

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'
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 会等工具注册表就绪后才加载你的插件,所以 applyctx.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 的插件行里给它配置:

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 注册的一切都会在插件卸载时自动回收——事件监听、工具、定时器都不用手动 removeListenerclearInterval。这是 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 字段下的清单不同,回答的问题也不同:

概念是什么回答的问题
bundle
dsh.bundle
一个携带配置层的 npm 包「这个包贡献了什么?」——一个插入或覆盖插件行的 patch 文件
profile
dsh.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 行引用的插件模块
hello-plugin/package.json
{
  "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" } }
}
hello-plugin/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.bundledsh 会把它追加进 dsh.profile.bundles。 没有这个声明的包也能装上,但只作为普通依赖——dsh plugin 会警告并且不激活任何层。

08配置层叠顺序

最终配置在一棵空树上依次叠加,后面的层按行 id 覆盖前面的

顺序
1dsh.profile.bundles 里的各 bundle patch,按列表顺序(dsh-base 最先)
2profile 自己的 cordis.patch.yml
3home 级 $DSH_HOME/cordis.patch.yml(机器本地偏好,跨 profile 共享)
4命令行 --patch 覆盖,按 argv 顺序
整块替换,不深合并

一个 patch 替换目标行的整个 config,不会按键深合并。对 bundle 作者有两个后果:

  • 你可以按 id 覆盖前面层的行,但必须把该行需要的每个键都重写一遍,只写改动的那个不行
  • 用户也能在自己的 profile patch 里覆盖你的行——所以默认值要选用户大概率愿意保留的,其余交给 schema

09分发上架

三条路,安全含义完全不同:

方式用户命令要不要构建许可
npm 发布dsh plugin add your-package不要(装的是预构建产物)
tarballdsh 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.yaml
    allowBuilds:
      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.jobsjob_* 工具会统一管控
拦截请求 / 工具 / 轮次用对应的 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 多个仓库的源码都在,按分类翻