跳转至

第 6 章:进阶扩展口(选做,任选其一深入)

对应 dsh 真实源码:docs/capability-seams.md + 各子系统页(sandbox / credentials / subagent) 前置:第 1~5 章。产出文件:miniharness/seams/subagent/(基础三件套)+ seams/sandbox_local.py、seams/credentials_local.py(真后端)+ tests/test_seams.py、tests/test_stage6.py

6.1 这一章要做什么

前面几章反复出现一个模式:接口 + 可替换实现 + 只依赖接口的消费方。这一章把这个模式正式化——"能力扩展口三角色",并且亲手实现三遍,每一遍都在验证同一句话:

换一个 Provider,不改 Consumer,即换行为。

三个扩展口,任选其一做深(各自 2~3 天工作量):

  1. 沙箱:Sandbox 接口 + wrap(argv)——把 argv 包裹进受限执行环境
  2. 凭据:CredentialProvider 接口 + resolve(key)——配置只存引用,每次操作解析
  3. 子 agent:SubAgentProvider 工厂接口 + spawn(name, prompt)

6.2 概念:扩展口三角色

flowchart LR
  D["Service Definition<br/>接口 + 生命周期 + 错误码约定"]
  P["Service Provider<br/>实现(可整体替换)"]
  C["Consumer<br/>只依赖接口"]
  P -->|"实现"| D
  C -->|"依赖"| D

一个角色单独不构成扩展口。新增能力 = 同时设计三个角色:定义接口(Service Definition)、提供实现(Provider)、写消费逻辑(Consumer)。三者缺一,替换性就不成立——没有接口,Consumer 直接依赖具体实现,一换就炸。

还有一个联动值得知道:共享执行世界。dsh 里 FS 与 subprocess 的 Provider 指向远程沙箱后,Bash / PTY / LSP 会全部跟随迁移——因为它们共享同一个"执行世界"对象,换一次配置,所有相关能力一起搬家。

6.3 扩展口 1:沙箱(Sandbox)

class Sandbox:
    """Service Definition:把 argv 包裹进受限执行环境。"""
    def wrap(self, argv):
        raise NotImplementedError

class PassthroughSandbox(Sandbox):
    """本地直通(danger:不设防,等同 danger-full-access)。"""
    def wrap(self, argv):
        return argv

class ReadOnlySandbox(Sandbox):
    """模拟只读沙箱:含写操作标志的命令直接拒绝(失败即拒)。"""
    WRITE_MARKERS = ("-w ", "--write", "-o ", ">", ">>", "rm ", "mv ", "touch ", "mkdir ")

    def wrap(self, argv):
        cmd = " ".join(argv)
        if any(marker in cmd for marker in self.WRITE_MARKERS):
            raise PermissionError(f"只读沙箱拒绝写操作: {cmd}")
        return argv

class CommandConsumer:
    """Consumer:只依赖 Sandbox 接口。换 Provider 即换行为。"""
    def __init__(self, sandbox):
        self._sandbox = sandbox

    def run(self, command):
        if os.name == "nt":
            argv = self._sandbox.wrap([command])
            proc = subprocess.run(argv[0], shell=True, capture_output=True, text=True, timeout=10)
        else:
            argv = self._sandbox.wrap(shlex.split(command))
            proc = subprocess.run(argv, capture_output=True, text=True, timeout=10)
        return proc.stdout.strip() or proc.stderr.strip()

常规做法是把安全策略写死在工具内部("这个工具自己小心点")。dsh 反过来:策略是独立的 Provider,工具(Consumer)只声明"我要执行一个命令",至于在什么环境里执行,由注入的沙箱决定。

PassthroughSandbox 对应 danger-full-access(不设防),ReadOnlySandbox 对应 read-only 的简化模拟。验收测试的关键是 Consumer 代码一字不改:

passthrough = CommandConsumer(PassthroughSandbox())
readonly = CommandConsumer(ReadOnlySandbox())
# Consumer 代码不变,只换 Provider
with self.assertRaises(PermissionError):
    readonly.run("echo a > tmp_evil.txt")
assert passthrough.run("echo ok") == "ok"

注意 ReadOnlySandbox 的"失败即拒"(deny on failure):检测到写操作标志直接抛错,而不是"试试看能不能写"。dsh 的真实约定也是如此——无法确认安全就拒绝执行。

真实 dsh:sandbox-local 用 bwrap / Landlock / Seatbelt / Windows ACL 后端;native/landlock-run 是独立发布的原生 launcher(C 源码包)。失败即拒是共同约定。

6.4 扩展口 2:凭据(Credentials)

class CredentialProvider:
    """Service Definition:按操作解析凭据;配置只存引用,绝无明文。"""
    def resolve(self, key):
        raise NotImplementedError

class EnvCredentialProvider(CredentialProvider):
    """env-over-.env:配置项 -> 环境变量名(引用),每次调用解析。"""
    def __init__(self, mapping=None):
        self._mapping = mapping or {"api_key": "DEEPSEEK_API_KEY"}

    def resolve(self, key):
        env_name = self._mapping[key]
        value = os.environ.get(env_name, "")
        if not value:
            raise KeyError(f"凭据 {key}(环境变量 {env_name})未配置")
        return value

常规做法是配置文件里直接写 key(或者至少把 key 读进内存常驻)。dsh 的纪律是:配置里存的是 apiKeyEnv 这样的引用,不是明文;ctx.credentials 让 API key 每次调用解析——改环境变量不用重启进程,而且配置仓库里永远找不到一个真实的 key。

第 4 章 DeepSeekAdapter 里 os.environ.get("DEEPSEEK_API_KEY") 就是 EnvCredentialProvider 的雏形;这里把它升级成接口,是为了给 .env 文件、keyring、提示注入等来源留位置。

6.5 扩展口 3:子 agent(SubAgent)

class SubAgent:
    def run(self, task): raise NotImplementedError

class SubAgentProvider:
    """Service Definition:子 agent 工厂。"""
    def spawn(self, name, system_prompt): raise NotImplementedError

class InProcessSubAgentProvider(SubAgentProvider):
    """in-process Provider:复用主循环(真实还有 fork / ACP / Codex / Claude Code)。"""
    def __init__(self, make_loop):
        self._make_loop = make_loop
    def spawn(self, name, system_prompt):
        return _InProcessSubAgent(self._make_loop(system_prompt))

class _InProcessSubAgent(SubAgent):
    def __init__(self, loop):
        self._loop = loop
    def run(self, task):
        self._loop.followup(task)
        return self._loop.last_response()

子 agent 的 Provider 选择直接决定"子 agent 跑在哪":同进程复用主循环(in-process)、独立进程(fork)、外部协议(ACP)、甚至另一个商业 CLI(Codex / Claude Code)。Consumer(subagent 工具)只依赖 spawn + run,不知道也不关心子 agent 背后是什么。

真实 dsh 的 ctx.subagents Provider 有 in-process / fork / ACP / Codex / Claude Code / dsh-sdk 六个。把我们的 InProcessSubAgentProvider 换成任何一个,spawn + run 的调用方代码一字不改。

6.6 延伸:Agent Teams 接缝(多 agent 协作)

子 agent 是"换 Provider 换行为";Agent Teams 是它之上的协作层(上游 packages/experimental/agent-team,实验族,名叫 agent-teams):给多个子 agent 一个共享的任务板与信箱,agent 之间通过模型可见的工具互相派活、收消息。三角色这里依然成立,只是把"接口"换成了"事件":

  • Service Definition:四个 log-only 会话事件(全量投标,换人即换值)——team/member 成员的权威快照、team/task 任务板的权威快照、team/message/queued 入队、team/message/delivered 送达
  • Provider:Team Lead 会话 = 权威 journal。任何工具调用 = 在这一条事件日志上 append,再由 fold 投影出 roster / task board / mailbox
  • Consumer:模型侧的 team_task_* / team_peer_* 工具,只 append + fold,不看别人怎么实现

成员怎么来?复用第 6.5 节的子 agent:spawn_teammate = start_continuable(agent_options={provider})——一个成员就是一个 durable 子会话(冷恢复、DAG 依赖都在)。projection 折叠可以小到只有几行:

class TeamProjection:
    """从 Lead 会话日志折叠成员列表(全量投标,最后一条胜出)。"""
    def __init__(self, log):
        self._members = {}
        for ev in log:
            if ev.type == "team/member":
                self._members.update(ev.data["member"])

    @property
    def ids(self):
        return list(self._members)

任务板的"状态机"不是 switch:每个动作是 claimed -> in_progress -> completed 这样的边,team_task_update 校验当前状态允许这条边,然后 append 一条 team/task 全量投标取代旧快照(revision 从 1 连续,CAS 即"版本号对得上"。两个 agent 同时改同一任务,后写者因 revision 冲突被拒)。

投递有两个载体,取决于宿主有没有事件循环:

service.spawn_teammate(name, system_prompt)      # 无循环态(harness/CLI):同步 spawn
await service.spawn_teammate_async(...)          # 事件循环内(驱动载体):checkpoint await 让出控制

真实 dsh 是纯 async 形态,没有同步载体;mini 的同步门面是给无 driver 的同步 harness/CLI 用的。事件循环内绝不能用 time.sleep 等成员 checkpoint——会把整个循环卡死,这也是异步载体存在的唯一原因。

简化与取舍(诚实标注):上游的 todo 事件(todo-list UI 的面)与 client-ui-agent-team / web-profile 属于前端 wire 面,mini 不引入;wire/Remote 端点不承载——错误语义用一个 TeamError.code 闭集表达,而不是 HTTP/端点层。

6.7 延伸:会话检查点策略(语义持久化屏障)

前面几章把"什么事实该落盘"交给了宿主:turn/step 括号、工具结果、压缩都在事件层保证。但什么时候落盘、落到哪个粒度,是另一个独立决策——这就是检查点策略(上游 packages/session/session-checkpoint-policy,mini seams/session_checkpoint.py)。

它的职责一句话:在副作用发生前,把"将要发生的承诺"先写进磁盘,崩了不丢已承诺事实。实现是"三屏障":

屏障 挂点 时机
模型请求前 agent/checkpoint(boundary=request) 请求信封落日志后、adapter 派发前
顶层工具体前 tools/pre-execute exec_.agent 有且 exec_.parent 为空(子派发复用外层检查点)
每步边界 agent/pre-step 新 step 开始前
# 语义示意(seams/session_checkpoint.py)
SessionStore.checkpoint(session, boundary)   # 经 ctx.checkpointService 派发
# 有持久化参与者才真正 flush;无参与者 → fail-closed 抛 SessionCheckpointError

两个关键决定:

  1. 取消折叠:请求/工具体落在检查点窗口内被取消,折叠成 canonical ABORTED_BEFORE_DISPATCH——不落"未发生的副作用"。
  2. fail-closed:没有持久化参与者时,checkpoint 直接失败而不是静默跳过——"不知道数据存没存"比"没存"更糟,宁可停。

载体差异(诚实标注):上游经 llm/stream 服务 waterfall 延迟适配器构造(agent/checkpoint 挂 waterfall 上延迟决定是否重新适配器),mini 是单一 adapter 直接调用,故改用 agent/checkpoint 完成同一承诺语义。装配 = install_checkpoint_policy(ctx)(opt-in)。

6.8 验收

python -m unittest tests.test_seams -v

6.9 检查点练习(挑一个做深)

  1. 沙箱:实现 DenyListSandbox(基于 deny 黑名单)与 AllowListSandbox(基于 allow 白名单)两个 Provider,共享一个测试套件证明 Consumer 不变。
  2. 凭据:实现 FileCredentialProvider(从 .env 文件读取,逐行 KEY=VALUE),与 EnvCredentialProvider 共用同一接口测试。
  3. 子 agent:用第 4 章的 DeepSeekAdapter 实现 RemoteSubAgentProvider(真实 API),跑一次"主 agent 派发任务给子 agent"的完整链路。

6.10 回到 dsh:真实源码对照

打开 deepseek-harness/docs/capability-seams.md:

  • 完整扩展口列表与每个扩展口的三角色实例
  • "共享执行世界"的迁移方式(FS/subprocess Provider 指向远程沙箱 → Bash/PTY/LSP 跟随迁移)

三个扩展口与上游的接口差异(简化命名,语义一致):

扩展口 我们的接口 上游真实接口 备注
沙箱 Sandbox.wrap(argv) SandboxProvider.confine(argv): ConfinedArgv(含 runner/profile/分离符 + allowedExitCodes/fatalSignatures/denialSignatures) SandboxMode:read-only / workspace-write / danger-full-access;"失败即拒"约定一致
凭据 CredentialProvider.resolve(key) resolve(ref): ResolvedCredential(值 + 来源层)+ describe(ref);本地 provider 层:env / file / project-env / user-env 引用是带 brand 的 POSIX 环境变量名语法;每次操作重新解析
子 agent SubAgentProvider.spawn(name, prompt) SubagentProvider.start(...) + prepareContinuable(可继续对话)+ SubagentCapabilities 能力门(不支持则 UNSUPPORTED_CAPABILITY 拒绝) 六个真实 Provider:in-process / fork / ACP / Codex / Claude Code / dsh-sdk

6.11 进阶实现:真后端 / 四层凭据 / 远程三通道 / PTC 运行时

基础三件套讲清"换 Provider 不改 Consumer";进阶三件把每个接缝推向与 dsh 一致的形态(产出:miniharness/seams/sandbox_local.py + credentials_local.py + subagent/providers.py + subagent/worker.py,验收测试在 tests/test_stage6.py)。

沙箱真后端(sandbox_local.py + landlock_run.py,对应上游 sandbox/sandbox-local 与 native/landlock-run):按平台选链(linux bwrap → landlock、darwin seatbelt、win32 windows-acl),多候选由功能探测仲裁、单候选免探测;候选全不可用 → SandboxUnavailableError(SANDBOX_UNAVAILABLE)fail closed,命令绝不裸跑。confine(argv, policy) 返回 ConfinedArgv:包裹后的 argv + enforcement(full/partial)+ 该后端专属的 denial 方言与 runner 失败规则——"命令没跑起来"与"被沙箱拦住"可区分。三个 profile 生成器与上游 profiles.ts 逐条一致(bwrap 挂载、landlock grant、seatbelt SBPL 剖面,可写根与进程内 fs fence 共用 writable_roots 同一推导)。landlock 梯队由 seams/landlock_run.py ctypes 自限制执行器真执行:上游 native/landlock-run 是 C11 launcher 二进制,mini 以 python -m miniharness.seams.landlock_run 复刻同一 CLI 约定(--ro/--rw/--/--probe、launcher 失败 exit 125 绝不 exec、probe 报告行逐字一致),内核侧走 Landlock UAPI(ABI 协商 → PATH_BENEATH 规则 → PR_SET_NO_NEW_PRIVS → restrict_self → execvp;full ⟺ 内核 ABI ≥ 5,否则 partial 但仍受限;非 Linux 宿主干净退出 125)。

windows-acl 真后端(seams/sandbox_windows_acl/,对应上游 sandbox/sandbox-windows-acl 的进程内 koffi FFI):ctypes 直调 Win32 三件——CreateRestrictedToken 铸 WRITE_RESTRICTED 受限令牌,SetEntriesInAclW + SetNamedSecurityInfoW 把能力 SID 的可写 ACE 物化到授权目录(workspace 常驻、会话私有 temp 可撤销),CreateProcessAsUserW 在该令牌下 spawn 子进程。runner 以 python -m miniharness.seams.sandbox_windows_acl.runner --workspace <dir> --temp <dir> --mode <m> [--write-sid … --temp-write-sid …] -- <argv> 包裹命令,自身任何失败打印 windows-acl-run: <detail> 并 exit 127——消费者靠「127 + fatal 行」双条件区分"没跑起来"与"跑起来后被拒"。约定测试经 internals 注入钩子验证链选择与包裹形状;真内核行为由门控 e2e(MINIHARNESS_INTEGRATION_WINDOWS_ACL=1)覆盖。

理解这个后端只需要一块内核知识:受限令牌的两遍求值。

  1. CreateRestrictedToken(..., WRITE_RESTRICTED) 产生一个派生令牌:常规组保持原样,额外携带一张限制列表。mini 放进三个 SID:本次登录会话的 logon SID、Everyone、以及一个凭空合成的能力 SID(形如 S-1-4-<hash>,不对应任何账号)。
  2. 内核对每次访问对同一 DACL 做两遍求值:第一遍以常规组为主体,第二遍以限制列表为主体。写类访问必须两遍全部放行;读/执行类只看第一遍。这就是"WRITE_RESTRICTED 只限写"的全部原理。
  3. 能力 SID 的唯一用途就是在第二遍求值中匹配我们授予的 allow ACE——"给目录授能力 SID 写 ACE"即"把目录加入该子进程的白名单",撤销 ACE 即收回授权。合成 SID 在第二遍完全有效,不需要是真实的组或账号。
  4. 反直觉推论一:OWNER RIGHTS ACE(S-1-3-4)对受限进程无效。文件属主自己的非受限令牌能借它拿到全权,但两遍求值都不会把它算给受限主体。
  5. 反直觉推论二:目录怎么生出来,与授什么 ACE 同等重要。DACL 里若没有任何一条能让"常规组"过第一遍的 ACE,第二遍再完美也白搭。

用同一受限令牌对不同 DACL 跑 AccessCheck,矩阵一目了然(目标位 FILE_GENERIC_WRITE,限制列表 [logon, Everyone, CAP_B]):

目录 DACL 非受限令牌 受限令牌
仅 CAP_B:(F) 拒 拒
CAP_B + 用户 SID :(F) 过 过
CAP_B + Everyone :(F) 过 过
CAP_B + OwnerRights :(F) 过 拒

"仅 CAP_B 连非受限都被拒"正是第 5 条:这个 DACL 里没有用户侧的第一遍通路。而 CPython 恰好有个坑踩在这里——tempfile.mkdtemp(0o700) 会显式构造安全描述符(SYSTEM/Administrators/OWNER RIGHTS 三条,唯独没有用户自己的 ACE),于是用它创建的沙箱目录永远过不了第一遍;上游 node 的 fs.mkdtempSync 不构造描述符、纯继承父目录 DACL,%TEMP% 链路自带的 user:(I)(F) 天然喂饱第一遍。所以 mini 的 runner 私有 temp 与 provider 会话 temp 都用继承式 os.mkdir 创建。Everyone 与 logon SID 同时出现在两个列表里,一条 Everyone:(F) 能同时喂饱两遍——这正是上游文档标注 enforcement=partial(Everyone 边界)的由来。这条经验超出本例:在 Windows 上做沙箱或临时目录,先问"DACL 从哪继承",再谈授什么 ACE。

沙箱策略服务 + bash 消费者(seams/sandbox_policy.py + shell/,对应上游 sandbox/sandbox-policy、session-mode.ts 与 shell/bash-sandbox):策略与强制分属两个服务——ctx.sandboxPolicy 是唯一的共享策略家(Config {mode: 缺省 read-only, workspaceRoot} fail-loud 校验;resolve() 决议完整策略:显式 mode > 会话日志最后一条 sandbox/mode > 部署缺省;workspace 根先 canonical 后词法规范化,会话 cwd 即 workspace-write 边界),ctx.sandbox 把模式物化为 runner argv。会话覆盖以会话日志为存储:set_sandbox_mode 追加恰一条 sandbox/mode log-only 事件,effective_sandbox_mode 纯 fold 逆序取最后——切换即事件本身,重放即状态。三档策略文案经 systemPrompt .context('sandbox:policy', order=110) 进模型可见上下文(loop 侧投影在上下文变化时把快照铸成 durable user 消息注入对话流,见 core/agent_loop/runtime_context.py)。消费者在 shell/ 层:bash_local.py 是 ctx.shell 缺省 provider(execute(spec) -> ShellExecution:bash -c 直跑句柄,带 observed 非消耗偏移读、消费式 read_output()、kill() 与 memoized result();resolve 补齐 onExpiry(kill|none) 与输出预算),bash_sandbox.py 子类每次调用把精确 argv 经 confine 包裹后 spawn 并报告 {mode, denied, enforcement};三路归因同上游 helpers.ts——runner 启动失败(ENOENT/EACCES 且错误路径恰为 argv[0]、cwd 可用性独立校验)与 runner 失败规则命中抛 SandboxUnavailableError 且优先于 denial,denial = 非零退出 + stderr 大小写不敏感签名命中(普通非零退出仍是正常结算);danger-full-access 直通不包裹。工具面(tool_bash/,经 cli/default_tools.py 装配)检测到 ctx.shell 时把教学 stub 换成真执行器,逐调用以调用方会话决议策略;jobs 在场时前台超时提升为后台作业、run_in_background 直接开作业,ctx.shellEnv 注入托管 DSH_*;headless 入口 run_headless(..., sandbox=配置) 一键装配全栈。载体差异:无 ctx.subprocess 托管范围/spill(Popen + 读线程 + 内存增长缓冲);pwsh 变体(M16)未复现。

沙箱发布/部署平台矩阵与 fail-closed 约定(release/ops 视角):上面按平台选链只讲了"怎么选",这里把它收敛成操作用(release/ops)视角的矩阵:

平台 候选后端(链序) 真实强制门槛 enforcement
Linux bwrap → landlock landlock 需内核 ABI ≥ 5 才 full(否则 partial 但仍受限);非 Linux 宿主 landlock_run 干净退出 125 full / partial
macOS (darwin) seatbelt 真 SBPL 剖面 full
Windows (win32) windows-acl 真内核写法受扫描由门控 e2e 覆盖(MINIHARNESS_INTEGRATION_WINDOWS_ACL=1),非 win32 import 即抛 OSError partial(Everyone 边界)

fail-closed 约定(沙箱下"命令绝不裸跑"):

  1. 无候选:当前平台没有可用后端 → SandboxUnavailableError(SANDBOX_UNAVAILABLE),不降级为裸跑。
  2. runner 起不来:可真后端启动失败(ENOENT/EACCES 且错误路径恰为 argv[0]、cwd 不可用)→ 同样 SandboxUnavailableError,且优先于 denial。
  3. 跑起来后被拒:non-zero 退出 + stderr 大小写不敏感签名命中 → denial(普通非零退出仍是正常结算)。
  4. windows-acl 方言:runner 自身失败打印 windows-acl-run: <detail> 并 exit 127——靠「127 + fatal 行」双条件区分"没跑起来"与"跑起来后被拒"。
  5. 唯一直通:danger-full-access 直通不包裹——这是操作者的显式决策,不是后端缺省;除此之外沙箱层 fail-closed 绝不静默放行。

纯 sdk/profile 教学形态不受影响(不走真沙箱装配)。

凭据四层(credentials_local.py,对应上游 credentials-local):env > file > project-env > user-env 按信任度排序——继承环境只读胜出(CI secret / -e 是显式意图且进程内不可编辑)、管理文件层可写(set/unset 读-改-写补丁单键,外部编辑合并、删掉的条目不残留)、project .env 优先于 user .env。文档解析严格:非映射根 / 非 POSIX 标识符 key / 非字符串 / 空串值整体拒绝,绝不静默跳过;describe 报告 {configured, source, writable}(只有 env 层不可写);env 已提供时 set/unset 拒绝(写了也被遮蔽成无效果);POSIX 上组/其他可读的文档读前直接拒绝(Windows 无 mode 可查则跳过)。载体简化/决策:文档用 JSON 替代 YAML(解析语义不变;JSON 重复键 fail-closed 更稳,为受评审设计决策);跨进程写锁用 filelock 实现(同 withFileLock,credentials_local.py:553,写入等待 30s——DOCUMENT_LOCK_WAIT_SECONDS 覆盖 atomic-write 2s 默认)。凭据变更感知双通道:① 读侧热重载(缺省 pull 兜底)——resolve/describe/read_record 等读入口先 _refresh_if_changed()——os.stat 比对 mtime/size,变了才重解析整表(外部编辑/删除即时生效),写路径 _reconcile_from_disk 折叠不变;无撕裂(atomic write os.replace),读侧不需锁。② 可选文件 watch(显式开启)——LocalCredentialProvider(watch=True)(或 CredentialsService 装配)以 watchdog 主动监听外部编辑(同上游 chokidar watch + reconcileFromDisk 文本比对 + 逐 entry fan-out),缺省关闭(与 ACP 持久化同构,避免默认起线程);watch 事件去抖单飞折叠,逐 entry 发 credentials/reference-updated/credentials/record-updated(经 CredentialsService 转发 ctx 事件),自写 echo 由文本比对抑制。credentials_local.py 还实现记录(record)服务侧五件套:read_record/describe_record/list_records/modify_record/delete_record(modify_record 是唯一写路径:持锁内 reconcile 折叠外部编辑 → mutate 收到当前记录返回替换/拒绝 → 写前准入与读路径同一套 → 原子写入磁盘;<scope>/<id> 键语法 [a-z][a-z0-9-]*,段非法逐字 TypeError)。

子 agent 远程三通道(subagent/providers.py + subagent/worker.py,对应上游 subagent-fork-in-process / subagent-acp / subagent-dsh-sdk):

  • fork(进程内):子 agent 以父会话日志的 completed-turn 前缀作 seed(到最后一个 turn/end 为止,in-flight 工具回合不平衡不能重放),Session(seed=...) 回放 + 自动补 session/end-seed 标记(fork 切点恒 {inherited:true},inherited_cut 由 marker 派生)——子会话直接继承父上下文。
  • ACP(真子进程):python -m miniharness.seams.subagent.worker acp 起独立进程,newline-delimited JSON-RPC over stdio(与上游 ndJsonStream 同帧形状);initialize → newSession → prompt → cancel → shutdown;唯一从父读的是 workspace cwd;permission 策略自动应答(reject 默认 / allow),不上报人;session/update 经 update_sink 在回合执行期间逐事件并发流式写通知、先于响应帧(同上游 notify 并发流)。
  • SDK(真子进程):subagent_worker sdk 承载 SdkRuntime,initialize → session/prompt(懒创建会话)→ shutdown;回合级透传 session.event(本次回合新增的 inbox 回执、assistant/message、turn/end 逐条,_event_boundary 边界保证会话复用不重发历史事件)+ 末尾 session.status idle 通知,可被官方 Python SDK 客户端直接驱动(见第 7 章 7.6.4)。

三者保持同一 Consumer 接口 spawn(name, prompt) -> SubAgent:换通道只改 Provider 构造,消费方代码不动。

PTC 运行时(miniharness/ptc_runtime/,对应上游 packages/ptc-runtime/{ptc-runtime,ptc-runtime-node} + packages/experimental/ptc-runtime-python):第 3 章延伸讲了 run_code 工具怎么把程序交进来;这里补执行侧。PTC(programmatic tool calls,程序化工具调用)的编排单元是程序而非单条工具调用,所以 PtcRuntime 是一条 Service Definition(ctx.ptcRuntime):

  • run(request) -> {result, failure?}:拿模型写的程序 + 绑定表,执行并返回结果。绑定表里是可调用的工具(tools.<name>),程序 await 它。
  • 保留名与绑定校验:RESERVED_BINDING_GLOBALS / RESERVED_ERROR_MEMBERS / PORTABLE_RESERVED_WORDS / DUNDER_MEMBER——程序不能碰宿主保留名,绑定不能撞 __dunder__ 私有面(失败分类 binding-conflict)。这是安全边界的第一道:程序能调什么由绑定表显式枚举,不是宿主进程全局。
  • PythonPtcRuntime 的执行载体:每个请求在全新 CPython 子进程跑模型 Python(顶层 await/return)。绑定经 stdin/stdout 行 JSON 协议桥接——宿主进程不经 eval 接触模型代码。墙钟预算 / 中止信号 / 输出上限三件套,配合正交失败分类闭集:exception / timeout / abort / worker-exit / invalid-output / output-limit / protocol。
# 语义示意(ptc_runtime/types.py)
run(request=PtcRunRequest(sandbox=spec, program, bindings, timeout_ms, max_log_bytes))
# -> PtcRunResult(bytecode?, sandbox)  或 failure=PtcRunFailure(code=..., message=...)

载体差异(诚实标注):上游是 Node 后端(worker/subprocess + fd-3 wire),mini 用 CPython 子进程 + 行 JSON——和沙箱一样,子进程不是安全边界(上游同款声明:防的是失控程序毁掉宿主进程,不是躲恶意代码)。装配 = install_ptc_runtime(ctx)(opt-in)。

6.12 手册收尾

全部 6 章做完,你应该能用 Python 亲手证明这三件事(报告《结语》篇 §12 同样强调):

  1. 事件溯源日志是唯一数据源(第 1、5 章)
  2. 注册 = 可逆副作用 + waterfall 短路(第 2、3 章)
  3. 能力扩展口三角色带来整体可替换性(第 3、6 章)

如果这三件事你现在都能不查资料写出来,对 dsh 的理解就已经到位了。接下来可以:

  • 精读第 12 章:MiniHarness 已是异步形态(asyncio + 真正的 parallel / 并行屏障),看它如何实现第 2 章的派发语义
  • 用官方 Python SDK(deepseek-harness-sdk)驱动真实 harness,对照你的约定
  • 给 dsh 仓库提第一个插件 PR(docs/cookbook/adding-a-tool.md)