跳至内容

如何写一个合规插件

本篇做一个最小的 DSH 第三方插件:它通过公开的 tools/result 事件观察工具结果,使用自己的包名,通过 Bundle 的 cordis.patch.yml 装入 Profile,并说明怎样测试卸载和清理。

这里的“合规”不是法律认证,而是指扩展遵守宿主公开接口、生命周期、身份标识和信任边界。它仍然是第三方代码,不代表 DeepSeek AI 官方维护或背书。

先把本篇示例和dsh-super-injector分开:本篇写的是只调用公开 Cordis/DSH 接口的普通第三方插件;dsh-super-injector 是第三方 Bundle 加运行时注入器,会进入 Loader、Fiber、模块缓存和宿主内部表。它可以作为社区生态的研究案例,但不应把它的低层实现复制成普通插件模板,也不应把它的运行时能力写成 DSH 官方公开 API。

如果你还不知道自己的需求到底该写普通插件、Hook bridge、配置 Bundle 还是 patched fork,先看工具预算与插件责任决策卡的“五问决策卡”;确定身份后再按本篇写代码和测试。

不想一开始就读很长的 TypeScript 片段时,先完成最小插件示例与学习检查。它提供一个可直接运行的学习用第三方 Bundle:只观察 tools/result、自带 Node 单元测试和 lint,并明确写出这些检查还没有证明真实 Loader、模型或卸载行为。完成后再回到本篇的三个例子会更容易。

互动组件插件事件流实验室单独打开

正在载入互动组件……

不看组件也能读的说明

不打开组件也能带走三条结论。第一,订阅决定收不收得到:未订阅的插件照样被挂载,但广播不会送到它面前,预览数为零;「中途订阅」场景还能看到登记之前的广播同样收不到——订阅时机决定错过什么。第二,日志跟订阅无关:tool/calltool/result 由宿主写入,策略拒绝甚至卸载插件都不影响日志完整性。第三,注册与注销必须成对:结束时活跃效果数不为零,就说明有监听器还挂着。时间线支持逐步推进:滑杆停在任意一步,看这一步谁在做动作、日志里有没有多一行。

组件里的预览文本来自固定教学常量;它不能证明真实 Loader 的挂载顺序、真实审批界面或该插件在真实 DSH Host 中的表现。

开始前先做四个决定

  1. 先决定你要观察、做决策,还是提供新能力。观察优先选事件;决策选文档化的 waterfall 或 guard;提供能力选 Service、provider 或工具注册表。
  2. 只选择固定提交和包 README 已经说明的入口。不要因为能在调试器里看到一个私有函数,就把它当成公共 API。
  3. 给包使用自己的名称、仓库和版本,README 中写明支持的 DSH 版本或 commit 范围。不要使用 @deepseek-ai/* 等上游命名空间伪装身份。
  4. 列出插件拥有的资源:监听器、工具、服务、timer、文件 watcher、子进程、网络连接和缓存文件。每一项都必须有可验证的清理路径。

第一个例子:只观察工具结果

这个插件不改变工具行为,只在工具结果完成后打印一行脱敏、限长的预览。它适合作为第一步,因为它没有权限决策,也不需要修改 agent loop。tools/result 只提供结果观察能力,事件本身不会自动授予文件、网络或凭据权限;但插件仍运行在宿主进程和安装环境的权限范围内,安装前仍要检查它的依赖、文件访问、网络访问和凭据使用。

先用一段可以在浏览器里直接运行的 JavaScript 看清骨架(真实实现是 TypeScript,事件名和生命周期一致):

js-run
// 教学版事件总线:只实现 tools/result 的广播。
const bus = {
  handlers: [],
  on(name, fn) { this.handlers.push(fn); return () => { const i = this.handlers.indexOf(fn); if (i >= 0) this.handlers.splice(i, 1) } },
  emit(resultText) { for (const handler of [...this.handlers]) handler({ name: 'tools/result', resultText }) },
}

function createObserver({ maxLength = 20 } = {}) {
  const plugin = { previews: [], effects: [] }
  plugin.setup = () => {
    plugin.effects.push(bus.on('tools/result', (event) => {
      const text = String(event.resultText)
      const preview = text.slice(0, maxLength)
      plugin.previews.push(preview)
      console.log((text.length > maxLength ? '截断预览:' : '预览:') + preview)
    }))
    console.log('已订阅 tools/result')
  }
  plugin.unload = () => {
    plugin.effects.splice(0).forEach(off => off())
    console.log('已卸载')
  }
  return plugin
}

const observer = createObserver({ maxLength: 12 })
observer.setup()
bus.emit('这是一段会被截断的工具结果文本')
observer.unload()
bus.emit('卸载后这条不应产生输出')
console.log('previews 总数:', observer.previews.length)

maxLength 改成 6 再运行一次;把 observer.setup() 注释掉再运行——挂载了但没订阅,广播从它面前经过。

目录

text
dsh-yourname-study-plugin/
├── package.json
├── index.js
└── cordis.patch.yml

插件入口 index.js

js
export const name = 'dsh-yourname-study-plugin'

// `tools/result` 由 dsh-tools 声明和发出;`inject` 声明插件依赖 tools 服务。
export const inject = ['tools']

function previewResult(result) {
  return result.content
    .filter(block => block.type === 'text')
    .slice(0, 3)
    .map(block => block.text.slice(0, 160).replace(/[\r\n]+/g, ' '))
}

export function apply(ctx) {
  ctx.on('tools/result', (exec, result) => {
    const preview = previewResult(result)
    console.log(`[study-plugin] ${exec.name} -> ${JSON.stringify(preview)}`)
  })
}

这里的 ctx.on() 是 Cordis 的公开注册方法。监听器属于当前插件 Fiber,卸载 Fiber 时会自动移除;插件不需要访问 ctx.events._hooks,也不需要自己从数组中删除监听器。示例只保留三段、每段 160 个字符以内的文本预览;真实插件还应根据业务做字段白名单、凭据脱敏和日志保留期限,不能把完整文件内容、工具参数或网络返回直接写进日志。

inject 声明的是硬依赖:Loader 可以并发声明多个插件,Cordis 会等 tools provider 变为 ACTIVE 再运行本插件。例如 inject: ['tools'] 的插件,其 apply 一定晚于 tools 服务就绪;可见性和生命周期由这条依赖关系决定,真正的服务注册仍由 provider 完成。

Bundle 配置层 cordis.patch.yml

yaml
- insert:
    - id: study-plugin
      name: dsh-yourname-study-plugin

这份 YAML 只负责把包加入插件树。它不是修改 DSH TypeScript 源码的 patch,而是配置组合层;用户可以在自己的 Profile 中覆盖或移除这一行。

包清单 package.json

json
{
  "name": "dsh-yourname-study-plugin",
  "version": "0.1.0",
  "type": "module",
  "main": "index.js",
  "files": ["index.js", "cordis.patch.yml"],
  "dsh": {
    "bundle": {
      "patch": "./cordis.patch.yml"
    }
  }
}

dsh.bundle.patch 把包标记为带有配置层、可装配进 DSH 的 Bundle。Bundle 的身份仍由包发布者决定;拥有这个字段不会让它变成 DeepSeek 官方包。

上面的 JavaScript 示例是可以直接看懂的最小骨架;下面的 TypeScript 片段主要展示公开 API 的类型形状,不是复制后无需配置即可发布的完整项目。

实际发布时要把 @deepseek-ai/cordis@deepseek-ai/dsh-tools 等依赖声明为与目标 DSH 版本相容的 peerDependencies 或明确的运行时依赖,补上 TypeScript 构建配置和 lockfile;不能把 workspace:* 之类只在官方 monorepo 内可解析的版本号直接发布给用户。

第二个例子:注册自己的工具

如果你的目标是让模型能够调用新能力,使用 ctx.tools.register(),不要直接向工具 Map 写入。下面的代码沿用官方工具教程的 API 名称,展示结构而不是完整产品功能。

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

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

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'study_greet',
    description: 'Return a greeting for a supplied 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}!`
    },
  }))
}

ctx.tools.register() 返回的清理函数会被当前 Fiber 追踪,工具在插件卸载后不再出现在该作用域。工具的参数校验、结果 schema、模型展示和执行流水线由 dsh-tools 负责,插件只提供定义和执行逻辑。

工具扩展点不是权限边界。若功能涉及文件、网络、shell、凭据或子进程,应继续遵守对应 provider 的权限和审批规则,不能因为工具已经注册就默认获得更高权限。

第三个例子:提供一个可替换服务

当多个插件要共享一项能力,并且测试或部署需要替换实现时,才值得定义自己的服务。服务名称使用有辨识度的前缀,避免和 DSH 已占用的名称冲突。

ts
import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context {
    studyGreeting: StudyGreetingService
  }
}

export class StudyGreetingService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'studyGreeting')
  }

  greet(name: string) {
    return `Hello, ${name}!`
  }
}

export const name = 'dsh-yourname-greeting-service'

export function apply(ctx: Context) {
  ctx.plugin(StudyGreetingService)
}

消费方声明 inject: ['studyGreeting'] 后使用 ctx.studyGreeting。运行时注册由 Service 完成,TypeScript 声明合并只负责让消费方得到类型;两者缺一不可时,应在同一个包或清楚关联的接口包中维护。

普通插件 Context 和 Agent scoped Context 也要分清:在普通 Context 上注册的工具或服务通常对该 Context 的子树可见;通过 agent.ctx 注册的能力可以只对该 Agent 生效。作用域只说明“谁能看到这项能力”,不等于安全权限边界,文件、网络、Shell 和凭据权限仍要由对应 provider 和审批策略决定。

通过 Profile 安装

在已经安装 DSH CLI 的环境中,可以把本地插件加入一个 Profile:

sh
dsh plugin --profile study add ./dsh-yourname-study-plugin
dsh --profile study --dump-config
dsh --profile study

--dump-config 用来检查 Bundle 层是否真的被组合进去。看到配置行不等于插件已经成功运行,还要看依赖是否就绪、插件是否进入 ACTIVE,以及工具或事件是否产生预期结果。

移除时使用:

sh
dsh plugin --profile study remove dsh-yourname-study-plugin

如果你从 DSH 源码 checkout 运行,把命令中的 dsh 换成 pnpm dsh。官方 CLI 会把 Profile、Bundle、home patch 和 --patch overlay 按固定顺序组合;插件作者不应要求用户复制或修改 DSH 核心源码。

从 GitHub 安装时要锁定 commit:

sh
dsh plugin --profile study add github:yourname/dsh-yourname-study-plugin#<commit-sha>

Git 依赖可能在安装时执行 prepare 构建脚本。这个过程会在用户机器上执行第三方代码,不属于 agent 沙箱;只对可信来源授权,并优先提供已经构建好的 npm 包或 tarball。

生命周期和卸载

Cordis 已经替你管理的、并且已经登记到当前生命周期的注册,不要重复写清理逻辑。ctx.on()ctx.tools.register()ctx.provide()ctx.plugin() 都会把相应 disposer 挂到当前 Fiber;Fiber 不会自动发现插件私下创建的 timer、watcher、连接或子进程。

对于 timer、watcher、连接或子进程这类 Cordis 不认识的资源,使用 ctx.effect()

ts
export function apply(ctx: Context) {
  ctx.effect(() => {
    const timer = setInterval(() => {
      console.log('plugin heartbeat')
    }, 1000)
    return () => clearInterval(timer)
  })
}

如果清理步骤有先后关系,把它们放进同一个 disposer 并在其中按顺序等待。不要把一个资源交给全局数组,再希望某个未来的 patch 能记得删除它。

子插件也属于父插件的所有权范围:const fiber = ctx.plugin(child) 后,父 Fiber 卸载会递归处理子 Fiber;需要提前结束时可以 await fiber.dispose()

测试不能只看“加载成功”

检查层要证明的事实例子
静态检查入口、导出、类型和包清单能构建pnpm run typecheckpnpm run lint
单元测试监听器、工具参数和错误处理符合预期用固定输入调用纯函数或最小 Context
组合测试Profile/Loader 能找到包并按依赖挂载dsh --profile study --dump-config 后启动实际树
行为测试真实工具流水线或事件确实触发断言 tools/result、工具返回内容或 Session 记录
卸载测试监听器、工具、timer、子进程和文件 watcher 都消失await fiber.dispose() 后再次触发并确认没有旧输出
信任检查包名、来源、权限、依赖和版本声明清楚自有 namespace、锁 commit、README 写风险

根据 DSH 固定提交的官方测试策略,面向产品用户的插件需要使用真实组合测试,而不是只手工创建一个 Context 并断言某个函数被调用。

最小 Context 测试适合快速定位;Loader 启动的组合测试才验证了 package manifest、配置层和实际依赖关系。库内复用、不会单独进入产品 Bundle 的模块,应按自己的发布边界写清测试要求。

没有 API key 时,可以验证插件加载、工具 schema、事件和卸载;不能把没有真实模型请求的结果写成“已验证 DeepSeek 模型调用”。没有浏览器 E2E 时,也不能写成“Web UI 交互已完整验证”。

插件 README 应该写什么

一个让别人敢安装的 README 至少应回答:

  1. 这是官方维护、社区维护还是个人实验包?
  2. 支持哪些 DSH 版本或固定 commit?使用了哪些服务、事件和工具权限?
  3. 它会读取哪些文件、环境变量、凭据、网络地址或命令?是否启动子进程?
  4. 如何安装、配置、检查已装配层、卸载和恢复?
  5. 每个注册和外部资源在何时清理?热重载或依赖消失时会发生什么?
  6. 有哪些单元、组合、行为和卸载测试?哪些结果仍未在真实 API 或真实浏览器中验证?
  7. 包名、仓库、许可证、依赖版本和发布构建来自哪里?是否锁定 Git commit?

如果 README 只写“支持 hook、功能强大、兼容官方”,却不写接入点、权限、版本和卸载方式,读者无法区分真正的插件与运行时补丁。

没有公开 hook 时怎么办

第一步是检查相邻的公开能力:同一包是否有服务方法、事件、工具注册表或配置 overlay。很多“缺少 hook”的需求其实可以通过观察事件或增加独立 provider 完成。

第二步是查看固定提交中的类型、README、测试和官方扩展手册。只有源码里出现私有函数,不代表上游承诺它的名字、参数和调用时序不会变。

第三步是向上游提出一个明确的扩展需求:说明要观察什么、需要返回什么、生命周期如何清理、是否影响 Session 可恢复性,以及希望哪些测试锁定行为。

如果必须改变核心行为,就把它作为 fork 或 patched build 发布,记录上游 SHA、补丁文件、构建命令和同步成本;不要把 fork 的内部 hook 包装成“无需 patch 的官方插件”。

五种方案的准确叫法

实际做法推荐名称主要责任
只写 apply(ctx),调用公开 service/event API第三方 Cordis 插件遵守公开 API、Fiber 清理和版本范围
插件包带 dsh.bundle.patch,通过 Profile 装配第三方 DSH Bundle / 组合包维护 patch 层、manifest 和安装说明
修改 DSH 源码并重新构建patched fork / 私有 fork维护源码差异、同步上游和完整构建
改私有 Map、替换导出或向进程塞代码registry patch / monkey patch / 运行时注入承担脆弱性、权限和回滚风险,不得冒称官方插件
把外部 hook 协议翻译成 DSH 事件hook bridge / 兼容层维护协议版本、失败处理和身份说明

最终检查清单

  • [ ] 插件包有自己的名称、仓库、版本和许可证。
  • [ ] 所有接入点都能在 DSH 固定提交的类型或官方文档中找到。
  • [ ] 没有访问私有 registry、私有 Map、构建产物内部路径或未声明的模块导出。
  • [ ] ctx.onctx.tools.registerctx.provide 和自定义资源都有 Fiber 所有权。
  • [ ] Profile 能通过 --dump-config 看见组合层,卸载命令能移除包和配置行。
  • [ ] 如果插件会进入产品 Bundle 或面向用户安装,有至少一个真实 Loader 组合测试和一个卸载测试;仅供库内复用的模块按自己的发布边界记录相应测试。
  • [ ] README 写清权限、数据、网络、构建脚本和真实验证边界。
  • [ ] 没有使用官方包名、官方 namespace 或容易造成官方背书错觉的标识。
  • [ ] GitHub 安装示例锁定 commit,npm 或 tarball 安装说明给出完整性和来源检查建议。

固定提交中的进一步阅读