第 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与 entrydisabled里插值,其余元数据必须静态- 看到
# == A, patched by B标记,知道那是"逐前缀重算 + JSON 比对"算出来的- 改了 patch 一定要
--dump-config验证,这是唯一可靠手段
本章目标
- 说清 Profile / Bundle / Patch 三层各自的准确边界
- 修正书稿 §2.8 最关键的一处理解偏差:层叠不是"依次应用",是"扁平化成同一个列表应用一次"
- 解释 M0 里那些
# == base, patched by X标记究竟是怎么算出来的 - 掌握
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-appbundle 既是"应用层",又是"四个 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++])))
})
}
三个细节值得注意:
- 根是空列表
[]—— 所有东西都是insert进来的,没有"基线配置" layers.flat()—— 层的概念在应用时已经消失,只剩一个有序的 patch 数组- 与 boot 共用同一个函数 —— 注释明说:"the same single
applyEntryPatchescall 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 里出现过的事:
disabled: !!js '!ctx.get(''profileContext'')'——disabled在每一次挂载决策时对 loader 上下文求值(vendor #18)。它不是"加载时算一次的布尔",而是"这一行在我现在所处的上下文里是否有效"。- M0 §5.2 那个结论的机制:preset 里的
tool-plugin-manager写着disabled: !!js "!ctx.get('profileContext')",而在 preset 作用域内 profileContext 存在,所以它激活;同一个包在dsh-base层(无 profileContext)就不激活。同一个包、同一个表达式、不同的作用域、相反的结果。 Include的 tree-carrier 标记(include/src/index.ts:162-167):Include 的 config 本身是"条目列表 + patch 列表",所以 Loader 的internal/config插值对它保持字面量——Include 自己的path、enableLogs因此永远是字面量。
这就是 AGENTS.md 那句「
cordis.ymlallows!!js(never!js) under pluginconfigand entrydisabled; other metadata stays literal」的精确含义。
七、HMR:patch 改了之后谁在动
本章只点到为止(M2 的重点是组装,HMR 的完整机制留到 M9/M10)。三条关键线索先记下:
Include.refresh()(include/src/index.ts:279-287):文件内容变了才重读;解析失败只 warn 并保留上一棵好树——"a hot-reload of a live app must never take the process down"。Include对internal/update行使否决权(:190-201):它否决自己的 fiber 重启,但自己把新 config 应用到子树上。注释解释了为什么不能直接重启——Fiber.update只在next()之后才赋值this.config,滞后的this.config.patches会让下一次refresh()重新套用旧 overlay。- 写入是去抖且串行的(
: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 · 勘误总表。