能力 seam:换一个提供方,换掉半个产品
专栏:DeepSeek Harness · 第 10 / 14 篇主干读完,这篇换一种读法:不追流程,看设计思想。架构文档里有句话值得抄在开头:「seam 正是替换一个提供方就能改变整个产品的原因。文件系统与进程提供方共享同一个执行世界,因此把它们指向远程沙箱,也就把 Bash、PTY 和 LSP 一并搬了过去,无需提供方专用 fork。」
seam 的正式定义
一个 seam(接缝)是一项可替换能力,包含三种角色:
- Service Definition:声明接口——一个
declare module的 ctx 键 + 一个抽象类(语义契约写在 JSDoc 里)+ 配套的类型化事件词汇。 - Service Provider:实现抽象类、作为插件加载、填充该 ctx 键。一个 ctx 一个实现,重复加载抛错。
- Consumer:只 import Definition 包、只在运行时从 ctx 读服务,对具体 Provider 零依赖。
一个包可以兼任多角色,但单一角色本身不是 seam——添加一项能力意味着把三者一并设计。全仓 60+ 个服务的角色总图由脚本生成(docs/capability-seams.zh.md),每个 ctx 键都标注了实现与消费方。
一条完整链路:文件系统
以 fs 为例,三个角色各自的代码长相:
Definition(packages/fs/fs)——抽象类即契约,连「读写边界」都定死在接口上:
// packages/fs/fs/src/index.ts:87-126(节选)
export abstract class FileSystem extends Service {
constructor(ctx: Context) { super(ctx, 'fs') }
/**
* The sandbox mode this backend enforces on mutations BY DEFAULT, or
* `undefined` when it does not confine at all — the capability fact the tool
* layer reads to advertise the escalation fields honestly …
*/
get sandboxMode(): SandboxMode | undefined { return undefined }
/** Resolve a model/plugin-supplied path into a stable FsTarget. … */
abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>
/**
* Return the canonical absolute path a subprocess in this filesystem's
* execution world can open. … consumers may pass this value to another OS
* capability, but must continue treating the target key as opaque.
*/
abstract processPath(target: FsTarget): string
}
Provider(packages/fs/fs-local)——具体实现;同构的还有 fs-sandbox(继承 local,只给写/编辑加围栏)与远程的 fs-e2b:
// packages/fs/fs-local/src/index.ts:58-77(节选)
/**
* The host-filesystem backend. Reads resolve relative paths from Config.cwd
* (a resolution default, NOT a containment boundary …); enforce containment
* with a stricter backend or a `tools/execute` permission plugin.
*/
export class LocalFileSystem extends FileSystem {
/** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
* window can't interleave, making concurrent writes/edits deterministically
* ordered … */
private locks = new Map<string, Promise<unknown>>()
Consumer(packages/fs/tool-fs)——注意 inject 清单里只有 'fs',没有任何后端包:
// packages/fs/tool-fs/src/index.ts:53-79(节选)
export const inject = ['tools', 'fs', 'systemPrompt']
export function apply(ctx: Context, config: Config): void {
applyReadTool(ctx, {...})
ctx.inject(['attachments'], (imageCtx) => { applyReadImageTool(imageCtx) })
// 升权 API 由「挂载的 ctx.fs 是否隔离」决定——能力事实从 seam 读取
const sandbox = new FsSandboxController(ctx)
applyWriteTool(ctx, sandbox)
applyEditTool(ctx, sandbox)
}
write 工具的执行体是三个角色在运行时的会合点——消费接口、路过策略事件、再消费接口:
// packages/fs/tool-fs/src/write.ts:101-121(节选)
async execute(args: WriteToolArgs, exec) {
const input = parseWriteArgs(args)
const sandboxPolicy = await sandbox.resolvePolicy('write', args, exec)
const target = await ctx.fs.resolve(input.filePath, ...)
// 单槽决策:策略插件产生 createIfAbsent/replaceIfVersion;裸默认无条件
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
let outcome: FsWriteOutcome
try {
outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy)
} catch (error: unknown) {
// 沙箱拒绝变成模型认识的 [sandbox: …] 标记
throw remediateFsError(sandbox.mapError(error, sandboxPolicy), target.displayPath)
}
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
三角色关系图
注意右下:连 LLM 适配器都通过 ctx.get('fs')?.processPathFromHostPath(...) 消费 fs seam——附件图片的路径描述跟着执行世界走。
为什么「换提供方 = 换掉半个产品」
四个咬合点,缺一不可:
- 同一组 seam 键共享一个「执行世界」抽象。
ctx.fs的processPath()返回「本世界里子进程可打开的路径」——文件身份跨能力族过桥的接口。 - E2B 后端成对出现。
ctx.e2b持有共享的远程运行时,fs-e2b 与 subprocess-e2b 落在同一个 Linux 沙箱里。换掉这对提供方,bash 执行器(只依赖ctx.subprocess)、PTY(terminal-bash)、LSP(lsp-stdio)自动全部搬进远程世界——它们的代码一行不改。 - 策略跨能力族共享。
ctx.sandboxPolicy统一保存模式与工作区根,bash 执行器与 fs 提供方都读它——不会出现「bash 能写但 fs 不能写」的裂缝。 - 消费方与策略完全不动。fs 组 README 的总结:「后端可以更换,无需改动工具或策略」。
sandbox:单方法的极简 seam
进程限制 seam 只有一个抽象方法,契约严格到「静默放行是禁止的」:
// packages/sandbox/sandbox/src/index.ts:152-176
export abstract class SandboxProvider extends Service {
/**
* Wrap `argv` so it executes confined under `policy` on this host; the
* caller spawns the returned argv in place of its own.
* @param argv - the exact argv the caller is about to spawn (program plus
* arguments), NOT a shell string — a shell-shaped consumer passes
* `['bash', '-c', command]`.
* @param policy - the file-effect policy this execution runs under, carried
* per call.
*/
abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
}
本地实现按平台选 runner(Linux: bwrap→landlock,macOS: Seatbelt,Windows: ACL),把 argv 包成 [runner, ...profileArgs, '--', ...argv]:
// packages/sandbox/sandbox-local/src/index.ts:316-333
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
if (this.runnerCommand !== undefined) {
return {
argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv],
enforcement: 'full',
denialSignatures: DENIAL_SIGNATURES.runnerCommand,
runnerFailureRules: [{ fatalSignatures: this.configuredRunnerFailureSignatures }],
}
}
const selected = this.selectRunner(policy.mode)
const runnerArgv = this.runnerArgv(selected.runner, policy)
return {
argv: [...runnerArgv, '--', ...argv],
enforcement: selected.enforcement,
denialSignatures: DENIAL_SIGNATURES[selected.runner],
runnerFailureRules: RUNNER_FAILURE_RULES[selected.runner],
}
}
消费方(bash-sandbox 执行器)就一行:this.ctx.sandbox.confine(['bash', '-c', command], policy)。seam 之上还能叠 seam——bash 执行器消费沙箱提供方,两者各自可替换。
一次 write 的时序
与其他模块的联系
- fs/shell/subprocess/sandbox 共享同一个「执行世界」——换 fs-e2b + subprocess-e2b 这对提供方,Bash/PTY/LSP 一并搬进远程(第 7 篇的编译期对照);
- llm 适配器(第 11 篇)也消费 fs seam:附件路径经
processPathFromHostPath映射进执行世界; - 工具流水线(第 9 篇)的把关事件(fs/write-intent)挂在 seam 的策略事件上——策略插件而非工具内建。
判断一个项目「架构好不好」的土办法:数一数替换一个底层实现要动几个文件。在 dsh 里替换文件系统后端是改一行配置(第 5 篇的 patch:- id: fs ... name: 换成新提供方)。把可替换性做成一等公民的代价是每个能力都要 Definition/Provider/Consumer 三份代码——dsh 认为值得,这份「昂贵的一致性」就是它架构气质的核心。
下一篇看同样按 seam 思路设计的 LLM 适配器层,以及它引以为傲的流式语义。