云计算百科
云计算领域专业知识百科平台

DeepSeek Harness(DSH)源码精读 01:DSH 是什么,以及它凭什么「一切皆插件」

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)

三条经验规律,帮你快速定位代码:

  • 想知道某个能力的接口长什么样 → 去 docs/subsystems/<名字>.zh.md(那里是类型定义的权威),或直接搜 packages/<组>/<包>/src/types.ts。
  • 想知道某个行为为什么这样设计 → 搜 .agents/notes/,仓库要求"非平凡改动必须附决策记录"。
  • 想知道模型能看到哪些工具、有哪些事件、有哪些配置 → 看 docs/ 下三个自动生成的目录文件,它们由 scripts/gen-*.ts 从源码扫描生成,并且有门禁保证不腐化。
  • 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 明说:“每条都是这里真实发布过或差点发布的缺陷类”):

  • 品牌类型(branded types)替代裸字符串:SessionId、FsVersion、ToolCallId 都是 Branded<'…'>,跨边界不可能传错(packages/util/brand)。
  • 不重复造轮子,但内部框架整体 vendor:Cordis 及依赖以源码形式放进 vendor/,并逐条记录本地改动与上游 SHA(vendor/README.md)。
  • 配置错误宁可不启动:误配在加载时大声失败;不静默跳过缺失的引用目标。
  • 可替换的东西不留特权:包括 loop 本身。扩展插件只依赖 dsh-agent(接口),永不直接依赖 dsh-agent-loop(实现),所以循环是可换的。
  • 闭集用 assertNever,可合并扩展的集合留具名 default 分支——枚举的封闭性由编译器守。
  • 禁止在配置里内联凭据或端点环境变量,由 verify-config-source-ownership 门禁机械检查。
  • 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 的解法是三条规矩:

  • 插件不 import 彼此,只按名字取服务:ctx.tools、ctx.fs、ctx.llm。名字(叫 service key)是唯一的寻址方式。
  • 需要哪些服务用 inject 声明出来,框架负责"等它们出现",于是加载顺序由依赖图自动决定,没人写启动脚本。
  • 一切注册都是可撤销的副作用(effect),插件卸载时自动倒序回滚,于是热重载与隔离是免费得到的。
  • 类比: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]
    }
    }

    三个细节值得注意:

  • 注册发生在构造函数里。你 new FileSystem(ctx, 'fs') 的瞬间,ctx.fs 就生效了;fiber 卸载时自动注销。
  • [Service.check] 是可用性谓词。DSH 用它表达"我这个实现只在某些条件下算 ready"(比如 provider 是否配了 key)。
  • [symbols.filter] 实现隔离。DSH 的多 agent 世界靠它做到"子 agent 读到的 ctx.fs 是自己的那份"。
  • 同一个文件里还有两处容易错过、但很能体现框架功力的细节:

    // 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 }

    状态含义(全部出自源码 JSDoc)
    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/* 都是这么来的)。派发有五种:

    模式是否 await顺序有返回值典型用途
    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 篇)落在三件事上:

  • 条目(entry):{ id, name: 包名, config, disabled }。id 是 patch 的靶子,name 是解析出的模块,config 会被该插件声明的 Config schema(schemastery/zod,standard-schema)校验后再传进去。
  • !!js 表达式:只有 config 与 disabled 两个位置支持(vendor/include/src/index.ts),求值发生在该条目注入服务激活之后、以该插件自己的 ctx 为作用域。所以你能在 patch 里写 disabled: !!js process.platform === 'win32'。注意 !!js 而不是 !js。
  • patch 层:按 id 替换整条 config(不合并!)或 insert 新行。这条"整段替换"的规矩解释了为什么 dsh-base 的 patch 里,模式相关的行都完整重述一遍。
  • 配置校验失败会抛 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 要求"每个 registry 贡献必须有 HMR-safety 测试"?(→ 撤销器漏登记会怎样)
  • tools/execute 为什么是 waterfall 而不是 emit?(→ 超时、重试、指标都要环绕)
  • 子 agent 不继承父 scope,那 subagent_fork 的"继承对话"是怎么做到的?(→ seed 事件,见第 3、6 篇)
  • 下一篇进入 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 的原话,不替它美化。觉得有用的话欢迎收藏 + 关注,本系列会随版本更新。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » DeepSeek Harness(DSH)源码精读 01:DSH 是什么,以及它凭什么「一切皆插件」
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!