stevessr / 不止是 '又一个 Agent 框架':dsh 背后的 Cordis 与时空组合论文

Created Sun, 16 Aug 2026 10:30:00 +0800 Modified Tue, 15 Sep 2026 14:44:12 +0000

不止是 “又一个 Agent 框架”:dsh 背后的 Cordis 与时空组合论文

本文是针对 dsh 技术架构的深度分析,不是 Quick Start 教程。
需要的基础知识:你知道什么是 dependency injection,知道 LangChain/AutoGen 大概是干嘛的。


一、这篇论文到底在说什么

README 里有一句话容易被忽略:

powered by Cordis, whose design is described in A Programming Paradigm for Spatiotemporal Composability.

这行不是装饰。它意味着 dsh 不是用插件"做装饰",而是整个运行时都是这篇论文框架的一个实例化。先讲清楚论文的核心论点,才能理解 dsh 为什么长这样。

1.1 旧的组合困境

传统的组合思考有三个局限:

  • 对象组合(OOP)只能组合 实例,不能组合产生这些实例的 容器 本身。两个插件如果共享同一个全局注册中心,就无法拥有彼此隔离的状态。
  • 函数式组合(Haskell/FP)擅长组合 ,但不擅长处理 时间维度——什么时候注册、什么时候清理、热重载中间的状态怎么迁移。
  • 主线程循环(LangChain 的 AgentExecutor,AutoGen 的 ConversableAgent)把编排协议硬编码进一个类里。你要改 executor 的阶段划分?去继承吧。你要给同一个循环插两个独立的策略?很困难。

这三个局限在 agent 运行时 这个具体场景里尤为尖锐,因为 agent runtime 同时需要:

  • 多个 agent 各自隔离的工具集和沙箱(空间问题)
  • 插件在热重载、会话恢复、动态挂载时正确清理资源(时间问题)
  • 对每轮 tool call 加策略干预、改写、否决(控制问题)

1.2 时空组合的定义

论文提出,如果把"组合"这一操作本身提升到 时空两个维度,就可以统一解决上面三个问题。

空间维度(Spatial):一个 Context 树。Context 树不是对象的容器,而是 服务作用域 的容器。每个 Context 节点拥有独立的服务绑定,通过 extend(新增属性)、isolate(符号隔离同名服务)、intercept(覆盖配置)三种操作派生。这样两个嵌套的 Context 可以各自拥有独立的 ctx.toolsctx.sandbox,互不干扰,而父 Context 仍然可以访问子 Context 的变化(通过 Events)。

时间维度(Temporal):一个 Fiber 状态机。Fiber 是插件运行时的生命周期句柄,完整状态链是:

PENDING → LOADING → ACTIVE → FAILED
                  ↘         → UNLOADING → DISPOSED
stateDiagram-v2 [*] --> PENDING: "插件注册,inject 未满足" PENDING --> LOADING: "所需服务就绪" LOADING --> ACTIVE: "apply() 正常返回" LOADING --> FAILED: "apply() 或校验抛异常" ACTIVE --> UNLOADING: "热重载 / 显式释放 / 服务消失" FAILED --> UNLOADING: "同上" UNLOADING --> DISPOSED: "所有 disposer 完成" UNLOADING --> PENDING: "HMR:新版本依赖新服务" note right of PENDING 不占用事件循环退出锁 不崩溃,静默等待 end note note right of DISPOSED ctx.on() 监听器自动移除 ctx.effect() disposer 已调用 所有子 Fiber 也已 disposed end note

“注册是可逆的副作用” 是 Cordis 的核心不变式。ctx.effect() 返回一个 disposer;所有内建注册 API(ctx.onctx.pluginctx.tools.register)自身都是 effect,卸载时自动调用 disposer。多个异步 disposer 并发运行,但按注册逆序发起——如果拆除步骤必须串行执行,就要把所有步骤放进同一个 disposer 里顺序 await。这是论文约束论文本身的时间原语。

交互原语Typed Events。服务之间的通信通过 TypeScript interface Events merge 声明,有五种分发模式 emit / parallel / serial / bail / waterfall。每种模式都固化了"谁来调用谁、有没有返回值、能不能短路"这组契约。waterfall 是论文最核心的模式之一:它把"环绕中间件"从字符串约定的实践,变成了编译器可以检查的类型契约。

组合操作统一:论文的核心论点可以总结成一个式子:

Composable System = Context(空间)× Fiber(时间)× Event(通信)

三者正交,任意一变不影响另外两个的语义。一个插件注册 effect 时,它在 Context 上注册、绑定到当前 Fiber、通过 Event 被其他 Fiber 监听——没有隐式的全局状态。

flowchart LR subgraph 空间轴["Spatial(空间):Context 树"] direction TB Root["Root Context
全局服务层"] AgentA["Agent A scoped ctx
isolate label=A"] AgentB["Agent B scoped ctx
isolate label=B"] Root --> AgentA Root --> AgentB end subgraph 时间轴["Temporal(时间):Fiber 状态机"] direction LR P["PENDING"] --> L["LOADING"] --> A["ACTIVE"] --> U["UNLOADING"] --> D["DISPOSED"] L --> F["FAILED"] end subgraph 通信轴["Communication:Typed Events"] direction TB E1["ctx.emit()"] -->|broadcast| L1["监听器(注册序)"] E2["ctx.waterfall()"] -->|with next()| L2["环绕中间件链"] E3["ctx.parallel()"] -->|并发| L3["所有监听器并行"] E4["ctx.serial()"] -->|顺序| L4["第一个非空返回值胜出"] end 空间轴 -.-> 正交1["正交"] 时间轴 -.-> 正交1 通信轴 -.-> 正交1 正交1 -.-> 论文["论文不变量:三者独立,可任意组合"]

二、Cordis 核心架构:四个概念拆解

为了避免抬杠,以下分析全部来自 dsh 源码中 vendored cordis 包的实现(vendor/cordis/packages/core/src/ 对应 @deepseek-ai/cordis 4.0.0-rc.7,上游 commit 56b3d4f7)。dsh 的三个本地修改点我会在后面单独讨论。

2.1 Context:一个 Proxy 包裹的 DI 容器

Context 在源码里只有一个类文件,context.ts,不到 80 行。 Constructor 里的关键代码(简化):

// vendor/cordis/packages/core/src/context.ts
constructor() {
  this[isolate] = Object.create(null)
  this[intercept] = Object.create(null)
  // 用 Proxy 拦截所有属性访问,走 ReflectService
  return new Proxy(this, ReflectService.handler) as any
}

ctx.tools 不是 this.tools——是 ReflectService.handler.get(target, 'tools', proxyCtx) 的结果。当访问 ctx.tools 时,handler 会根据当前 proxyCtx 的 isolate/intercept 字典,查找对应的 Service 实例。

sequenceDiagram participant Plugin as "plugin.apply(ctx)" participant Proxy as "ctx (Proxy)" participant Reflect as "ReflectService.handler" participant Isolate as "symbols.isolate dict" participant Service as "Service Instance" Plugin->>Proxy: ctx.tools Proxy->>Reflect: get(target, 'tools', proxyCtx) Reflect->>Reflect: 读取 proxyCtx[isolate]['tools']? alt 有 isolate label Reflect->>Isolate: 按 Symbol(tools) + label 查找 Isolate-->>Reflect: 返回隔离实例 else 无 isolate Reflect->>Reflect: 读 root context Reflect-->>Proxy: 返回默认绑定 end Proxy-->>Plugin: ToolRegistry 实例(无 import!)

这不是"装饰器式的 DI",而是 基于 proxy 的按需解析 DI。插件不需要 import 任何服务实现,只需要 declare module '@deepseek-ai/cordis' { interface Context { tools: ToolRegistry } },然后写 ctx.tools.register(...)。具体的工具注册从哪来,由 composition 决定。

三种派生机制:

操作 实现 用途
ctx.extend({ baseUrl }) 普通属性继承 传递元数据
ctx.isolate(name, label) symbols.isolate 字典按 label 隔离 为单个 session 或 agent 提供独立服务实例
ctx.intercept(name, config) symbols.intercept 覆盖配置 当前 fiber 子树内替换服务配置

isolate 的做法是用 WeakMap 按 Symbol(key) × label 存实例,同一 label 下的 fiber 共享同一个隔离实例,不同 label 的 fiber 看到不同实例。这比 “每个 agent 一个 register” 的方案更底层,能让 scopeOf() 这样的零依赖库直接消费。

2.2 Fiber:Plugin 的运行时期语义

Fiber 的状态设计在论文的框架下是非常有野心的:

// vendor/cordis/packages/core/src/fiber.ts
enum FiberState {
  PENDING, LOADING, ACTIVE, FAILED, UNLOADING, DISPOSED
}

PENDING 状态不是"失败",而是 “依赖服务尚未就绪”。当 inject: ['shell'] 声明的插件加载时,如果 shell 提供方还没启动,它就停在 PENDING——不崩溃、不输出、不占用事件循环退出锁。

dsh 的三个本地 Fiber 补丁(见 vendor/README.md 的变更日志第 7 条)解决了三个具体问题:

  1. 重入式卸载:从 setup body 内部调用 dispose() 时,先注册 owner-list wrapper 再运行 setup,确保 setup 中创建的 effect 被正确的 owner 回收,不会被静默泄漏。
  2. 异步清理的 owner 可见性:异步 disposer 保持 owner 可见直到 quiescence,防止 Fiber 提前进入 DISPOSED 而被上层误杀。
  3. LOADING 期禁止创建 effect:在 UNLOADING 状态下拒绝 ctx.effect() 调用,防止清理过程中注册新 effect 逃逸出 unload snapshot。

这些都是 论文"可逆副作用是时间不变式" 这一约束在真实并发下的边界情况处理。在论文里这部分只是文字,在 dsh 里它变成了一道 fence。

2.3 Service:不是 class,是契约 + 生命周期对

Service 基类在 service.ts,抽象方法只有两个:

abstract init(ctx: Context): void   // 构造时不带 ctx,init 时注入
abstract invoke(...args: any[]): any  // 调用接口

initapply 分离的原因:一个 Service 可以被多次挂载到不同的 isolate label,每次 init 拿到的是 isolate 后的 ctx。这样同一个 Adapter 类可以同时给多个 agent 服务,互不影响。

Service 的实现方和调用方没有 import 关系。调用方只感知 ctx.tools 是一个接口,具体是 ToolRegistryImpl 还是别的什么,由 ctx.registry 在 mount 时绑定。这意味着换一个 tool 注册表实现,不需要改任何一个插件——只要新类满足同样的 Service.init/invoke 契约。

2.4 Events:五模式类型化事件系统

Cordis 的事件系统有三个正交维度:

维度 选择
分发模式 emit / waterfall / parallel / serial / bail
作用域 当前 Fiber 子树,还是 global root
生命周期 通过 ctx.on() 注册,随 Fiber 卸载自动取消监听

waterfall 是 dsh 最重要的模式。它的语义是:(...args, next) => newResult,调用者传一个 next continuation,监听器可以:

  • return next() ——委托给下游
  • return wrap(next()) ——转换下游结果
  • return decision ——短路,next() 永远不会被调用

dsh 中所有"拦截点"都用 waterfall 实现,包括:

  • agent/pre-step:重写进入模型的消息,或整轮拒绝
  • tools/pre-execute:sandbox 策略拦截危险的 tool call
  • approval/request:审批策略是否替用户按下按钮
  • fs/write-intentfs/edit-intent:文件观测策略决定是否放行写入

ctx.on 返回的 disposer 会在 Fiber 卸载时被调用,绝不需要手动 removeListener。这篇论文一个精巧的设计选择:事件的订阅绑定到 Fiber 的生命周期,而不是到 EventEmitter 的 list——EventEmitter 是全局对象,Fiber 是局部作用域对象。

flowchart LR Caller["agent-loop
分发 tools/execute"] Caller -->|ctx.waterfall| L1["监听器 1
timeout-policy
超时则返回 error"] L1 -->|return next()| L2["监听器 2
session-checkpoint
记快照后委托"] L2 -->|wrap(result)| L3["监听器 3
repeat-tool-reminder
加提示后委托"] L3 -->|next()| Default["默认工具调用
ToolCall.run()"] Default -->|tool_result| L3 L3 -->|包装后返回| L2 L2 -->|包装后返回| L1 L1 -->|返回| Caller AltBlock["短路路径"] L2 -.->|检测到拒绝| AltBlock AltBlock -.->|直接返回 error| Caller

三、把框架落地成 dsh:大结构怎么对应

3.1 四层 Bundle 栈 = Context 树的分层服务注册

dsh-base  →  模型适配器 + 工具注册表 + 会话日志 + 沙箱 + 审批
                                         ↓
dsh-web-app  →  浏览器应用、Slot UI、WebSocket 通道
dsh-headless  →  一次性 CLI runner,无服务端
                                         ↓
profile cordis.patch.yml  →  用户自定义的服务覆盖
home ~/.dsh/*  →  个人配置 + 自定义 preset
--patch overlay  →  单次启动的临时覆盖

每一层是一个 cordis.patch.yml(或 bundle 导出的 patch),Loader 按顺序 apply,后面的层直接覆盖前面的同名条目。这不是"配置优先级",而是 Context 树的快照合并——因为 dsh 把条目当成值来合并,不是当命令来执行。

dsh 在这一层有一个很不寻常的设计:ordis 循环 import 自身cordis.yml 里可以写 !!js 表达式,当这个表达式引用了 ctx.someService 时,Loader 会先激活声明了 inject: ['someService'] 的插件,再对这些条目的 config 执行插值(包括嵌套的 !!js)。这个设计让环境选择"哪一组合"成为一个纯数据操作,而不是代码分支。

flowchart TB subgraph 加载顺序["Bundle 栈加载顺序(后覆盖前)"] direction TB L1["Layer 1:dsh-base
llm/tools/session/sandbox/approval"] L2["Layer 2:dsh-web-app
browser Slot / WebSocket"] L3["Layer 3:dsh-headless
one-shot CLI runner"] L4["Layer 4:profile cordis.patch.yml
用户自定义覆盖"] L5["Layer 5:~/.dsh/* 全局配置"] L6["Layer 6:--patch overlay
单次启动临时覆盖"] L1 --> L2 --> L3 --> L4 --> L5 --> L6 end subgraph 对应机制["Cordis 层叠机制"] direction TB R1["Loader 将每个 cordis.patch.yml
解析为 EntryTree"] R2["后层同名条目直接覆盖前层"] R3["!!js 表达式在目标 ctx 上求值"] R4["inject 未满足的 Fiber 停在 PENDING"] end 加载顺序 -.-> R1 对应机制 -.-> 论文["论文主张:composition 是纯数据,不是代码"]

3.2 dsh-base 的核心服务契约

下面每个服务都是 dsh 和插件作者之间的正式契约,由 TypeScript 接口定义:

ctx.<key> 契约接口 角色
ctx.llm LlmAdapter 模型调用统一接口
ctx.tools ToolRegistry 模型可见工具列表
ctx.sessions SessionStore 会话日志(单数据源)
ctx.sandbox SandboxProvider 进程隔离接口
ctx.subprocess SubprocessProvider 命令执行
ctx.agents AgentRegistry Agent 生命周期接口
ctx.agentLoop Agent 接口实现 循环的具体实现,而非契约本身
ctx.fs FsProvider 文件系统访问
ctx.settings SettingsSeam 用户配置
ctx.credentials CredentialSeam API 密钥等

注意最后一行:ctx.agentLoop 是一个 具体实现(dsh 默认的 agent-loop 包),而 ctx.agents 是面向外部插件的 契约接口。这意味着你可以写一个新的 loop 实现,注册到 ctx.agents.setFactory(),而不需要改会话管理系统、工具管道或 UI。agent-loop 的 role 是 Cordis 的 Service class,它的工厂工厂本身也是 effect 的一部分。

3.3 why “swappable agent loop” 这件事重要

LangChain 的 AgentExecutor 是一个 monolith class;AutoGen 的 ConversableAgent 也是如此。它们的协议——什么时候调用模型、什么时候停下来等工具返回——是继承树的一部分。

dsh 把循环本身变成了 fire-and-forget 的 service provider:

  • 循环的 启动条件 由配置条目声明(on: agent/created),不是硬编码。
  • 循环的 行为扩展 通过 agent/* waterfall events(12 个监听器!)实现。
  • 循环的 生命形式Agent 接口定义,不是由某个具体类。

这意味着:循环可以被替换,不需要触动会话日志、工具注册表、审批策略。协议是事件,不是类。


四、事件是真实的研究对象:dsh 的完整事件地图

dsh 的事件不是"顺便用的 pub/sub",而是 架构的设计层。整个事件资料库可以在 docs/event-producer-consumer.md 里看到,我给你挑几个关键:

4.1 agent/pre-step — 模型的"最后一道防线"

Mode: waterfall
Producers: agent-loop
Consumers (12个):
  agent-instructions  ← 注入人设 / 上下文
  compaction-basic    ← 压缩旧消息
  goal-round-driver   ← 目标驱动的消息改写
  hooks-claude-code   ← 兼容 Claude Code 协议
  hooks-codex         ← 兼容 Codex 协议
  plan-mode           ← Plan mode 禁用工具调用
  repeat-tool-reminder ← 提示模型不要重复调用
  session-checkpoint-policy
  subagent-in-process-driver
  time-context        ← 注入当前时间
  tmux-context        ← 注入 tmux 会话状态
  tool-cordis          ← 动态注入工具
  tool-skill           ← 注入 skills

12 个 waterfall 监听器合作改写进入模型的 prompt。任何一个都可以不调用 next() 直接驳回本轮请求。如果把这类逻辑写进循环自身的 if/else,循环会爆炸成 300 行不可控的 spaghetti。Waterfall 把它变成:每个 listener 的职责是单点的,共同拼出 prompt 的最终形态

flowchart LR Loop["agent-loop
emit agent/pre-step"] Loop --> W1["1. agent-instructions
注入人设/系统指令"] W1 --> W2["2. time-context
注入当前时间"] W2 --> W3["3. tmux-context
注入终端状态"] W3 --> W4["4. compaction-basic
压缩旧消息"] W4 --> W5["5. goal-round-driver
目标驱动改写"] W5 --> W6["6. plan-mode
Plan mode 禁用工具"] W6 --> W7["7. repeat-tool-reminder
防重复调用"] W7 --> W8["8–12. 其余监听器"] W8 --> FinalPrompt["最终 prompt 送入模型"] Any -.->|"任一 listener 不调 next()"| Reject["整轮拒绝"] Any -.指的是| Loop

4.2 tools/pre-execute / tools/execute / tools/post-execute — 工具执行的三段流水线

Mode: waterfall
Consumers:
  hooks-claude-code     ← 协议兼容
  hooks-codex
  tool-jobs             ← 后台任务管理
  timeout-policy        ← 工具调用超时
  session-checkpoint    ← 执行前快照

工具执行是 waterfall 的经典使用场景:timeout-policy 可以在 tools/execute 阶段返回一个错误消息,短路真实的工具调用。整个工具执行的结果类型是 tool_use | error_message | rejected——三个分叉在同一个 waterfall 事件里完成选择,而不是互相插桩。

4.3 事件的类型化契约

每个事件在 interface Events 里用 TypeScript declaration merge 声明:

declare module '@deepseek-ai/cordis' {
  interface Events {
    'tools/execute'(call: ToolCall, next: () => Promise<ToolResult>): Promise<ToolResult>
  }
}

这行代码规定了:1) 事件名 tools/execute;2) 监听器参数类型;3) 返回值类型;4) 是否存在 next()(waterfall 特征)。代码里分发 ctx.waterfall('tools/execute', call, next) 时,TypeScript 会检查签名是否匹配。书面上这只是一个接口合并,实际上它让事件系统变成了一个有静态类型保障的 跨插件协议层

flowchart LR subgraph 编译时 direction TB Merge["interface Events 声明合并
'tools/execute'(call, next): Promise"] TypeCheck["TypeScript 类型检查
ctx.waterfall('tools/execute', call, next)
签名必须匹配"] Merge --> TypeCheck end subgraph 运行时 direction TB Dispatch["ctx.waterfall(name, ...args, next)"] Dispatch --> L1["listener 1
检查权限 → next() 或 reject"] L1 --> L2["listener 2
记日志 → wrap(next())"] L2 --> L3["listener 3
超时控制 → error 或 next()"] L3 --> Default["默认工具执行"] Default --> Result["Promise 沿原路返回"] end 编译时 -.->|"类型保证事件契约"| 运行时 运行时 -.->|"运行时保证协议执行"| 全链路["完整协议:编译时类型 + 运行时执行
= 跨系统可靠通信"]

五、Session 日志:运行时不变式终于被认真对待了

会话日志(packages/core/session)是 dsh 最被低估的设计。

5.1 单数据源(SSOT)不变式

dsh 源码中的原话

Model-visible means logged. Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it.

这不仅仅是"我们记日志"。这是一个运行时不变式——向量化校验在代码里执行,违反它会 throw。含义是:如果模型看到了一个工具调用的结果,这个结果对应的 tool/calltool/result 事件一定已经在 session log 里了。差一点都不行。

5.2 日志驱动的消息历史

每次模型请求的 message history,不是从"某个全局对话数组"里拉,而是从 session log 通过 deriveMessages() 投影出来的。session log 有这些原始事件类型:

user/message        ← 用户输入,不经过模型
assistant/chunk     ← 模型流式片段
assistant/message   ← 模型最终回复
tool/call           ← 工具调用请求
tool/result         ← 工具返回
turn/start          ← 一轮新的循环启动
turn/end            ← 一轮循环结束

会话恢复、fork、replay、compaction 都从这个 log 出发,不维护第二个 source of truth。fork 的本质是:复制 log 到某个 SessionId,之后两条链各自追加事件。会话断线重连后,不靠"传给模型的上下文快照重建",而是靠 log 重放。

flowchart LR subgraph SessionLog["Session Log(append-only 事件流)"] direction TB E1["user/message"] E2["assistant/chunk"] E3["assistant/message"] E4["tool/call #1"] E5["tool/result #1"] E6["turn/end"] E7["user/message"] E8["tool/call #2"] E9["tool/result #2"] E1 --> E2 --> E3 --> E4 --> E5 --> E6 --> E7 --> E8 --> E9 end subgraph 派生操作["所有下游视图均从 Log 投影"] direction TB P1["deriveMessages()
→ 模型请求 message history"] P2["fork(resumeAt)
→ 复制 log 到新 SessionId"] P3["replay()
→ 逐事件重放,用于前端"] P4["compaction
→ 生成摘要事件追加回 log"] end SessionLog --> P1 SessionLog --> P2 SessionLog --> P3 SessionLog --> P4

5.3 Compaction:协议级的上下文压缩

compaction-basic 监听 agent/requestagent/status,在 token 预算接近上限时触发。它的压缩策略是:保留 turn/start 和 turn/end 边界、保留最后 N 轮 tool call 的完整结果、压缩中间轮次为摘要——但所有摘要操作都生成新的 durable session event,所以 压缩本身也是可审计的

这不是"砍掉前面的消息",这是"生成一段新的 log 条目来描述被压缩的内容"。


六、Capability Seams:swappable 不是口号

dsh docs 里面有 capability seams 概念。seam 的定义:

A seam is a swappable capability with three roles: a Service Definition declaring the interface, a Service Provider implementing it, and a Consumer using it.

三个角色必须同时设计,而不是事后补。一个 row 只有提供方没有消费方,不是 seam;只有消费方没有提供方,也不是 seam。

ctx.tools(工具注册表)、ctx.sandbox(沙箱)、ctx.fs(文件系统)、ctx.subprocess(子进程)、ctx.credentials(密钥)、ctx.storage(非会话持久化)、ctx.settings(配置)都是 seam。

ctx.fs 为例:

  • 定义FsProvider 接口(readFile、writeFile、edit、stat……)
  • 消费方tool-fs(让模型能读写文件)、skill-filesystem(skill 里的文件工具)
  • 提供方fs-local(本机文件系统)、fs-e2b(E2B 沙箱内文件系统)

fs-local 被替换成 fs-e2b所有注入 fs 的插件自动看到新的实现,不需要改代码。更关键的是,ctx.subprocessctx.sandbox 共享同一组 execution world:如果 shell-localsubprocess-local 都在同一个 E2B 沙箱里启动,一个配置切换就把 Bash、PTY(terminal-bash)和 LSP(lsp-stdio)一起移到远端——无需 fork provider

这篇论文关于ontology的论述在这里有了具体的工程对应:一个存在者(plugin)的空间边界(它能看到哪些 Context.bindings)和时间边界(它注册的 disposer 何时被调用)是独立的正交轴。

flowchart TB subgraph Seam["Capability Seam = Definition × Provider × Consumer"] direction TB D["Service Definition
接口契约
(如 FsProvider)"] P["Service Provider
具体实现
(fs-local / fs-e2b)"] C["Service Consumer
使用方
(tool-fs / skill-filesystem)"] end subgraph 替换示例["以 ctx.fs 为例:替换提供方 = 移动整个执行世界"] direction LR Local["fs-local
+ subprocess-local
+ bash-sandbox"] E2B["fs-e2b
+ subprocess-e2b
+ bash-e2b"] Terminal["terminal-bash 也在其中"] LSP["lsp-stdio 也在其中"] Local -.->|"一键切换"| E2B E2B --> Terminal E2B --> LSP end D -.->|"由 ctx.registry.bind 在 mount 时聚合"| P P -.->|"被消费方通过 ctx.fs 按需解析"| C 替换示例 -.-> 论文["论文:如果一个 seam 的三个角色同时设计,
swap 只需要替换 Provider,不碰 Consumer"]

七、Agent Loop 作为 Plugin:这对架构意味着什么

这是整篇文章最关键的一个观察,值得展开。

7.1 Agent 是一个有生命周期的实体

消费方通过 ctx.agents.create()ctx.agents.resume() 创建 agent,拿到一个 AgentHandle——一个包含 agent 实例和 dispose 能力的 tuple。dispose 的能力是一个 capability seal:只有持有 handle 的一方才能销毁 agent;如果持有 handle 的方自己是 Fiber 的一部分,Fiber 卸载时会自动处理 teardown。

loop 本身通过 ctx.agents.setFactory() 注册自己的 factory。loop 客户代码不需要"继承 loop",只需要 ctx.agents.create()——factory 就是柯里化的创建逻辑。

flowchart LR Consumer["消费方
UI / schedule / subagent"] Consumer -->|ctx.agents.create()| Registry["ctx.agents
AgentRegistry"] Registry -->|setFactory 注册| Factory["loop 的 setFactory
(柯里化创建逻辑)"] Factory -->|创建| Handle["AgentHandle
(agent + dispose capability seal)"] Handle --> Agent["Agent 接口实例
id / session / inbox / status / ctx"] Handle -->|dispose 是 capability| Teardown["teardown 流程:
stop loop → await exit →
unregister agent → remove session →
unwind scoped world"] Registry -.->|"只有持有 handle 的一方才能销毁"| Capability["Capability Seal
(持有即授权)"]

7.2 Initator scope 和代理场景

进入每轮 tool call 的执行环境时,agent-loop 会调用 ctx.agents.withInitiator(agent) 创建一个 scoped context。这个 scoped context:

  • 只能看到当前 agent 的 AgentHandle
  • ctx.toolsctx.sandbox 等注册表继承自父 context,但可以 override
  • agent.send()agent.inject() 会路由到这个特定的 agent
flowchart TB Root["Root Context
Node.js 进程级"] Root -->|extend| AgentLoop["agent-loop Fiber
owning Agent A + B handles"] subgraph ScopedA["Agent A scoped ctx
withInitiator(handle_A)"] A_Ctx["Agent A ctx"] A_Tools["ctx.tools
(继承,可 override)"] A_Sandbox["ctx.sandbox
(继承,可 override)"] A_Handle["AgentHandle A
(只有这里能看到)"] A_Ctx --> A_Tools & A_Sandbox & A_Handle end subgraph ScopedB["Agent B scoped ctx
withInitiator(handle_B)"] B_Ctx["Agent B ctx"] B_Tools["ctx.tools
(同一注册表,独立沙箱)"] B_Sandbox["ctx.sandbox
(独立沙箱实例)"] B_Handle["AgentHandle B
(只有这里能看到)"] B_Ctx --> B_Tools & B_Sandbox & B_Handle end AgentLoop --> ScopedA AgentLoop --> ScopedB note1["A 的 tool 拦截函数
收不到 B 的 tool/call 事件"] ScopedA -.-> note1

这意味着:两个并发的 agent session,都在同一个 Node.js 进程里,但它们的 scoped context 是对象隔离的。一个 agent 的 tool 预执行拦截不会看到另一个 agent 的工具调用,除非程序员显式把它们接到 global scope。

7.3 协议不变式

Agent 接口(不是 loop class,是接口)定义了所有插件能感知的生命周期事件:

agent/created → agent/status(idle→running) → agent/pre-step →
tool/call* → tools/pre-execute → tools/execute → tools/post-execute →
tool/result* → agent/status(running→idle) → agent/turn-stopping → turn/end

这组事件是第三方插件(包括 hooks-claude-codehooks-codex 这样的协议兼容钩子)能正确工作的全部依赖。hook 不需要知道 loop 的内部状态机,只需要在 agent/statustools/* 上响应。这就是"用事件替代继承"的具现化。

sequenceDiagram participant User as "用户" participant Loop as "agent-loop driver" participant Session as "ctx.sessions (SSOT)" participant Agent as "ctx.agents" participant PreStep as "agent/pre-step waterfall
12 listeners" participant LLM as "ctx.llm adapter" participant Tools as "ctx.tools waterfall
pre/execute/post" participant Hook as "hooks-claude-code" User->>Loop: followup(message) Loop->>Session: turn/start (durable) Loop->>Agent: agent/pre-step (waterfall) PreStep->>PreStep: compaction-basic 改写 prompt PreStep->>PreStep: plan-mode 决定是否禁用工具 PreStep->>PreStep: agent-instructions 注入人设 PreStep-->>Loop: 最终 prompt(或 reject) Loop->>LLM: agent/request (waterfall) LLM-->>Loop: assistant/chunk* → assistant/message Loop->>Tools: tool/call Tools->>Tools: tools/pre-execute (waterfall) Tools->>Tools: tools/execute (waterfall) Tools->>Hook: tools/post-execute (waterfall) Hook-->>Tools: 协议兼容转换 Tools-->>Loop: tool/result alt 模型还需更多工具调用 Loop->>Tools: 下一轮 tool/call* else 无更多工具调用 Loop->>Session: turn/end (durable) Loop->>Agent: agent/status(idle) end

八、HMR 与配置热重载:论文时间原语在生产里

HMR(Hot Module Replacement)在 cordis 里不是一个附加功能——它是论文"可逆副作用"承诺的运行时保证。

cordis.yml 里的一行发生变更,以下序列展开:

  1. Loader import 新的插件模块
  2. Include(配置的容差合并层)对旧树和新树做 three-way diff,生成 patch
  3. 对每个需要更新的 Fiber:
    • 如果 fiber 是 ACTIVE → 走向 UNLOADING → DISPOSED
    • disposer 按注册逆序并发发起
    • 新 Fiber 从 PENDING 重新开始,所需服务就绪后 LOADING → ACTIVE
  4. Loader 在重新 check 依赖后 settling——如果新插件的 inject 声明里有新的服务依赖,Fiber 回到 PENDING,等依赖
  5. 所有操作打包成一个事务:失败则回滚到旧状态

这不是 CSS HMR——这是在 保持 Node.js 进程不下线、session 不丢失的前提下,替换了 agent loop、tool pipeline 或任何你写过的插件。dsh Web UI 在开发时的热更新就是这样工作的。

dsh 这个版本在 fiber 层有三处补丁(前面提到的 “Fiber lifecycle hardening”),让这个事务的边界更严格:不会出现 “旧 Fiber 的 disposer 还没跑完,新 Fiber 已经开始触发 effect” 的中断状态。

flowchart TB Trigger["cordis.yml 变更触发"] Trigger --> Step1["Step 1:Loader import 新模块"] Step1 --> Step2["Step 2:Include 对旧树/新树做
three-way diff → patch"] Step2 --> Step3{"Step 3:Fiber
生命周期事务"} Step3 -->|ACTIVE Fiber| S3a["UNLOADING → DISPOSED
disposer 按注册逆序并发发起"] S3a --> S3b["新 Fiber 从 PENDING 重新开始
inject 依赖就绪 → LOADING → ACTIVE"] Step3 -->|PENDING Fiber| S3c["重新检查 inject 声明
新依赖存在 → 仍为 PENDING"] Step2 --> Step4["Step 4:Loader settling
所有 Fiber 依赖 recheck"] Step4 --> Decision{"成功?"} Decision -->|Yes| Commit["提交:所有新 Fiber ACTIVE"] Decision -->|No| Rollback["回滚:恢复旧 Fiber 状态
session 不丢失"] Commit --> Done["事务完成"] Rollback --> Done

九、论文框架在 dsh 里欠缺的地方

flowchart TB subgraph 论文主张 ["论文框架主张"] direction LR S1["空间:Context 树
可派生、可隔离、可拦截"] T1["时间:Fiber 生命周期
可逆副作用、可重载"] E1["通信:Typed Events
五模式、类型化契约"] D1["分布式:Fiber 可在不同进程
物理空间 ≠ 逻辑空间"] S1 & T1 & E1 & D1 --> Clean["数学上干净的框架"] end subgraph dsh实现 ["dsh 已实现"] direction LR D2["extend/isolate/intercept"] D3["Fiber 状态机 + disposer"] D4["5 种分发模式 + TS merge"] Done2["✅ 完成"] D2 & D3 & D4 --> Done2 end subgraph 未完成 ["论文框架在 dsh 里未完全落地"] direction LR G1["Gap 1:类型检查在编译时
运行时不参与"] G2["Gap 2:事件命名空间全局扁平
无 scoped event 隔离"] G3["Gap 3:空间操作只有三种
无 partial merge / overlay"] D5["Gap 4:Fiber 单进程 in-memory
分布式 Fiber 未验证"] Gap["❌ 待逐步兑现"] G1 & G2 & G3 & D5 --> Gap end 论文主张 -.-> dsh实现 dsh实现 -.-> 未完成 未完成 -.-> 结论["结论:论文提供方向,
工程需要时间逼近理想"]

坦诚地说,论文框架的某些部分在 dsh 里还没完全落地:

1. Context 树的类型推断是静态的,不是动态的

Cordis 通过 TypeScript interface Context { ... } 的 declaration merge 实现类型化 DI。但这是 编译时 的——在运行时,Context 是一个 Proxy,属性的存在与否由 Reflect handler 决定,TypeScript 类型系统不参与运行时检查。如果插件注册的服务名拼写错,运行时静默返回 undefined,然后在某个 ctx.injectedService 的地方才 throw Cannot read properties of undefined。这是一个论文没解决的问题。

2. Events 是全局命名空间扁平结构

论文提到 “scoped events”,但在 Cordis 当前实现里,事件名 agent/pre-step 是全局字符串。Fiber 隔离作用在 订阅者 上,不在 事件名空间 上。这意味着你可以在 isolate 的 Context 里注册对 agent/pre-step 的监听器,但它仍然能收到任何 Context 里发出的同一个事件——filter 的责任落在了 listener 自己的代码里。

3. “Spatial” 的维度只有两层

目前 Context 只有 extend / isolate / intercept 三个派生方法,没有更丰富的空间变换操作(比如 partial merge、基于 scope 的 overlay)。如果未来要表达"这个 agent 只能用白名单工具",需要用 isolate 重新挂载 tool registry,或者手动在 waterfall 里拦截。这个表达力就是论文的潜力还没有完全兑现。

4. 论文的分布式 Variant 还未被验证

论文提到 “Fiber 可以在不同的进程中”,即物理空间 ≠ 逻辑空间。但当前 Cordis 的 Fiber 是单进程的 in-memory 状态机,不支持远程 Fiber。这意味着 dsh 的 “subagent 分布式”(subagent-inprocess vs subagent-remote 的 prospects)还没有一个框架层面的实现,只是靠自定义 provider 的接口来拼。


十、如果把 dsh 放回主流框架里比较

为了不被误解,我不建议用"谁更好"的方式来判断 dsh。判断框架的维度不同。

维度 LangChain AutoGen / CrewAI DSH (Cordis)
组合维度 Prompt + Chain Agent class + 协议类 Context × Fiber × Event
循环实现 硬编码 AgentExecutor 硬编码 ConversableAgent agentLoop 是一个可替换的 service
模型适配器 配置化,消耗 RV 配置化 ctx.llm,整个 “distributed LLM adapter” 设计
工具系统 Tool.run() function_map ctx.tools,swappable 注册表,三阶段 waterfall
热重载 不支持 不支持 HMR 是框架保证,协议级事务
事件/可观测性 LangSmith 外部系统 日志字符串 类型化事件矩阵,每个事件的 producer/consumer 都是显式的
扩展模型 Python class extend Python class extend ctx.effect() + ctx.on(),可组合,可逆,类型检查
多 agent 隔离 无(共享注册) 每 agent scoped ctx,isolate 符号隔离
状态来源 对话 state dict agent 内部状态 Session log SSOT,不可变
会话 fork/resume 手动 按 log 重放,原语级支持

结论:LangChain 和 AutoGen 解决的是「怎么写一个 agent」的问题,dsh 解决的是「怎么把 agent runtime 本身做成一个可组合的系统」的问题。前者是框架帮助你写代码,后者是你可以用框架的原子来组装一个 runtime,然后在这个 runtime 里写任意 agent 行为。


十一、Cordis 论文的三个长期影响

Impact 1:Agent loop 变成了可替换的部件

如果 agent-loop 只是一个 Service provider,那么 LangGraph 的全部编排逻辑(router node、edge function、state reducer)理论上可以改写成一个 dsh 的 agent loop 实现。不需要 fork dsh,不需要改 session 包。只要新的 loop 遵守 Agent 接口,触发相同的事件序列,整个工具管道、沙箱、审批系统会自动适配。

这是 LangChain 做不到的事:LangChain 的 LangGraph 是一个独立于 LangChain runnable 系统的状态机,两者并非正交。

Impact 2:Events 演变成跨系统协议

在一个 Events-as-protocol 的系统里,tools/pre-execute 既可以由本地 session-checkpoint-policy 消费,也可以由 apiproxy 消费转发到远程协调器。协议是事件名称 + 类型签名;实现是任何监听者。这意味着 dsh 可以很方便地把“人类审批”的瀑布逻辑从本地搬到远程服务——不换一个 provider,给水里加一个 filter。

Impact 3:Composable runtime 改变了 agent 工程的经济学

传统的 agent 工程:写流程 → 遇到边界情况(多 agent、长会话、工具爆炸) → 硬编码 patch → 继续写流程 → 再遇到…… 成因是一开始架构没有把"可替换"作为硬约束。

dsh 的经济学是:把 cost 留在架构层,不留在业务逻辑层。Fiber 的 reverses 保证了 undo;Events 的 typed contract 保证了演化时不用逆向工程;Capability seams 的 declaration 保证了 swap 不需要动 consumer。这些东西前期都很昂贵,但一旦写完,业务逻辑就真正变成"声明式组合"而非"过程式胶水"。


十二、你想试试的切入点

如果你看完觉得想碰代码,我建议这样的路径:

Day 1:把 npx @deepseek-ai/dsh web 跑起来,走一遍 Session 和 Session Log,观察 agent/pre-steptools/execute 的 event 发射顺序。打开 DevTools,会话日志的每个事件你都应该读一遍。

Day 2:读 docs/cordis-primer.zh.md + docs/cordis-tutorial/ 七章。教程都是用 cordis.yml!!js 和 TypeScript expression 写的 Playground 代码,不是玩具,是 dsh 自身的 compose 机制。

Day 3:读 docs/subsystems/core.md,特别是 turn flowcapability seams 两个部分。然后去翻 docs/event-producer-consumer.md,随机挑一个 event,走一遍它的所有 producers 和 consumers。

Day 4:读 packages/bundledocs/cookbook/adding-a-tool.zh.md,看 dsh-base 如何把 ctx.toolsctx.sandboxctx.shell 等 seam 组合成一个可用的默认配置。

Day 5:读 packages/bin.js + packages/boot/app-boot,理解 cordis.yml 是如何被解析并转化为 Fiber tree 的。这是 Cordis “composition 即程序” 思想的最终落地。


附录:关键技术细节索引

你需要知道的源码位置 说明
vendor/cordis/packages/core/src/context.ts Context Proxy 实现,不到 80 行
vendor/cordis/packages/core/src/fiber.ts Fiber 状态机 + 重入补丁
vendor/cordis/packages/core/src/reflect.ts Dependency-driven DI,按需解析
vendor/cordis/packages/core/src/events.ts 五模式事件系统
packages/core/session/src/index.ts Session log SSOT + deriveMessages
packages/core/tools/src/index.ts 三阶段工具流水线 + waterfall 分发
packages/core/agent/src/runtime-types.ts Agent 接口 + 所有 agent/* 事件声明
packages/core/agent-loop/src/index.ts 默认 loop 实现 + turn 状态机
packages/boot/app-boot/ cordis.yml 解析 → Fiber tree 事务化
packages/hmr/src/index.ts HMR 实现,hmr/reload 事件
docs/event-producer-consumer.md 全部事件 + producer/consumer 矩阵
docs/capability-seams.md seam 定义 + 提供方/消费方图
docs/cordis-primer.zh.md + docs/cordis-tutorial/* Cordis 概念 + 实操教程
vendor/README.md #7 “Local modifications” dsh 在 cordis 上的三处纤维补丁

结语:一篇论文和一个开发者预览的关系

把 dsh 放回论文框架看,DeepSeek 做的不是"一个又 fast(快)又 open(开源)的 agent runtime"。它的切入角度是 runtime architecture 问题——怎么让 agent runtime 本身的每一个部件都可以被替换、可被审查、可被组合。

论文提供了一个数学上干净的框架:空间(Context)和时间(Fiber)正交,事件是跨空间跨时间的通信原语,所有注册都可以逆操作。dsh 在这个框架上实现了一个具体领域(LLM agent runtime)的完整映射, Vendored cordis、三层起跳的 Bundle 栈、事件矩阵、capability seams、Fiber 补丁——每一层都在论证论文的主张是不是在工程上可行。

当前版本是 0.1.0-rc.5,开发者预览,未来有破坏性变更。这没什么好惊讶的:如果论文框架真如它所声称的那样可以通用化,dsh 要经历几次大的架构重构反而才是正常的事。

但对一个正在研究怎么把 “agent runtime” 这个东西本身设计好的人,读 dsh 的源码——尤其是 cordis-primerdocs/cordis-tutorial 里这几百行概念示例——是理解 Cordis 框架最直接的入口。这篇论文不是学术空谈,它正在驱动你手边的这个工具。