DeepSeek Harness 的设置分几层:用户设置的读取顺序与覆盖规则
先说清楚前提:DeepSeek Harness 仓库的 README 里有一节标题就叫 Developer preview,正文写明项目处于开发者预览阶段、迭代很快,并且用大写强调「会有破坏兼容性的变更」。下面提到的所有路径、字段名和默认值都是仓库快照里的样子,随时可能变,别当成稳定接口记。
从一个具体问题进去
假设你在用户设置文档里给 agent-loop 这个分节写了 maxParallelToolCalls,同时插件在 cordis.yml 的组合配置里也写了一个值,schema 本身还带默认值。运行时读到的到底是哪一个?如果你把用户文档里那一行删掉,会退回到哪一层?
这个问题的答案全在 packages/settings/settings/src/index.ts 一个私有方法里。它叫 resolve,注释一句话写死了顺序:schema defaults,然后 base,然后 user layer。方法体的有效逻辑只有两行:
const value = schema(mergeLayers(base, section) as never)
validate?.(value)
抄自 packages/settings/settings/src/index.ts 的 resolve()。读法是这样:base 和用户分节先合并,合并结果交给 schemastery schema 做运行时校验,schema 默认值是在这一步被补上的,不是提前铺一层对象再被覆盖。所以「三层」的顺序虽然写作「默认值 → base → 用户层」,代码里真正做合并的只有后两层,默认值是兜底填空。
base 是什么?SettingsRegisterOptions 的字段注释写得很直白:composition-layer values resolved below the user layer(entry-config subset)。也就是插件在组合配置里的那份 entry config 的一个子集。子系统文档 docs/subsystems/settings.md 开头那段还补了一句:组合配置仍然留在 cordis.yml,namespace 只承载用户可编辑的那个子集。
合并规则:对象递归,数组和标量整体替换
同一文件里的 mergeLayers 决定了「覆盖」到底覆盖到多深:plain object 逐键递归合并,其余任何值——数组也算——整体替换下层。所以一个数组型配置项不存在「在用户层追加一项」这种写法,你写多少就是多少。
还有一处很容易踩:update() 传进去的稀疏 patch 删不掉下层的键。写入前所有 payload 都要过 cloneJsonShaped,它在对象里遇到 undefined 直接跳过(数组里的 undefined 则是报错),注释解释这是和 mergeLayers 一致的稀疏补丁语义。于是删除只剩两条路:replace() 整体替换分节,缺席的键重新继承 base 和 schema 默认值,replace({}) 就是整节复位;或者 mutate() 的 { op: 'unset', path } 按路径删一个字段。
cloneJsonShaped 顺手还卡住了一类值:只有 JSON 数据(plain object、数组、字符串、有限数、布尔、null)能落到 provider 文档里,Date、Map、BigInt、非有限数、循环引用都会带着一个以 $ 开头的路径被拒,而且是在持久化之前就拒。注释里给的理由是 YAML/JSON 存储会在 reload 往返时悄悄改变这些值——这是文档自述的动机,不是我们的推测。
另外一条纪律写在 README 和 scope 注释两处:update 只往用户层合并,绝不写进 base。你没法通过 API 改掉组合层。
不是每个配置项都能被用户改
agent-loop 是个好例子。它的设置 schema 只有一个字段:
export const AGENT_LOOP_SETTINGS_SCHEMA: z<AgentLoopSettings> = z.object({
maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
})
DEFAULT_MAX_PARALLEL_TOOL_CALLS 的值在 packages/core/agent-loop/src/constants.ts 里是 10。而 AgentLoopSettings 接口的注释明说,它是插件 Config 的一个「刻意为之的严格子集」:agents 是启动期消费一次的组合数组,存进用户文档只会「看起来像是生效了」。换句话说,用户层能覆盖的范围由注册方给出的那份 schema 划定,不是插件配置有什么你就能改什么。
反方向也有一例。packages/core/agent-default-model/src/index.ts 里,设置 schema 是 provider、model、reasoningEffort 三个字段,而同一文件里的组合入口 Config 只有 provider 和 model 两个。把这两处放在一起看:reasoningEffort 只可能来自用户层,组合层根本没有能表达它的字段。事实就到这里,作者为什么这么切我们不猜。
permission 这个 namespace 更特别:packages/interaction/permission-presets/src/index.ts 里的 schema 是运行期拿当前已注册的 preset 名字 union 出来的,base 则是 { defaultPreset }。也就是说这个分节的合法取值集合,取决于当前组合里挂了哪些 preset。
文档落在哪个文件,什么时候被重新读
存储由 provider 决定,仓库里的文件 provider 是 packages/settings/settings-file。它的 resolveSpec() 把默认值集中在一处:路径默认是 harness home 下的 settings.yaml;harness home 取 $DSH_HOME,没有则是 ~/.dsh(这两个常量在 packages/util/home-paths/src/index.ts,分别是 DSH_HOME_ENV 和 DSH_HOME_DIR_NAME)。扩展名决定格式,只认 .yaml、.yml、.json,其它扩展名在解析 spec 时直接抛错。watch 默认 true,debounceMs 默认 100。
Windows 这边有一条专门的处理写在该包 README 的 Behavior 一节:交给 Chokidar 的是 canonical 路径,provider 会先把最深的已存在祖先目录 realpath 一遍再拼回缺失的后缀,原因是 libuv 内部不能把 8.3 短名别名和长格式事件路径混着用;文件访问和给用户看的诊断信息仍然用你配置的那个路径。
写回这条链更保守:每次持久化都是 read-modify-write,先在跨进程写锁里重新读盘、把本进程还没看到的外部改动发布进 seam,再渲染。锁是 <file>.lock 兄弟文件,packages/util/atomic-write/src/index.ts 里退避从 20 毫秒起、上限 200 毫秒、总获取超时 2000 毫秒;抢不到的一方超时退出而不会删掉已有锁文件,README 原文说残留锁的清理属于运维动作。YAML 侧的写回是叶子级 diff,只 set 变了的值、只 delete 被移除的键,未触碰节点的注释和格式保留。
改了没生效?按这几个岔口排
第一步,先确认解析值到底有没有变。 commit() 里有一道 deep-equal 门:resolved 值和上一次深相等就不发 settings/updated,watcher 也不会被调用。所以「文档确实变了但解析值没变」(比如你显式写了一个和 base 一模一样的值)时,消费方什么都收不到——这个场景对应的是另一个事件 settings/document-updated,它带 namespace 和新的 revision,是给配置界面用的,因为字段从「继承」变成「用户覆盖」对界面是有意义的变化。
第二步,看 owner 的 applies。 子系统文档明说这是给 UI 的提示而非机制:声明 restart 的 owner 只是从不 watch,值在构造期读一次;默认是 live。所以看到某个值改了不动,先去看那个插件是不是压根没有 watch。
第三步,看有没有被判成非法分节。 三个时机的行为不一样,都是白纸黑字写着的:启动时文档存在但不合法,是 plugin load 失败,fail loud;注册时存的分节就过不了 schema 或 validate,注册本身失败;已经跑起来之后从磁盘读到坏值,则保留该 namespace 的最后一个好值并 warn,其它 namespace 照常提交。日志里那句 settings: keeping last good "%s" after invalid stored section 就是这条路径。
第四步,如果是程序在写,看是不是撞了 revision。 每个 descriptor 带一个针对原始分节的单调 revision,写入时可以把它作为 expectedRevision 回传,不匹配就抛 SettingsConflictError(code 是 SETTINGS_CONFLICT,两个 revision 都挂在错误对象上)。注意这个检查做在写队列的前端而不是调用时刻,注释解释的理由是队列只能保证顺序,分不出「新写入者」和「拿着过期快照的写入者」。
什么情况说明不是这条链的问题。 如果那个值压根不在注册方的 schema 里(像上面 agent-loop 的 agents),或者你改的是 cordis.yml 里 namespace 没有承载的那部分组合配置,那就跟读取顺序无关,得回到组合层去看。同样,如果 settings/updated 确实发了、watcher 也进了,行为却没变,那问题在消费方怎么读这个值——比如 agent-loop 是用 getter 每次调度时读一次,别的插件未必如此。
它自己承认的边界
这几条都写在 packages/settings/settings/README.md 与 packages/settings/settings-file/README.md 的 Known Limitations 里,值得原样记住:
- 只有一层用户层,而且解析结果不记录每个字段最终由哪一层提供。所以你拿到一个 resolved 值,无法从中反推它是默认值、
base还是你自己写的。 - 同一 namespace 的跨进程并发仍是 last-write-wins,文件 provider 的写锁保证的是不同 namespace 不会互相抹掉,不是同一分节的逐值合并。
- watcher 漏掉的事件不会被主动补:读路径不会重新 stat 文件,漏掉的改动要等下一次事件、下一次写入或者重启才折进来。
- 没有值间接引用:
${env:VAR}这类给密钥用的引用是 deferred 的 seam 级特性,当前分节里存的是字面值。 redactSecrets自述不是一个已证明的传输边界:那个 walker 只走object/dict/array,藏在 union、intersection 或 transform 后面的role('secret')字段会被原样返回而secrets列表为空,并且schema.toJSON()会把 secret 字段的.default(...)一起带给客户端。README 里说 fail-closed 的describeForWire()才是真正的答案,并且这件事被推迟了。
最后再放两处放在一起看的事实:register 对重复 namespace 的行为是 duplicate registration fails loud;而 packages/shell/bash-local/src/index.ts 和 packages/shell/pwsh-local/src/index.ts 用的是同一个 SHELL_SETTINGS_NAMESPACE。这两条摆在一起,说明这个 namespace 同一时刻只能有一个 owner。至于组合层怎么保证只挂一个,我们没有再往下追。
想自己核一遍读取顺序,只要看三个地方就够:packages/settings/settings/src/index.ts 的 resolve 与 mergeLayers、packages/settings/settings-file/src/index.ts 的 resolveSpec、以及你关心的那个插件里 installSettingsSection 那一行传进去的 entry——那一行就是它的 base。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。