L2 · 组合配置
Lesson 02 · 模块 A · 会用

组合的艺术:
cordis.yml、profile 与 bundle。

L1 你用 --dump-config 看到了插件树的全貌。这一课回答它从哪来: 运行中的 dsh 不是一个程序,而是一叠配置层组合出来的结果。 学会读 cordis.yml、分清 profile 与 bundle、背出叠加顺序,你就拿到了改造这套框架的钥匙。

精读 20 分钟 练习 10 分钟 · 6 题 前置 见课程首页
学完你将能够
  • 读懂 cordis.yml 条目的 id / name / config / disabled 字段与 !!js 的合法位置
  • 说清 profile 与 bundle 各自是什么、由哪个字段声明
  • 背出 bundle → profile patch → home patch → --patch 的叠加顺序
  • 写一个最小 patch,并用 --dump-config 前后对比验证它生效
01 · Anatomy

解剖 cordis.yml:一切都是条目

cordis.yml 的顶层不是一棵嵌套的树,而是一列条目(entry)。每个条目是一行扁平的记录: 一个 id、一个插件名 name(裸包名或相对路径),加上可选的 configdisabled。 新条目通过 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: true
  • config:传给插件的配置。插件导出一个同名的 Schemastery Config schema,加载时校验并填充默认值;不合法就当场加载失败、报出明确错误。harness 的约定是无硬编码可调参数——部署间可能不同的值都必须能在 cordis.yml 里改。
  • disabled:在每次挂载决策时求值,决定这个条目此刻挂不挂载。
  • idname:其余元数据保持字面量,不会被求值。

!!js:唯一的表达式逃生舱

配置里可以写表达式,但规则很严:!!js(两个感叹号)只允许出现在条目的 configdisabled。 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 或别的字段里塞表达式,它只会被当成普通字符串。

02 · Profile & Bundle

profile 与 bundle:组装与零件

profile 是 Harness home($DSH_HOME,未设置时为 ~/.dsh)里的一个具名目录 profiles/<name>/。它由三样东西组成:

  • 有序叠加的 bundle 列表package.json 里的 dsh.profile.bundles 字段,按数组顺序应用。
  • 树外插件:同一个 package.jsondependencies,用 dsh plugin --profile <name> add <pkg> 安装,由 pnpm 管理在 profile 目录里。
  • cordis.patch.yml:这个 profile 自己的覆盖层,你的个性化都写在这里。

随发行版自带的应用全部通过 dsh CLI + 命名 profile 启动,共五个自带模板:webpnpm dsh web 是它的别名)、headlesssdksdk-minimalacp,首次使用自动初始化。 其中 web = dsh-base + dsh-web-app,headless = dsh-base + dsh-headless(一次性运行器,完全不带服务器)。 其他名字不会凭空创建,CLI 会提示你用 dsh plugin 初始化。

profile 还有两个值得知道的开关。patchReload(写在 package.jsondsh.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 回答「这批零件连同代码怎么打包分发」。 前者是你机器上的目录,后者是可安装的包。

03 · Layer Order

叠加顺序:四层,后写的赢

每次 profile 启动,都从一个空条目列表开始,自下而上依次应用四层。 对同一个 id 的行,后应用的层覆盖先应用的——顺序就是优先级。

后写的层 整行覆盖 ④ --patch overlay · 命令行临时层 按 argv 顺序应用:一次性实验、按环境选插件,不改动任何文件 ③ home 级 patch $DSH_HOME/cordis.patch.yml 机器本地偏好,所有 profile 共享,因此优先于逐 profile 的层 ② profile patch profiles/<name>/cordis.patch.yml 这个 profile 自己的覆盖:改配置值、禁用插件、插入新条目 ① bundles dsh.profile.bundles 按序:base → web-app / headless 插入配置行并挂载代码;这一层的一切都能被上面三层 patch 同一 id 的行,后写的层整行覆盖先写的;patch 也可以用 insert 插入新行。
图 2-1 · 四层叠加:bundles → profile patch → home patch → --patch overlay。

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 里必须重述原表达式。

04 · Try It

动手试:写一个最小 patch

目标:把会话标题的最大长度从默认的 maxTitleBytes: 80 改成 40。 因为 patch 是整行替换,session-title 行没改的字段要照原样重述。

动手试 · 5 分钟
  1. 先存一份基线 dump:
dsh --profile web --dump-config > before.yml

2. 新建 my.patch.yml,写一条整行替换(想试禁用插件的话,换成 - id: session-title-llmdisabled: 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.yml

4. 确认 diff 里 session-titlemaxTitleBytes 变了,其余行原样。

dump 输出不只是配置:每段行之前的 # == 注释标明它来自哪个文件、被哪些 overlay 改过, !!js 表达式保持未求值的原样。你的 patch 若找不到目标 id,警告会出现在 stderr 上。 整个过程不启动应用,所以随便试,试坏了删掉 patch 文件就还原。

顺手一记

--dump-default-config 只打印 bundle 各层,--dump-config 才叠加 profile patch、home patch 和 --patch。 想区分「出厂状态」和「我的实际状态」时,这两条命令就是一对。

05 · Pitfalls

常见坑

  • 按环境选择插件,用 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 静默不生效

最常见的「我改了配置没用」:patch 里的 id 写错了,或层顺序想反了(比如在 profile patch 里改,却被 home patch 又盖回去)。 诊断手段永远是同一条——--dump-config 看最终组合结果和 stderr 警告,不要猜。

06 · Quiz · 10 分钟

课堂练习

2 单选 + 2 多选 + 2 问答。选择即时批改,问答先写下你的答案再对照。成绩保存在本地浏览器。
单选
关于 cordis.yml 中的 !!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 两层颠倒了,是常见的记法错误。

多选
一个 profile 由哪些部分组成?(选出所有正确项)

正文 02 节:profile 是 profiles/<name>/ 目录,= bundle 列表 + 树外插件 + cordis.patch.yml。C 错:dsh 没有特权内核,agent loop 本身也是插件,想改变行为是在配置里替换插件或挂扩展点,不是改源码。

多选
关于 bundle 与 patch,哪些说法正确?

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 与凭据。