扩展实战:编写第一个插件

📑 目录

前 13 篇我们一直在读代码。最后一篇换个姿势:改代码。

dsh 的全部设计承诺可以浓缩成一句话——扩展 dsh 的方式是把插件挂载到其他插件旁边,而不是给循环打补丁。本篇把这句话变成三次实操:一次不写代码的配置层练习,一个从零安装的最小工具插件,以及两个简述方向(LLM 适配器与 Conversation 节点)。开始前提醒:dsh 处于 developer preview 阶段,公共 API 与 session 格式仍可能变动;本文基于 2026 年 10 月 master 分支(0.2.1-alpha.1 前后),所有路径与签名均已对照源码核实。

选型:四种扩展姿势

把新行为放进 dsh 有四种姿势,由轻到重:

姿势载体适合
改配置 / patch 层cordis.patch.yml、--patch overlay调参数、开关内置行为、替换整行配置
组合包(bundle)带 dsh.bundle 声明的 npm 包把"插件 + 配置层"作为一份可安装单元分发
树外插件安装进 profile 的组合包不改仓库、跟随用户 profile 的第三方扩展
仓库新包packages/ 下的一等公民贡献回上游,接受仓库级测试与文档门禁

选型没把握时,第一个查询点是 docs/architecture.zh.md 末尾的「新行为的归属位置」表——新行为必须落在已文档化的扩展点上,改动循环本身才需要动那张映射。抄几行最常用的:

  • 添加模型提供方 → 在 ctx.llm 上注册其适配器
  • 添加面向模型的能力 → 在 ctx.tools 上注册,schema 自动加入提示词组装
  • 添加 shell 执行 → 注册 ctx.shell 后端
  • 拦截请求、工具或轮次 → 监听相应的 agent/* 或 tools/* 事件
  • 添加 Web Client Chat 节点 → 注册 ConversationNodeDefinition + keyed renderer

组合包与仓库新包的界线也值得说清:二者都是"插件 + patch 层",差别在交付渠道。树外插件走 npm(或 git、tarball),装进用户自己的 profile,版本节奏由你定;仓库新包则必须接受仓库的门禁——组装测试、文档同步、覆盖率——但能与内置包共享 workspace 与 review 流程。自用实验从 --patch overlay 起步,分发给别人就打包,想合入上游再开 PR,这条路由对大多数需求都够用。

flowchart TD
    A["我想改 dsh 的行为"] --> B{"需要写新代码吗?"}
    B -->|"否,只是调参或开关"| C["cordis.patch.yml / --patch overlay"]
    B -->|"是"| D{"要分发给别的用户吗?"}
    D -->|"否,自用"| C
    D -->|"是"| E{"进上游仓库吗?"}
    E -->|"否,树外安装"| F["组合包 + dsh plugin add"]
    E -->|"是"| G["仓库新包(packages/ + bundle patch + 测试门禁)"]

树外插件的生命周期

树外插件的机制建立在两个 manifest 上(详见 docs/user/develop/basic/publish.zh.md):组合包声明 dsh.bundle,回答"这个包贡献什么"——一个插入或覆盖插件行的 patch 文件;profile 声明 dsh.profile,回答"这套配置由哪些 bundle 按什么顺序组成"。组合包是你编写和分发的东西,profile 是用户用 dsh --profile <name> 启动的东西,没有东西同时是两者。profile 目录里还有两个常驻文件:pnpm 管理的 package.json(树外依赖 + bundles 列表)与 cordis.patch.yml(用户自己的 patch 层),后者在每个 bundle 层之后应用,所以你永远可以在不改动包的前提下覆盖它插入的行。

安装命令是 dsh plugin --profile demo add ./hello-plugin(apps/cli/src/plugin.ts:70 的 runPlugin)。它在 profile 目录内把参数转发给 pnpm,所以一切 pnpm 子命令都可用;首次使用自动以 @deepseek-ai/dsh-base 初始化 profile。装完后 plugin-manager 的 reconcile()(packages/boot/plugin-manager/src/operations.ts)扫描新依赖:声明了 dsh.bundle 的包被追加进 profile package.json 的 dsh.profile.bundles(原子写,文件权限 0o600);没声明的包打印警告,只作为普通依赖躺在那里,不激活任何层。从 GitHub 安装要多一道坎:git 依赖装的是源码而非构建产物,pnpm ≥10 默认拒绝运行 prepare 脚本,需要把 pnpm 打印的包键抄进 profile 的 pnpm-workspace.yaml 的 allowBuilds 再重跑——这项授权意味着允许该包代码在你的机器上执行,请只对可信源码授予;不想让用户授权,就发布预构建的 npm 包或 tarball。

注册表行为由 packages/boot/plugin-manager/src/registry.ts 收口:配置的第一个 registry 加公共回退(npmjs 与 npmmirror);被请求的私有 registry 绝不回落到公共 registry——你的内网包名不会被发到公网去"试试运气"。

启停走的也是声明式路径:plugin-manager 的 patch.ts 用注释保留的 YAML 编辑,在 profile 的 cordis.patch.yml 写入或更新 {id, disabled: true} 覆盖(writePluginEnabled)——有同 id 覆盖就改写,没有就在 insert 条目之后追加。它只写 disabled 覆盖与 bundles 列表,从不重写你 layer 里的其他内容。桌面 profile 是个例外:要求先在应用里启动过一次桌面、完全退出后再执行 dsh plugin --profile desktop(apps/cli/src/plugin.ts:10 的 requireDesktopProfile)。

运行中的实例怎么办?dsh web 运行时再执行插件操作时,plugin-manager 服务把变更包进 hmr.runExclusive 事务(packages/boot/plugin-manager/src/index.ts:776),与配置热重载互斥,对新引入的行做原子路由替换;没有 HMR 的行则老实标记 restart-required,等下次启动。HMR 配置重载本身监视三个精确路径(profile patch、home patch、profile package.json),内容快照比对变了才 reconcileProfilePatches,不会为一次无害的 touch 重启世界。

flowchart TD
    A["dsh plugin add ./pkg"] --> B["pnpm 安装(registry 回退)"]
    B --> C{"声明了 dsh.bundle?"}
    C -->|"是"| D["追加进 dsh.profile.bundles(原子写)"]
    C -->|"否"| E["普通依赖,打印警告"]
    D --> F{"应用正在运行?"}
    F -->|"是,有 HMR"| G["runExclusive 事务内原子路由替换"]
    F -->|"否"| H["下次启动经 readProfilePatches 合成层"]
    G --> I["entry.update,新 fiber 沉降后生效"]

实战一:用 cordis.patch.yml 关掉一个内置行为

第一个练习不需要写任何代码。base 组合包默认挂载会话遥测插件,行 id 是 session-telemetry-otel(packages/bundle/base/cordis.patch.yml:204,名字为 @deepseek-ai/dsh-session-telemetry-otel)。在你的 profile 目录 $DSH_HOME/profiles/<name>/cordis.patch.yml 里写:

- id: session-telemetry-otel
  disabled: true

胜负由层顺序决定:bundle 层按 dsh.profile.bundles 列表顺序,然后是 profile 层、$DSH_HOME/cordis.patch.yml、每个 --patch overlay,后写的层按行胜出;home 层是机器级偏好,对所有 profile 生效,适合放"这台机器上永远关掉某行为"之类的个人默认值。这也正是 DSH_TELEMETRY_DISABLED 环境变量在启动时的等价物——app-boot 注入的就是同样的 disabled patch。同一机制还能替换整行 config,但要记住 patch 是整体替换 config 而非深度合并:覆盖一行就要重述它需要的全部键,只写改动的那一个键会把其余键清空。另外 insert 条目会立即进入 id 索引,所以同一 patch 文件里靠后的条目可以改写前面 insert 的行——这是叠加算法刻意支持的顺序。

验证不需要启动应用:dsh --profile <name> --dump-config 会把合成后的条目树带注释打印出来,每行都标注 # == <来源>, patched by <层>,可精确溯源;--dump-default-config 跳过用户层与 overlay,是用户层写坏时的恢复诊断起点。

实战二:写一个最小工具插件

包结构三个文件,与官方教程(docs/user/develop/basic/publish.zh.md)一致:

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

cordis.patch.yml 把插件行插进组合树;插件行按包名引用,Node 的模块解析才能找到已安装的代码:

- insert:
    - id: greet
      name: dsh-greet-plugin

插件入口(docs/user/develop/basic/tool.zh.md 的 greet 教程与 docs/cookbook/adding-a-tool.zh.md 最小形态的结合):

import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-plugin'
export const inject = ['tools']
export function apply(ctx) {
  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: (_a, v) => [{ type: 'text', text: v }] },
    async execute(args) { return `Hello, ${args.name}!` },
  }))
}

写完后对照源码确认三个事实。其一,ctx.tools.register 的签名是 register(definition: ToolDefinition): () => void(packages/core/tools/src/index.ts:1063),返回注销器;注册基于副作用,dispose 插件 fiber 即注销工具,所以 HMR 下热替换是安全的。其二,inject = ['tools'] 让 Cordis 在工具注册表就绪前挂起这条 fiber——加载顺序由服务依赖表达,不存在"启动第几步挂载谁"的编排;函数插件形态要求具名导出 name / inject / apply,混用 default export 会被 Loader 丢弃命名空间,这是仓库 postmortem 记过的真实事故。其三,defineTool 在 execute 运行前按 schema 校验模型给出的参数,execute 拿到的 args 是类型安全的;它只返回 output.schema 声明的规范 JSON 值,渲染交给 output.render,抛异常或返回无效值自动记为 isError。ctx.tools.register 也直接接受原始 JSON Schema 的 ToolDefinition——MCP 来源的工具就是这样进注册表的。其四,并发调度只认 isConcurrencySafe(args) 返回精确 true(packages/core/tools/src/index.ts:1305;声明见 schema.ts:508),未声明即 exclusive,fail-closed——你不表态,调度器就按最保守的来。

再往生产走一步的清单,docs/cookbook/adding-a-tool.zh.md 都已写明:遵守 exec.signal 取消进行中的工作;长时间任务经 ctx.jobs.start() 走 run_in_background;UI 卡片用 presentCall / presentResult 声明,且必须是纯函数——它们在实时流式与日志回放两条路径上都会运行。注意内置 Web Client 并不消费这两个展示方法,它从 tool/call / tool/result 原始事件与持久化的 result.meta 自行派生卡片,插件在 tool.call.toolview slot 注册自己的 wire 名称。

安装与验证,一步不落:

dsh plugin --profile demo add ./dsh-greet-plugin   # 装包 + 登记 bundle
dsh --profile demo --dump-config                   # 应看到 "# == dsh-greet-plugin" 层
dsh --profile demo                                 # 打开 Web UI,让模型调用 greet

schema 会自动流入系统提示词组装,不需要你碰提示词一个字。

实战三(简述):适配器、Conversation 节点与钩子

接新的模型提供方,路径是 LLM 适配器:class MyAdapter extends LlmAdapter,实现 stream(options): AsyncIterable<StreamChunk>,然后 ctx.llm.registerAdapter(['my-provider'], new MyAdapter(...))(packages/llm/llm/src/index.ts:389)。注册全有或全无,重复路由抛 DUPLICATE_ADAPTER,返回的句柄既是 disposer 又带原子路由替换 replace()。协议义务见 docs/cookbook/adding-an-llm-adapter.zh.md,最关键的三条:usage 必须在 finish 之前发出,finish 之后不再发任何内容;提供方不支持的选项抛 LlmError(..., 'UNSUPPORTED_OPTION'),不许静默丢弃;错误只有两条合法路径——从 stream() 抛出(传输与协议故障),或以 finish {kind:'error'|'aborted'} 结束流(提供方带内故障)。参照实现:packages/llm/llm-deepseek(直连 HTTP,SSE 由 eventsource-parser 分帧)与 packages/llm/llm-pi-ai(封装 pi-ai 库,一次解析产出不可变快照)。

给 Web Client Chat 加业务节点,是渲染侧的扩展点:注册一个 ConversationNodeDefinition(match 判别事件、start / update 折叠纯 State、publication 声明发布节奏),再经 ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({key}, View)) 挂上 keyed renderer——约定见 docs/subsystems/conversation.zh.md:239,数据面与渲染面的纪律我们在第 13 篇已经领教过。

还有一类什么都不"添加"、只"拦截"的插件:钩子。cookbook 的权限门禁示例只有十几行——监听 tools/pre-execute waterfall,返回 {kind:'deny', reason} 或调 next() 放行。这条 waterfall 是可重排的策略层;要不可翻案的最终拒绝用 ctx.tools.guard()(守卫故意没有 allow 返回),包裹分发生命周期用 tools/execute,观测不可变结果用 tools/result。选对扩展点,比写更多代码重要。

小结与全系列回顾

四个要点收束本篇:选型先查「新行为的归属位置」表;树外插件 = 两个 manifest + pnpm 安装 + disabled 覆盖,plugin-manager 只碰这两处;工具注册是副作用,签名以 packages/core/tools/src/index.ts:1063 为准;适配器、Conversation 节点、事件钩子分别覆盖模型侧、UI 侧与策略侧。

回到本系列的主题「一切皆插件」。读完 13 篇源码,这句话现在有了具体含义:dsh 自带的每个功能——权限、沙箱、plan mode、MCP、甚至 UI 卡片——都站在和你完全相同的扩展点上;docs/cookbook/extension-cookbook.zh.md 的「功能→机制映射」表里,没有一行需要修改 agent-loop。官方仓库规约写得更直白:"Plugins, not loop changes"。所以,扩展 dsh 的正确姿势不是研究怎么打补丁,而是找到那个已经为你留好的扩展点,把插件挂载到其他插件旁边——其他插件,包括内置的每一个。

全系列 14 篇回顾导航:

  • 架构全景:一切皆插件如何落地
  • 启动链路:从 npx dsh 到 profile 就绪
  • 插件树组装:patch 层的叠加算法
  • Cordis 基础:Context、服务与事件分发
  • HMR 与配置热重载:事务化重载
  • Agent 循环:turn 与 step 的状态机
  • 事件系统:五个域与扩展点的分发语义
  • 工具执行流水线:从 pre-execute 到 result
  • 系统提示词:section 组装与准入
  • 会话日志:模型可见即已记录
  • 持久化:JSONL 代际迁移与投影查询
  • LLM 适配层:StreamChunk 协议与能力 seam
  • 客户端:从会话投影到桌面壳
  • 扩展实战:编写第一个插件

最后再次提醒:dsh 尚处于 developer preview,写插件时把版本钉住,升级前过一遍 upgrade guide。祝 hacking 愉快。