青雲的博客
深入浅出 DeepSeek Harness 第六部:能力接入——Skills、MCP和插件 第 34 章

cordis.yml 怎样声明可替换的组合

root cordis.yml每次启动被writeFileSync覆写为空数组[],只是占位符——Loader需要真实include root锚定baseUrl。真正组合来自五层patch叠加(bundle→profile→home→overlay→telemetry)。YAML顶层是EntryOptions数组{id,name,config?,disabled?},name是npm包名或builtin(cordis:include/cordis:group)。config支持!!js表达式可访问process.env和ctx。patch通过{id,disabled?,config?}做id-targeted override或{insert:[...]}插入。cordis:group+isolate创建isolate realm。HMR:watcher监听patch文件变化→entry.update(newPatches)→Loader重新apply→新Fiber创建/旧Fiber dispose→effect LIFO cleanup。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

打开 profile 目录下的 cordis.yml,你会看到里面只有 [],一个空数组。真正的配置并不直接写在这个文件里;如果你手工往里加 entries,下次启动很可能又回到空数组。

root cordis.yml不是真正的配置文件。它每次启动被writeFileSync覆写为空数组。真正的组合来自五层patch叠加:bundle层、profile层、home层、overlay层、telemetry开关。直接改cordis.yml等于往/dev/null写东西。

你要改的是cordis.patch.yml——通过id-targeted patch或insert来覆盖或插入entries。HMR监听到patch文件变化后,自动触发Fiber dispose/reload——旧Fiber的effect LIFO清理,新Fiber用新patches重建。不需要重启进程。

root cordis.yml是空数组:只是baseUrl锚点

prepareProfile()每次启动都做这件事:

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 function prepareProfile(name: string, userLayer = true): Profile {
  healProfilesModuleFallback(INSTALL_ANCHOR)
  const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
  writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
  return profile
}

为什么要写这个空文件?两个原因:

  1. Loader需要真实include root锚定baseUrl。 cordis-plugin-include(cordis:include)从root配置文件的目录解析相对路径——它需要一个实际存在于磁盘上的文件来确定baseUrl(profile目录)。没有这个文件,Loader无法解析相对插件路径。
  2. 防止Loader write-back烤入重复行。 Loader有一个机制:plugin self-disposing时可能把当前tree持久化写回root文件。如果不每次清空,上次运行写入的composed rows会留在文件里,下次启动bundle层又insert相同行——导致重复注册。

所以代码注释说得很直白:“Edit cordis.patch.yml, not this file.”

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "PROFILE_ROOT_CONFIG" "$repo/apps/cli/src/profile-boot.ts" -A5

五层patch叠加:真正的组合来源

composeProfile()按固定顺序组装五层patch:

function allPatches(composed: ComposedProfile): PatchOptions[] {
  return [
    ...composed.bundlePatches,
    ...composed.profile.patches,
    ...composed.homePatches,
    ...composed.overlays,
  ]
}

让我数清楚(从低优先级到高优先级):

  1. bundlePatches:package.json里dsh.profile.bundles数组指定的bundle layers,按数组顺序应用。bundle是随安装分发的基础组合,比如base bundle提供shell stacks等核心行。
  2. profile.patches:profile目录下的cordis.patch.yml。这是每个profile自己的用户层。
  3. homePatches$DSH_HOME/cordis.patch.yml(通过homePatchPath()解析,默认~/.dsh/cordis.patch.yml)。机器本地偏好,对所有profile生效——优先级高于profile层,因为你在home层设的偏好应该覆盖profile默认值。
  4. overlays--patch命令行参数指定的overlay文件,按argv顺序。另外还包含agent-presets的shipped preset root注入和telemetry开关。
  5. telemetryPatch:如果设置了 DSH_TELEMETRY_DISABLED 环境变量(任何非空值),追加一个 {id: 'session-telemetry-otel', disabled: true} patch。

HMR时composeLive()用structuredClone对五层做fresh clones——不缓存parse结果,每次都重新读文件。原因注释说得很清楚:“the include pushes insert rows into the mounted tree BY REFERENCE and later id-targeted patches mutate those objects in place. Reusing one parsed patch object across applications would bake a user override into the bundle’s in-memory insert row, so removing the override could never revert the row to the bundle default.”

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "bundlePatches\|profile.patches\|homePatches\|overlays" "$repo/apps/cli/src/profile-boot.ts" | head -20
flowchart TD
    A["root cordis.yml\n(空数组[] 仅作baseUrl锚点)"] --> B["bundlePatches\npackage.json dsh.profile.bundles"]
    B --> C["profile.patches\n<profile>/cordis.patch.yml"]
    C --> D["homePatches\n$DSH_HOME/cordis.patch.yml"]
    D --> E["overlays\n--patch CLI参数"]
    E --> F["telemetryPatch\nDSH_TELEMETRY_DISABLED"]
    F --> G["最终EntryOptions[]\n传给Loader apply"]
    G --> H["Fiber树创建\nServices注册/Plugins加载"]
    H --> I["patch文件变化\nwatcher触发"]
    I --> J["composeLive()\nfresh structuredClone"]
    J --> K["entry.update(newPatches)"]
    K --> L["旧Fiber _unload\nLIFO disposers"]
    L --> M["新Fiber _reload\n新plugins/effects"]
    style A fill:#555,color:#fff
    style G fill:#006400,color:#fff
    style L fill:#8b0000,color:#fff
    style M fill:#006400,color:#fff

EntryOptions结构、!!js表达式、patch语义

YAML顶层是EntryOptions[]数组。每个entry有这些字段:

  • id:字符串,稳定标识。patch通过id定位目标entry。没有id的entry无法被patch覆盖。
  • name:字符串,npm包名(如@deepseek-ai/dsh-tool-bash)或builtin(cordis:includecordis:group)。
  • config:对象,传给插件apply函数的配置。支持!!js表达式。
  • disabled:布尔,true时该entry不加载。

!!js是Cordis自定义的YAML tag。vendor/include/src/index.ts里定义了JsExpr schema:!!js process.platform === 'win32'这样的scalar被解析为{__jsExpr: "process.platform === 'win32'"},Loader在entry激活时eval该表达式,可以访问process.env等JavaScript运行时值。比如standard preset里:

- id: tool-bash
  name: '@deepseek-ai/dsh-tool-bash'
  disabled: !!js process.platform === 'win32'

tool-bash在Windows上自动禁用,tool-pwsh在非Windows上禁用。这是纯声明式的条件加载,不需要代码判断。

patch的语义由applyEntryPatches实现。它先structuredClone(data)防止mutate输入,然后buildMap递归索引所有entry(包括group的config子数组)。然后逐个patch处理:

  • {insert: [...]} 无id:push到顶层数组
  • {id: 'xxx', insert: [...]}:找到id对应的entry(必须是group),往其config数组push
  • {id: 'xxx', disabled: true}:id-targeted覆盖disabled字段
  • {id: 'xxx', config: {...}}:id-targeted合并/覆盖config字段
  • 找不到id的patch:warn并跳过,不报错

patch是按顺序应用的,后一个patch可以覆盖前一个patch的效果。而且insert的新entry会被立即索引,所以同一patch列表里后面的patch可以target前面insert的entry。

cordis:group + isolate创建realm、HMR热替换

cordis:group是一个builtin entry,用来分组子entries并可选地创建isolate realm。看standard preset里的compaction组:

- id: compaction
  name: cordis:group
  group: true
  isolate:
    compaction: true
    toolResultPruner: true
  config:
    - id: compaction-basic
      name: '@deepseek-ai/dsh-compaction-basic'
    - id: tool-result-pruner
      name: '@deepseek-ai/dsh-compaction-tool-result-pruner'

isolate: {serviceName: true}告诉group为这些service创建isolate realm。true表示entry-local——每个preset的group有自己的Symbol realm key,不会与其他preset冲突。如果两个group用了相同的label symbol而不是true,它们共享realm——但同一realm里provide同名service会throw,所以默认用true最安全。

没有isolate的group里,子entries注册到root/parent realm,变成process-global——第二个session用相同preset时,同名service注册会冲突。所以注释说:“A service row here MUST sit inside a group carrying an isolate realm. Without one it publishes into the root realm, where it is process-global — another preset publishing the same name collides.”

HMR的工作流程:

  1. watchUserPatches用chokidar文件监听监控profile.patchPath和homePatchPath
  2. 文件变化时,调用composeLive()重新读所有层(fresh structuredClone)
  3. Loader拿到新patches后,对变化的entry调用fiber.update(newConfig)
  4. Fiber.update内部:解析config→触发internal/update waterfall→调用this.restart()
  5. restart()→_setEpoch(INACTIVE)_refresh()_unload()开始LIFO清理所有disposers
  6. _unload完成后→_reload()执行新plugin callback→新effects注册→新services provide
  7. 整个过程中,已创建的Agent/Session不受影响——它们持有的是旧Fiber的引用吗?不是。关键在于:HMR替换的是composition层面的Fiber(loader entries),而Session/Agent持有的是agent scope内的引用。已创建Session的状态不会被HMR改变,因为HMR只影响后续创建的资源。effect scope管理着撤销:旧Fiber dispose时所有通过effect注册的贡献(tools、prompt sections、skill providers)被LIFO反注册,新Fiber创建时重新注册。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "watchUserPatches\|composeLive" "$repo/apps/cli/src/profile-boot.ts" | head -10
grep -n "async restart\|update(config" "$repo/vendor/cordis/src/fiber.ts" -A5 | head -20

容易踩的坑

坑一:直接编辑cordis.yml。 下次启动就被覆写为空数组。你要改的是同目录下的cordis.patch.yml。如果这个文件不存在就创建它。patch通过id-targeted覆盖或insert来修改组合。

坑二:patch的id不存在不报错。 applyEntryPatches对找不到id的patch只是warn(‘patch insert: entry %C not found’, id)然后跳过。你以为patch生效了,实际上因为id拼写错没命中任何entry,组合没变。启动后用dsh --dump-config验证最终组合。

坑三:!!js表达式写错导致启动失败。 !!js表达式在entry激活时eval,如果引用了undefined变量或有语法错误,plugin加载失败Fiber进入FAILED状态。常见错误:用了process.env.SOME_VAR但没设默认值(undefined参与运算报错)。

坑四:group不加isolate导致service冲突。 如果你在cordis:group里放了注册service的plugin但没配isolate,service发布到root realm。第二个session(或另一个preset)加载相同service时throw”service already registered”。standard preset注释明确说了:group里的service row必须配isolate realm。

坑五:以为HMR影响已创建的Session。 HMR做的是composition层面的Fiber reload——新配置只影响HMR之后创建的资源。已经在运行的Session继续使用旧Fiber的贡献(tools、prompt sections等),直到Session结束。你不会看到正在进行的对话突然换了一套工具。

下一章预告

你现在知道了组合是怎么通过YAML声明和patch叠加的。接下来进入具体的能力类型——Skills。Skills是被发现、装载并注入模型上下文的能力包,但它们不是工具。Skills提供知识和指令,Tools提供可执行操作。Skills怎么被发现?装载后怎么进入system prompt?为什么Skill loading是per-session的?