Files
deepseek-harness/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md
T
Turtle f290a8b851 refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.

- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
  web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
  patches never cross an include boundary. Precedence: base < surface <
  (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
  test tree never inherits the user's route; new `--config-replace` boots a file
  as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
  configure or disable it. Upstream built the id index once before the patch
  loop, leaving every surface-only row — the whole TUI front door — silently
  unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
  dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
  TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
  examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay

Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.

Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 21:15:42 +08:00

6.3 KiB
Raw Blame History

Agent Note: 一份共享 base 配置加各 surface 的 overlay

Status: implemented

English | 中文

问题

dsh 交付了两棵完整的配置树,其中 43 个配置项完全相同。apps/cli/cordis.yml 以 74 个平铺配置项组合 web surface,而 TUI 启动的是 examples/tui-agent/cordis.yml——其中单独一行 @deepseek-ai/dsh-tui-demo 挂载了十二个插件,并把它们的配置重新声明为自己那份二十个键、仅作透传的 Config。

这两份文件都名不副实。examples/tui-agent 并不是示例:apps/cli/src/tui.ts 把它硬编码为产品的默认配置;它还拥有 TUI 的 PTY 冒烟测试、八个终端快照场景,以及被 cordis-agent 叶节点 import 的 PTY harness。dsh-tui-demo 也不是 demo——它就是应用本身,由交付的二进制从 packages/examples/ 中挂载。

真正决定性的问题是重复。43 个共享配置项中,38 个逐字节相同,5 个因各 surface 的正当理由而不同;因此每次能力改动都必须改两处,而且可能无声漂移。该组合包还反转了一个默认值:composeTuiApp 读取 config.goals ?? {},于是交付的 TUI 挂载了 goals、tool-goal、goal-session 和 /goal——尽管没有任何配置键要求它们。

决策

一份共享 base,每个 surface 一份 overlay,以平级 patch 列表的形式组合。

apps/cli/base.cordis.yml 持有两个 surface 都会挂载的 43 个配置项。apps/cli/tui.cordis.yml 与 apps/cli/web.cordis.yml 是 patch 列表,不是配置树:各自声明少数取值因 surface 而异的配置项,并 insert 自己的配置项。启动器只 include base 一次,并把每个 overlay 作为同一 include 层级上的平级 patch 列表应用——因为 include patch 不会跨越 include 边界,把 overlay 堆叠成嵌套 include 会使其静默地无法触达 base 配置项。

优先级即列表顺序,逐配置项后写者胜:base,然后是 surface overlay,接着是 --config overlay 或个人 ~/.dsh/config.yaml,最后是启动器自身的 flag 与 profile patch。

--config <path> 现在应用一个 overlay 来取代个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model。--config-replace <path> 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 --config 的行为,所以像 examples/web-cordis 这样的树改用了新 flag。两个 flag 都会在 /resume 的 execve 交接中保留,否则 resume 会静默更换 agent。

patch 会整体替换目标配置项的 config 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 dsh-agent-loop 的 CONFIGURED_AGENT_IDENTITIES_KEY,如启动器持有身份的 note 现在所记录。

examples/tui-agent、examples/cordis-agent 与 packages/examples/tui-demo 均被删除。TUI 测试迁往 apps/cli/tests/,cordis 工具集的 e2e 迁入 packages/cordis/tool-cordis/tests/,而 examples/code-mode 作为一个名副其实的示例保留下来:一个 patch tools.mode 并 insert code runtime 的 overlay。

备选方案

保留两棵平铺且重复的树。 拒绝:43 个配置项维护两份正是缺陷本身,而用一个门禁断言二者保持一致只会固化重复,而非消除它。

把 overlay 嵌套成 include(code-mode → tui → base)。 在对 Loader 实测后拒绝:patch 不会跨越 include 边界,因此外层文件的 patch 只会伴随一条告警被丢弃。三层链条使 tools 无法被 patch,而位于一层 include 之后的 base,会让每个个人 patch 都变成静默的空操作。

把所有配置项的并集放进 base,由各 overlay 禁用自己不需要的部分。 拒绝:base 将不再意味着「共享」,而每个 surface 都要携带仅为将其关闭而存在的配置项。

把因 surface 而异的配置项留在 base 中,由 overlay 去 patch。 仅对必须同时存在于两棵树中的那五个配置项采用,因为 patch 无法创建配置项。它们在 base 中的条目携带插件名与两个 surface 共享的配置,其余部分由各 overlay 声明。

影响

指名 @deepseek-ai/dsh-tui-demo、或 patch tui-agent 配置项的 overlay 与 --config 树将不再可解析。overlay 现在要 patch 拥有对应键的那一行:模型路由在 agent-loop,人设在 system-prompt,呈现设置在 tui。

若某个 patch 的 id 不匹配任何配置项,Loader 仍只告警而不报错。这是有意为之:同一份个人 overlay 会跨 surface 共用,而 insert 配置项按设计本就不匹配任何目标,因此仅在 web 下存在的配置项不能让 TUI 启动失败。

dsh web 新增 --config,作为一份额外 overlay 传入 AppCLIEntry。AppCLIEntry 在为自身 patch 合并恢复配置项默认值时会同时读取 base 与其 surface overlay,因为 flag 覆盖必须保留同一配置项上 overlay 的其他字段。

验证

组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:TUI 就绪 55 个条目、web 就绪 75 个,两者都没有未加载或未就绪的配置项,且 web 的 httpServer 已启动。三层叠加的情形(base + tui + code-mode)确认 tools.mode 越过 TUI overlay 的 native 达到了 code。

全部八个终端快照场景在迁移后逐字节重放一致,14 个用例的 PTY 冒烟测试全部通过,其中两个用例断言个人 overlay 能触达一个 insert 进来的配置项——这正是 vendored plugin-include 修复所启用的行为(vendor/README.md 本地修改第 8 条,由 packages/ui/app-boot/tests/config-reload.spec.ts 覆盖)。

平铺过程暴露出三处潜伏缺陷,均在此一并修复:TUI 曾在构造时一次性捕获可选的 sessionQuery 服务,因此在挂载竞争中胜出时会永久禁用 /resume;交付的会话存储根目录曾静默退回项目本地的 ./.sessions;--config-replace 曾在 resume 交接中被丢弃。