不止是 “又一个 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.tools、ctx.sandbox,互不干扰,而父 Context 仍然可以访问子 Context 的变化(通过 Events)。
时间维度(Temporal):一个 Fiber 状态机。Fiber 是插件运行时的生命周期句柄,完整状态链是:
PENDING → LOADING → ACTIVE → FAILED
↘ → UNLOADING → DISPOSED
“注册是可逆的副作用” 是 Cordis 的核心不变式。ctx.effect() 返回一个 disposer;所有内建注册 API(ctx.on、ctx.plugin、ctx.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 监听——没有隐式的全局状态。
全局服务层"] 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 实例。
这不是"装饰器式的 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 条)解决了三个具体问题:
- 重入式卸载:从 setup body 内部调用
dispose()时,先注册 owner-list wrapper 再运行 setup,确保 setup 中创建的 effect 被正确的 owner 回收,不会被静默泄漏。 - 异步清理的 owner 可见性:异步 disposer 保持 owner 可见直到 quiescence,防止 Fiber 提前进入 DISPOSED 而被上层误杀。
- 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 // 调用接口
init 和 apply 分离的原因:一个 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 callapproval/request:审批策略是否替用户按下按钮fs/write-intent、fs/edit-intent:文件观测策略决定是否放行写入
ctx.on 返回的 disposer 会在 Fiber 卸载时被调用,绝不需要手动 removeListener。这篇论文一个精巧的设计选择:事件的订阅绑定到 Fiber 的生命周期,而不是到 EventEmitter 的 list——EventEmitter 是全局对象,Fiber 是局部作用域对象。
分发 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)。这个设计让环境选择"哪一组合"成为一个纯数据操作,而不是代码分支。
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 的最终形态。
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 会检查签名是否匹配。书面上这只是一个接口合并,实际上它让事件系统变成了一个有静态类型保障的 跨插件协议层。
'tools/execute'(call, next): Promise
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
= 跨系统可靠通信"]
五、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/call 和 tool/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 重放。
→ 模型请求 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/request 和 agent/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.subprocess 和 ctx.sandbox 共享同一组 execution world:如果 shell-local 和 subprocess-local 都在同一个 E2B 沙箱里启动,一个配置切换就把 Bash、PTY(terminal-bash)和 LSP(lsp-stdio)一起移到远端——无需 fork provider。
这篇论文关于ontology的论述在这里有了具体的工程对应:一个存在者(plugin)的空间边界(它能看到哪些 Context.bindings)和时间边界(它注册的 disposer 何时被调用)是独立的正交轴。
接口契约
(如 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 就是柯里化的创建逻辑。
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.tools、ctx.sandbox等注册表继承自父 context,但可以 overrideagent.send()、agent.inject()会路由到这个特定的 agent
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-code、hooks-codex 这样的协议兼容钩子)能正确工作的全部依赖。hook 不需要知道 loop 的内部状态机,只需要在 agent/status 和 tools/* 上响应。这就是"用事件替代继承"的具现化。
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 里的一行发生变更,以下序列展开:
- Loader import 新的插件模块
- Include(配置的容差合并层)对旧树和新树做 three-way diff,生成 patch
- 对每个需要更新的 Fiber:
- 如果 fiber 是 ACTIVE → 走向 UNLOADING → DISPOSED
- disposer 按注册逆序并发发起
- 新 Fiber 从 PENDING 重新开始,所需服务就绪后 LOADING → ACTIVE
- Loader 在重新 check 依赖后 settling——如果新插件的
inject声明里有新的服务依赖,Fiber 回到 PENDING,等依赖 - 所有操作打包成一个事务:失败则回滚到旧状态
这不是 CSS HMR——这是在 保持 Node.js 进程不下线、session 不丢失的前提下,替换了 agent loop、tool pipeline 或任何你写过的插件。dsh Web UI 在开发时的热更新就是这样工作的。
dsh 这个版本在 fiber 层有三处补丁(前面提到的 “Fiber lifecycle hardening”),让这个事务的边界更严格:不会出现 “旧 Fiber 的 disposer 还没跑完,新 Fiber 已经开始触发 effect” 的中断状态。
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 里欠缺的地方
可派生、可隔离、可拦截"] 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-step 和 tools/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 flow 和 capability seams 两个部分。然后去翻 docs/event-producer-consumer.md,随机挑一个 event,走一遍它的所有 producers 和 consumers。
Day 4:读 packages/bundle 和 docs/cookbook/adding-a-tool.zh.md,看 dsh-base 如何把 ctx.tools、ctx.sandbox、ctx.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-primer 和 docs/cordis-tutorial 里这几百行概念示例——是理解 Cordis 框架最直接的入口。这篇论文不是学术空谈,它正在驱动你手边的这个工具。