DeepSeek Harness(DSH)源码精读 01:DSH 是什么,以及它凭什么「一切皆插件」
本文是《DeepSeek Harness 源码精读》系列第 1 篇(共 9 篇)。
读的是 deepseek-harness(DSH)官方仓库源码,含 vendored 的 Cordis 框架;文中路径、标识符与行号都取自实际源码,可以边读边跳转。
前置阅读:无(系列开篇,建议从这里读)。
本篇导读
从「没有特权内核」讲起:DSH 的目录地图、一条用户消息走过的 15 步,以及把它撑起来的 Cordis 框架——Context 是个 Proxy、Service 构造即注册、Fiber 生命周期与 epoch 驱动的自动重启、五种事件派发、可逆的 effect 注册。
本篇由原稿的两章合并而成:《全景与设计哲学》 + 《Cordis 插件框架》。两章是同一条线索的两段,所以放在一起读更顺。
本系列目录
| 第 1 篇 | DSH 是什么,以及它凭什么「一切皆插件」 | 全景与设计哲学 + Cordis 插件框架 |
| 第 2 篇 | Agent 循环的心脏:turn/step、Inbox 与 18 行 preStep | Agent 循环与上下文装配 |
| 第 3 篇 | 事件溯源日志、模型可见面与 JSONL 版本迁移 | 会话日志与持久化 |
| 第 4 篇 | 工具执行流水线与能力缝:一次 write 走过的 13 个关卡 | 工具系统与能力缝 |
| 第 5 篇 | 压缩、token 计量、结果溢出与动态上下文快照 | 上下文工程 |
| 第 6 篇 | 多智能体编排(subagent/workflow/goal)与四个独立权限旋钮 | 编排与多智能体 + 安全、审批与交互 |
| 第 7 篇 | profile/bundle 组合、Web GUI 传输与 LLM 会话词汇表 | 应用外壳与传输层 + LLM 接入层 |
| 第 8 篇 | 48 个 verify 门禁、无 key 录制重放与防御性编程规矩 | 工程化基建 |
| 第 9 篇 | 按目标查文件的地图、三个动手实验与踩坑清单 | 源码地图与学习路线 |
第一部分 · 《全景与设计哲学》(原稿第 01 章)
目标:读完这一篇,你能回答三个问题——DSH 到底是个什么东西?代码放在哪儿?一条用户消息进来之后,系统里到底发生了什么?
1. 它是什么
dsh(DeepSeek Harness)是一个 agent 运行时(agent harness)。
如果把"用一个 LLM 自动写代码/查资料/跑命令"这件事拆开,你会得到一堆必须解决的工程问题:
- 模型的上下文(那一坨 token)从哪儿来、放到哪儿去?
- 模型说"我要执行 rm -rf",谁批准?在什么约束下执行?
- 聊了 200 轮之后上下文爆了,怎么压缩?
- 进程崩在模型回复的一半,重启后怎么接得上?
- 一个模型不够,能不能派子智能体并行干活?
- 想换个模型、换个沙箱、换个存储后端,要改多少代码?
DSH 把这些问题的答案全部实现成插件,而不是写死在一个"主程序"里。它的口号式的架构描述是:
产品的每一部分都是插件,包括模型适配器、工具注册表、会话日志,以及 agent loop 本身,因此每个都可以从配置替换。
不存在需要打补丁的特权内核。
—— <repo>/docs/architecture.zh.md
这句话是理解整个仓库的钥匙。你看不到 src/main.ts 里有一个"agent 类"在跑循环;你看到的是:一个循环插件、一个日志插件、一个工具注册表插件、一个模型适配插件……它们互相通过"服务键"和"类型化事件"对话,由一个更底层的框架(Cordis)在启动时组装成一棵树。
类比:传统 agent 框架像一辆焊死的汽车,你能改的只是内饰;DSH 更像港口——吊机(工具)、船(模型)、堆场(日志)、海关(沙箱与审批)是各自独立的设备,通过标准接口(服务键 + 事件)协作,换掉任何一台都不用重新设计港口。
2. 目录地图
<repo>/
├── AGENTS.md 给 AI 看的"厂规"(也给人看的最佳入门读物)
├── docs/ 官方文档:架构、术语、子系统参考、生成目录
│ ├── architecture.zh.md ★ 架构总纲(中文版)
│ ├── cordis-primer.zh.md ★ Cordis 框架五个概念
│ ├── subsystems/*.zh.md ★ 每个子系统一页参考(类型定义在这里)
│ ├── tool-catalog.md 自动生成的全部模型可见工具
│ ├── config-catalog.md 自动生成的全部插件 Config 字段
│ └── persistence-catalog.md 自动生成的全部会话事件
├── vendor/ 内联的上游框架源码(Cordis 及其依赖)
│ └── cordis/src/ ★ 框架本体:context / fiber / service / events
├── packages/ ★ 主体:80+ 个 npm 包,按"能力家族"分组
│ ├── core/ session / system-prompt / tools / agent / agent-loop / scope
│ ├── llm/ 模型词汇表 + DeepSeek provider + 重试 + token 计量
│ ├── fs/ shell/ subprocess/ terminal/ sandbox/ lsp/ web/ code-runtime/
│ ├── subagent/ workflow/ jobs/ goal/ plan/ todo/ guard/ skill/
│ ├── session/ settings/ credentials/ storage/ workspace/ context/ compaction/
│ ├── interaction/ hooks/ mcp/ preset/ bundle/ boot/ sdk/ api/ typert/ acp/
│ └── client/ host/ Web GUI 的浏览器半区与后端半区
├── apps/
│ ├── cli/ `dsh` 命令行入口(唯一受支持的启动方式)
│ └── web/ Vite 前端壳(浏览器里的 GUI)
├── python/ Python SDK + 打包成单文件 exe 的运行时
├── native/ landlock-run(Linux Landlock 启动器,C11)
├── scripts/ ★ 48 个门禁脚本(verify-*)+ 14 个生成器(gen-*)
├── snapshots/ 录制-重放测试的会话样本
└── .agents/ 给 agent 用的 skill 与"决策记录"(Agent Note)
三条经验规律,帮你快速定位代码:
packages/ 下的组别清单见 <repo>/packages/README.md(一句话职责表格,值得通读)。
3. 一次真实请求:从上到下走一遍
假设你在浏览器里输入"读一下 README.md 的第一行,并告诉我它说了什么"。下面是这条消息在 DSH 内部的完整旅程(括号里是负责这件事的包):
| 1 | 浏览器把输入通过 HTTP/WebSocket 发给本机 host 进程 | packages/host/、apps/web/ |
| 2 | host 侧把它变成一个 Agent.followup() 调用,消息进入持久化收件箱 | packages/core/agent-loop/src/agent.ts |
| 3 | 驱动机器被唤醒:状态从 idle 变 running,追加 turn/start 事件 | 同上(#run / #beginTurn) |
| 4 | 追加 user/message 事件,把消息变成模型历史的一部分 | packages/core/session/ |
| 5 | 向所有插件收集系统提示片段 + 动态上下文 + 工具 schema,走一遍 system-prompt/assemble 瀑布事件 | packages/core/system-prompt/ |
| 6 | 把渲染后的 system/tools 作为 request/header 事件落盘(“模型可见即已记录”),再用 session.deriveMessages() 从日志投影出历史 | packages/core/agent-loop/src/agent.ts |
| 7 | 冻结请求对象,交给 ctx.llm.stream();重试/超时/指标作为环绕式监听器层层包住它 | packages/llm/llm/、packages/llm/llm-retry/ |
| 8 | DeepSeek 适配器把消息翻译成 HTTP + SSE,边收边产出 StreamChunk | packages/llm/llm-deepseek/src/{serialize,sse,translate}.ts |
| 9 | 每个 chunk 变成一条临时的 agent/assistant-stream 事件 → host 推给浏览器渲染打字机效果 | packages/core/agent-loop/src/assistant-stream.ts |
| 10 | 流结束:完整的 assistant/message 事件落盘(v2 事件内嵌带时间戳的精确流) | packages/core/session/ |
| 11 | 模型回复里含 tool-call: read → 追加 tool/call 事件,按调用序 pre → execute → post 走工具流水线 | packages/core/agent-loop/src/tool-calls.ts、packages/core/tools/src/index.ts |
| 12 | 文件工具受沙箱与"读后才可改"策略约束,产出规范 JSON 值,output.render() 变成给模型的文本 | packages/fs/tool-fs/、packages/fs/fs-sandbox/ |
| 13 | tool/result 事件落盘,UI 卡片由参数 + 结果 + presentationMeta 渲染(diff 等) | packages/core/tools/src/presentation.ts、packages/client/ |
| 14 | 还有工具欠着结果或结果里有续跑意图 → 同轮下一步;否则 agent/turn-stopping → turn/end,状态回 idle | packages/core/agent-loop/src/agent.ts |
| 15 | 每一步之前都会做 flush 检查点,把日志真正写到磁盘 | packages/session/session-checkpoint-policy/ |
这张表其实就是整个仓库的目录:第 4/6/10/13/15 步是"日志派",第 5 步是"上下文派",第 7/8 步是"模型派",第 11/12 步是"工具与能力派",第 3/14 步是"循环派",第 1/2/9 步是"外壳与传输派"。后续各篇按这个划分展开。
3.1 一个关键澄清:日志才是"真相"
很多框架把"对话历史"存在一个数组里,顺手往里 push。DSH 不是。它把一切都写成类型化事件,模型的上下文是事件的投影(projection):
┌──────────────── 磁盘 JSONL(持久化)
仅追加事件日志 ───────┤
(source of truth) └── surface(模型可见面)→ deriveMessages() → 模型请求
┌── 各种 projection:turnBoundary / permissions / title / tokenMeter …
└── session/event 广播 → UI、遥测、测试重放
好处是:任何时刻你都能从日志重建出模型上一轮到底看到了什么,包括 fork、resume、调试、录制回放。代价是所有东西都必须先设计成事件——于是有了下面第一条设计哲学。
4. 三条最重要的设计决定
决定一:Model-visible ⟺ logged(模型能看到的,日志里必须有)
这条被写成运行时不变量(packages/core/session/src/invariant.ts),规定:
任何抵达模型请求的内容,都必须能从会话日志中重建。新增一项模型可见输入,就必须新增一个会话事件。
具体表现:
- 工具的 additionalContexts 必须包装成 user/message 事件,不能塞进工具结果内部(packages/core/tools/src/index.ts)。
- 动态运行时上下文(时间、沙箱模式、工作区指令)被投影成 user-role 的快照消息落盘(packages/core/agent-loop/src/runtime-context.ts)。
- 请求用的 system 文本与工具清单以 request/header 事件锚定在轮次开头(packages/core/session/src/request-header.ts)。
为什么值得这么做:LLM 应用最难调试的一点是"模型为什么这么答"。有了这条不变量,你只要拿到日志文件,就能字节级复刻出当时的请求。DSH 的测试体系(见第 8 篇)正是靠这个做到的:录一次真实会话,之后无 API key 重放。
决定二:注册是可逆的副作用(effect)
Cordis 要求每一次"我贡献点东西"(注册工具、加提示片段、监听事件、提供实现)都通过 ctx.effect() 或 ctx.on(),并返回一个撤销器:
// <repo>/packages/core/system-prompt/src/index.ts:375
section(section: PromptSection): () => void // 返回值就是"撤销这个注册"
// 典型插件写法(docs/cordis-primer.md)
ctx.effect(() => ctx.tools.register(myTool)) // 插件卸载 → 工具自动消失
这条纪律换来的能力是热重载与作用域隔离:一个 agent 的整个"世界"就是一个作用域,它的注册在 agent 销毁时精确回滚;改一个 cordis.patch.yml 文件,对应插件 fiber 重启,它的旧贡献消失、新贡献生效,而别的插件毫无察觉。
决定三:能力缝 = Service Definition + Provider + Consumer,三段一起设计
DSH 里说"seam(能力缝)"时,指的不是一个接口,而是三个角色的完整组合(docs/glossary.zh.md):
| Service Definition | 声明 ctx.<key>、词汇类型、抽象基类 | @deepseek-ai/dsh-fs(ctx.fs) |
| Service Provider | 提供具体实现(可多个、可替换) | dsh-fs-local、dsh-fs-sandbox |
| Consumer | 使用它,通常是模型可见工具 | dsh-tool-fs(read/write/edit)、dsh-tool-fs-search(glob/grep) |
一个包可以兼任多个角色(当它们本来就是一件事时),但单一角色不叫缝。加一个能力 = 一次设计三个角色。
这条决定解释了一个现象:为什么 packages/ 下有 80 多个小包而不是几个大文件。因为每个能力都按角色切开,切点即演进点。也解释了为什么把沙箱 provider 换成远程 provider,就能把 Bash、PTY、LSP 一起搬到远端——它们共享同一个"执行世界"的缝,而不是各自 fork 一份。
5. 值得学习的工程口味(先给结论,第 8 篇展开)
读 DSH 源码时,你会发现一些反复出现的"洁癖",它们不是风格偏好,而是踩坑后的规则(docs/defensive-patterns.md 明说:“每条都是这里真实发布过或差点发布的缺陷类”):
6. 小结
- DSH = 用插件框架(Cordis)组装出来的 agent 运行时;没有特权内核,loop 本身也是插件。
- 三条支柱:事件溯源的会话日志(真相)、可逆的注册副作用(隔离与热更新)、三段式能力缝(可替换)。
- 想找代码:接口看 docs/subsystems/,理由看 .agents/notes/,清单看 docs/*-catalog.md。
下一篇我们下沉一层,去看那个"没有特权内核"背后的框架到底怎么运转:本文的「Cordis 插件框架」部分。
第二部分 · 《Cordis 插件框架》(原稿第 02 章)
Cordis 是 DSH 底下那个"一切皆插件"的框架,源码以 vendored 形式放在 <repo>/vendor/cordis(上游版本与 SHA 记录在 vendor/README.md,本地改动逐条列在该文件的 “Local modifications”)。
这一篇不讲 API 清单,讲它到底解决了什么问题、代价是什么、DSH 为什么非它不可。
1. 要解决的问题:插件之间怎么认识彼此
一个 agent 运行时里,模块之间是强互相依赖的:循环要用工具注册表,工具要用文件系统,文件系统要用沙箱,沙箱要用会话……如果用 import 直连,你就得到一张改不动的网,而且永远无法替换任何一个环节。
Cordis 的解法是三条规矩:
类比:Cordis 像一个"有服务发现能力的容器"。你不是 new FileService(),你说"我要 fs",容器说"现在没有,等它出现我叫你";它出现时你自动激活,它被换掉时你自动重启。
2. Context:一个 Proxy 包裹的服务仓库
ctx 不是普通对象。看构造函数(vendor/cordis/src/context.ts:71):
constructor() {
this[symbols.isolate] = Object.create(null)
this[symbols.intercept] = Object.create(null)
const self = new Proxy<this>(this, ReflectService.handler) // ★ 一切从这里开始
this.root = self
this.fiber = new Fiber(self, {}, Object.create(null), null, () => [])
this.reflect = new ReflectService(self)
this.registry = new RegistryService(self)
this.events = new EventsService(self)
this.logger = new LoggerService(self)
return self // ★ 构造返回的是代理
}
ReflectService.handler 拦截属性读写,于是:
ctx.tools // 被拦截 → 查服务表 → 返回当前提供 tools 的那个服务实例
ctx.tools = x // 被拦截 → 只有"当初 provide 它的 fiber"才允许写
ctx.someUndefined // 被拦截 → 抛错(不是 undefined!)
最后一条很重要:DSH 依赖"声明了 inject: ['tools'] 才能读 ctx.tools"这个约束。没声明就读,代理会抛错(错误还能被 internal/get 这个 waterfall 事件拦截改写)。想"有就读、没有就算了",用严格的 ctx.get('tools')——它返回 undefined 而不抛错。这条区别在 DSH 的规范里被明确写下来(packages/AGENTS.md):
可选服务用 ctx.get(name)。ctx.<name> 只留给声明过的注入;属性代理对拓扑敏感,而严格的 ctx.get 读全局服务表。
2.1 三种派生:extend / isolate / intercept
ctx.extend({ foo: 1 }) // 原型继承出一个子 ctx,只加元数据
ctx.isolate('tools', label) // ★ 换掉某个服务的"作用域标签"
ctx.intercept('llm', { … }) // ★ 给下方加载的插件覆盖某个服务的配置
- isolate 是"同一个服务名,不同世界"。服务表按 name → label 隔离(context.ts:121),标签写在一个原型链对象里,所以只有被点名的那个服务换了命名空间,其它照旧。DSH 用它实现"一个 agent 一个工具世界"。
- intercept 是"配置注入"。子 ctx 里带一张 intercept 表,插件被装载时把它自己的 inject 也合并进去(fiber.ts:240),最终由 Service[resolveConfig] 沿原型链收集所有层的覆盖项并按顺序合并(service.ts:86)。DSH 的 preset / per-session 覆盖就靠这个。
3. Service:占据一个 ctx.<key> 的对象
vendor/cordis/src/service.ts 只有 105 行,但它是整个框架的支点:
export abstract class Service<out T = never> {
constructor(protected ctx: Context, name: string) {
name ??= this.constructor['provide'] as string
let self = this
if (self[symbols.invoke]) { // 支持"服务本身可调用",如 ctx.logger('x')
self = createCallable(…)
}
self.ctx = ctx
self.name = name
self.ctx.reflect.provide(name, self, this[symbols.check]) // ★ 注册,随 fiber 自动注销
return self
}
protected [symbols.filter](ctx: Context) { // ★ 隔离过滤:只看同 label 的实现
return ctx[symbols.isolate][this.name] === this.ctx[symbols.isolate][this.name]
}
}
三个细节值得注意:
同一个文件里还有两处容易错过、但很能体现框架功力的细节:
// service.ts:86 —— 配置合并:沿原型链收集各层 intercept,"离 root 近的先生效"
[symbols.resolveConfig](base?: T, head?: T): T {
let intercept = this.ctx[Context.intercept]
const configs: any[] = []
while (this.name in intercept) {
if (Object.hasOwn(intercept, this.name)) configs.unshift(intercept[this.name])
intercept = Object.getPrototypeOf(intercept)
}
if (base) configs.unshift(base)
if (head) configs.push(head)
return this['Config']?.merge ? this['Config'].merge(…configs) : Object.assign({}, …configs)
}
// service.ts:104 —— instanceof 被重写,因为从 ctx 取出的实现可能是 Proxy
static [Symbol.hasInstance](instance: any) {
let constructor = instance.constructor
while (constructor) {
constructor = constructor.prototype?.constructor // constructor may be a proxy
if (constructor === this) return true
constructor &&= Object.getPrototypeOf(constructor)
}
return false
}
第一条是 intercept 能层层覆盖的实现基础;第二条是"框架替你扛掉的一个坑"——你写 ctx.fs instanceof FileSystem 之所以能成立,就是因为它。
DSH 里 Service Definition 长这样(packages/fs/fs/src/index.ts,节选):
export abstract class FileSystem extends Service {
constructor(ctx: Context) { super(ctx, 'fs') }
abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
abstract writeText(target, content, expected?, signal?, sandboxPolicy?): Promise<FsWriteOutcome>
// …共 13 个原语
}
declare module '@deepseek-ai/cordis' { interface Context { fs: FileSystem } }
注意它是 abstract class 而不是 interface——因为 interface 不能占据 ctx.fs 这个运行时键。DSH 的术语表把这点写得很清楚:Service Definition 是"拥有 ctx.<key> 和词汇类型的 Cordis Service,抽象类或具体注册表,绝不是 TypeScript interface"。
4. 插件的三种形状与 Fiber 生命周期
4.1 三种形状(registry.ts:92)
type Plugin<T> = Plugin.Function<T> | Plugin.Constructor<T> | Plugin.Object<T>
// 函数:(ctx, config) => any 可带静态字段
// 类: new (ctx, config) 通常是 Service 子类
// 对象:{ apply(ctx, config) }
// 五种形状共用的元数据(Plugin.Base,registry.ts:100):
// name? 诊断与 logger 用的显示名
// Config? standard-schema 校验器(装载前先校验,不合法就不装载)
// inject? 依赖的服务;全部可用才加载
// provide? 本插件提供哪个(些)服务名 —— Loader 与工具链读它
// intercept?声明消费哪些服务的 intercept 配置 —— Loader 据此推导依赖
DSH 里最常见的写法(docs/cookbook/adding-a-tool.md):
export const name = 'my-tool'
export const inject = ['tools'] // 声明依赖 → 加载顺序自动推导
export function apply(ctx: Context) {
ctx.tools.register(defineTool({ /* … */ })) // 注册即 effect,自动可撤销
}
⚠️ DSH 踩过的坑(postmortem 0001):一个包要么用"类 + default export",要么用"函数 + 具名 inject/apply",不能混。混了之后 Loader 会丢掉函数插件的 inject,于是它以为自己不依赖任何服务,提前激活、崩在半路,现场极难诊断。规范写在 packages/AGENTS.md 第一行。
4.2 Fiber:一次装载的生命周期
ctx.plugin(P, config) 返回一个 Fiber(同时是 thenable,await 它等于等装载完成,且会把启动期错误重新抛出)。状态机在 fiber.ts:147,是个 const enum:
export const enum FiberState { PENDING, LOADING, ACTIVE, FAILED, DISPOSED, UNLOADING }
| PENDING | 等 inject 声明的服务出现 |
| LOADING | 插件回调正在执行 |
| ACTIVE | 已装载、正在提供/消费服务 |
| FAILED | 回调或配置校验抛错 |
| UNLOADING | 撤销器正在执行 |
| DISPOSED | 已移除,不能重启 |
关键内部字段(fiber.ts:185 起,逐个都有 JSDoc):
| uid | registry 内唯一 id;root fiber 是 0,dispose 后置 null |
| store | 装载期间的服务实现快照;未装载时 undefined |
| inertia | 正在进行中的装载/卸载过渡;用来避免并发重入 |
| _hooks / _disposables | 本 fiber 的事件钩子与撤销器列表 |
| _config / config | 原始配置(每次激活前重解析)/ 已解析配置 |
"依赖变了自动重启"不是魔法,而是一次 _unload() 之后再 _reload():fiber 记住它当时拿到的每个服务实现的身份;某个上游 provide() 了新实现(例如热重载了 ctx.fs 的 provider),所有依赖它的 fiber 会先倒序撤销自己的 effect、再用新依赖重新激活。DSH 的 dsh web 能改一行 cordis.patch.yml 就让某个插件换血而整个会话不中断,靠的就是这个。
拆除顺序里有个诚实的设计值得注意(fiber.ts:287 注释):inertia 自身永不 reject——_reload 与 _unload 都用自己的 ctx.logger.error 吞掉工作错误;如果它真 reject 了,唯一可能就是 logger 本身坏了,此时代码选择让 rejection 传播出去、进程崩掉,注释原话是 “process-level crash is the honest outcome”。这与 DSH 的 abortOnUnhandledRejection(packages/core/agent-loop/src/index.ts)是同一种世界观。
4.3 effect:注册必须是可逆的
ctx.effect(() => {
const off = something.subscribe(handler)
return () => off() // 返回撤销器
}, 'label') // label 进诊断树
effect() 能接受:单个撤销器、Promise<撤销器>、同步/异步可迭代(每次 yield 一个撤销器,边产边登记)。撤销器倒序执行(fiber.ts:431 的 disposables.splice(0).reverse()),且嵌套 effect 会形成诊断树(EffectMeta = { label, children }),所以卸载现场能打印出"是哪一次注册留下的"。
返回的 wrapper 还是个 AsyncDisposable,所以 using x = ctx.effect(…) 也成立。里面有一段特别硬核的注释值得看(fiber.ts:517):effect 在 execute() 跑任何插件代码之前就被推进 _disposables,这样"插件代码运行中触发的可重入 owner unload"也能看见它——这种时序细节是"注册即 effect"这句话真正的成本。
框架内部同样遵守这条规矩:ctx.on(…) 本身就是 effect(events.ts:245 注释:“Store a listener record as an effect on the current fiber”)。所以监听器一定会随 fiber 消失,DSH 里你从来看不到"忘记 removeListener"这类 bug 的容错代码——结构上不可能。
DSH 因此得到一条硬规则(packages/AGENTS.md):每个 registry 贡献都必须证明可撤销,测试要求"dispose fiber 然后观察它消失了"(HMR-safety 测试)。
4.4 epoch:依赖变化自动重启是怎么算出来的
上面"上游换了实现、下游自动重启"不是魔法,只有 12 行(fiber.ts:611):
_refresh() {
let epoch: string | boolean = ''
for (const name of Object.keys(this.inject)) {
const impl = this._store[name]
if (!impl) { epoch = INACTIVE; break } // ★ 依赖没了 → 直接不激活
epoch += ':' + impl.fiber.uid // ★ 依赖身份 = 提供它的 fiber 的 uid
}
this._setEpoch(epoch)
}
private _setEpoch(epoch) {
const oldEpoch = this._runner.epoch
if (epoch === oldEpoch) return // 没变,什么都不做
this._runner.epoch = epoch
if (this.inertia) return // 正在过渡中,交给那次过渡
this._updateState(() => {
if (epoch !== INACTIVE && oldEpoch === INACTIVE) { this.inertia = this._reload(); return FiberState.LOADING }
else { this.inertia = this._unload(); return FiberState.UNLOADING }
})
}
_unload() 跑完所有撤销器后,如果 epoch 仍然不是 INACTIVE,它会接着调 _reload()(fiber.ts:688)——"先撤销一切、再用新依赖重新激活"就是这么来的。_reload() 里还有一道 stale 守卫:await Promise.resolve() 之后先比对 epoch,若已被别人改过,就不跑插件代码(:651 注释专门解释了这一点)。
三个可读性收益:
- 判定"变没变"是纯字符串比较,代价极低,且不需要任何订阅机制。
- _store[name] 是装载时抓的实现快照,而 _checkImpl(:597)会用 impl.check(即 [Service.check])再问一次"这个实现现在算 ready 吗",check 自己抛错也只是当作"不 ready"并记日志。
- this.store 只在 _reload 时快照发布(:647),所以外部永远看不到半装配状态。
5. 类型化事件:五种派发模式,其中 waterfall 是灵魂
事件表 Events 是一个空 interface,所有插件通过 declaration merging 往里加方法(events.ts:320 的 SessionEventMap、tools/*、agent/* 都是这么来的)。派发有五种:
| emit | 否 | 注册序 | 无 | 通知(session/event、tools/start) |
| waterfall | 否 | 环绕式 | 有 | 拦截/改写(tools/execute、agent/request、approval/request) |
| parallel | 是 | 并发 | 无 | 广播给多个观察者,等它们都完 |
| serial | 是 | 注册序,遇 bail 停 | 有 | 依次征询(agent/turn-stopping) |
| bail | 否 | 注册序,遇 bail 停 | 有 | 快速否决 |
bail 值定义得很朴素(events.ts:13):非 null/false/undefined 即算"拦下了"。
5.1 waterfall 就是 around 中间件
waterfall(…args) {
const cbs = this.dispatch('waterfall', args)
const inner = args.pop() // 最后一个参数是"最内层 next"
const next = () => { const cb = cbs.shift() ?? inner; return cb(…args) }
args.push(next)
return next()
}
监听器签名是 (…args, next)。不调 next() 就等于短路接管;调了就把(可能被改过的)结果交下去。DSH 的规范因此有一条铁律(AGENTS.md):
waterfall 监听器必须调 next() 来委托;不调就短路整条链。
而有些事件的设计意图就是让你短路,DSH 会把这点写在注释里。例子:fs/write-intent 是"单槽决策"事件,fs-observation-policy 占住它并且故意不调 next(),从而保证"改一个我没读过的文件"必然失败。
5.2 事件可以带 this:作用域过滤的入口
export interface Events {
'agent/pre-step'(this: Scoped<Agent>, decision, next): Promise<PreStepDecision>
}
派发时把 agent 的 scope carrier 当 thisArg 传进去,EventsService.dispatch 会读 thisArg[Context.filter] 来筛监听器(events.ts:171):
const filter = thisArg?.[Context.filter]
return (this._hooks[name] || []).filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx))
于是"关于 A 的事件不会打扰 B"这件事,在框架层就解决了,不需要每个插件自己判断。{ global: true } 选项可以显式绕过过滤。
dispatch 还有一个细节:非 internal/* 事件都会先 emit('internal/dispatch', …)——DSH 的测试与诊断层正是挂在这里,把每一次派发都记下来(docs/cordis-api/context.md#internaldispatch)。
6. Loader 与配置层:cordis.yml 怎么变成插件树
vendor/loader + vendor/include + vendor/group 提供声明式装载。DSH 的组合语法(详见第 7 篇)落在三件事上:
配置校验失败会抛 ValidationError(fiber.ts:19),message 逐条列出 issue 与路径。DSH 的态度是"误配宁可不启动",绝不在运行时静默兜底。
7. DSH 在框架之上补的三块
Cordis 故意只管"容器 + 事件",DSH 补的是 agent 场景特有的三件事:
| scope(作用域注册) | packages/core/scope | 一个进程里 20 个 agent,工具/提示片段/监听器要按 agent 划分,且子 agent 不继承父的私有注册 |
| typert(类型远程层) | packages/typert/* | 把 @Remote() 服务方法的 TS 类型编译成可在浏览器端调用的 RPC 契约 + 双向 zod 校验 |
| invariant(运行时不变量) | packages/core/*/src/invariant.ts | 把"两个独立观测值不该分叉"的断言统一注册、在测试启动时全量校验 |
其中 scope 值得多说一句,它是 Cordis isolate 的 agent 化封装:
// packages/core/agent-loop/src/index.ts:601 起,创建 agent 时
const scopeCtx = createScope(this.runtime.ctx, agent, { label: `agent (${agent.id})` })
// scopeCtx 的注册带 agent 标签;agent 销毁时整个 scope 一次性卸载
createScope 造一个带 Context.filter 的子 ctx(所以监听器自动过滤),并给它一个 ScopeKey(DSH 的约定:活着的 agent 对象就是自己 scope 的 key)。注册表用 ScopedLayers(packages/core/scope/src/store.ts)实现"全局层 + 各 scope 层,scoped 遮蔽 global"。
DSH 明确规定只有两级,且作用域不向下继承(docs/architecture.zh.md):
每个 agent 只有一个作用域键,就是它自己;两级、扁平:agent 作用域的注册不会被子 agent 继承。
子 agent 想要父的东西怎么办?显式复制。subagent-fork-in-process 里就有把父作用域搬过去的动作(packages/subagent/subagent-fork-in-process/src/index.ts)。这条"不隐式继承"的规矩避免了"子 agent 意外拿到父 agent 的私有工具"这类极难排查的问题。
8. 一图总结
new Context() → Proxy(ReflectService.handler)
│
┌───────────────────┼────────────────────┬──────────────────┐
│ │ │ │
RegistryService EventsService LoggerService ReflectService
ctx.plugin() emit/parallel/ ctx.logger() get/provide/set
ctx.inject() serial/bail/waterfall accessor/mixin
│ │
▼ ▼
Fiber ──────── effect() 栈(倒序撤销)
PENDING→LOADING→ACTIVE→UNLOADING→DISPOSED
│
│ 插件在 ACTIVE 期间贡献:
▼
┌──────────────────────────────────────────────────────────┐
│ provide 服务 · 注册工具/提示片段 · 监听事件 · 派生 scope │
└──────────────────────────────────────────────────────────┘
▲ 上游服务被重新 provide → 依赖者自动 restart
9. 小结与思考题
- Cordis 把"模块耦合"换成"名字 + 依赖声明 + 可撤销注册",从而让替换、热重载、隔离成为架构属性而不是补丁。
- 你要接受的成本:ctx 是 Proxy(调试时要清醒)、服务可用性是动态的(fiber 会在依赖变化时重启)、waterfall 忘调 next() 会静默短路。
- DSH 的一切上层设计——会话日志、工具流水线、审批链、子 agent——都是在这五条规矩内写出来的。
思考题(答案都在后面的章节):
下一篇进入 DSH 的心脏:第 2 篇《Agent 循环与上下文装配》。
关于本系列
- 对象:deepseek-harness(DSH)源码,pnpm monorepo,Node ^22.19 || >=24,TypeScript ESM。框架层 Cordis 以 vendored 形式位于 vendor/cordis,上游 SHA 与本地改动记录在 vendor/README.md。
- 方法:先读仓库自带文档(docs/architecture.zh.md、docs/subsystems/*、生成的 docs/capability-seams.zh.md、docs/persistence-catalog.md 等),再逐包读 src/ 与 README 的 Known Limitations;文档与源码不符时以源码为准。
- 诚实声明:这是第三方源码学习笔记,不是官方文档,也不代表项目方立场。DSH 的公共 API 标注为 pre-stable,会随版本漂移;若本文某处与新版源码不符,请以源码为准。文中引用的代码片段均为学习目的的节选与删节(用 // … 标注),版权归 deepseek-ai 所有。
- 术语约定:capability seam 译「能力缝」;turn/step 译「轮次/步骤」;surface 译「模型可见面」;effect 译「副作用(可逆注册)」;scope 译「作用域」;fail closed 译「默认拒绝」。
写作风格上我尽量做到:先讲人话,再讲代码;每个判断给文件路径;坑和限制照抄官方 README 的原话,不替它美化。觉得有用的话欢迎收藏 + 关注,本系列会随版本更新。
网硕互联帮助中心



评论前必须登录!
注册