跳到主要内容

数据截至 (上游 commit 99f6f02fecdb)

第 5 章 · 能力接缝:换一个 Provider,整个产品换一个执行世界

30 秒导读: 这一章讲 dsh 复用性的总规律——它把每一样"可换的能力"都拆成三个角色放进三个包。 结果是:把 ctx.fsctx.subprocess 两个服务换成远端实现,Bash、终端、LSP 会一起搬到远端沙箱里, 而模型看见的工具名字、参数、返回格式一个字都没变。

前四章讲的是"一个产品怎么跑起来":插件树(第 1 章)、 事件日志(第 2 章)、主循环(第 3 章)、 工具流水线(第 4 章)。这一章反过来问:这些跑起来的东西,有多少是可以整块换掉的?


1. 先讲清楚:什么叫「接缝」

1.1 一句话定义

接缝(capability seam)= 一整个可替换的能力,由三个角色共同构成。少一个角色,就不是接缝,只是一个类。

仓库自己把这条写进了词汇表:seam 指"完整能力,从来不是单个角色" (docs/glossary.md:9## capability-seam 条目)。

1.2 三个角色

角色白话它拥有什么典型包
Service Definition(服务定义)「这个能力是什么」ctx.<key> 这个键、抽象方法、词汇类型dsh-shell
Service Provider(服务提供方)「它怎么真的跑起来」具体实现、平台细节、进程/网络机制dsh-bash-local
Consumer(消费方)「模型和其它插件对着什么编程」工具名、JSON schema、提示词、渲染dsh-tool-bash

三者的关系是这样连的(从左到右是依赖方向,中间那个键是唯一的会合点):

Service Definition Service Provider
┌──────────────────┐ ┌──────────────────┐
│ 抽象类 + 词汇类型 │◀── 继承/注册 ──│ 本地 / 沙箱 / 远端│
│ 拥有 ctx.shell │ │ 实现抽象方法 │
└────────┬─────────┘ └──────────────────┘
│ inject: ['shell']

┌──────────────────┐
│ Consumer │ 模型看见的那一面:
│ 工具名 + schema │ bash(command, timeout_ms, ...)
└──────────────────┘

怎么读这张图: Provider 和 Consumer 之间没有箭头——它们互不认识,只认识中间的服务定义。 这正是"换一个 Provider 不动工具 schema"的全部原因。

1.3 为什么非要拆成三个

因为这三件事变化的速度和理由完全不同。把它们塞进一个包,就等于把三条互不相干的变更曲线焊死: 换个执行后端,本来跟模型无关,却会连带改动模型看见的 schema。

设计笔记把这句话写在最前面:三个关切"以不同速率、因不同理由而变" (.agents/notes/implemented/architecture/2026-06-13-capability-seams.md,Problem 一节)。

1.4 判定方法:三个问题

拿到一段代码,想知道它是不是接缝、属于哪个角色,问这三句:

问题答"是"意味着
它是否声明了 ctx.<key> 并且只依赖契约本身需要的词汇?Service Definition
它是否注册/继承了别人的服务,并带进了平台或网络细节?Service Provider
它是否 inject 了服务键、且从不 import provider 专属类型Consumer

还有一条反向判据,仓库把它写成"气味":一个公共 service 方法只有一个内部调用者, 说明它根本不该出现在契约上,应该改传一个私有能力闭包(packages/AGENTS.md, "Design Service Definitions for all current Consumers" 一条)。

1.5 一个不显然的取舍:不要预拆

拆包是有成本的——多一份 package.jsontsconfig、README 和注入接线。

所以规矩是:只有一个可想象的 Provider、只有一个 Consumer 时,就先合成一个包,等第二个出现再拆 (同一份笔记的 Decision 一节,原文 "Don't split preemptively")。 LLM 接缝就是被允许折叠的那个:dsh-llm 同时是 Service Definition 和 Consumer, 因为它的"消费方"是主循环本身,不是一层可替换的 schema。


2. 用包名布局证明:三件套是真的

抽象说完了,看真东西。packages/ 下面的目录结构本身就是这套规矩的证据。

2.1 文件系统三件套

角色关键符号
packages/fs/fsService Definition:ctx.fsFileSystempackages/fs/fs/src/index.ts:86
packages/fs/fs-localProvider:本地文件系统LocalFileSystempackages/fs/fs-local/src/index.ts:64
packages/fs/fs-sandboxProvider:在本地实现上加路径围栏SandboxedFileSystempackages/fs/fs-sandbox/src/index.ts:59
packages/e2b/fs-e2bProvider:远端 E2B 沙箱注册 ctx.fs
packages/fs/tool-fsConsumer:模型面的 read/write/editapplypackages/fs/tool-fs/src/index.ts:54

抽象方法就是这个契约的全部内容,例如原子编辑:

// packages/fs/fs/src/index.ts:243
abstract editText(
target: FsTarget,
edit: FsEditRequest,
expected?: { version: FsVersion },
signal?: AbortSignal,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsEditOutcome>

注意最后一个参数:沙箱策略是按调用传进来的,不是后端自己的全局状态。 会围栏的后端按它设限,裸后端直接忽略——同一个签名同时服务两种 Provider。

2.2 Bash 三件套

角色关键符号
packages/shell/shellService Definition:ctx.shellShellExecutorpackages/shell/shell/src/index.ts:65
packages/shell/bash-localProvider:走本地子进程LocalBashExecutorpackages/shell/bash-local/src/index.ts:102
packages/shell/bash-sandboxProvider:先套沙箱再执行注册 ctx.shell
packages/shell/pwsh-localProvider:PowerShell 语义注册 ctx.shell
packages/shell/tool-bashConsumer:bash 工具 + 后台任务applypackages/shell/tool-bash/src/index.ts:190

服务定义只有三个抽象方法,一眼看完:resolve(补默认值和上限, packages/shell/shell/src/index.ts:85)、run(前台,:93)、start(后台句柄,:100)。

2.3 两个容易被忽略的角落

第一,有些包一个角色都不是。 packages/fs/fs-observation-policy 既不定义服务也不注册服务, 它只挂 fs/* 事件当闸门(applypackages/fs/fs-observation-policy/src/index.ts:106)。 它的价值恰恰在于可以整包拿掉:拿掉就退化成"没有读过就不许改"的策略消失, 而不是工具报错(packages/fs/README.md 里明确写了这个降级语义)。

第二,搜索故意不进契约。 packages/fs/tool-fs-searchglob/grep 不走 ctx.fs,而是通过 ctx.subprocess 拉起打包好的 @vscode/ripgrep 二进制。 理由很实在:如果把"全库搜索"写进文件系统契约,那么每一个文件系统后端都得实现一遍搜索。


3. LLM 接缝:模型侧的可替换性

这一节讲 packages/llm/。它是唯一被允许"折叠"的接缝——服务定义和消费方在同一个包里。

3.1 词汇:消息进去,流出来

接缝先要有一套与厂商无关的词汇,两端各一套:

  • 进去的是消息。 Message 及其 MessageSource 联合类型 (packages/llm/llm/src/message.ts:129:126)。每条消息带"谁产的"(kind) 和"是什么形态的"(ContextForm:48)两个互相独立的轴。 消息一旦创建就冻结(freezeMessage:169)。
  • 出来的是流块。 StreamChunk 是一个七元联合 (packages/llm/llm/src/types.ts:312):block-start / text-delta / reasoning-delta / tool-call-delta / block-end / usage / finish

这套流协议还带着几条成文的时序约定usage 必须在 finish 之前发;finish 之后什么都不许发; 工具调用参数从头到尾是原始 JSON 字符串,不做中途解析。

3.2 适配器注册表:按 provider 路由

服务是个注册表:LlmRuntimepackages/llm/llm/src/index.ts:311), 适配器是抽象类 LlmAdapter:180),唯一必须实现的方法只有 stream():232)。

注册用 registerAdapter(providers, adapter):338),返回的句柄有两个能力:

能力语义
直接调用句柄释放这次注册当前持有的全部路由
handle.replace(next)原子换路由:先整体校验候选集,冲突就抛错且不动现有路由;换的过程是一个同步段,请求观察不到空窗

replace([]) 是合法的——一个 settings 段落被清空时,注册仍然活着但持有零条路由; 而初始注册传空数组则直接抛 INVALID_ADAPTER:344 一带的守卫)。这个不对称是刻意的。

3.3 prepareCall:把"能力查询"和"真正发车"绑在同一次注册上

这是整个 LLM 接缝里最不显然的一处设计。

它要解决的小问题: 一次模型调用其实分两步——先查这个 provider/model 支持什么 (上下文窗口、推理档位、默认 maxTokens),再真正发请求。如果中间发生热重载(HMR), 适配器可能被换掉,于是你会拿 A 适配器查出来的能力,去 B 适配器上发车。

做法: prepareCall(config, signal)packages/llm/llm/src/index.ts:824) 一次性抓住当前注册,返回一个只能发一次车的句柄:

// 示意,非源码
const prepared = await ctx.llm.prepareCall({ provider: 'deepseek', model: 'v4-pro' })
logHeaders(prepared.config, prepared.adapterDefaults) // 记日志用的是同一份冻结配置
for await (const chunk of prepared.stream(request)) { /* 第二次调用会直接抛错 */ }

三条硬约束都写在实现里:

  • 配置被 structuredClone + deepFreeze 后返回,调用方改不动。
  • 第二次调用 stream()INVALID_PREPARED_CALL;发车前配置对不上,同样抛这个码。
  • adapterDefaults 记录"哪些字段不是调用方提的,而是适配器补的" (:786):只有当调用方没给 reasoningEffort / maxTokens、而解析后有值时才置 true。 UI 因此能诚实地区分"用户选的"和"默认来的"。

顺带一提:不支持的推理档位在任何网络 I/O 之前就拒绝,不做钳制也不做别名映射 (resolveCallFor:733,抛 UNSUPPORTED_REASONING_EFFORT)。

3.4 重试与 token 计量:两个独立的消费方

这两件事都没有塞进 ctx.llm,而是各自成包。

挂在哪做什么
packages/llm/llm-retry监听 agent/request-error 瀑布(applysrc/index.ts:99按 provider 应用重试策略
packages/llm/token-meterctx.tokenMeterTokenMetersrc/index.ts:74从持久日志推进每会话的 token 折叠

llm-retry 有一个反直觉的选择:它不包裹 ctx.llm.stream()。 每次适配器调用仍然是"一次 provider 尝试",每次重试另开一个编号 turnpackages/llm/llm-retry/README.md 开头即写明)。好处是重试在会话日志里是可见的一等公民, 而不是藏在某个函数内部的循环。

策略本身是 provider 拥有的,在路由注册时被捕获成不可变值 (ResolvedRetryPolicypackages/llm/llm/src/retry-policy.ts:79resolveRetryPolicy:145),两种模式:

模式边界用途
normal有限次(默认 2 次),只重试 EMPTY_RESPONSE / RATE_LIMIT / SERVER / TIMEOUT / TRANSPORT常规瞬时故障
always无上限,直到成功、取消或插件销毁长跑批处理

一个细节值得记:provider 回的 Retry-After超过 maxDelayMsnormal 模式会把这次失败下放给下游恢复,而 always 模式改用自己的本地退避—— 否则一条超长的 Retry-After 就能让"永不放弃"的策略被 provider 单方面终结。

3.5 双生子:两个适配器是设计验证装置

packages/llm/ 下面有两个适配器,而且是故意用不同内核写的:

适配器内核关键符号
llm-deepseek直接 fetch + 仓内翻译,SSE 帧交给 eventsource-parserDeepSeekAdaptersrc/adapter.ts:158
llm-pi-ai@earendil-works/pi-ai 库,库有自己的事件词汇PiAiAdaptersrc/adapter.ts:186

它们执行的规矩只有一句:任何 StreamChunk 词汇无法为两边同时表达的东西,都是核心词汇的 bug.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md,Decision 一节)。

这对双生子确实抓出过东西:库版适配器暴露出两条合法的错误路径—— 从 stream() 抛出,或者以 finish {kind:'error'|'aborted'} 结束—— 单一直连适配器会把这个分歧永远藏起来。

pi-ai 侧还多做一层"目录发现",让"有哪些 provider 可用"从代码变成配置:

用户 settings 里的 providers 字典

├─ 路由名命中 pi-ai 内置目录 ──▶ 继承端点/协议/模型表,再逐字段覆盖
│ catalogProvider() / resolveRouteModels()
└─ 目录里没有的路由 ──────────▶ 整个 provider 由配置声明
buildProvider()


registerAdapter(routes, adapter) ← 空字典 = 休眠挂载,零路由
关注点文件符号
目录解析与模型表合并packages/llm/llm-pi-ai/src/catalog.ts:782resolveRouteModels
从配置构造 providerpackages/llm/llm-pi-ai/src/provider.ts:167buildProvider
向端点问"你有哪些模型"packages/llm/llm-pi-ai/src/discovery.ts:195discoverModels
注册发现回调与路由packages/llm/llm-pi-ai/src/index.ts:254:270registerModelDiscovery / registerAdapter

discoverModels 里有个漂亮的短路:如果这条路由是内置目录路由,直接回目录里的条目,根本不上网—— 因为内置条目带着上下文窗口和输出上限,而 /models 列表接口通常不报这两个数。

这一节的不显然取舍: 维护两个真适配器(外加两套要密钥的 e2e)是实打实的双倍成本, 换来的是"provider 中立"这句话被持续验证,而不是等到接第三家时才发现词汇早就漏了 DeepSeek 的假设。


4. 执行世界接缝:一次搬走 Bash、PTY 和 LSP

这是整章威力最大的一处,也是"换 Provider 换整个产品"这句话的字面来源。

4.1 核心断言:ctx.fs + ctx.subprocess 合起来定义一个世界

一起挂载的这两个 Provider 必须描述同一个路径命名空间、同一批可执行文件、同一套进程和终端会话。 更高层的能力只消费这两个接口,从不点名 Provider.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md,Decision 一节)。

tool-bash tool-terminal tool-lsp ← Consumer(模型面)
│ │ │
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ bash-local│ │terminal-bash│ │ lsp-stdio │ ← 通用消费方(领域语义)
└─────┬─────┘ └──────┬──────┘ └──┬───────┬──┘
│ │ │ │
▼ ▼ ▼ ▼
┌───────────────────────────────┐ ┌──────────────┐
│ ctx.subprocess │ │ ctx.fs │ ← 执行世界(两个基本面)
│ spawn / spawnTerminal / which │ │ 路径·文本·原子改│
└───────────────────────────────┘ └──────────────┘
▲ ▲
subprocess-local | subprocess-e2b fs-local | fs-sandbox | fs-e2b

怎么读这张图: 只替换最底下那一行的两个 Provider,上面三列一行代码都不用改

4.2 两个基本面各自拿走了什么

SubprocessRuntimepackages/subprocess/subprocess/src/index.ts:102)只有三个抽象方法:

方法语义
resolveExecutable:118可执行文件查找
spawn:130普通托管子进程(裸流或收集式)
spawnTerminal:139一个深原语:PTY 分配 + 前台进程组 + 可证实的会话清理

FileSystempackages/fs/fs/src/index.ts:86)除了读写,还刻意多暴露三样**"路径事实"**, 好让别的能力用得上而不必窥探它的不透明标识:

  • processPath(target):126)——这个执行世界里子进程能打开的绝对路径。
  • fileUrl(target):135)——LSP 初始化要的 file: URI,编码由后端拥有(宿主平台可能和执行平台不同)。
  • contains(parent, child):144)——不解析标识就能判定包含关系。

4.3 三个通用消费方各自怎么落到这两面上

消费方落到哪领域语义留在自己手里
dsh-bash-localctx.subprocess.spawn()Bash 语义、超时、后台句柄
dsh-terminal-bashctx.subprocess.spawnTerminal()提示符检测、静默推断、回滚缓冲、所有者生命周期
dsh-lsp-stdioctx.fs 读源码 + ctx.subprocess 拉语言服务器JSON-RPC、连接池、文档同步、结果归一化

服务定义层分别是 TerminalSessionServicepackages/terminal/terminal/src/index.ts:105) 和 Lsppackages/lsp/lsp/src/index.ts:82)。LSP 那一层只暴露四个语义操作goToDefinition / findReferences / goToImplementation / hover), 没有通用 JSON-RPC 逃生口——所以换后端不会改变模型提问的方式。

4.4 对照组:packages/e2b/ 只用三个包搬走整个世界

ctx职责
packages/e2b/e2bctx.e2b建一个沙箱、准备工作目录、暴露唯一的 SDK 句柄、超时或销毁时删除它
packages/e2b/fs-e2bctx.fs在 E2B Filesystem API 上实现文件系统接缝
packages/e2b/subprocess-e2bctx.subprocess在 E2B Commands / PTY 上实现查找、进程组、远端溢出文件、终端会话

没有 bash-e2bterminal-e2blsp-e2b。挂上这三个包,Bash、PTY、LSP 的可变工作就都跑在远端 Linux 沙箱里了。

同样重要的是没搬走的东西:Cordis 插件对象、agent 主循环、会话日志与持久化、 LLM 调用、提示词与工具、权限、技能、子代理编排——全部留在宿主进程。 这条线是刻意画的:让执行能力可移植,不等于把整个 harness 塞进沙箱。

4.5 这一节的不显然取舍

被否掉的方案比被采纳的更说明问题:

  • "把终端当成普通管道子进程" —— 拒绝。管道分配不了控制终端,查不到前台进程组, 也证明不了终端会话被完整清理。一个深原语比一堆浅操作更诚实。
  • "给文件系统加一个有界读原语" —— 拒绝。只有 LSP 需要"整文档字节上限", 而它完全可以在消费现有文本流时自己设限。加一个原语,会强迫每一个 Provider 去实现稳定句柄和不跟随符号链接的机制,包括远端的辅助协议。
  • 代价老实记在案: 基础接口因此变宽了,而且 fs/subprocess 必须成对认同同一个世界。 E2B POC 的具体局限也逐条列着(远端启动无法同步公布 PID、数字 PID/PGID 操作没有身份围栏等), 它们被明确称为"provider 约束,不是加兼容层的理由"。

5. 进程围栏接缝:平台链路与失败关闭

5.1 它要解决的小问题

"别让模型跑的命令改到工作区外面的文件"——但三大平台的内核机制完全不同, 而且任何一个平台上都可能没有可用的机制

5.2 契约小到只有一个方法

// packages/sandbox/sandbox/src/index.ts:175
abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv

SandboxProvider:158)不执行任何东西,它只做一件事:把你要跑的 argv 包成另一段 argv, 由调用方去 spawn。模式只有三档(SandboxMode:29): read-only / workspace-write / danger-full-access

调用方的 argv confine() 实际 spawn 的 argv
['bash','-c',cmd] ──▶ 选平台链路 + 生成 profile ──▶ [runner, profile..., '--', 'bash','-c',cmd]
│ + enforcement: 'full' | 'partial'
│ + denialSignatures: [...]
└── 无可用后端 ──▶ 抛 SandboxUnavailableError(失败关闭)

5.3 平台链路:先按平台,再按探测

PLATFORM_CHAINSpackages/sandbox/sandbox-local/src/index.ts:159):

平台候选链(按偏好)是否探测
linuxbwraplandlock探测(两个候选需要仲裁)
darwinseatbeltsandbox-exec不探测(只有一个候选)
win32windows-acl 受限令牌 runner不探测(只有一个候选)

规则写得很直白:探测是用来仲裁的,不是用来复核一个没有替代品的选择的。 只有一个候选时,它自己在执行期的拒绝就是失败关闭的终点。

Linux 偏好 bwrap 而不是 Landlock,理由是它的挂载 profile 更贴近上面那三档模式词汇 (profile 构造见 packages/sandbox/sandbox-local/src/profiles.ts:16bwrapProfileArgs:30landlockProfileArgs:51seatbeltProfileArgs; Landlock 的原生启动器在 native/landlock-run)。

5.4 Windows 那条链的三件事

packages/sandbox/sandbox-windows-acl 这条 rung 额外拥有写授权本身:

  • 工作区写 SID 由工作区规范路径派生(workspaceWriteSid), 工作区根上的 ACE 每个工作区每个服务进程生命期物化一次,然后留着不撤—— 这个跨会话复用缓存让后续每次授权是 O(1) 而不是每会话重新遍历目录树。
  • 每个会话另得一个随机私有临时目录和自己派生的能力(tempWriteSid),销毁时撤销。
  • 它诚实地上报 partialSTATIC_ENFORCEMENTpackages/sandbox/sandbox-local/src/index.ts:177): WRITE_RESTRICTED 令牌必须在限制列表里保留 Everyone 才能完成进程初始化, 且 NTFS 硬链接能让一个文件对象在围栏外别名出现。

5.5 一个非常不显然的取舍:拒绝方言不做并集

ConfinedArgvpackages/sandbox/sandbox/src/index.ts:95)随每次包装带回 denialSignatures——当前这个后端的拒绝措辞:

后端stderr 特征串
bwrapread-only file system
landlockpermission denied
seatbeltoperation not permitted
windows-aclaccess is denied / access to the path / permission denied

DENIAL_SIGNATURESpackages/sandbox/sandbox-local/src/index.ts:205

为什么不给消费方一个"所有后端的并集"?因为并集会宣称某后端根本不会产生的拒绝, 把普通的命令失败误判成策略拒绝。同理,"runner 自己挂了"和"围栏起作用把命令挡了" 用一套结构化规则区分(RunnerFailureRulepackages/sandbox/sandbox/src/index.ts:81): 先要求匹配到致命 stderr 行,再过退出码闸门——windows-acl runner 的退出码闸门是 127 (:216WINDOWS_ACL_RUNNER_FAILURE_EXIT), 这样"一个受限命令碰巧打印了那串特征"就不会被误判成"命令压根没跑"。

5.6 越权申请:执行期审批,不进 schema

模型可以在一次工具调用里带 sandbox_permissions + justification 申请更宽的模式。 这套词汇集中在 packages/sandbox/sandbox/src/escalation.ts

关注点符号
严格变宽表:28WIDER_MODES
schema 里的候选枚举:41ESCALATION_TARGETS
参数配对校验:51validateEscalationArgs
模型面的拒绝标记:71sandboxDenialMarker
有序失败关闭的审批:157approveEscalation

关键区分:枚举是注册表全局的,有效模式是按调用的真相。 所以"能不能变宽"在执行时判,绝不烘焙进工具 schema—— 把 enum 砍成"比默认更宽的那些",会让一个被切窄了的会话完全没有杠杆可用。

审批通道被刻意定义成一个最小的结构化函数形状EscalationAsk), 而不是审批服务的类型,于是 sandbox 包不需要依赖 approval 包或 agent 包。


6. 委派接缝:多个 Provider 可以同时在场

6.1 和前面几个接缝的一处根本差别

ctx.shell 一个上下文里只能有一个实现(重复注册直接抛错,这是 Cordis 的标准行为)。 子代理不是SubagentRuntimepackages/subagent/subagent/src/index.ts:171) 是一个按名字的注册表,多个 Provider 可以并存。

// packages/subagent/subagent/src/index.ts:369
registerProvider(provider: SubagentProvider): () => void
// :414
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>

6.2 三种委派形态

方法语义
start(name, request):414一次性运行:Provider 的所有权持续到 promise 兑现
startContinuable(spec):212建立一个持久可续的子代理,在其收件箱接受首条 prompt 时返回
followup(parent, childId, content, options):231事后再投一条消息;常驻子代理直接进 Agent 收件箱,不在场的则从持久 Session 冷恢复

followup 有一句写死的语义值得抄走:Agent 收件箱是唯一的队列, 所以每条被接受的消息都有唯一可观察的顺序。

6.3 Provider 谱系:从同进程 fork 到外部代理

子代理跑在哪一句话差别
subagent-spawn-in-processsrc/index.ts:62同进程新 Agent全新会话,看不到父对话
subagent-fork-in-processsrc/index.ts:92同进程新 Agent用父会话最后一个 turn/end 为止的连续前缀做种子
subagent-acpsrc/index.ts:173独立子进程走 Agent Client Protocol,子代理有自己的运行时/模型/工具
subagent-codex外部 Codex app-server真外部代理
subagent-claude-code外部 Claude Code走官方 Claude Agent SDK
subagent-dsh-sdk进程外的 dsh 子代理走 TypeScript SDK

fork 那条前缀规则很讲究: 子代理启动时父代理当前这个工具调用回合还没闭合—— 日志里有 assistant 的工具调用,却还没有对应的工具结果和 turn/end。 照抄原始日志会给子代理一个不平衡的非法会话,所以 fork 只取到最后一个 turn/end 为止; 父代理一个回合都没走完,种子就是空的,行为退化成 spawn。

6.4 这一节的不显然取舍

模型看不到 provider 选择器。 tool-subagentpackages/subagent/tool-subagent/src/index.ts:276) 每个插件实例绑定一个 provider一个 toolName;要多暴露一种 transport, 就再加载一个不同名字的实例。换 Provider 只改 transport,不改执行契约。

另一条:注销 Provider 只挡新的 start,不撤销已经交出去的 runregisterProvider 的 JSDoc,:369)。热重载因此不会把正在跑的子代理连根拔掉。


7. 其余接缝速览

同样的三角在仓库里还重复了十来次。下面每条只给"解决什么问题 / 三角落在哪些包 / 一个不显然的取舍"。

接缝解决什么问题Definition · Provider · Consumer一个不显然的取舍
Webpackages/web/搜索与抓取要能换厂商webWebRuntimeweb/src/index.ts:74)· web-search-exa / web-search-perplexity / web-search-deepseek / web-fetch-http · tool-web服务定义是具体注册表而不是抽象类——多个搜索 Provider 可并存,与 shell 的"独此一家"相反
Skillpackages/skill/把可复用的 agent 指令做成目录skillSkillRegistryskill/src/index.ts:357)· skill-filesystem / skill-badge · tool-skill刻意留在核心控制主干之外:本地、内嵌、远端 Provider 都不改模型面契约
Workflowpackages/workflow/跑模型自己写的编排脚本workflowWorkflowEngineworkflow/src/index.ts:157)· workflow-worker-thread · tool-workflow / tool-ralphworker 线程是为了不阻塞宿主事件循环,README 明说它不是安全边界
Compactionpackages/compaction/上下文压力下压缩历史compactionCompactionEnginecompaction/src/index.ts:96)· compaction-basic · command-compacttoken 计量不在这个接缝里(在 token-meter),所以压力敏感的插件不必依赖压缩引擎
Jobspackages/jobs/长跑工具的后台协议jobsJobRegistryjobs/src/index.ts:62)· jobs-local · tool-jobs后台句柄注册进通用任务运行时,于是 bash 后台进程和后台子代理共用一套观察/取消/通知
Storagepackages/storage/会话日志以外的持久化storageStoragestorage/src/index.ts:47)· storage-json / storage-sqlite · storage-domain消费方对着数据形态编程,不直接碰后端
Settingspackages/settings/用户可编辑配置settingsSettingsProvidersettings/src/index.ts:350)· settings-file · 各插件注册命名空间支持热提交与外部编辑观察——settings 变化能让 LLM 路由在线增减(见 §3.5 的休眠挂载)
Credentialspackages/credentials/配置里不许出现明文密钥credentialsCredentialProvidercredentials/src/index.ts:177)· credentials-local · 各适配器配置携带引用,在各自的操作边界解析;引用配了却解析不出来 → 直接 MISSING_CREDENTIAL 失败,而不是回落到环境里某个不相干的 key
MCPpackages/mcp/mcp-client接入外部 MCP 生态无自有服务定义 · 客户端桥 · 把外部服务器工具注册到 ctx.tools它是纯 Consumer:外部工具走的是第 4 章那条同样的注册表与流水线
Hookspackages/hooks/兼容 Claude Code / Codex 的 shell hookhook-protocol(共享协议库)· hooks-claude-code / hooks-codex 两个桥 · 落到 harness 自己的拦截扩展点官方扩展面是类型化拦截点,"原生 hook"就是普通 Cordis 插件;这两个包只是把外部 shell 协议翻译过来的,不是第一公民

8. 边界与局限(诚实说)

  • 接缝不是安全边界。 沙箱接缝管的是文件效果,workflow 的 worker 线程 README 直接写明不是安全边界。
  • 一个上下文一个实现。 除了 subagent 和 web 这类显式注册表,服务定义是独占的: 加载第二个 ctx.shell 实现会抛错。
  • 成对约束。 ctx.fsctx.subprocess 必须描述同一个世界,代码层面无法自动校验这一点, 错配是部署者的责任。
  • E2B 是 POC。 无重连、无暂停/离开保留、无模板构建、无卷、无快照、无网络策略层、无工作区同步; 沙箱状态刻意是易失的,超时或销毁即删。
  • macOS 的一处已知漏洞(局部):会话领导者退出后无法枚举 POSIX 会话, 所以在两次巡检快照之间改父的子进程会漏掉——被明确记为本地 Provider 的局限, 而不是把进程机制搬回 PTY 消费方的理由。

9. 代码地图(导航索引)

主题文件路径符号名
接缝三角的原始决策.agents/notes/implemented/architecture/2026-06-13-capability-seams.mdService Definition / Service Provider / Consumer
词汇表条目docs/glossary.mdcapability-seam
文件系统服务定义packages/fs/fs/src/index.tsFileSystemprocessPathfileUrlcontainseditText
本地 / 围栏文件系统packages/fs/fs-local/src/index.tspackages/fs/fs-sandbox/src/index.tsLocalFileSystemSandboxedFileSystem
文件策略闸门(非角色)packages/fs/fs-observation-policy/src/index.tsapply
Shell 服务定义packages/shell/shell/src/index.tsShellExecutorresolverunstart
本地 Bash 实现packages/shell/bash-local/src/index.tsLocalBashExecutorENV_OVERRIDES
共享 DSH_* 环境packages/shell/shell-env/src/index.tsShellEnvRegistry
进程基本面packages/subprocess/subprocess/src/index.tsSubprocessRuntimespawnspawnTerminal
终端 / LSP 服务定义packages/terminal/terminal/src/index.tspackages/lsp/lsp/src/index.tsTerminalSessionServiceLsp
远端执行世界 POCpackages/e2b/dsh-e2bdsh-fs-e2bdsh-subprocess-e2b
可移植消费方决策.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md执行世界 / POC 边界
LLM 注册表与准备调用packages/llm/llm/src/index.tsLlmRuntimeLlmAdapterregisterAdapterprepareCall
流与消息词汇packages/llm/llm/src/types.tsmessage.tsStreamChunkMessageMessageSourcefreezeMessage
重试策略词汇packages/llm/llm/src/retry-policy.tsResolvedRetryPolicyresolveRetryPolicy
双生适配器packages/llm/llm-deepseek/src/adapter.tspackages/llm/llm-pi-ai/src/adapter.tsDeepSeekAdapterPiAiAdapter
pi-ai 目录与发现packages/llm/llm-pi-ai/src/{catalog,provider,discovery,index}.tsresolveRouteModelsbuildProviderdiscoverModels
重试与计量消费方packages/llm/llm-retry/src/index.tspackages/llm/token-meter/src/index.tsapplyTokenMeter
沙箱服务定义packages/sandbox/sandbox/src/index.tsSandboxProviderconfineConfinedArgvSandboxUnavailableError
平台链路与方言packages/sandbox/sandbox-local/src/index.tsprofiles.tsPLATFORM_CHAINSSTATIC_ENFORCEMENTDENIAL_SIGNATURESbwrapProfileArgs
越权申请packages/sandbox/sandbox/src/escalation.tsWIDER_MODESESCALATION_TARGETSapproveEscalation
子代理注册表packages/subagent/subagent/src/index.tsSubagentRuntimeregisterProviderstartstartContinuablefollowup
子代理 Provider 谱系packages/subagent/subagent-{spawn,fork}-in-process/src/index.tssubagent-acp/src/index.tsapply
委派工具packages/subagent/tool-subagent/src/index.tsapply

接着读: 知道了每样能力都可换,下一个问题是"谁来决定这次组装成什么"—— 答案在第 6 章 · 每会话独立组装与产品外壳。 想回头看这些 Consumer 在模型面怎么拼成一次工具调用,回到第 4 章