DSH plugin 自定义 agent preset 不生效?roots 被覆盖原因排查
如果你把自定义 agent preset 放在项目目录里(想随仓库分发、用 git 管理),配置文件里 agent-presets.roots 也写了,Web UI 的预设选择器里却始终看不到它——那不是你的 YAML 写错了。 DeepSeek Harness 的 composeProfile() 在组合完所有 patch 层之后,会额外注入一个覆盖层,把 agent-presets 行的 roots 整体替换成只含 shipped 根;它作为最后的 overlay 传给 loader,所以你无论在哪一层配 roots 都不会被扫描到(#403)。
现象:DeepSeek Harness 的 preset 放进项目里,UI 预设选择器却看不到
这个 bug 最消耗时间的地方,是它会让你先怀疑自己写错了配置,而所有自查手段都会告诉你「配置是对的」。 具体表现:
- 复现路径很干净:在
D:\project\.dsh\agent-presets\my-preset\下放agent.cordis.yml+preset.yml,写一个 patch 注入 roots:
# test.patch.yml
- id: agent-presets
config:
default: standard
includeUserRoot: true
roots:
- path: D:/project/.dsh/agent-presets
trust: user
然后 npx @deepseek-ai/dsh web --patch test.patch.yml 启动,打开 Web UI → 新建对话 → 预设选择器,my-preset 不出现(#403)。
2. 双对照实验锁定问题不在 preset 文件:把同一个 preset 复制到 <dshHome>/.agent-presets/my-preset/(includeUserRoot 的默认行为),立即可见。文件内容完全一样,唯一变量是根的来源,因此问题一定出在 roots 注入环节,而不是 preset 本身(#403)。
3. --dump-config 会给你「配置正确」的假象:--dump-config --patch test.patch.yml 的输出里,你的 roots 清清楚楚地显示着。这不是 dump 过期或缓存,而是它打印的数组在覆盖层 push 之前就已经构建完成——你传任何参数都看不到 loader 实际收到的值(#403)。
4. 失败看起来是「间歇的」而不是「全量的」:覆盖层只是展开你的配置、然后替换掉一个键,所以同一行里 default、includeUserRoot 等其他字段都会正常生效,只有 roots 被丢弃。用「roots + 其他字段」一起测试的人,会看到其他字段生效,从而误判成偶发问题(#403)。
5. 跨平台、跨版本一致:报错者在 Windows 11 / 0.1.0-rc.6 上定位到该行为;社区在 macOS / 0.1.1-rc.2 上拿到逐字节一致的源码位置;另一位在 0.1.0-rc.7 上确认,且 profile-boot-DG5t9aNs.js 文件名哈希在 rc.7 与 rc.2 之间完全相同,说明这个模块期间没被改动。所以这不是平台相关、也不是某一版的偶发问题(#403)。
6. 影响面:所有「把 preset 放在项目里、随仓库分发、用 git 管理」的部署场景不可用;与 dsh-agent-presets README 的 roots 配置表直接矛盾;团队共享 preset 根、多项目多根场景完全无法实现(#403)。
机制:DeepSeek Harness 的 composeProfile 最后压入覆盖层把 roots 整体替换
问题的本质不是「配置没读到」,而是「配置读到了,然后在装载前被一个你看不见的步骤改掉了」。 逐层拆解:
- 覆盖层代码就在 CLI 里:
apps/cli/src/profile-boot.ts的composeProfile()(编译产物lib/profile-boot-DG5t9aNs.js第 179-187 行)里有这么一段:
if (rows.has("agent-presets")) composedOverlays.push({
id: "agent-presets",
config: {
...rows.get("agent-presets")?.config ?? {}, // 保留 default/includeUserRoot
roots: [{ // ← roots 被整体替换
path: SHIPPED_PRESET_ROOT,
trust: "system"
}]
}
});
注意这里的顺序:先展开用户配置(所以 default、includeUserRoot 都保留),然后写死 roots(所以只有 roots 被覆盖)。这就是「配置看起来被尊重了一半」的原因(#403)。
2. 它为什么必然赢:这个覆盖层被 push 到 composedOverlays 数组的末尾,作为最后一个 overlay 传给 loader。后写的层覆盖先写的层,所以无论用户在 bundle、profile、home 还是 --patch 哪一层配 roots,最终值都是「只有 shipped root」(#403)。
3. --dump-config 为什么不同步:dump 输出的是 patch 层组合结果,而这次注入发生在 dump 之后、loader 加载插件之前。它不是陈旧数据,而是一个真实不同的值——这也是为什么文档化的自查手段在这个字段上全部失效(#403)。
4. 为什么用户根是唯一幸存者:dsh-agent-presets 自身源码里 includeUserRoot 默认为 true,它把 <dshHome>/.agent-presets 作为用户根,在被这个 bug 破坏的那次 roots 计算之后才追加。所以它是结构上免疫,而不是碰巧能用——这一点很重要,因为它意味着这个规避不会随版本变化而失效(#403)。
5. 一个反证:社区里有工具(dsh-blueprint)之所以写入 preset 一直正常,正是因为它只写默认用户根、从不碰 roots。另一位维护者原本给自家包文档写的是「复制到 CLI 自带的 shipped 根」,读到这条后改成了用户根方案——因为复制进 CLI 安装树每次升级都要重做,而用户根在用户空间、升级后依然有效(#403)。
规避与修复:DeepSeek Harness 用户根、overlay-check 与追加式补丁
立刻能用的是用户根;想彻底修则要把「替换」改成「追加」;还想要告警可以在自己的工具里加检测。 具体做法:
- 当前最稳的规避——放用户根:目录形状如下,
agent.cordis.yml与 shipped 的standardpreset 同构(一个用@deepseek-ai/dsh-persona的persona行,然后每个工具一个- id: <row> / name: '<package>'):
$DSH_HOME/.agent-presets/
my-preset/
preset.yml # name, description, order
agent.cordis.yml # persona row, then one flat row per tool
发现流程不做记忆化(unmemoized),所以文件落盘后无需重启就能在预设选择器里选中;另外,目录内容无法解析或不是一组具名行时,会被列为 broken 并给出原因而不是被静默跳过——手写配置时这个设计很友好(#403)。 2. 官方修复方向——把替换改成追加,让用户配置优先:
if (rows.has("agent-presets")) composedOverlays.push({
id: "agent-presets",
config: {
...rows.get("agent-presets")?.config ?? {},
roots: [
...(rows.get("agent-presets")?.config?.roots ?? []), // 用户配置优先
{ path: SHIPPED_PRESET_ROOT, trust: "system" } // shipped 兜底
]
}
});
关键是让 shipped 根退化成兜底项而不是唯一项,这样既修好自定义根,又保证内置 preset 仍然可见(#403)。
3. 项目根需要随仓库分发时的过渡做法:在修复合入前,把项目内目录复制或同步到用户根(写进启动脚本同步亦可)。这样既保住了 git 管理,又能被扫描到;等上游改成追加式之后再切回项目根(#403)。
4. 把「静默覆盖」变成告警:社区已经把这条检查做成了工具规则——dsh-overlay-check 0.4.0(MIT、无依赖)会对声明该配置的 overlay 明确拒绝沉默:
warning config-silently-overwritten "agent-presets.roots" is replaced at boot
with the shipped root, so setting it here does nothing — and --dump-config will
still show your value, because the override is applied after the composition it
prints. Put presets in $DSH_HOME/.agent-presets/<id>/ instead.
它是一个作用在 patch 行上的纯函数,因此任何会写配置的工具都能复用,不限于原作者自己的工具链;规则刻意只锁定这一行、这一个键,方便上游修好后干净删除(#403)。
5. 排查时的心态建议:遇到「配置 dump 正确但运行时无效」的组合时,优先怀疑配置在装载前被改写,而不是继续检查 YAML 语法。用运行时探针(读 agentPresets.config)直接看生效值,比任何静态检查都可靠。这也适用于你在做 DSH插件 或 DeepSeek插件、并在 DSH Plugin Hub 上分发带 preset 的插件时——如果插件的使用说明让用户去配 roots,那今天就等于给了他们一条走不通的路(#403)。
DSH plugin 排查注意事项
先记住这里不是 YAML 写错了——配置确实被读到了,只是在装载前被一个看不见的步骤改掉,所以任何静态自查都会告诉你「配置是对的」。 八条要点:
- 别只信
--dump-config:它不包含 composeProfile 的运行时注入,显示的是意图而非生效值。 - 用户根是结构上免疫:
includeUserRoot在损坏的计算之后追加,所以不受这个覆盖影响。 - 失败看起来是间歇的:同一行里其他字段会生效,只有
roots被丢弃。 - 跨平台跨版本一致:Windows/Linux/macOS、rc.6/rc.7/rc.2 表现相同,不是偶发。
- 用户根的优势:在用户空间,
dsh升级后无需重新复制,优于复制进 CLI 安装树。 - 发现流程不记忆化:文件落盘即可选中,通常不需要重启。
- 修复方向是追加而非替换:shipped 根应退化为兜底项。
- 给插件作者的提醒:别在自己的使用说明里推荐用户去配
roots,除非已确认上游修复。

常见问题
DeepSeek Harness 的 composeProfile() 会把你配置的 roots 在启动时**整体替换**,所以项目里的 preset 不会被扫描到。它在组合完所有 patch 层之后额外 push 一个覆盖层,把 agent-presets 行的 roots 换成只有 shipped 根 [{ path: SHIPPED_PRESET_ROOT, trust: "system" }];它作为**最后的 overlay** 传给 loader,所以你无论在哪一层配 roots 都不生效。
DeepSeek Harness 的 --dump-config 打印的是 **patch 层组合结果**(bundle + profile + home + --patch),而覆盖层是在这个数组构建完成**之后**才 push 进去的。所以 dump 不是过期也不是缓存,它只是**展示了一个和 loader 实际收到的不一样的值**——你传什么参数都看不到生效值,这也是这个坑最耗时的地方。
DeepSeek Harness 修复合入之前,把 preset 放到用户根 <dshHome>/.agent-presets/<preset-name>/ 一定生效(Windows 默认 %USERPROFILE%\.dsh\.agent-presets\,可用 $DSH_HOME 改位置)。includeUserRoot 默认为 true,而且它是在**被这个 bug 破坏的那次 roots 计算之后**才追加的,所以结构上不受这个覆盖影响;同时它在用户空间,dsh 升级后依然存在,不需要重新复制。
最快的确认方法是做一组对照:DeepSeek Harness 的 --dump-config --patch test.patch.yml 里能看到你的 roots,但 Web UI 预设选择器里就是不出现——这就是同一问题。更直接的证据是用运行时探针(动态插件读 agentPresets.config)看运行时 roots 是不是只剩 shipped,或者直接把同一个 preset 复制到用户根,如果立刻可见,就说明 preset 文件本身没问题、问题在 roots 注入。
相关术语
- composeProfile
- composeProfile 是 CLI 启动时把 bundle、profile、home、--patch 等 patch 层组合成最终配置的函数。它除了做组合,还会额外注入一个覆盖层专门处理 agent-presets 的 roots,而这个注入步骤不出现在 --dump-config 输出里。— https://github.com/deepseek-ai/deepseek-harness/discussions/403
- shipped root
- shipped root 是随 CLI 一起分发的内置 preset 根(SHIPPED_PRESET_ROOT),trust 标记为 system。它是覆盖层里唯一保留下来的 roots 项,也是为什么内置预设始终可见、而自定义根始终不可见。— https://github.com/deepseek-ai/deepseek-harness/discussions/403
- includeUserRoot
- includeUserRoot 是 agent-presets 的配置项,默认 true,效果是把 <dshHome>/.agent-presets 作为用户根追加进去。关键点是它在被覆盖破坏的那次 roots 计算之后才追加,因此是唯一不受 composeProfile 运行时覆盖影响的根。— https://github.com/deepseek-ai/deepseek-harness/discussions/403
来源
- deepseek-harness Discussion #403:agent-presets.roots 用户配置被 composeProfile 强制覆盖,项目级/自定义 preset 根永远不生效· deepseek-ai(GitHub Discussions)
- dsh-overlay-check 0.4.0(MIT,config-silently-overwritten 规则)· GitHub(taltara)