机制篇 / mechanism 版本基准 dsh v0.2.2-rc.1

插件式能力挂载

把 DeepSeek Harness 的插件机制逐层拆开:Cordis 的上下文与生命周期、profile 的层叠、能力进入模型的四种表面、客户端槽位的挂载契约。

代码渊源 插件开发指南 0.1.0-rc.6 / cordis 4.0.1 共 19 页
plugin · bundle · profile / context · fiber · slot ← → 翻页
插件式能力挂载 · 机制篇01

三层:插件做什么,bundle 打哪批,profile 组哪几批

层是什么实际形态
plugin 实现层:做一件事 导出 name / inject / Config / apply(ctx, config)。在 apply 里提供 Service、注册 Tool、注册 Command、贡献提示词段
bundle 打包层:把一批插件行打成一个 npm 包 package.json 声明 dsh.bundle.patch 指向 cordis.patch.yml,文件里用 - insert: 列出几十行插件
profile 组合层:一次启动到底装了什么 $DSH_HOME/profiles/<name>,package.json 里 dsh.profile.bundles = [dsh-base, dsh-web-app],外加自己的 patch
关键差异

DSH 没有硬编码的功能列表。功能集合由 profile 决定,功能实现由 plugin 决定,中间那层 bundle 只是把插件行打包发行。

为什么值得分开

同一批插件可以组成不同产品:web profile 多一个 dsh-web-app,headless profile 只有 base。界面不是“主程序的一部分”,是组合出来的。

插件式能力挂载 · 机制篇02

从敲命令到 Agent 能干活

启动分两半:前半段决定“加载什么”,后半段决定“什么时候可用”。这两件事经常被混为一谈,也是排障时最容易走错的地方。

01composeProfile读 profile,取出有序的 bundles 列表
02取每个 bundle 的 patch按列表顺序,base 永远第一
03叠加三层profile patch → home patch → --patch
04逐行加载每行 {id, name, config, disabled} 交给 import()
05依赖解析Cordis 按 inject 决定谁先激活,行序不管用
06起界面与循环HTTP 服务、客户端引导清单、agent loop
第 03 步回答

加载什么。这一层落空,插件根本不在树里,后面所有现象都不用看。

第 05 步回答

什么时候可用。行顺序不承载加载语义,写在文件里靠前不代表先启动——这是 cordis.patch.yml 注释里反复强调的一条。

插件式能力挂载 · 机制篇03

Cordis 的五个核心概念

插件一个实现 Service 的对象:带可选 inject 与 apply 的函数,或 Service 子类
上下文服务的容器。一个服务占一个稳定 key,别人按 key 取用,不导入具体实现
inject声明所需服务,等它们就绪才启动。加载顺序由依赖表达,不靠人工编排
事件类型化通信,六种分发模式,分别承担通知、包装、扇出、顺序执行、短路
可逆注册提示词段、工具 schema、适配器、监听器都在 reload 与 teardown 时撤销
// 插件的最小形态,四件事 export const name = 'tool-todo' export const inject = ['tools'] export const Config = z.object({ allowParallelInProgress: z.boolean().required(), }) export function apply(ctx, config) { ctx.tools.register(defineTool({ /* … */ })) }

四件东西都挂在插件身份上,apply 只是执行体。类插件、对象插件换的是写法,不是契约。

插件式能力挂载 · 机制篇04

01激活是服务可用性驱动的

硬依赖 inject

声明了但服务没提供 → 插件停在 inactive,apply 根本不执行,也不报错。服务被注销 → 依赖它的插件先卸载,等新实例出现再重启。

软依赖 ctx.get

ctx.get('fs') 拿不到就返回 undefined,不抛错。DSH 里可选能力走这条:有审批服务就走审批,没有就退化为 fail-closed。

排障含义

“插件写进 YAML 了却没生效”,多数不是加载失败,是停在 inactive。先看它 inject 的服务谁提供,而不是翻插件代码。

// 硬依赖:等它,没有就不启动 export const inject = ['tools'] // 软依赖:没有也能跑 const fs = ctx.get('fs') if (!fs) { /* 退化路径 */ } // 提供服务:随 fiber 自动注销 class MyService extends Service { constructor(ctx) { super(ctx, 'myService') } }

服务一注销,依赖者先下线。这条规则让“换掉一个 provider”不需要重启进程,也解释了为什么热更新能成立。

插件式能力挂载 · 机制篇05

02六种分发,waterfall 是扩展点主力

方法await顺序返回值
emit否注册序无
waterfall否注册序,环绕有,可改写
parallel是并行无
serial是顺序有
bail否顺序至命中有
once—仅一次—
waterfall 为什么重要

监听器收到 (...args, next)。调 next() 往下走;不调直接返回就短路。它像中间件,可以改参数、改结果、拦下来。

DSH 的扩展点几乎都是它

tools/pre-execute、tools/execute、tools/post-execute、system-prompt/assemble、internal/update 全是 waterfall。

权限门、提示词改写、热更新的否决,都挂在这一种分发上。要加“能拦住动作”的能力,只能走这里。

插件式能力挂载 · 机制篇06

03一个插件实例就是一个 Fiber

Fiber 是插件在运行期的记录:依赖满没满、配置是什么、有哪些 effect 要清。它的 state 直接对应你看到的现象。

0 inactive未激活依赖未满足,或已卸载
1 loading加载中reload 正在执行
2 active活跃apply 已完成
3 error启动失败error 里存着原因
4 disposed已销毁effect 已逆序清理
5 unloading卸载中清理尚未结束
fiber.await() // 等启动,有错就抛 fiber.dispose() // 卸载,逆序清理 effects fiber.restart() // 用当前配置卸载并重载 fiber.update(cfg) // 校验 → internal/update // waterfall(HMR 可否决)→ restart fiber.getEffects() // 诊断 effect 树

报“插件没生效”之前,先看它停在哪个 state。inactive 是等依赖,error 是代码炸了,两者修法完全不同。

插件式能力挂载 · 机制篇07

04配置是 schema,不是约定

启动前校验

Config 是 Schemastery 的 schema,Cordis 在启动前同步校验,失败抛 ValidationError 并聚合一整批 issue。所以 apply 拿到的一定是良构配置,作者不必再判空。

.default 与 .required

default 意味着可缺省,归一化后一定有值;required 意味着缺了就算失败。两者写错,现象是启动直接进 error,而不是运行中出怪值。

volatile:不重启改配置

字段带 .volatile(),插件页就能改。写法是每次调用点 config.x.get(),不是加载时读一次。保存落到 $DSH_HOME 的设置文档,重启仍在;重置则回到 patch 里的部署默认值。

// 源自 dsh-skill-filesystem 的 Config 节选 export const Config = z.object({ providerName: z.string().min(1).default('filesystem'), includeDefaultRoots: z.boolean().default(true), customSkillDirs: z.array(z.string()).default([]), watch: z.boolean().default(true), })

同步校验是硬约束:配置校验若返回 Promise,框架直接抛 Async config validation is not supported。

插件式能力挂载 · 机制篇08

05patch 是寻址,不是合并

四条规则

① 行由 id 寻址;② config 整体替换,不深合并;③ disabled 可以写成 !!js 表达式按环境求值;④ 行顺序不承载加载语义。

代价

改一个字段要把这一行需要的 key 全部重述。这是“任何一行都能被上层改掉”的必要代价,也是最常见的踩坑点。

五层,不是四层

除了 bundle / profile / home / --patch,启动器还会追加一层派生 flag patch(例如遥测开关),排在最后。

# 真实 bundle patch 节选(dsh-base) - insert: - id: timer name: '@deepseek-ai/cordis-plugin-timer' - id: agent-default-model name: '@deepseek-ai/dsh-agent-default-model' config: provider: deepseek-official model: deepseek-v4-flash - id: bash-sandbox name: '@deepseek-ai/dsh-bash-sandbox' disabled: !!js process.platform === 'win32' # 上层覆盖:只写 id 与要替换的整段 config - id: session-query-sqlite config: path: '/data/sessions.db' openAt: first-search
插件式能力挂载 · 机制篇09

能力进模型,有四张面孔

同一个插件,可以只占一张面孔,也可以同时占几张。决定它“是什么”的,不是包名,是 apply 里调了哪些 ctx.*。

表面注册点模型看到什么
工具ctx.tools.register(defineTool(…))名字、描述、参数 schema、输出 schema;调用后拿到结构化结果
Skillctx.skills.registerProvider()SKILL.md 的 name 与 description 进目录,正文按需加载
命令ctx.commands.register()斜杠命令由人敲,不占模型上下文
提示词段ctx.systemPrompt.section()直接拼进 system prompt 的文本,每轮都在
这张表的作用

想加能力时,先选面孔再写代码:要模型主动调用 → 工具;要模型读一段方法 → Skill;要人触发 → 命令;要无条件生效 → 提示词段。

值得注意

Skill 在 DSH 里也只是插件贡献的一个注册表项。它和工具、命令共用同一套加载、配置、卸载机制,没有特殊通道。

插件式能力挂载 · 机制篇10

一个工具,三种输出通道

defineTool({ name: 'greet', input: schema, // 参数 output: schema, // 规范返回值 execute: async (args, exec) => { /* 带取消信号 */ }, output: { schema, render: (v) => text, // 模型可见 }, presentResult: (v) => intent // 界面意图 })

执行、模型可见渲染、界面呈现是三件分开的事。混淆它们,工具就会在某一端表现失常。

权限门挂在事件上

ctx.on('tools/pre-execute', …) 返回 { kind: 'deny', reason } 就是拒绝,调 next() 就是放行。这是一个 waterfall,可组合。

并发要显式声明

isConcurrencySafe 决定同一个工具能否被并行调用。只读工具可以开,写文件的不行。

模型可见表面必须稳定

schema 一改,KV Cache 前缀失效,缓存命中率与成本立刻变化。这不是性能优化,是设计要求。

插件式能力挂载 · 机制篇11

客户端插件:一个包,两半代码

{ "exports": { ".": "./lib/index.js", // host 半边 "./client": "./lib/client.js" // 浏览器半边 }, "dsh": { "bundle": { "patch": "./cordis.patch.yml" }, "client": { "platform": "web" } } }

只有 host 能力的包不得声明 ./client。声明了却发不出文件,就是“装得上、启动正常、界面永不出现”。

浏览器半边怎么被加载

lib/client.js 是一个惰性 CJS 工厂:window.__ModuleLoader__.load({ id, factory })。宿主按包名发现 dsh.client 声明,把这个文件喂给页面。

运行时只依赖 react

react 由浏览器平台的模块表提供;所有 @deepseek-ai 客户端包只以 import type 出现,编译后被擦除。所以客户端半边不背 SDK 版本,跟着宿主走。

这解决了什么

插件作者不需要为每个宿主版本打一份客户端包,也不会因为 SDK 版本错配而白屏。

插件式能力挂载 · 机制篇12

界面上有十四個坐标

槽位名本身就是坐标。想加一块界面,先在这张表里找位置,而不是想办法往 DOM 里塞东西。

01plugins.bundle.config
02sidebar.footer.action
03conversation.input.dock
04shell.overlay
05conversation.session.header.utilities
06conversation.input.left
07conversation.input.right
08conversation.chat.commandview
09settings.general.item
10settings.plugins.tab
11settings.action
12conversation.session.header.actions
13conversation.composer.dock
14conversation.chat.assistant-actions
命名规则

前缀是区域,后缀是位置:settings.* 设置页,conversation.input.* 输入区,session.header.* 会话头。读到名字基本能猜到它出现在哪。

会变

这是某一版的清单,0.2.x 又加了菜单位。名字会增删,机制不变:宿主声明槽,插件往里注册条目。

插件式能力挂载 · 机制篇13

挂载的契约:inject 到声明,随 fiber 离开

// 注册一个侧栏动作 ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action' }, Action)) // 带 key:给自己的工具配专属结果视图 ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'greet' }, GreetView))
  • 一等到宿主声明了这个槽才注册;槽还没出现就排队等
  • 二宿主声明塌缩时,贡献被自动移除,不用自己收
  • 三随插件 fiber 一起离开,卸载即消失
  • 四注册到未声明的槽 → 直接抛错,不是静默跳过

第四条是这套设计里最实用的一个决定:槽名拼错当场炸。相比之下,“装上了但界面不出现”是最难查的一类故障。

插件式能力挂载 · 机制篇14

插件可以由对话产生

这是整套设计成立与否的验证点:只有当“随时装卸”真的成立,才敢让模型自己写插件、装进正在运行的进程。

01模型写三个文件package.json / cordis.patch.yml / index.js
02装进 profile走安装动作,成为一条配置层
03热更新不重启,fiber 重载,注册重放
04失败回滚热更新失败自动退回到上一版
版本上的证据

0.2.1-alpha.1 起,插件管理页有「让 Agent 创建插件」入口,草稿会保留进创造模式;0.2.2-rc.1 支持更新第三方插件包,热更新失败自动回滚。

它依赖的两个前提

volatile 配置(不重启改参数)+ effect 可逆注册(改完能干净卸)。少了任何一个,这条链路都会在第二次修改时烂掉。

插件式能力挂载 · 机制篇15

装上之后,它和宿主同权

  • 运行期插件在宿主进程内跑,按你的权限读文件、用凭证、连网络。工具审批不构成沙箱。
  • 安装期npm 包不在安装时执行代码;git 安装要跑 prepare,pnpm 10 默认拒绝,需在 profile 的 pnpm-workspace.yaml 写 allowBuilds——等于授权安装期执行。
  • 检查生态跑五项结构校验:patch-shipped、rows-resolvable、client-half-shipped、prebuilt、stale version。
  • 规模当时 719 个可安装插件,688 个全部通过。检查只看打包正确性,不看代码安全。
所以

“能装上”和“该装”之间没有任何自动闸门。这五项挡的是到货即坏,不是恶意代码。

对做平台的人

插件机制一旦开放,权限模型必须先想清楚:同权执行意味着插件作者和用户之间需要一层信任判断,靠校验清单替代不了。

插件式能力挂载 · 机制篇16

插件没生效,只有四个落点

  • 落点 1不在 --dump-config 的树里 层没进 profile,或 files 数组漏了 patch 文件
  • 落点 2在树里,状态 inactive inject 的服务没人提供,或服务名拼错
  • 落点 3状态 error Config 校验没过,或模块导入失败
  • 落点 4Host 正常、界面没有 客户端半边没发:缺 exports["./client"] 或 dsh.client 声明
一个专门的坑

--patch 覆盖层只加载 host 半边:模块解析够不到包级声明。要测界面,必须真的装进 profile。

顺序

从 1 往下走,每步只排除一种可能。先看树,再看状态,最后才看界面——反过来查会浪费很多时间。

插件式能力挂载 · 机制篇17

五条原则,各自靠什么机制成立

上一版把五条当做法讲。拆到这一层能看到:每一条背后都有具体机制,抽掉一个,那条就退化成约定。

原则支撑它的机制对应页
槽位化具名 slot + ctx.slots.inject/register + key 定位贡献对象P12 – P14
依赖声明inject 硬依赖驱动激活;ctx.get 软依赖退化P04
注册可逆ctx.effect 登记 disposer,fiber.dispose 时逆序清理P06
装配分层patch 按 id 寻址、五层叠加、config 整体替换P08
安装即验收五项结构校验 + --dump-config 可观测合成结果P15 – P16

反过来说,一个平台如果只提供“配置文件里加一行”和“卸载时调个清理钩子”,它就已经具备这五条里最难的两条;剩下的靠纪律就能补。

插件式能力挂载 · 机制篇18
← → 翻页