开篇总览:Everything is a Plugin
dsh 是什么、一次 turn 怎么流动、三类事件为什么必须分家
本章目标:搞清楚 dsh 到底是什么东西、它把"Agent 产品"切成了哪些零件、一次对话在这些零件之间怎么流动。读完这一章,你应该能对着
packages/目录说出每一组包在整条链路上的位置,并且知道后面 11 章分别在讲哪一段。解析基准版本:
0.1.1-rc.2,commitb150a551b8d465e31e418e1b2eaf5e79bbb7d28e。本书所有行号引用都以该 commit 为准。
1.0 从一个尴尬的问题开始
如果你写过一个 Agent,大概率写过这样一段代码:
while (true) {
const res = await llm.chat({ system, messages, tools })
messages.push(res.message)
if (!res.toolCalls.length) break
for (const call of res.toolCalls) {
const result = await runTool(call)
messages.push(toolResultMessage(call, result))
}
}
这段 20 行的循环几乎就是所有 Agent 的心脏。它足够短,短到让人产生一种错觉:Agent 产品的复杂度不在这里,而在"周边"——UI、权限、沙箱、压缩、子代理、持久化。
然后你开始加需求:
- 要在把消息发给模型之前塞一段"当前时间 + 目录树 + 最近改过的文件";
- 要在工具执行之前弹一个"是否允许写这个文件"的确认框;
- 要在上下文快满时把前面的对话压缩掉,但压缩后的历史还得能还原成原始事实;
- 要能中途插话(steering),插的话得在下一次模型请求生效,而不是等这一轮跑完;
- 要能把同一套工具换一套实现:本地 shell 换成沙箱里的 shell,本地文件系统换成远端容器的文件系统;
- 要能让另一个 Agent(子代理)用同一套机制跑在你这个 Agent 里,还得能限制它只看得见部分工具。
每加一条,那个 while 循环就要长出一个 if。加到第十条,它变成了一个谁都不敢碰的一千行函数——所有策略都硬编码在控制流里。
dsh 给出的答案很激进:那个循环本身也只是一个插件。
$ grep -n "class AgentLoop" packages/core/agent-loop/src/index.ts
296:export class AgentLoop extends Service implements AgentFactory {
它是一个 Service,声明了自己依赖哪些服务(packages/core/agent-loop/src/index.ts:297):
export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
也就是说:会话日志、系统提示词装配、工具注册表、模型调用、Agent 注册表,这五样东西对 Agent Loop 而言都是"别人提供的服务"。而 Agent Loop 自己,则通过 ctx.agents.setFactory(this) 把自己注册成"造 Agent 的工厂"。你把这个包从配置里删掉,产品里就没有 Agent 循环了;你换一个包实现同样的接口,产品的循环就换了。
这就是 dsh 的标语 "Everything is a Plugin" 的字面意思。本书要讲的,就是这句话是怎么在 20 万行 TypeScript 里被真正兑现的。
1.1 事实卡片:dsh 是什么
| 项 | 值 |
|---|---|
| 全名 | DeepSeek Harness(下文简称 dsh) |
| 仓库 | https://github.com/deepseek-ai/deepseek-harness |
| 定位 | DeepSeek 官方开源的 Agent Harness("驾具",而非"框架"或"应用") |
| 版本 | 0.1.1-rc.2(developer preview,明确声明会有破坏性变更) |
| 语言 | TypeScript,全量 ESM |
| 运行时 | Node `^22.19.0 |
| 包管理 | pnpm 11.7.0 workspace |
| 规模 | packages/ 下 50 个包组、227 个 workspace 包;packages/**/src 约 21.5 万行 TS |
| 底座 | vendored 的 Cordis(vendor/cordis,连同 cosmokit、schemastery 与 6 个 cordis 插件一起内置) |
| 入口 | apps/cli(dsh 命令)、apps/web(浏览器前端) |
| 官方文档 | docs/,包含 architecture.md、cordis-primer.md、glossary.md、subsystems/*.md(46 个子系统页)以及若干由脚本生成的关系图 |
几个第一眼容易忽略但很重要的点:
它自称 Harness,不是 Framework。 Harness 在英文里是"挽具/驾具"——套在马身上、把马的力气导出去的那套装置。模型是马,harness 是把模型接到真实世界(文件、终端、浏览器、你的工程)上的那一整套装备。这个词选得很准:dsh 不试图抽象"什么是智能",它抽象的是"围绕一次模型调用需要发生的所有事"。
它是一个 monorepo,但不是"一个大包拆成很多小文件"。 227 个包意味着每个包都有自己的 package.json、自己的依赖边界、自己的 README。包的粒度经常细到让人惊讶:dsh-tool-bash(bash 工具)、dsh-bash-local(本地 bash 执行器)、dsh-bash-sandbox(沙箱 bash 执行器)、dsh-shell(shell 能力的服务定义)是四个独立的包。这不是过度设计,是后面 1.5 节要讲的 capability seam 的必然结果。
官方文档自己就带中文版。 docs/ 下几乎每个 .md 都有配对的 .zh.md。所以本书的价值不在翻译,而在:把散落在 46 个子系统页、200 多个包 README、以及源码 JSDoc 里的信息,按"读者第一次理解一个 Agent Harness 时的顺序"重新串起来,并且每一句结论都落到具体的文件和行号上。
1.2 "Everything is a Plugin" 到底插在哪儿
一个插件能对系统做三件事,仅此三件:
┌────────────────────────────────────────────────────────────┐
│ 一个 dsh 插件能做的三件事 │
├────────────────────────────────────────────────────────────┤
│ ① 提供服务 ctx.foo = new Foo() → 别人 inject 得到它 │
│ ② 收发事件 ctx.on('x', fn) → 在别人的流程里插一脚 │
│ ③ 安装效果 ctx.effect(() => undo) → 任何注册都可逆 │
└────────────────────────────────────────────────────────────┘
这三件事分别对应三个问题:我提供什么能力、我参与谁的流程、我卸载时怎么收拾干净。
第三条是最容易被低估的。在大多数插件系统里,"注册"是单向的:你 registerTool() 之后,工具就永远在那儿了。dsh 里每一次注册都返回一个 disposer,而 ctx.effect() 保证插件被卸载时这些 disposer 会按相反顺序执行。这带来一个很实际的能力:运行时热插拔。你可以在 Web UI 里关掉某个插件,它注册的工具从下一次系统提示词装配里消失、它的事件监听器停止生效、它占用的资源被释放,而 Agent 循环连一次都不用重启。
第二条的形态比 ctx.on 三个字看起来丰富得多。Cordis 提供四种事件派发模式:
| 模式 | 是否 await | 派发顺序 | 有返回值 | 典型用途 |
|---|---|---|---|---|
emit |
否 | 注册顺序 | 否 | 广播通知(agent/status、session/event) |
waterfall |
否 | 注册顺序 | 是 | 改写流程中的数据(agent/pre-step、tools/execute) |
parallel |
是 | 全部并发 | 否 | 并发副作用(session/flush) |
serial |
是 | 注册顺序 | 是 | 有序检查点(agent/turn-stopping) |
其中 waterfall 是 dsh 扩展性的主力。它的语义是"责任链 + 洋葱模型":监听器签名是 (...args, next),你可以
- 直接返回一个值 → 短路,后面的监听器和默认实现都不执行;
await next()拿到"如果我不干预会发生什么",再对结果做包装 → 环绕;- 修改参数后
next()→ 改写输入。
一旦理解了这一点,dsh 的架构图就可以简化成一句话:dsh 是一条由若干 waterfall 串成的流水线,每个扩展点都是一个可以被任意插件包裹的洋葱层。
后面三个关键的 waterfall 值得先记住名字,本书第 4、6、7 章会各花一节讲透:
packages/core/system-prompt/src/index.ts:31 'system-prompt/assemble' 装配系统提示词
packages/core/agent/src/runtime-types.ts:231 'agent/pre-step' 决定这一步进不进、带什么消息进
packages/core/tools/src/index.ts:163 'tools/execute' 环绕工具真正的执行
1.3 一次对话的完整生命周期
先建立词汇。dsh 的 docs/glossary.md 把循环层级定义得非常干脆:
- turn(轮):一次"把已接纳的输入排空"的过程。它在模型和工具都停下来、或者某个终止策略介入时结束。
- step(步):一次模型请求,加上这次响应引发的工具执行。一个 turn 包含 0 个或多个 step。
- round(回):更外层的策略迭代(比如 goal round、Ralph round)。它属于那个策略,不是每个 turn 都算一 round。
注意 "一个 turn 可以包含 0 个 step"。这不是抽象洁癖:如果 agent/pre-step 里的某个插件(比如权限插件)决定拒绝这一步,turn 会开了又关,日志里留下 turn/start / turn/end,中间没有任何 step/*。这一点在读会话日志时非常重要。
官方 docs/architecture.md 给出的 turn 流程,是整个 dsh 最该背下来的一段:
turn/start
claim next-step input plus one queued message
assemble prompt sections + tool schemas
-> agent/pre-step reject | enter(messages)
reject, or a first enter rewritten empty -> close the turn with no step
step/start
append entered messages as user/message
derive model history from the log
agent/request -> llm/stream -> assistant/chunk* -> assistant/message
tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result*
step/end
tools owe another request, or next-step input arrived -> claim -> next step
-> agent/turn-stopping
turn/end
这段伪代码里藏着五个设计决定,我们逐条对照源码验证。
① 输入不是参数,是"收件箱"
模型循环不接受"一条用户消息"作为参数。用户消息进的是 Inbox,而 Inbox 有两个有序队列:next-turn 和 next-step。
// packages/core/agent/src/types.ts
type InboxTarget = 'next-turn' | 'next-step'
Agent 上因此有三个投递方法,它们只是同一个 send(message, target, wakeup) 的三种预设(packages/core/agent-loop/src/agent.ts:113-132):
followup(input: UserMessage): void { this.send(input, 'next-turn', true) } // 排下一轮,唤醒
steer(input: UserMessage): void { this.send(input, 'next-step', true) } // 插到下一步,唤醒
inject(input: UserMessage): void { this.send(input, 'next-step', false) } // 插到下一步,不唤醒
这三行代码解决了一个所有 Agent 产品都会遇到的难题:用户在模型正在流式输出时又发了一句话,怎么办? dsh 的答案是分三种情况给三个不同的 API:
- 这是新任务 →
followup,等这一轮干完,单独开一轮; - 这是对当前任务的纠正 →
steer,下一次模型请求就带上它,不用等工具全跑完; - 这是系统要塞的上下文(文件变更通知、子目录 AGENTS.md、定时任务提醒)→
inject,跟着下一次请求捎过去,但绝不因为它而唤醒一个空闲的 Agent。
claim 的规则同样精确:claim(target) 取走全部 next-step 输入,加上——只在 turn 边界时——一条 next-turn 消息。所以"一轮只处理一条用户消息"是被 claim 规则保证的,不是靠调用方自觉。
② 提示词是"装配"出来的,不是拼字符串
preStep 里第一件事就是装配(packages/core/agent-loop/src/agent.ts:230):
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
systemPrompt 是一个注册表:插件用 section() 注册提示词段落、用 context() 注册运行时上下文段、用 variable() 注册可插值变量、用 tools() 注册工具 schema 提供者。assemble() 把它们排序、插值、跑一遍 system-prompt/assemble waterfall,产出一个 PromptAssembly(既含渲染好的 system 文本,也含要发给模型的 tool schemas)。
第 6 章会完整拆这个装配过程。这里先记住一个结论:dsh 里没有任何一个地方存着"完整的 system prompt 模板"。它是每一步、按当前作用域、动态装出来的。这也是"同一个 Agent Loop 能同时驱动一个全能主代理和一个只能读代码的子代理"的原因。
③ 历史是"推导"出来的,不是存出来的
// packages/core/agent-loop/src/agent.ts:340
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
)
this.session.deriveMessages()——每一次请求,消息历史都是从追加式事件日志里重新推导的。Session 不维护一个 messages: Message[] 数组,它维护的是 SessionEvent[]:
// packages/core/session/src/types.ts:236
export interface SessionEventMap {
'turn/start': { turn: number }
'turn/end': { turn: number; reason: TurnEndReason }
'step/start': { turn: number; step: number }
'step/end': { turn: number; step: number }
'user/message': UserMessage
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
'assistant/message': { turn: number; step: number; message: AssistantMessage; ... }
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
'tool/result': { turn: number; step: number; message: ToolResultMessage; ... }
'todo/write': { todos: TodoItem[] }
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
'request/context': RequestContext
'session/end-seed': Record<string, never>
}
这个 map 的注释里写着 dsh 最硬的一条不变式:
The append-only source of truth for an agent interaction. Message history is derived from this log. Every event is lossless JSON and sequence numbers stay contiguous, including raw chunks, so persistence can store the canonical log verbatim.
连原始流式 chunk 都逐条落日志(assistant/chunk)。代价是日志体积,收益是:任何时刻的会话都能被逐 token 重放,fork 一个会话就是"复制前 N 个事件",压缩不是删历史而是往日志里追加一个"替换视图"的事件。第 5 章会专门讲这套 append-only 日志和它派生出的 surface 机制。
配套的另一条规则同样重要——docs/architecture.md 原话是 "Model-visible means logged":任何进入模型请求的东西,都必须能从会话日志重建。这条规则一旦确立,"为什么模型会这么说"这个问题就永远有确定答案。
④ 工具执行是一条有四个扩展点的流水线
tool/call (先落日志)
→ tools/pre-execute waterfall 钩子、权限、沙箱决策:allow | deny | ask
→ 注册的 monotonic guards 只能拒绝或弃权,不能放宽
→ tools/execute waterfall 环绕真正的 execute():超时、重试、指标
→ 工具本体 execute()
→ tools/post-execute waterfall accept | block | replace | 追加上下文
→ finalizeContent 工具自己最后一次约束内容形状
→ tools/result emit 观察不可变的最终结果
tool/result (再落日志)
四个扩展点的声明位置:
packages/core/tools/src/index.ts:152 'tools/pre-execute' waterfall
packages/core/tools/src/index.ts:163 'tools/execute' waterfall
packages/core/tools/src/index.ts:175 'tools/post-execute' waterfall
packages/core/tools/src/index.ts:197 'tools/result' emit(同步通知,只读)
注意 tool/call 在执行之前就落日志,tool/result 在拿到最终结果之后落日志。这个顺序意味着:进程在工具执行中途崩溃,日志里会留下一个没有结果的 tool/call——一个可被识别、可被恢复策略处理的事实,而不是一段消失的历史。
第 7 章会把这条流水线连同 ToolExecution / PreToolDecision / PostToolDecision 的类型一起拆开。
⑤ 结束一轮需要"谈判"
turn 结束前有一个 serial 检查点(packages/core/agent-loop/src/agent.ts:295-299):
if (turnEnds && this.inbox.nextStep.length === 0) {
await this.dispatch.serial('agent/turn-stopping', { turn, signal })
signal.throwIfAborted()
}
if (turnEnds && this.inbox.nextStep.length === 0) break
看清楚:同一个条件判断了两次,中间隔着一次 serial 派发。为什么?因为 agent/turn-stopping 的监听器可以往 inbox 里塞东西。goal(目标续跑)、plan mode、hooks 都可以在"这一轮本来要结束了"的时刻说一句"还没完,再跑一步"。第二次检查读的是被监听器改过之后的 inbox。
这就是 dsh 处理"自动续跑"这类策略的方式:不在循环里加 if (goal.active),而是提供一个终止检查点,让策略插件在那儿争取一次机会。
1.4 三类事件:为什么必须分家
dsh 的事件不是一个大池子,是三个语义完全不同的家族。搞混它们是读这套代码最常见的坑。
| 家族 | 例子 | 存在形态 | 回放行为 | 谁该用 |
|---|---|---|---|---|
| Session event | turn/start、user/message、tool/result |
持久化事实,落在 append-only 日志里 | 会被重放 | 任何需要"这件事真的发生过"的地方 |
| Agent event | agent/status、agent/pre-step、agent/error |
进程内实时协调,不落盘 | 不重放 | UI 更新、实时控制、拦截决策 |
| Capability event | fs/write-intent、llm/stream、tools/execute |
能力缝隙上的扩展点 | 不重放 | 换实现、加策略、加观测 |
判断一个新事件该属于哪一家,只需要问一句:"重放这个会话时,这件事需要再次为真吗?" 需要 → session event;不需要 → agent/capability event。
turn/start、step/start 这样的边界为什么是 session event 而不是 agent emit?因为你必须能从日志里看出"第 3 轮第 2 步发生了什么",否则日志就只是一堆消息,没有结构。
反过来,agent/status 为什么不落盘?因为"当前是否在跑"是进程状态,重放一个历史会话时它毫无意义——重放时 Agent 当然是空闲的。
这个划分在 docs/event-producer-consumer.md 里被生成成了一张完整的矩阵表:每个事件的派发模式、声明位置、谁派发、谁监听。比如:
| `session/event` | emit | packages/core/session/src/index.ts:76 | session | acp, agent-instructions, agent-loop, compaction, ... 26 个包 |
| `tools/pre-execute` | waterfall | packages/core/tools/src/index.ts:152 | tools | hooks-claude-code, hooks-codex, tool-jobs |
| `llm/stream` | waterfall | packages/llm/llm/src/index.ts:65 | llm | agent-loop, llm, llm-replay, session-checkpoint-policy, ... |
session/event 有 26 个监听方——这个数字本身就说明了 append-only 日志在这套架构里的中心地位:几乎每个子系统都在观察同一条事实流。
1.5 Capability Seam:dsh 最值得学的一个概念
前面说 dsh-shell / dsh-bash-local / dsh-bash-sandbox / dsh-tool-bash 是四个包。现在解释为什么。
docs/glossary.md 对 capability seam(能力缝隙) 的定义:
a swappable capability with three roles: a Service Definition(拥有
ctx.<key>和词汇类型的 Cordis Service——抽象类如ShellExecutor,或具体注册表如WebRuntime,永远不是 TypeScriptinterface), one or more Service Providers, and one or more Consumers that inject the service.
三个角色,缺一不成缝:
┌──────────────────────────────┐
│ Service Definition │ dsh-shell
│ 拥有 ctx.shell 与词汇类型 │ abstract class ShellExecutor
└───────────┬──────────────────┘
│ 实现 │ inject
┌───────────┴──────────┐ ┌────────┴─────────┐
│ Service Providers │ │ Consumers │
│ dsh-bash-local │ │ dsh-tool-bash │
│ dsh-bash-sandbox │ │ (模型看到的 │
│ dsh-pwsh-sandbox │ │ bash 工具) │
└──────────────────────┘ └──────────────────┘
为什么"服务定义必须是抽象类而不是 interface"?因为 Cordis 的服务注册是运行时行为:ctx.shell 这个键、它的依赖声明、它的生命周期都需要一个运行时存在的东西来承载。TypeScript 的 interface 编译后什么都不剩。这是一条很具体的、由运行时模型倒推出来的编码约束——dsh 的 AGENTS.md 把它写成了硬规则。
这个三角的实际威力在于:换一个 provider,整个产品的行为都变了,而没有一行 consumer 代码需要改。 packages/bundle/base/cordis.patch.yml 里默认挂的是 dsh-bash-sandbox(沙箱执行器);把这一行换成 dsh-bash-local,模型看到的 bash 工具一模一样、prompt 一个字都没变,但命令跑在了没有沙箱约束的本机上。同理,把 dsh-fs-local 换成远端文件系统 provider,所有文件工具、read-before-edit 检查、diff 渲染全部自动作用在远端。
docs/capability-seams.md 用生成的图列出了全部缝隙。挑几个最重要的:
ctx 键 |
能力 | 典型 provider |
|---|---|---|
ctx.llm |
模型调用 | dsh-llm-pi-ai(DeepSeek 适配器)等 |
ctx.fs |
文件系统 | dsh-fs-local、dsh-fs-sandbox |
ctx.shell |
shell 执行 | dsh-bash-local、dsh-bash-sandbox、dsh-pwsh-sandbox |
ctx.subprocess |
子进程 | dsh-subprocess-local |
ctx.sandbox |
沙箱 | dsh-sandbox-local(POSIX Landlock / Windows ACL) |
ctx.approval |
人工审批 | dsh-user-approval |
ctx.sessionPersistence |
会话持久化 | JSONL / SQLite |
ctx.compaction |
上下文压缩 | dsh-compaction-basic |
ctx.subagents |
子代理 | spawn / fork / Claude Code / Codex |
ctx.jobs |
后台任务 | dsh-jobs-local |
ctx.web |
Web 运行时 | dsh-web-* |
第 8 章会把这个三角当成一节主课来讲:怎么设计一条新缝隙、为什么"服务边界"比"文件夹划分"重要得多。
1.6 仓库地图
packages/ 下 50 个包组。按在链路上的位置,可以归成七片:
核心骨架(core/)
| 包 | 拥有 |
|---|---|
core/session |
append-only 会话日志与内存 store(ctx.sessions) |
core/system-prompt |
提示词段落与工具 schema 装配(ctx.systemPrompt) |
core/tools |
作用域化的工具注册表与执行流水线(ctx.tools) |
core/agent |
Agent 接口、活跃 Agent 注册表、agent/* 事件词汇(ctx.agents) |
core/agent-loop |
唯一一个具体的循环实现(ctx.agentLoop) |
core/scope |
作用域原语——唯一一个不是 service 的核心包 |
core/scope 是个很能说明设计品味的例子:它是无依赖的纯库(createScope / scopeOf / scopeTarget),位置刻意压在 session/ 和 system-prompt/ 下面,就为了让它们能用作用域而不形成循环依赖。第 6、7 章会看到作用域怎么让"每个子代理有自己的工具集和人格"成为一行注册的事。
启动与装配:boot/(app-boot、cmdline)、bundle/(base、web-app、headless)。第 3 章的主角。
模型侧:llm/(llm、llm-pi-ai、llm-retry、token-meter……)。
能力实现:fs/、shell/、subprocess/、sandbox/、terminal/、code-runtime/、e2b/、lsp/、mcp/、storage/、workspace/。
策略与治理:interaction/(审批、权限预设)、guard/、hooks/、plan/、compaction/、spill/、identity/、credentials/、settings/。
编排:subagent/、workflow/、jobs/、schedule/、goal/、todo/、skill/。
对外接口:web/、api/、typert/、sdk/、acp/、client/、host/、extensions/。
typert/ 值得单独提一句:它是 dsh 自己的类型化 RPC 生成器——从 TypeScript 服务类型生成前后端契约。这就是为什么 Web UI 能直接"调用"运行在 Node 侧的服务,而中间没有手写的 REST 层。第 12 章会讲。
1.7 dsh 和别的 Agent 产品差在哪
先把边界划清楚:Claude Code 是闭源产品,我不会去猜它的内部实现;下面的对比只针对公开可见的设计取向,以及 dg-ai-notes 已经解析过的 pi。
vs. 典型的"三层架构" Agent(如 pi)
pi 的组织方式是经典的分层:底层类型 → 中层能力 → 上层应用,依赖方向单向向上。这套结构的好处是心智负担低——你能画出一张静态的层次图,然后所有代码都在图上有唯一位置。
dsh 换了一个轴。它的主结构不是"层",而是"缝隙 + 流水线":
- 静态看,它是一堆 capability seam,每条缝隙都可以独立换实现;
- 动态看,它是一条由 waterfall 串起来的流水线,每个扩展点都能被包裹。
代价是:你没法画一张"谁在谁上面"的完整层次图并靠它理解全部行为,因为很多行为取决于运行时装了哪些插件、按什么顺序。收益是:产品形态(CLI / Web / headless / 嵌入 SDK)、能力实现(本地 / 沙箱 / 远端容器)、策略(权限、压缩、审批)可以三个维度独立组合,而不需要在代码里预留 if。
vs. 大多数"可插拔"Agent 框架
很多框架的"插件"只能插在预留好的几个洞上:注册工具、注册模型 provider、注册中间件。dsh 的插件能插在任何被声明成 typed event 的地方,而"任何"包括:
- 模型请求的配置本身(
agent/requestwaterfall,可以按 step 换模型); - 流式响应的整条流(
llm/streamwaterfall,llm-replay就是靠它做录制回放的); - "这一步到底要不要发出去"(
agent/pre-step,可以直接 reject); - "这一轮能不能结束"(
agent/turn-stopping); - 甚至 Cordis 本身——
tool-cordis让模型可以在运行时读写自己的插件配置(第 12 章)。
代价必须诚实地说出来。 这套架构有三笔明确的账:
- 理解成本高。 想知道"我这次请求为什么带了这段 prompt",你得知道有哪些插件注册了 section,而不是打开一个模板文件。dsh 用大量生成的关系图(
composition.md、capability-seams.md、event-producer-consumer.md)来对冲这个成本——本身就说明了成本存在。 - 包数量爆炸。 227 个包,每个都有 README、依赖边界、发布约束。仓库里有一大批
verify-*脚本(verify-package-invariants、verify-module-graph、verify-doc-graphs……)专门守这些边界,pnpm run hygiene一次跑完。 - waterfall 的调试是非线性的。 一个 waterfall 里某个监听器忘了调
next(),行为就是"某个功能静默失效"。这也是 dsh 的AGENTS.md把"waterfall 监听器必须调用next()"写成硬规则的原因。
1.8 先把它跑起来
读源码之前,强烈建议先让它在你机器上跑一遍——很多设计只有在你亲手改一行 cordis.patch.yml 之后才会突然合理。
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
pnpm dsh web # 启动 Web 形态
几条对读源码特别有用的命令:
pnpm dsh --profile web --dump-config # 打印最终装配出来的插件树(不启动)
pnpm dsh --profile web --dump-default-config # 打印默认配置
pnpm run typecheck # 全量类型检查
pnpm run test # vitest
pnpm run doc-sync # 重新生成文档里的关系图
pnpm run hygiene # 一次跑完全部边界校验
--dump-config 是本书第 3 章的主要工具。它把"profile 的 bundles 按顺序 patch、然后 profile 自己的 cordis.patch.yml、然后 $DSH_HOME/cordis.patch.yml、最后 --patch overlay"这套分层的最终结果打平给你看。第一次看到那个输出,你会对"227 个包是怎么变成一个运行中的产品"有非常直观的感受。
1.9 本书结构与读法
12 章分成四段:
第一段:底座(第 2–3 章)
2. Cordis 底座——Context、Service、Fiber、Effect、四种派发
3. 启动装配——profile、bundle、cordis.patch.yml 分层
第二段:主干(第 4–7 章)
4. Agent 与 Agent Loop——turn / step / inbox / agent/pre-step
5. 会话日志——append-only 事件、deriveMessages()、surface、fork、持久化
6. 系统提示词与工具 schema 装配
7. 工具系统与执行流水线
第三段:能力与治理(第 8–10 章)
8. Capability Seam 三角——fs / shell / subprocess / sandbox
9. 沙箱与权限审批——sandbox-policy、approval、Landlock
10. 上下文压缩——compaction-basic、token-meter、pruner、spill
第四段:编排与对外(第 11–12 章)
11. 子代理、工作流与后台任务
12. 对外接口与自我修改——Web、typert RPC、SDK、ACP、hooks、tool-cordis
每章的写法都一样:是什么 → 怎么做 → 为什么这么做 → 可以带走什么。所有结论都带 文件路径:行号,你随时可以在自己的 checkout 上验证;如果你的 commit 比 b150a551 新,行号可能漂移,但符号名和结构结论应该仍然成立。
建议的读法:
- 只想搞懂 Agent 怎么跑的 → 4 → 5 → 7,其余按需;
- 想给 dsh 写插件 → 2 → 3 → 8,然后按你要插的扩展点查对应章;
- 想抄架构做自己的产品 → 1 → 2 → 8 → 10,这四章讲的是可迁移的方法论;
- 想读完 → 按顺序,前面章节建立的词汇后面会反复用。
本章小结
- dsh 是 DeepSeek 官方的开源 Agent Harness,227 个包,21.5 万行 TS,底座是 vendored 的 Cordis。
- "Everything is a Plugin" 是字面意思:连 Agent Loop(
packages/core/agent-loop)都是一个声明了static inject = ['agents','sessions','llm','tools','systemPrompt']的插件服务。 - 插件只能做三件事:提供服务、收发事件、安装可逆效果。第三件(可逆)是热插拔的基础。
- 四种派发模式里,
waterfall是扩展性的主力:它同时提供短路、环绕、改写三种能力。 - 循环层级是 turn > step,一个 turn 可以有 0 个 step;输入走 Inbox 的
next-turn/next-step两个队列,对应followup/steer/inject三种投递语义。 - 两条铁律:历史从 append-only 日志推导(
deriveMessages()),模型可见即已记录(Model-visible means logged)。 - 事件分三家:session(持久事实)、agent(实时协调)、capability(扩展点)。判据是"重放时这件事需不需要再次为真"。
- Capability Seam 是最值得抄走的概念:Service Definition(抽象类)+ Provider + Consumer,换 provider 就换掉整个产品的一层能力。
下一章我们下潜到底座:Cordis 的 Context 到底是什么,ctx.effect() 为什么能保证可逆,Fiber 的状态机怎么处理"插件加载失败"。
本章引用的源码位置
| 位置 | 内容 |
|---|---|
packages/core/agent-loop/src/index.ts:296-297 |
AgentLoop 服务与 static inject |
packages/core/agent-loop/src/agent.ts:113-132 |
send / followup / steer / inject |
packages/core/agent-loop/src/agent.ts:230 |
每一步装配提示词 |
packages/core/agent-loop/src/agent.ts:295-299 |
agent/turn-stopping 双重检查 |
packages/core/agent-loop/src/agent.ts:340-341 |
deriveMessages() 推导历史 |
packages/core/session/src/types.ts:236 |
SessionEventMap |
packages/core/tools/src/index.ts:152/163/175/197 |
工具流水线四个扩展点 |
packages/core/system-prompt/src/index.ts:31 |
system-prompt/assemble |
packages/core/agent/src/runtime-types.ts:231/244/278 |
agent/pre-step / agent/request / agent/turn-stopping |
packages/llm/llm/src/index.ts:65 |
llm/stream |
docs/architecture.md |
turn flow、事件分家、Model-visible means logged |
docs/glossary.md |
capability-seam、turn / step / round |
docs/event-producer-consumer.md |
事件生产者/消费者矩阵 |
apps/cli/README.md |
profile / bundle 分层与 CLI 语法 |