能力 Seam:可替换的执行世界

📑 目录

上一篇我们看到,ctx.llm 本身就是一个 seam:服务定义在 dsh-llm,提供方是三个适配器包,消费方是 agent loop 与压缩,彼此只通过接口说话。本篇把视角放大到整个产品:文件读写、shell 执行、终端、沙箱、子进程、subagent,每一项能力都是同样的三角色结构。我们要回答的问题是:一项能力怎样被设计成可替换的?换掉提供方,消费方需要改一行代码吗?

docs/capability-seams.zh.md 维护着全量 seam 清单,scripts/gen-doc-graphs.ts 生成定义、实现、消费三方关系图并设有完整性守卫。本文基于 2026 年 10 月的 master 分支(0.2.1-alpha.1 前后),项目处于 developer preview 阶段。

一个接口,三个角色

seam 的定义并不神秘:一个包声明抽象服务(服务定义),若干包各自提供实现(提供方),另一些包只依赖抽象、通过 ctx.<name> 调用(消费方)。docs 里的原话是:一项能力应当三者一并设计。这句话的份量在替换时才显现——替换一个提供方,就能改变整个产品。

能力 Seam:一个接口,三个角色,多个世界

三个角色各自回答一个问题。定义包回答"能力有哪些动词":抽象类的方法集就是能力的语法,它必须完整到让提供方之间可以互换。提供方包回答"动词怎么落地":每个提供方是一个独立插件,单独安装、单独失效,注册进同一个 ctx 键。消费方只依赖定义包,完全不感知当前生效的是谁。

落到 Cordis(dsh 的底层插件框架)里,三角色有明确的物理形态。定义包导出一个继承 Service 的抽象类,并挂上 ctx 键;提供方在自己的作用域内注册,注册随 fiber 生命周期销毁——上一篇 registerAdapter 注释里的 "Disposed with the fiber" 就是这条约定。依赖方向因此是单向的:提供方和消费方都指向定义包,定义包不 import 任何提供方,ctx 键是唯一的交汇点。

「替换一个提供方就能改变整个产品」不是口号。权限预设(ctx.permissionPresets)把沙箱模式与审批策略组合成 workspace-write、danger-full-access 这类档位:用户切换一次,写入一个 permission/preset 事件,贯通到沙箱与审批两个选项——背后动用的就是 ctx.sandbox 与 ctx.approval 两个 seam 的提供方组合。产品层的开关、seam 层的换提供方、事件日志里的持久记录,三层各说各话又互相对齐。

ctx.shell 是最直观的例子。定义包 packages/shell/shell 声明 ShellExecutor 抽象类(src/index.ts:64):resolve() 加 execute() 两个动词。消费方 tool-bash、tool-pwsh、claude-code 与 codex 的 hooks 只 import 这个定义包。提供方有三个:bash-local(本地 bash)、bash-sandbox(经沙箱的 bash)、pwsh-local(PowerShell)。把组合里的 bash-local 换成 bash-sandbox,执行立刻进了沙箱,消费方一行不改;反过来,PowerShell 用户组合进 pwsh-local,同一批工具就在 Windows 上工作——产品形态的切换,被压缩成组合清单里的一行差异。打开 shell/src/index.ts:64 看这个"窄"字怎么落笔:ShellExecutor 只有 resolve() 与 execute() 两个抽象方法,没有日志、没有渲染、没有配置面的影子;与"怎么跑"无关的关切,全部被推回消费方或提供方各自的所有权里。

提供方也不一定是注册表条目。ctx.approval(审批 seam)的"提供方"是 approval/request waterfall 的监听者:一次性权限决策由监听器回答,ACP 这类宿主就为自家 agent 注册桥接应答;一个回答方都没有时,决策以 unavailable 关闭失败,而不是挂起等待。三角色的骨架不变——定义包声明能力,某处认领实现,消费方只面对 ctx 键——只是"提供方"在这里落成了监听器。判断一段代码是不是 seam 消费方,问一句就够:它 import 的是定义包,还是某个实现包。

flowchart TD
  subgraph OLD["替换前:全部在本地"]
    C1["tool-bash 等消费方"] --> SE1["ctx.shell 接口"]
    SE1 --> P1["bash-local 提供方"]
  end
  subgraph NEW["替换后:执行世界移到沙箱或远程"]
    C2["tool-bash 等消费方 代码不动"] --> SE2["ctx.shell 同一个接口"]
    SE2 --> P2["bash-sandbox 提供方"]
    P2 --> SB["ctx.sandbox 包裹 argv"]
    P2 --> SP["ctx.subprocess 远端 spawn"]
  end

六个执行 seam 对照

把执行相关的主要 seam 排成一张表(提供方与消费方从 capability-seams 文档及包结构提炼):

ctx 键定义包提供方消费方
ctx.llmllm(LlmAdapter,index.ts:208)llm-deepseek、llm-pi-ai、llm-replayagent-loop、compaction-basic
ctx.fsfs(FileSystem 抽象类,index.ts:87)fs-local、fs-sandbox、fs-sshtool-fs 等
ctx.shellshell(ShellExecutor,index.ts:64)bash-local、bash-sandbox、pwsh-localtool-bash、tool-pwsh、hooks
ctx.terminalsterminal(TerminalSessionService,index.ts:105)terminal-bashtool-terminal
ctx.sandboxsandbox(confine() 返回 ConfinedArgv,index.ts:177)sandbox-local、sandbox-sshbash-sandbox、terminal-bash
ctx.subprocesssubprocess(SubprocessRuntime,index.ts:117)subprocess-local、subprocess-sshbash 系、terminal-bash、lsp-stdio、subagent 外部进程系

这张表有三个读法。其一,提供方数量没有约束:一个到六个都可以。其二,提供方可以跨部署形态:local、sandbox、ssh 三种后缀是三种执行世界。其三,消费方列是闭合的——没有任何消费方 import 提供方包,seam 的边界就是依赖图的边界。

逐行再挑几处设计看。ctx.terminals 只有一个提供方 terminal-bash,但接口仍是 seam:后端经 registerBackend() 注册(terminal/src/index.ts:125),换后端不换工具。ctx.sandbox 的策略按调用携带,不是固定在提供方上——同一时刻,bash 可以按 read-only confine,另一个消费方按别的策略 confine,互不相干。ctx.fs 还多一个配套插件位:fs-observation-policy 通过 fs/* 事件门禁贡献基于观测状态的检查,是 seam 上"旁路插件"的范例。ctx.llm 一行就是上一篇的全文:适配器注册表,agent loop 与压缩共用。

另一个读法关于"谁来选":调用点永远只面对 ctx 键,选择哪组提供方上线是插件组合(profile)的决定。ctx.storage 各后端以不同名称并列注册、ctx.web 的搜索提供方由路由显式选择,都是把运行时选择收敛到注册表查询,而不是散在消费方代码里。

共享执行世界:一起搬家

单独替换 ctx.shell 没有意义。bash 在远程跑,而文件读写还在本地,工具立刻出错。所以 dsh 的执行 seam 被设计成共享同一个执行世界:fs/subprocess/sandbox 三条 seam 各有一个 *-ssh 提供方(fs-ssh、subprocess-ssh、sandbox-ssh),它们挂在 ctx.ssh(一条经过认证的 OpenSSH 连接)上,指向同一台远端机器。三条一起换,Bash、PTY、LSP 这些建立在它们之上的工具就一起搬了过去——消费方始终无感。

这组远程提供方的共同地基是 ctx.ssh:一条认证连接、远端已安装 helper 程序的身份、独立程序流,以及断连时配套提供方的清理。它的消费方不是工具,而是另外三个提供方包。seam 叠 seam,世界因此成组。

执行世界还有一个安静的见证者:ctx.jobs。后台 bash、pwsh、PTY 发送和 subagent 委派这些长寿命工作登记为 job,tool-jobs 让模型读取、列出、终止它们,tool-bash、tool-subagent 等生产方在发起时登记。世界搬走,job 名册跟着走,模型看到的工具语义不变。

本地路径同样清晰。看 bash-local/src/index.ts:259:消费方 ctx.subprocess.spawn(this.spawnSpec(spec, argv, ...))。seam 故意不设默认值——每条 stdio 处置、grace 时长、env 都显式给出;spawn 同步返回 handle,done 给出退出事实,collect 读者按全流字节偏移增量读,terminate() 是唯一的终止动词。显式换来的好处是可预测:看调用点,就知道子进程拿到什么。

环境变量过一道单向门。scrubbedParentEnv()(subprocess/src/index.ts:66)先用 SENSITIVE_ENV_PATTERN(/KEY|PASSWORD|SECRET|TOKEN/i,index.ts:47)剥掉凭证形状的变量,再剥掉全部 DSH_*,最后才补代理覆盖——显式声明的 env 层排在清洗之后合并,所以刻意传给子进程的值总能存活。Harness 自己的 DEEPSEEK_API_KEY 不会隐式漏进任何被 spawn 的进程。

沙箱是 fail-closed 的。SandboxProvider.confine(argv, policy) 必须返回"强制执行中的 argv"(sandbox/src/index.ts:177),没有可用后端时抛 SANDBOX_UNAVAILABLE(index.ts:120-136 一带),宁可拒绝也不静默透传——tool/result 里的结构化错误码让调用方明确区分"命令没跑"和"沙箱拦住了"。返回值 ConfinedArgv 是方言而非并集:它携带后端专属的 denialSignatures(该后端拒绝文件操作时 stderr 上真实出现的子串——bwrap 的只读挂载是 EROFS,Landlock 是 EACCES,Seatbelt 是 EPERM)与 runnerFailureRules(运行器自身失败的结构化证据规则)。消费方归因有顺序:先查 runner 失败,再看拒绝签名——runner 失败意味着命令根本没跑,denial 才意味着限制真的生效;签名按本后端匹配,不拿跨后端并集去猜,因为并集声称的拒绝有的是这个后端永远不会产生的。

subagent:同一接口,六种提供方

ctx.subagents(subagent/src/index.ts:198 的 SubagentRuntime)是一个具名多提供方注册表:registerProvider(provider)、getProvider(name)、start(name, request)。提供方契约(types.ts:344)很短:

interface SubagentProvider {
  readonly name: string
  readonly capabilities: SubagentCapabilities
  readonly inheritsParentContext: boolean
  start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>
  prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>
}

契约里每个字段都有分寸:capabilities 声明 start 时支持哪些特性,服务据此预检;inheritsParentContext 只是描述性事实——子 agent 是否看到父会话已完成的前缀,由模型工具的措辞如实转达,服务不拿它做校验;agentRouteDefaults 是提供方自选的静态模型路由,消费方在预检前把自己的覆盖合并上去。

六个提供方同处一接口:进程内的 subagent-spawn-in-process 起一个全新子会话,subagent-fork-in-process 让子 agent 看到父会话已完成的前缀;另外四个把轮次委派给外部产品进程——subagent-acp 走 ACP 协议,subagent-codex 与 subagent-claude-code 分别把活派给本机安装的 Codex 和 Claude Code,subagent-dsh-sdk 用 dsh 自己的 SDK 驱动另一个 harness 进程。对模型工具而言,它们只是注册表里不同的名字。服务在 start() 之前按 capabilities 做静态校验,请求了不支持的能力直接拒绝,fail-loud。

start() 建立的是一次性子 run:返回 SubagentRun 之前,装配责任在提供方,半成品资源不许漏;发布之后所有权转移,此后的轮次失败、结果结算、拆卸都经由这个 run。委派深度与并发数由服务的 Config 收口:maxDepth 默认 1,maxActiveSubagents 默认 8(subagent/src/index.ts:190-198)。

prepareContinuable? 是可选方法,注释明说"方法存在即能力"。而可继续子 agent 的设计更极端:它根本不经过提供方。续跑管理器握有身份预留、组合、Agent 创建、提示词投递、冷恢复与拆卸的全套生命周期:它自己组合 AgentHandle,经子 agent inbox 把后续消息排序成轮次,父 agent 可以持续喂话。提供方唯一参与的机会是贡献一份 ContinuableCreateSpec 数据——只有"子会话是否 seed 父历史"这一项,永远不会看到子 agent 的句柄、轮次或拆卸。进程内后端与外部产品进程后端在同一张注册表里共存,这就是"两种产品、一个接口"的准确含义。

消费方一侧的分工同样明确:tool-subagent 在一次性委派与可继续委派之间做选择,tool-subagent-control 负责继续传递后续消息,tool-ralph 则要求一条全新的结构化输出路由。提供方枚举不变,三种工具组合出不同的产品形态。

flowchart LR
  subgraph PROV["提供方插件 六家同接口"]
    P1["subagent-spawn-in-process"]
    P2["subagent-fork-in-process"]
    P3["subagent-acp"]
    P4["subagent-codex"]
    P5["subagent-claude-code"]
    P6["subagent-dsh-sdk"]
  end
  RT["ctx.subagents
具名注册表 SubagentRuntime"] P1 --> RT P2 --> RT P3 --> RT P4 --> RT P5 --> RT P6 --> RT RT --> T1["tool-subagent"] RT --> T2["tool-subagent-control"] RT --> T3["tool-ralph"]

MCP 与 skills:不进 seam 的两种接法

不是所有能力都发布成服务,MCP 是有意识的反例。packages/mcp 不发布 ctx.mcp 服务:每个 MCP 服务器是一个连接插件(mcp-client),connection.ts 用官方 SDK 协商 stdio 或 Streamable HTTP 传输。发现的工具走 tools.ts:150 注册为普通的 Harness 工具(ctx.tools.register()),连接同时向共享的 ctx.mcpResources.register(server, provider) 登记资源,服务器指令则发布为系统提示词的作用域章节。

为什么不给 MCP 单开服务?因为工具一旦注册进 ctx.tools,就自然走进 Harness 统一的工具管道:策略前处理、单调守卫、环绕分派、结果观测,一样不落,还自动获得取消与权限检查。为 MCP 复制一条专用管道,等于养两套权限模型,还要回答"谁说了算"。把协议差异收敛在连接插件里,是更便宜的选择。代价同样存在:MCP 服务器发现的工具与其他 Harness 工具同权,模型发起的每次调用都过同样的权限检查与审批配置,没有"MCP 例外"——这是安全模型保持单一的原因。

skills 走了 seam,但把"分层"做进了注册表内部。packages/skill/skill 的 SkillRegistry(src/index.ts:356)是分层提供方注册表:host 全局层加 per-scope 链,近层同名覆盖远层。提供方接口 SkillProvider 只有 name、list(options)、get(candidate, options) 三个成员。内置的 skill-filesystem(src/index.ts:150)扫描四类根:项目 .dsh/skills、.agents/skills,用户 ~/.dsh/skills、~/.agents/skills(index.ts:250-251),每个技能是一个带 YAML frontmatter 的 SKILL.md,按 rank 排序,近根同名胜出。四类根的分工也清楚:.dsh 与 .agents 是随仓库走的项目级约定,~/ 下两类是跨项目的用户级共享。消费方 tool-skill 渲染带目录前缀的列表;get() 懒加载正文,统一包成 <skill_content> 块。

小结

能力 seam 的答案可以浓缩成三条纪律。接口先行:能力以抽象类定义,提供方与消费方只在 ctx 键上相遇,依赖图替你把关。世界成组:单换 ctx.shell 没有意义,fs/subprocess/sandbox 成组指向同一个世界,本地、沙箱、远程三种后缀成组替换。失效要响:沙箱 fail-closed、子 agent 能力预检、环境变量单向清洗——seam 可以缺提供方,但绝不允许静默降级。这套纪律有机器守着:docs/capability-seams.zh.md 的关系图由 scripts/gen-doc-graphs.ts 生成并设有完整性守卫,定义、实现、消费三方缺一,构建就会报警——三角色不是文档约定,是被检查的依赖结构。

至此,"模型与能力"这一章收束:第 11 篇的 ctx.llm 与本篇的六个执行 seam、subagent 注册表,共享同一套三角色语法。下一章我们回到会话本身,看这些能力产出的事件流如何被压缩、查询与回放。