第 2 章讲了 cordis 的机制,第 3 章讲了配置怎么拼。但还有一个问题没回答:为什么 dsh 的 185 个包能互相替换,而不需要改别的包的代码?
答案是本章的主角:seam(能力接缝)架构。dsh 把 agent 运行时需要的每一项能力,都拆成「抽象服务(Service Definition)+ 具体实现(Provider)」两层。这一章把整套 seam 清单摊开,并解剖其中一个最完整的例子:文件系统。
假设你要实现"把 agent 的文件操作包进沙箱"。最直觉的做法:
这能用,但马上遇到三个问题:
dsh 的解法是把「能力」拆成四层,每层一个包,层与层之间只通过事件词汇通信,互不 import。
dsh 的文档里反复出现一个句式(本机 100+ 处)[实测]:
服务:FileSystem(ctx 键:fs) —— dsh-fs/README.zh.md
这背后是一对分工:
| 角色 | 包 | 职责 |
|---|---|---|
| Service Definition(服务定义) | dsh-xxx | 定义 ctx.xxx 的接口、事件词汇、错误分类。只依赖 cordis,绝不依赖任何实现 |
| Provider(提供方) | dsh-xxx-local / dsh-xxx-sandbox / … | 继承 Service 定义,提供具体实现。可替换、可叠加 |
dsh 官方把这个设计叫做 capability seam(能力接缝):
该拆分使每个关注点可以独立演进和替换(见能力 seam Agent Note 2026-06-13-capability-seams.md) —— dsh-fs/README.zh.md
素材盲区说明 dsh 官方文档大量引用 .agents/notes/ 目录下的 Agent Note(设计决策文档)。本机安装的 npm 包不含这个目录,所以这些设计文档的原文本教程看不到。能看到的是:README 里的结论性描述、打包源码、以及运行时的真实行为。凡涉及 Agent Note 的引述,本教程只转述 README 里出现的内容。
为什么 Service Definition 只依赖 cordis? 这是刻意为之。dsh-sandbox 的 README 说得很清楚 [官方文档]:
作为能力 seam 拆分中的 Service Definition 角色,它只依赖 cordis(及 harness 错误基类),绝不依赖后端。 —— dsh-sandbox/README.zh.md
好处是:定义可以脱离实现独立演进。新增一个沙箱后端(比如未来的容器后端),不用动 dsh-sandbox 一行;改沙箱后端的实现,不用动所有依赖 ctx.sandbox 的插件。
我在本机 185 个包的文档里提取了全部 ctx.* 服务键,按域分组如下 [实测]:
| ctx 键 | 服务定义包 | 本机实际提供者 |
|---|---|---|
| ctx.llm | dsh-llm | dsh-llm-pi-ai(pi-ai 后端) |
| ctx.tools | dsh-tools | 工具注册表(本体) |
| ctx.sessions | dsh-session | 会话存储(本体) |
| ctx.agents | dsh-agent | Agent 注册表(本体) |
| ctx.agentLoop | dsh-agent-loop | 循环实现(本体,唯一) |
| ctx.systemPrompt | dsh-system-prompt | 提示词装配(本体) |
| ctx.compaction | dsh-compaction | dsh-compaction-basic |
| ctx.tokenMeter | dsh-token-meter | 计量(本体) |
| ctx.agentDefaultModel | dsh-agent-default-model | 默认模型(本体) |
| ctx 键 | Service Definition | 本机可用的 Provider |
|---|---|---|
| ctx.fs | dsh-fs | dsh-fs-local(宿主)· dsh-fs-sandbox(沙箱)· dsh-fs-observation-policy(政策) |
| ctx.shell | dsh-shell | dsh-bash-local · dsh-pwsh-local · dsh-bash-sandbox · dsh-pwsh-sandbox |
| ctx.subprocess | dsh-subprocess | dsh-subprocess-local |
| ctx.sandbox | dsh-sandbox | dsh-sandbox-local(内探测 bwrap/landlock/Seatbelt/ACL) |
| ctx.sandboxPolicy | dsh-sandbox-policy | 策略解析(本体) |
| ctx.jobs | dsh-jobs | dsh-jobs-local |
| ctx.web | dsh-web | dsh-web-search-deepseek(搜索) |
| ctx.spillStore | dsh-spill | dsh-spill-local |
| ctx.storage | dsh-storage | dsh-storage-json |
| ctx.settings | dsh-settings | dsh-settings-file(settings.yaml) |
| ctx.credentials | dsh-credentials | dsh-credentials-local($DSH_HOME/.env) |
| ctx.attachments | dsh-attachment | dsh-attachment-local(内容寻址) |
| ctx.codeRuntime | dsh-code-runtime | dsh-code-runtime-worker-thread |
| ctx.sessionPersistence | dsh-session-persistence | dsh-session-persistence-jsonl |
| ctx.sessionQuery | dsh-session-query | dsh-session-query-sqlite(FTS5) |
| ctx.sessionTitle | dsh-session-title | dsh-session-title-first-prompt-llm |
| ctx.subagents | dsh-subagent | spawn / fork 两种 in-process 后端 |
| ctx.workflowEngine | dsh-workflow | dsh-workflow-worker-thread |
| ctx.goals | dsh-goal | 目标状态(本体) |
| ctx.approval | dsh-user-approval | 审批(本体) |
| ctx.permissionPresets | dsh-permission-presets | 权限预设(本体) |
| ctx.userQuestions | dsh-user-questions | 提问(本体) |
| ctx 键 | 包 |
|---|---|
| ctx.sessionProjections | dsh-session-projection |
| ctx.sessionProjectionCache | dsh-session-projection-cache |
| ctx.sessionReferenceResolver | dsh-session-reference |
| ctx.invariants | dsh-invariants |
| ctx 键 | 包 |
|---|---|
| ctx.webServer | dsh-host-webserver |
| ctx.apiProxy | dsh-host-apiproxy |
| ctx.remote | dsh-api-remotes |
| ctx.directoryPicker | dsh-host-directory-picker-*(三种后端) |
| ctx.loader | dsh-typert-loader |
| ctx.typert / ctx.typertGateway | dsh-typert-* |
| ctx.workspaceRegistry | dsh-workspace |
阅读方法 这张表不用背。它的价值是给你一个心智模型:dsh 的任何一个能力,你都可以在文档里找到"ctx 键 → Service Definition 包 → Provider 包"三条线索。看到 ctx.fs 就去找 dsh-fs 的定义和 dsh-fs-local 的实现,看到 ctx.sandbox 就去找 dsh-sandbox 和 dsh-sandbox-local。

配图说明:工具层(dsh-tool-fs,面向模型 schema)→ 政策层(dsh-fs-observation-policy,无服务,只监听 fs/* 事件门禁)→ 提供方约定层(dsh-fs,12 个原语 + 结构化错误码,只依赖 cordis)→ 提供方层(dsh-fs-local / dsh-fs-sandbox 平级,换实现 = 改配置)。层间靠事件通信而非 import。
dsh-fs 的 README 给出了 dsh 里最完整的分层示例,原文表格:
| 层 | 包 | 角色 |
|---|---|---|
| 工具/执行器 | dsh-tool-fs | 面向模型的 read/write/edit schema、读取窗口和文本渲染;通过 ctx.fs 读写编辑,并分派 fs/* 事件 |
| 政策 | dsh-fs-observation-policy | 已观察状态、编辑前读取和版本防护的写入/编辑,通过 fs/* 事件门禁贡献(无服务) |
| 提供方约定 | dsh-fs | ctx.fs:执行世界路径、文本 I/O 与原子变更原语(可选版本防护);拥有 fs/* 事件词汇 |
| 提供方 | dsh-fs-local | 宿主文件系统实现 |
(dsh-fs/README.zh.md)
四层的依赖方向是单向的:工具层用 ctx.fs,政策层监听 fs/* 事件,约定层定义接口,提供方层实现接口。层与层之间通过事件通信,而不是 import。
ctx.fs 的接口是 12 个方法,每个都有明确语义 [官方文档]:
| 成员 | 语义要点 |
|---|---|
| resolve(path) | 解析为稳定的 FsTarget(不透明 targetKey + displayPath) |
| processPath(target) | 子进程可打开的执行世界绝对路径 |
| fileUrl(target) | 规范化 file: URI |
| contains(parent, child) | 包含关系检查(不暴露 target key) |
| stat(target) | 元数据(version/type/size),绝不返回内容 |
| lstat(path) | 不跟随符号链接的元数据 |
| readText(target) | 完整文本读取;负责 UTF-8 解码与二进制拒绝(FS_NOT_TEXT) |
| streamText(target) | 大文件分片流式读取 |
| readBytes(target, signal, maxBytes) | 原始字节读取,maxBytes 必填,超限 FS_TOO_LARGE |
| listDir(target) | 按稳定名称列出直接子项,绝不读内容 |
| writeText(target, content, expected?) | 原子创建/替换;expected 可选版本防护 |
| editText(target, edit, expected?) | 字面量编辑;版本防护 + 原子重写 |
(dsh-fs/README.zh.md)
三个值得注意的设计决策:
决策一:ctx.fs 只负责"文本"层。 readText 会拒绝二进制(FS_NOT_TEXT),writeText 只写文本。原始字节只有 readBytes 一条路。README 解释:
ctx.fs 有意接近 fsspec 风格的存储原语,比字节级 cat/open 高半层,因为它会解码文本并拒绝二进制,使政策层绝不接触原始字节。
决策二:明确的"不做"清单。 12 个原语没有:删除、重命名、复制、监视、glob、分页。README 的"已知限制"直接点名:
只有十二个原语:没有删除、重命名/移动、复制或监视;listDir 只支持一层,递归、glob、分页和搜索不在范围内。
glob 和 grep 是另一个 seam 的事(dsh-tool-fs-search 用打包的 ripgrep 二进制实现)。
决策三:结构化错误分类。 所有失败都抛 FsError,携带稳定错误码:FS_NOT_FOUND / FS_NOT_DIRECTORY / FS_NOT_TEXT / FS_TOO_LARGE / FS_PERMISSION_DENIED / FS_STALE_VERSION / FS_NOT_OBSERVED / FS_AMBIGUOUS_EDIT / FS_EDIT_NOT_FOUND / FS_ABORTED。工具注册表会把 {name, code} 附到 isError 结果上。
为什么重要?沙箱判断"能不能写"和文件系统"能不能写"是两种错误。FS_PERMISSION_DENIED 是文件系统层的,SANDBOX_UNAVAILABLE 是沙箱层的。结构化错误让上层能区分"策略拒绝"和"系统失败",而不是看到一个笼统的 EACCES。
四层里最特殊的是 dsh-fs-observation-policy —— 它不是服务,README 明确标注"(无服务)"。它通过 fs/* 事件词汇工作:
| 事件 | 类型 | 作用 |
|---|---|---|
| fs/write-intent | 单槽决策 waterfall | 监听器完整决策,绝不调用 next() |
| fs/edit-intent | 单槽决策 waterfall | 同上 |
| fs/observed | 发后即忘 | 记录 FsObservation(存在带版本 / 确认缺失) |
(dsh-fs/README.zh.md)
这就是第 2 章"插件可以插进事件流"的实际应用。 工具层(dsh-tool-fs)做执行器,在写之前广播 fs/write-intent;政策层监听这个事件,决定放行还是拒绝。"编辑前先读"、"版本防护"、"已观察状态"这些政策都在这层实现,而工具层完全不知道政策层的存在。
README 用一句话点破这个分工:
工具就是执行器;策略是事件门禁。 —— dsh-tool-fs/README.zh.md 的小节标题
dsh-fs-local 是宿主文件系统实现。dsh-fs-sandbox 是沙箱实现。README 特别指出:
fs-sandbox 与 fs-e2b 实现该接口,无需更改政策层和工具层。
也就是说:换提供方,上面的三层一行不用动。 这就是 seam 架构的终极回报。
光有 seam 还不够 —— 你还需要回答"谁负责强制执行"。dsh 的做法是每个能力 seam 都带一个沙箱变体:
| 能力 | 无沙箱提供方 | 沙箱提供方 |
|---|---|---|
| 文件 | dsh-fs-local | dsh-fs-sandbox |
| Shell | dsh-pwsh-local / dsh-bash-local | dsh-pwsh-sandbox / dsh-bash-sandbox |
| 子进程 | dsh-subprocess-local | (由 shell 沙箱覆盖) |
第 3 章实测过:base patch 用 !!js process.platform 动态选择——Windows 走 pwsh-sandbox,非 Windows 走 bash-sandbox。沙箱是默认启用的,不是可选项。
沙箱的语义在 dsh-sandbox 里定义:
| 概念 | 取值 | 含义 |
|---|---|---|
| SandboxMode | read-only / workspace-write / danger-full-access | 文件操作的三种权限档 |
| SandboxEnforcement | full / partial | 每种内核 ABI 的强制强度 |
| SANDBOX_UNAVAILABLE | 错误 | 后端不可用时 fail-closed 抛出的错误 |
第 10 章会完整展开沙箱与审批。这里只需要记住:seam 架构让"换沙箱后端"变成纯配置操作。
有些能力不属于任何单一 seam,而是横切所有工具调用。dsh 的做法是做成 tools/* 事件监听器:
| 插件 | 挂在哪个事件 | 干什么 |
|---|---|---|
| dsh-tool-call-timeout-policy | tools/execute 包装层 | 给每个工具装截止时间,超时返回 TOOL_TIMEOUT |
| dsh-spill-policy | tools/post-execute 转换器 | 超长纯文本结果溢出到文件,返回预览 + 路径 |
| dsh-repeat-tool-reminder | tools/result | 重复工具调用时提醒 |
| dsh-session-checkpoint-policy | llm/stream + tools/execute + agent/pre-step | 在模型请求和工具副作用前写持久化检查点 |
这些插件不注册服务、不占 ctx 键,只是监听事件。 它们的存在本身证明了第 2 章的设计价值:事件总线让"横切"不需要侵入任何具体包。
这一章必须诚实列出代价,否则第 13 章无法做总结。
代价一:调用链变长。 ctx.fs.readText() 一次调用可能经过:工具层 → fs/read-text 事件 → 约定层 → 提供方层 → 操作系统。每个环节都可能抛结构化错误。调试时你需要追踪事件流,而不是单步进一个类。
代价二:类型跳转多。 ctx.fs 的类型来自 dsh-fs 的声明合并,实现在 dsh-fs-local。IDE 里 Ctrl+点击经常跳到接口而不是实现,你要自己知道"谁是当前 provider"。
代价三:小包多到吓人。 文件系统一个能力拆 4 个包(tool / policy / definition / local),185 个包就是这么来的。新读者面对目录列表会先被吓一跳 —— 第 1 章讲过的"包数量是拆分粒度不是代码规模",在这里再次体现。
代价四:部分 seam 是"单实现"的,抽象显冗余。 比如 ctx.storage 目前只有 dsh-storage-json 一个后端。对这种 seam,抽象层的价值暂时是"未来可换",而不是"今天能换"。
那为什么还值得? 因为 dsh 面对的是一个多平台、多形态、多用户偏好的问题空间:Windows/Linux/macOS、Web GUI/headless CLI、不同的沙箱后端、不同的模型提供方。没有 seam,这些维度会交叉爆炸;有了 seam,每个维度独立演进。
seam 架构是 dsh 的第二个支柱(第一个是 cordis):
到这里,骨架部分(第 1-4 章)完成。从下一章开始进入主干:agent loop —— 整个 harness 唯一包含具体循环逻辑的包。