组合的艺术:
cordis.yml、profile 与 bundle。
L1 你用 --dump-config 看到了插件树的全貌。这一课回答它从哪来:
运行中的 dsh 不是一个程序,而是一叠配置层组合出来的结果。
学会读 cordis.yml、分清 profile 与 bundle、背出叠加顺序,你就拿到了改造这套框架的钥匙。
- 读懂 cordis.yml 条目的
id/name/config/disabled字段与!!js的合法位置 - 说清 profile 与 bundle 各自是什么、由哪个字段声明
- 背出 bundle → profile patch → home patch →
--patch的叠加顺序 - 写一个最小 patch,并用
--dump-config前后对比验证它生效
解剖 cordis.yml:一切都是条目
cordis.yml 的顶层不是一棵嵌套的树,而是一列条目(entry)。每个条目是一行扁平的记录:
一个 id、一个插件名 name(裸包名或相对路径),加上可选的 config 与 disabled。
新条目通过 patch 的 insert 列表加入;已有条目则按 id 被后续层改写。
# 一行 = 一个条目:id + 插件名 + 可选 config(摘自 dsh-base 的 cordis.patch.yml)
- insert:
- id: agent-default-model
name: '@deepseek-ai/dsh-agent-default-model'
config:
provider: deepseek-official
model: deepseek-flash
# 按 id 改写已有条目:禁用它(摘自 dsh-web-app 的 cordis.patch.yml)
- id: tool-bash
disabled: trueconfig:传给插件的配置。插件导出一个同名的 SchemasteryConfigschema,加载时校验并填充默认值;不合法就当场加载失败、报出明确错误。harness 的约定是无硬编码可调参数——部署间可能不同的值都必须能在 cordis.yml 里改。disabled:在每次挂载决策时求值,决定这个条目此刻挂不挂载。id与name:其余元数据保持字面量,不会被求值。
!!js:唯一的表达式逃生舱
配置里可以写表达式,但规则很严:!!js(两个感叹号)只允许出现在条目的 config 和 disabled 下。
Loader 会在条目声明的 inject 服务就绪后,基于插件上下文插值它的 config——所以表达式能读到
ctx.webStartup.port 这种普通服务;disabled 则基于 loader 上下文求值。其余字段永远保持字面值。
# web profile 的真实写法:命令行 flag 优先,配置值兜底
- id: webserver
config:
host: !!js ctx.webStartup.host ?? '127.0.0.1'
port: !!js ctx.webStartup.port ?? 3080写成 !js 不行——那只是 YAML 的本地标签,不是 harness 的表达式机制。
合法写法只有 !!js,且只在 config / disabled 两处生效;
想在 name 或别的字段里塞表达式,它只会被当成普通字符串。
profile 与 bundle:组装与零件
profile 是 Harness home($DSH_HOME,未设置时为 ~/.dsh)里的一个具名目录
profiles/<name>/。它由三样东西组成:
- 有序叠加的 bundle 列表:
package.json里的dsh.profile.bundles字段,按数组顺序应用。 - 树外插件:同一个
package.json的dependencies,用dsh plugin --profile <name> add <pkg>安装,由 pnpm 管理在 profile 目录里。 cordis.patch.yml:这个 profile 自己的覆盖层,你的个性化都写在这里。
随发行版自带的应用全部通过 dsh CLI + 命名 profile 启动,共五个自带模板:web(pnpm dsh web 是它的别名)、headless、sdk、sdk-minimal、acp,首次使用自动初始化。
其中 web = dsh-base + dsh-web-app,headless = dsh-base + dsh-headless(一次性运行器,完全不带服务器)。
其他名字不会凭空创建,CLI 会提示你用 dsh plugin 初始化。
profile 还有两个值得知道的开关。patchReload(写在 package.json 的
dsh.profile 里)决定 patch 文件的生命周期:live 会监视 profile 与 home 两层
cordis.patch.yml,保存即事务性重载——自带模板里只有 web 是 live,其余四个是
startup,自建 profile 默认 live;startup 则只在启动时应用一次。
而新 flag --from-default-profile <模板> 能从五个自带模板之一初始化一个自定义
profile 再启动:模板的 bundle 列表与 patchReload 值会被复制进新 manifest,依赖与用户 patch 从空白开始。
bundle(组合包)则是分发格式:Cordis 配置行 + 配置所挂载的代码,一起打成 npm 包。
它在 manifest 里声明 "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }。
关键保证是:bundle 插入的内容始终可以被其上各层 patch——它不是最终答案,只是下一层的起点。
自带的 dsh-base 是每个 profile 的第一层:模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测,全在里面。
profile 回答「这次启动用哪些零件、怎么调」,bundle 回答「这批零件连同代码怎么打包分发」。 前者是你机器上的目录,后者是可安装的包。
叠加顺序:四层,后写的赢
每次 profile 启动,都从一个空条目列表开始,自下而上依次应用四层。
对同一个 id 的行,后应用的层覆盖先应用的——顺序就是优先级。
patch 的语义:按 id 整行替换
一条 patch 做两件事之一:按 id 定位某个条目并替换它的整个 config,或用 insert 插入新条目。
注意是整行替换,不是深度合并——没改的字段也要在 patch 里重述,否则它们会变回 schema 默认值。
patch 找不到目标 id 时不会启动失败,只会在 stderr 打一条警告,所以验证动作(下一节)必不可少。
为什么不做深度合并?因为整行替换让每一层可预测、可逆、可审计:这层声明的就是该行的最终值,读者不用在脑内做合并运算;
加一层有确定的效果,删掉它就还原;配置即数据,git diff 和 dump 输出里的来源注释能直接告诉你哪一层改了哪一行。
用字面量整行替换一个含 !!js 的 config,会连同表达式一起移除。
例如把 port: !!js ctx.webStartup.port ?? 3080 改成 port: 3081 之后,
--port flag 就对这行失效了——要保留 flag 优先,patch 里必须重述原表达式。
动手试:写一个最小 patch
目标:把会话标题的最大长度从默认的 maxTitleBytes: 80 改成 40。
因为 patch 是整行替换,session-title 行没改的字段要照原样重述。
- 先存一份基线 dump:
dsh --profile web --dump-config > before.yml2. 新建 my.patch.yml,写一条整行替换(想试禁用插件的话,换成 - id: session-title-llm 加 disabled: true 即可):
# my.patch.yml —— 顶层是一个 patch 列表
- id: session-title
config:
fallbackMaxWords: 5 # 未改字段也要重述
fallbackMaxBytes: 40
maxTitleBytes: 40 # 唯一真正改的键3. 带上 --patch 再 dump 一次,然后 diff:
dsh --profile web --patch ./my.patch.yml --dump-config > after.yml && diff before.yml after.yml4. 确认 diff 里 session-title 的 maxTitleBytes 变了,其余行原样。
dump 输出不只是配置:每段行之前的 # == 注释标明它来自哪个文件、被哪些 overlay 改过,
!!js 表达式保持未求值的原样。你的 patch 若找不到目标 id,警告会出现在 stderr 上。
整个过程不启动应用,所以随便试,试坏了删掉 patch 文件就还原。
--dump-default-config 只打印 bundle 各层,--dump-config 才叠加 profile patch、home patch 和 --patch。
想区分「出厂状态」和「我的实际状态」时,这两条命令就是一对。
常见坑
- 按环境选择插件,用 overlay,不要在配置里塞条件表达式。
!!js的职责是插值(读环境变量、读已就绪的服务),不是条件组合。「生产挂 A、调试挂 B」的正确姿势是准备两份--patch文件按环境传入,配置主体保持字面量。 - 裸插件名必须在 resolver manifest 的 dependencies 里。在仓库的组合示例(如
apps/cli/config/examples/下的 patch overlay)里引用@deepseek-ai/dsh-*这类 bare plugin 时,它必须出现在解析所用 manifest 的dependencies中,verify-cordis-config门禁会强制检查,漏了就红。 - 空 patch 文件会抛异常。顶层是 YAML 数组的 patch 文件,如果为空或只有注释,解析结果不是列表,加载直接报错。想「这一层暂时什么都不做」,正确写法是一个空数组
[]。
最常见的「我改了配置没用」:patch 里的 id 写错了,或层顺序想反了(比如在 profile patch 里改,却被 home patch 又盖回去)。
诊断手段永远是同一条——--dump-config 看最终组合结果和 stderr 警告,不要猜。
课堂练习
!!js 表达式,哪一项说法正确?正文 01 节:!!js 由 Loader 解析为表达式节点,config 在插件 inject 就绪后基于插件上下文插值,disabled 在每次挂载决策时基于 loader 上下文求值;其余元数据保持字面量,所以 C、D 错。!js 只是 YAML 本地标签,不是这套机制,A 错。
dsh --profile web --patch ./extra.yml 时,各配置层从先到后的应用顺序是?正文 03 节:空条目列表之上,先按 dsh.profile.bundles 顺序应用各 bundle,再 profile 的 cordis.patch.yml,再 home 级的 $DSH_HOME/cordis.patch.yml(机器本地偏好优先于逐 profile 层),最后按 argv 顺序应用 --patch。同一行后写的层赢。B 把 home 和 profile 两层颠倒了,是常见的记法错误。
正文 02 节:profile 是 profiles/<name>/ 目录,= bundle 列表 + 树外插件 + cordis.patch.yml。C 错:dsh 没有特权内核,agent loop 本身也是插件,想改变行为是在配置里替换插件或挂扩展点,不是改源码。
B 错:patch 是整行替换而非深度合并,未改的字段必须重述,否则回到 schema 默认值(03 节)。C 错:bundle 的设计保证恰恰是其内容永远可被上层 patch(02 节)。A、D 都是正文对 bundle 保证与 patch 语义的原话。
问答为什么 patch 选择「按 id 整行替换」而不是对 config 做深度合并?写下答案后点击对照›
参考要点:① 可预测——每层声明的就是该行的最终值,读者不用在脑内对多层的零散键做合并运算;② 可逆、幂等——加一层有确定效果,删掉即还原,重复应用结果不变;③ 可审计——配置即数据,git diff 和 --dump-config 的 # == 来源注释能直接指出哪一层改了哪一行。深度合并会把各层的部分键搅在一起,最终值必须算出来才知道,排错成本陡增。
问答描述用 --dump-config 验证一个 patch 生效的完整流程。写下答案后点击对照›
参考要点:① 基线:dsh --profile web --dump-config > before.yml;② 写 patch:顶层 YAML 数组,按 id 整行替换(未改字段重述)或 insert 新条目;③ 应用后 dump:dsh --profile web --patch ./my.patch.yml --dump-config > after.yml;④ diff 对比,确认只有目标行变化,并借 # == 注释确认该行被你的 overlay 层标记;⑤ 检查 stderr——patch 找不到目标 id 时有警告,说明 id 写错或层顺序想反了。dump 不启动应用、不求值 !!js,所以整个验证安全且可反复做。
本课小结
现在你手里的 dsh 不再是一个黑盒:cordis.yml 是一列扁平条目,!!js 只在 config/disabled 两处逃生;
profile 组装 bundle、树外插件和自己的 patch,bundle 把配置连同代码打包分发且永远可被上层改写;
四层叠加自下而上、后写的整行覆盖。下一课我们顺着配置里的模型条目往下走:接入任意模型的 provider 与凭据。