深入浅出 DeepSeek Harness 第六部:能力接入——Skills、MCP和插件 Chapter 38

cordis.yml 组合:空根与五层 patch 栈

用户想改一行配置,为什么不该动 cordis.yml?因为它每次启动都被重写成空数组 []——整棵接线不是写出来的,是五层 patch 叠出来的:bundle layers → profile 用户层 → home 层 → --patch overlays → telemetryPatch。patch 整行替换 config 不 merge,行顺序不承载语义,同 id 后写赢。

Written in Chinese

Source version
dsh-v0.1.7-rc.2
Verified on
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,一个用户想把某行配置关掉。他打开 cordis.yml 改了一下,重启——改动没了。为什么?因为这个文件每次启动都被程序重写成一个空数组 []。你不该动根文件,你该动 cordis.patch.yml——整棵接线根本不是写在一个文件里的,而是五层 patch 在启动时叠出来的。这一章就讲这五层各是什么、谁压过谁、patch 的语义为什么是”整行替换”而不是”合并”。

这章接第 37 章:那章讲 Context 上每个能力名字怎么接上线;这章讲这张地图上的名字和实现,是怎么从磁盘上叠出来的。

五层 patch 栈:根 cordis.yml 是空数组每次重写;① bundle layers 铺共享行;② profile 用户层 cordis.patch.yml;③ home 层 $DSH_HOME/cordis.patch.yml;④ --patch overlays;⑤ telemetryPatch 最后追加;语义:整行替换不 merge,同 id 后写赢。

空根哲学:为什么根文件故意是空的

看源码里根配置长什么样:

const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
# --patch overlays. Edit cordis.patch.yml, not this file.
[]
`
export const PROFILE_ROOT_FILENAME = 'cordis.yml'

注释自己就警告了:“Edit cordis.patch.yml, not this file.” 为什么这个文件每次启动都被重写?prepareProfile 的注释讲透了:“The root is always rewritten: the whole composition is patch layers, and the vendored Loader’s tree write-back (a plugin self-disposing persists the current tree) can bake composed rows into this file — which would duplicate every bundle insert on the next boot.”

也就是说:Loader 有个回写行为(插件自拆时会把当前树持久化)。如果不每次清空,组合出来的行会被”烤”进根文件,下次启动再叠一次——所有 bundle 行翻倍。根文件存在的唯一理由是给 Loader 一个真实文件作 baseUrl 锚点;它不是配置,是锚点。

本节源码依据(3 处)

五层 patch 栈:谁压过谁

readProfilePatches 是组合的核心。它读的栈按应用顺序是:

const patches = structuredClone([
  ...profile.layers.flatMap(layer => layer.patches),   // ① bundle layers
  ...(loadOptionalPatches(binName, context.patchPath) ?? []),  // ② profile 用户层
  ...(loadOptionalPatches(binName, join(context.home, PROFILE_PATCH_FILENAME)) ?? []),  // ③ home 层
  ...context.overlays,                                 // ④ --patch overlays
])
const telemetryPatch = resolveTelemetryPatch(...)       // ⑤ telemetryPatch 最后 push
层文件语义
① bundle layersdsh.profile.bundles 列出的各 bundle 的 patch基座:base bundle 用一个大 insert 把全部共享行铺出来
② profile 用户层<profile>/cordis.patch.yml这个 profile 的本地覆盖
③ home 层$DSH_HOME/cordis.patch.yml机器级偏好,压过单 profile 层
④ overlays--patch 命令行参数本次启动临时覆盖,不写盘
⑤ telemetryPatch程序生成最后追加,关掉 telemetry 行

注意 ③ 的设计:为什么机器层压过 profile 层?注释说”machine-local preferences that apply to every profile, so it outranks the per-profile layer”——一台机器上的偏好(比如统一代理)应该覆盖单个 profile 的设置。

composeProfile 把这堆冻结成一次启动的 ComposedProfile;runProfile 再把 profileContext(含 profile 名、目录、home、overlays、telemetryDisabledEnv)provide 进 Context——插件 inject 它即知自己在哪个 profile 里跑。

本节源码依据(5 处)

patch 语义:整行替换,不合并

packages/bundle/base/cordis.patch.yml 开头的注释把语义钉死了:

“A patch replaces the targeted row’s whole config rather than merging into it, so a row whose value differs by mode does NOT live here: it belongs to each mode bundle.” “Row order carries no load semantics (activation is service-availability driven); the grouping is for readers.”

两条硬规则:

  1. 整行替换 config:你只想改某行 5 个 key 里的 1 个?不行——你必须把 5 个全部重述。这就是为什么”按模式变化的行”不放 base bundle,而放各 mode bundle 自己。
  2. 行顺序不承载语义:分组只是给人读的。插件 activation 由服务可用性驱动(第 37 章的 inject 等待),不是由 YAML 里排第几行驱动。

同 id 后写赢:五层栈里后面的层命中同 id,就覆盖前面的。

本节源码依据(4 处)

!!js:同一文件里的条件行

条件分支不用拆文件——!!js 标量在挂载时求值:

- id: plugin-manager
  name: '@deepseek-ai/dsh-plugin-manager'
  disabled: !!js "!ctx.get('profileContext')"

plugin-manager 只在”由 dsh profile 启动”时加载;被嵌进别的应用时 ctx.get('profileContext') 是 undefined,该行自动 disabled。同一套组合既能独立跑 CLI,又能被嵌入宿主——靠的就是条件行,而不是两份配置。

本节源码依据(2 处)

telemetry 开关:off-by-mistake 优先

最后一层 patch 是程序生成的:

export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
  if ((disabledEnv ?? '') === '' || !hasRow) return undefined
  return { id: TELEMETRY_ROW_ID, disabled: true }  // 'session-telemetry-otel'
}

注释把隐私取向写得很清楚:“ANY non-empty value (including '0'/'false') disables: a privacy switch prefers off-by-mistake over on-by-mistake.” 设了 DSH_TELEMETRY_DISABLED=1 或者随便填个 no,telemetry 行就被 patch 成 disabled。另外:如果组合里根本没有 telemetry 行(自定义 profile 没装它),patch 不生成——开关不该变成启动错误。

把前面几章接回这张栈

回头看前面章节引用过的行,它们都躺在这同一张 base bundle 里:

行 id是谁章节
tool-subagent-control全局 send_message 工具注册者第 34 章
tool-subagent / tool-subagent-forkspawn / fork 两种子代理入口第五部
ptc-runtimedsh-ptc-runtime-node(NodePtcRuntime 落地)第 35 章
workflow-ptcPtcWorkflowEngine,provider: spawn第 35 章、第 37 章

base bundle 对 tool-subagent-control 的注释还解释了分工:“Continuable background children are selected per delegation tool. The separately loaded follow-up tool registers the one global send_message.”——委派工具(tool-subagent)和后续消息工具(tool-subagent-control)是两行,各管一段。

本节源码依据(3 处)

启动窗口的细节:信号、代理与 loud failure

组合的最后一公里不是”配置对了就开机”——runProfile 在挂第一行插件之前还做了几件与组合同等重要的事:

第一件:代理先于一切请求装上。 Node 的 fetch 自己不认代理环境变量,所以每个 profile 启动第一件事是 installProxyFromEnvironment(options.environment, ...)——从启动快照(而不是当时的 process.env)读代理。注释说这是为了让 .env 层声明的代理生效。任何插件发请求之前,代理必须在位。

第二件:信号从启动窗口第一天就归 shutdown 管。 注释原文:“Signals own teardown throughout the startup window, not only after boot() settles: an inserted provider can publish before sibling rows finish mounting.” 想想为什么——如果信号只在 boot 完成后才接管,那启动中按 Ctrl-C 就没人收尾了;而某个插入的 provider 完全可能在兄弟行还没挂完时就发布服务。SIGTERM 按 0 退出( supervisor 的常规停止),SIGINT 按 130 退出(用户中断)。

第三件:失败要响。 installFailLoud 挂上后,未捕获异常/未处理拒绝都会先 fiber.dispose() 再炸出来——不能让启动期的错误被吞进静默。

启动失败时的清理顺序同样写死:dispose() 把 app.current.fiber.dispose() 和 disposeProxy 两个释放放一个 Promise.all 里,各自 catch;单失败直抛,多失败聚成 AggregateError('dsh: profile cleanup failed')。启动失败的清理本身失败,也按聚合纪律处理——和第 36 章的拆除失败聚合同构。

本节源码依据(3 处)

改配置速查:动哪个文件

把这章收进一张实操表:

我想…改哪里
关掉某个 profile 里的一行<profile>/cordis.patch.yml(加 - id: 某行, disabled: true)
给某行换配置同文件整行重述(整行替换,不 merge)
对这台机器所有 profile 生效$DSH_HOME/cordis.patch.yml(压过单 profile)
只这一次启动改一下--patch /path/overlay.yml(不写盘)
加新的默认能力层bundle 包的 patch(dsh.profile.bundles 列出)
关 telemetry环境变量 DSH_TELEMETRY_DISABLED=1
直接编辑 cordis.yml别——每次启动被清空重写

小结

  • 根文件是空的锚点:每次启动重写,改配置永远动 cordis.patch.yml。
  • 五层栈:bundle layers → profile 用户层 → home 层 → overlays → telemetryPatch;home 压过 profile。
  • 整行替换不 merge:改一个 key 要重述全部;行顺序无语义。
  • !!js 条件行:同一套组合 CLI 独立跑、嵌入宿主跑两不耽误。
  • telemetry off-by-mistake:任何非空值都关。

下一章:第 39 章——发现 skills 之后,它们是怎么作为这棵组合树的一部分被注入进模型上下文的。