DeepSeek Harness 源码精读 03:插件树是怎么组装出来的

第 03 章 插件树是怎么组装出来的

代码基线:commit 8f86b979a1(2026-10-01)| 0.2.0-rc.1 | 本系列讲次:M2

本章导读

这一章讲"这一大堆插件是怎么被组装起来的"。

第 2 章讲插件怎么活起来,这一章讲它们怎么凑在一起。主角是三个词:Profile(这个进程由什么组成)、Bundle(这一层装什么)、Patch(改哪一行)。

本章有一个核心纠正,而且它是质的区别不是措辞问题。书稿画的层叠图暗示"一层一层往上叠"。真实实现是把所有层的 patch 拍平成一个列表,对一个空列表应用一次——applyEntryPatches([], structuredClone(layers.flat()))。层在应用时已经不存在了。

为什么这个区别重要?因为它决定了跨层可见性:扁平模型里第 2 层的 patch 和第 1 层在同一个循环里执行,而不是"看到第 1 层成品之后再改"。这条性质又直接连到本地修复的那条上游 bug——上游在 patch 循环前只建一次 id 索引,导致"先 insert 一行再 patch 它"完全做不到。

本章还会给你两条日常救命的知识:patch 命中失败(id 拼错、name 不符)只警告不报错,启动照常成功——这是"我改了怎么一点用都没有"的第一大来源。

学习要点

  • 层叠是扁平化后一次应用,不是逐层替换
  • patch 是逐个顶层字段赋值,config 整体替换且不做深合并
  • !!js 只在插件 config 与 entry disabled 里插值,其余元数据必须静态
  • 看到 # == A, patched by B 标记,知道那是"逐前缀重算 + JSON 比对"算出来的
  • 改了 patch 一定要 --dump-config 验证,这是唯一可靠手段

本章目标

  1. 说清 Profile / Bundle / Patch 三层各自的准确边界
  2. 修正书稿 §2.8 最关键的一处理解偏差:层叠不是"依次应用",是"扁平化成同一个列表应用一次"
  3. 解释 M0 里那些 # == base, patched by X 标记究竟是怎么算出来的
  4. 掌握 applyEntryPatches 的 6 条语义(§四,其中两条是"静默失效",是你日常踩坑的来源)+ insert 后可再 patch(§五)

一、书稿 §2.8 的三条主张,逐条对质

# 书稿主张 结论
A 层叠是"一个空列表 → 依次应用每个 bundle → profile patch → home patch → --patch" ⚠️ 顺序对,机制描述不准(见第三节)
B "基础 Bundle 有三个:dsh-base、dsh-web-app、dsh-headless" ❌ 已过时(M0 E-04)
C "web 和 headless 是官方随附的两个模板 profile" ❌ 已过时(M0 E-03)
D "Bundle 的自我声明:包在 package.json 的 dsh 字段里声明" ✅ 成立,但 dsh.bundle.patch 可以是数组
E "!!js 被解析成表达式节点,在插件激活时对其 config 求值" ⚠️ 不完整(见第六节)
F "允许 !!js,绝不允许 !js" ✅ 成立,且机制比书稿说的更严格

二、三层的准确边界

2.1 Profile —— 回答"这个进程由什么组成"

一个 profile 是 Harness home 下一个命名目录,里面是一个普通的 package.json:

{  
  "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"] } },  
  "dependencies": { ... }  
}  

五个模板 profile 定义在 packages/boot/app-boot/src/profile.ts 的 PROFILE_TEMPLATES。profile 目录里没有 package.json 时,loadProfile(profile.ts:704-721)会用模板初始化它。

2.2 Bundle —— 回答"这一层装什么"

bundle 的自声明实测如下:

包 dsh.bundle.patch
dsh-base "./cordis.patch.yml"
dsh-headless "./cordis.patch.yml"
dsh-sdk-minimal "./cordis.patch.yml"
dsh-web-app ["./cordis.patch.yml", "./presets/standard.patch.yml", "./presets/ptc.patch.yml", "./presets/minimal.patch.yml", "./presets/cordis.patch.yml"]

书稿说"一个 dsh.bundle 字段指向它的 patch 文件"——是一个路径。实测可以是路径数组,dsh-web-app 一次列了 5 个。 这正是 M0 里四个 Agent preset 存在于 web-app bundle 的原因。

教学点:bundle 和 preset 住在同一个包里。web-app bundle 既是"应用层",又是"四个 Agent 预设的提供者"——这恰好说明 bundle 只是 patch 文件的打包单位,不承担语义分类。

2.3 Patch —— 回答"改哪一行"

书稿:"按 id 锁定一条配置行,然后要么整行替换它的 config,要么插入新的配置行。"

方向对,精确规则要改(vendor/include/src/index.ts:120-123):

const { id, insert, name, ...overrides } = patch  
// ...  
for (const [key, value] of Object.entries(overrides)) {  
  if (key === 'id') continue  
  target[key] = value  
}  

它是逐个顶层字段赋值:id 保留、name 只用来做守卫、其余每个字段(config / disabled / inject / isolate / 任意自定义字段)各自整体替换。

对 config 而言"整体替换"就是"新 config 对象取代旧对象",所以书稿的直觉没错;但一旦 patch 里同时写了 config 和 disabled,两者是两条独立的赋值,不是"整行换掉"。


三、★ 层叠的真实机制:扁平化,不是依次应用

这是本章最重要的一处纠正。

书稿画的图暗示"一层一层往上叠"。真实实现是把所有层的 patch 拍平成一个列表,对一个空列表应用一次:

// packages/boot/app-boot/src/profile.ts:731-738  
export function composeEntries(  
  layers: readonly PatchOptions[][],  
  warn: (message: string) => void = () => {},  
): EntryOptions[] {  
  return applyEntryPatches([], structuredClone(layers.flat()), (message, ...args) => {  
    let index = 0  
    warn(message.replace(/%C/g, () => JSON.stringify(args[index++])))  
  })  
}  

三个细节值得注意:

  1. 根是空列表 [] —— 所有东西都是 insert 进来的,没有"基线配置"
  2. layers.flat() —— 层的概念在应用时已经消失,只剩一个有序的 patch 数组
  3. 与 boot 共用同一个函数 —— 注释明说:"the same single applyEntryPatches call the boot include makes, so flag derivation and config dumps see exactly what mounts"。这就是 --dump-config 不会与真实启动产生偏差的保证

3.1 为什么"扁平化"比"依次应用"更重要

因为它决定了跨层可见性。在"依次应用"的模型里,第 2 层只看到第 1 层的成品;而在扁平模型里,第 2 层的 patch 和第 1 层的 patch 在同一个循环里执行,顺序即应用序。

这直接连到第五节的本地修复。

3.2 M0 里 # == 标记是怎么算出来的

renderConfigDump(packages/boot/app-boot/src/index.ts:430-493)为了让 dump 标注"哪一层改了这行",用了一个逐前缀重算的办法:

// snapshot_k = 第 1..k 层扁平化后的一次应用;snapshot_N 就是真正挂载的组合  
const snapshot = (count: number, warnings: string[]) => {  
  const flattened = structuredClone(layers.slice(0, count).flatMap(l => l.patches))  
  return applyEntryPatches(base, flattened, ...)  
}  
  
for (let count = 1; count <= layers.length; count += 1) {  
  const before = previous.map(entry => JSON.stringify(entry))  
  // ...  
  for (let index = 0; index < composed.length; index += 1) {  
    if (index >= before.length) entryOrigins.push({ origin: layer.label, patchedBy: [] })  
    else if (JSON.stringify(composed[index]) !== before[index]) entryOrigins[index]?.patchedBy.push(layer.label)  
  }  
  previous = composed  
}  

逐个前缀重算一次,然后按 JSON 序列化对比找出哪一行变了,归因给这一层。 groupedDump(:496-513)再把连续的同源行合并成一个 # == <origin>[, patched by <layer>...] 段落。

所以 M0 输出的 # == @deepseek-ai/dsh-base, patched by /Users/ygs/.dsh/profiles/web/cordis.patch.yml 这一行,含义精确来说是:这一段连续的行的原始来源是 dsh-base,其中若干行在应用到你那份 patch 时发生了变化。


四、applyEntryPatches 的六条语义(全部实测)

实验脚本 labs/M2-patch-lab.ts 直接调用这个纯函数。

4.1 config 整块被换掉

# base:  { id: tool-bash, config: { timeout: 30, retries: 2 } }  
- id: tool-bash  
  config: { timeout: 99 }  
# 结果: config = { timeout: 99 }     ← retries 直接消失  

patch 不做深合并。想保留原字段,必须写全。

4.2 其它顶层字段独立赋值

- id: chat-logic  
  disabled: false      # 只翻开关,不碰 config  

4.3 name 守卫 —— 防 id 撞车

- id: tool-bash  
  name: '@deepseek-ai/dsh-tool-fs'   # 与目标实际 name 不符  
  config: { timeout: 1 }  
# → [warn] patch: name mismatch for "tool-bash" (expected  
#   "@deepseek-ai/dsh-tool-bash", got "@deepseek-ai/dsh-tool-fs"), skipping  

整条 patch 被跳过。 这是防呆设计:不同 bundle 里可能存在同名 id,写错时宁可不改也不改错。

4.4 ★ id 找不到 —— 只警告,不报错

- id: tool-basah        # 拼错了  
  disabled: true  
# → [warn] patch: entry "tool-basah" not found  
# 树完全不变,启动照常成功  

这是"我明明改了 patch,怎么一点用都没有"的第一大来源。 它必须 fail-soft(因为 patch 层之间可以互相引用,而包可能被移除),代价就是拼错无声。唯一可靠的检查手段是看 --dump-config 输出里有没有对应变化。

4.5 insert 落进 group 内部

- id: chat              # 一个 group 行  
  insert:  
    - id: chat-new  
      name: '@deepseek-ai/dsh-new'  
# → chat-new 出现在 chat.config 数组末尾  

带 id 的 insert 进该 group;不带 id 的 append 到顶层列表。另外 insert 的目标若不存在或不是 group,也只是警告跳过(include/src/index.ts:82-91)。

4.6 输入永不被污染

applyEntryPatches 在有 patch 时 structuredClone(data)(:63)。源码注释解释了原因:

patching shared entry objects would bake earlier patch values into the cached parse, so repeated application (config hot-reloads) could never revert a removed or changed patch

不加深拷贝,热重载就永远无法撤销一个被删掉的 patch。 这是一个直接服务于 HMR 的实现细节。


五、★ vendor #11:修掉的上游 bug 与它揭示的架构事实

include/src/index.ts:95-101:

// Index what this patch added so a LATER patch in the same list can  
// target it. Patch lists compose one layer per source (each bundle  
// layer, then the user's, then `--patch` overlays), and a layer must be  
// able to configure or disable a row an earlier layer inserted; without  
// this, inserted rows were silently unpatchable.  
buildMap(insert)  

上游在 patch 循环之前只建一次 id 索引。 于是"先 insert 一行,再 patch 它"是做不到的——插入的行对后续 patch 完全不可见。

这条修复加上第三节的扁平化,共同说明一件事:

dsh 的层叠模型是"一个有序的 patch 列表",不是"一棵树上的 N 次替换"。 一个 bundle insert 的行,和官方 dsh-base 里的行,在可被 patch 的能力上完全平等。这就是"官方与第三方地位平等"在代码层的真正实现方式。

实测(lab 第 6 条):第 1 层 insert 一个 fresh 行,第 2 层 patch 它的 config → {"on": false} 生效。


六、!!js 的精确规则:只有 disabled 是元数据插值字段

书稿 §2.8 只说了 config。实测规则在 scripts/verify-cordis-config.ts:502-540:

`disabled` 是唯一被插值的元数据字段:  
  - 它自己的 `!!js` 表达式节点允许,且必须能解析  
    (用 `new Script('(...)')` 只编译不执行,语法错在 boot 之前就被拒绝)  
  - 嵌套在 disabled 下面的表达式永远不会求值,必须保持字面量  
其余所有元数据字段必须完全静态,否则报 "!!js is not interpolated here"  

这解释了三件 M0 里出现过的事:

  1. disabled: !!js '!ctx.get(''profileContext'')' —— disabled 在每一次挂载决策时对 loader 上下文求值(vendor #18)。它不是"加载时算一次的布尔",而是"这一行在我现在所处的上下文里是否有效"。
  2. M0 §5.2 那个结论的机制:preset 里的 tool-plugin-manager 写着 disabled: !!js "!ctx.get('profileContext')",而在 preset 作用域内 profileContext 存在,所以它激活;同一个包在 dsh-base 层(无 profileContext)就不激活。同一个包、同一个表达式、不同的作用域、相反的结果。
  3. Include 的 tree-carrier 标记(include/src/index.ts:162-167):Include 的 config 本身是"条目列表 + patch 列表",所以 Loader 的 internal/config 插值对它保持字面量——Include 自己的 path、enableLogs 因此永远是字面量。

这就是 AGENTS.md 那句「cordis.yml allows !!js (never !js) under plugin config and entry disabled; other metadata stays literal」的精确含义。


七、HMR:patch 改了之后谁在动

本章只点到为止(M2 的重点是组装,HMR 的完整机制留到 M9/M10)。三条关键线索先记下:

  1. Include.refresh()(include/src/index.ts:279-287):文件内容变了才重读;解析失败只 warn 并保留上一棵好树——"a hot-reload of a live app must never take the process down"。
  2. Include 对 internal/update 行使否决权(:190-201):它否决自己的 fiber 重启,但自己把新 config 应用到子树上。注释解释了为什么不能直接重启——Fiber.update 只在 next() 之后才赋值 this.config,滞后的 this.config.patches 会让下一次 refresh() 重新套用旧 overlay。
  3. 写入是去抖且串行的(:289-334,vendor #14):EACCES/EBUSY/EPERM 的 rename 失败会退避重试最多 10 次;Windows 上 Loader 子项卸载后目标句柄可能短暂残留,不重试就会把 disabled 状态写丢。

注意第 2 条和 M1 的 epoch 是同一件事的两面:epoch 负责"服务变了自动重载插件",internal/update 负责"配置变了自动重载子树",而 Include 是这两条路线的交汇点。



本章实验(附录)

八、动手验证

export PATH="/opt/homebrew/bin:$PATH"  
cd /Users/ygs/ygs/deepseek-harness  
node --import tsx/esm "ygsdoc/学习笔记/labs/M2-patch-lab.ts"  

七条全部通过。要看的关键输出:

3) name 守卫  
   [warn] patch: name mismatch for "tool-bash" (expected "@deepseek-ai/dsh-tool-bash",  
   got "@deepseek-ai/dsh-tool-fs"), skipping  
4) id 拼错  
   [warn] patch: entry "tool-basah" not found  
6) ★ 结果中 fresh 行的 config = { "on": false }    ← 第 2 层成功 patch 了第 1 层 insert 的行  
7) 原始 src 保持 { "k": 1 }                          ← 输入未被污染  

外加一个真机验证(不需要重启,直接看自己机器的树):

pnpm dsh --profile web --dump-config | grep -c '^# =='  
grep -B1 -A2 'name: .@deepseek-ai/dsh-tool-skill' /tmp/dump-web.yml  

M2 通过标准:能解释 M0 输出里 # == @deepseek-ai/dsh-base, patched by @deepseek-ai/dsh-web-app 这一行是怎么算出来的(即 renderConfigDump 的逐前缀重算 + JSON.stringify 比对),并说出为什么"patch id 写错"只会 warn 而不会让启动失败。


实验脚本:labs/M2-patch-lab.ts

运行(在仓库根目录):

export PATH="/opt/homebrew/bin:$PATH"  
cd /Users/ygs/ygs/deepseek-harness  
node --import tsx/esm "dsh-源码精读/labs/M2-patch-lab.ts"  

预期输出(本机实测,任何一行对不上就说明基线变了):

1) config 整块被换掉  
    "id": "tool-bash",  
    "name": "@deepseek-ai/dsh-tool-bash",  
    "config": {  
      "timeout": 99  
    }  
    "id": "chat",  
    "name": "cordis:group",  
    "group": true,  
    "config": [  
      {  
        "id": "chat-ui",  
        "name": "@deepseek-ai/dsh-client-ui-chat",  
        "config": {  
          "locale": "en"  
        }  
      },  
      {  
        "id": "chat-logic",  
        "name": "@deepseek-ai/dsh-chat",  
        "disabled": true  
      }  
    ]  
   ↑ id 与 name 保留,只有 config 这个顶层字段被整体赋值;retries 消失  
2) disabled 单独翻开关  
    "id": "tool-bash",  
    "name": "@deepseek-ai/dsh-tool-bash",  
    "config": {  
      "timeout": 30,  
      "retries": 2  
    }  
    "id": "chat",  
    "name": "cordis:group",  
    "group": true,  
    "config": [  
      {  
        "id": "chat-ui",  
        "name": "@deepseek-ai/dsh-client-ui-chat",  
        "config": {  
          "locale": "en"  
   …(完整输出见运行脚本)  

本章的勘误条目见 附录 A · 勘误总表。