DeepSeek Harness 完整原理学习文档
DeepSeek Harness 是一个构建在 vendored Cordis 之上的插件式 Agent 框架——"一切皆插件"。
本文档基于当前 master(47f943859b)源码,按“设计公理 → Cordis 与组合 → 事件日志与不变量 →
循环与工具 → 上下文与作用域 → 持久化与恢复 → 接缝与扩展点”的路径组织。所有机制均标注
文件:行 引用以便核对。
0全局心智模型
三层视角
五条设计公理
一切皆插件
循环、模型、工具、持久化、提示和交互都挂在 Cordis 插件树上;应用层负责组合,不把能力硬编码进循环。
日志是唯一真相
内存状态是可丢弃投影。崩溃恢复、回放、压缩和遥测都从有序事件前缀重建。
模型可见 ⟺ 已记录
到达模型请求的输入必须能从日志重建;新的模型可见输入必须先拥有对应的会话事件。
能力接缝三角色
每个可替换能力由 Service Definition、Provider、Consumer 完成,三者缺一不可。
注册是可逆效果
注册监听器、服务、工具和 provider 都通过 ctx.effect() / ctx.on() 完成,并返回可执行的 disposer。
核心不变量(Invariants)
| 不变量 | 含义 | 由谁保证 |
|---|---|---|
| Model-visible ⟺ Logged | 到达模型的任何输入必须可从日志重建 | session.append() + 不变量校验 |
| seq 连续 | seq = log.length,无空洞无重复 | Session.append (session/index.ts:629) |
| turn/step 必闭合 | 每个 turn/start 必有配对 turn/end | 崩溃修复 interruptedTurnClosers |
| tool/call 必配对 | 正常收敛或冷恢复后的每个 assistant 工具调用都有对应 tool/result;调度器内部失败可暂时留下未配对 call,由崩溃修复补齐 | 中止路径合成错误结果;内部失败交给 cold repair |
| 注册即副作用 | 每个贡献走 ctx.effect(),可逆 | Cordis + dsh-scope |
| 显式 > 隐式 | 跨包边界的默认值是显式 resolve(request) 步骤 | 各 Service 方法 |
session/event;
live(存活)事件只是进程内扩展点(如 agent/*、tools/*),不落盘。
分清这两者是理解恢复机制的前提。
不变量的三层强制
surfaceOp 的模型可见事件必须在类型层提供表面选项;声明合并扩展 SessionEventMap,让源码获得类型事件。持久化读侧再由生成的 KNOWN_SESSION_EVENT_TYPES catalog 判断当前构建是否认识该类型。Session.append() 快照 JSON、校验 surface 元数据、深冻结事件,再推进连续 seq;失败时日志不变。agent-loop/src/invariant.ts 独立派生 messages,并与 request header 比较;请求随后深冻结。不能只相信某个调用者“正确传入”。因此,新的模型可见输入必须对应新的会话事件;agent/request 可以改变路由和调用配置,但不能偷偷改变 messages。
1底层完成 · 启动与插件组合
"一个运行中的 dsh"本质是一棵在启动时按有序层组合而成的插件树。组合的产物是一份
cordis.yml 配置行(entry)列表,Loader 据此导入插件、激活 fiber。
1.0 Cordis 插件内核:服务、依赖与效果
在 Harness 中,插件不是一段孤立的初始化函数,而是向当前 Context 注册服务与事件的可拆卸单元。
理解下面四个概念,再读后面的 profile、agent 和工具流程会更容易:
| 概念 | 在运行时做什么 | 学习时要抓住的约束 |
|---|---|---|
| Plugin / Service | 插件向 ctx 提供服务对象,服务进入上下文注册表 | 服务是否可用取决于 fiber 是否激活,不是看模块是否被 import |
| ctx.inject() | 声明依赖并等待目标服务出现,再运行注册逻辑 | 依赖缺失应保持 pending 或在最早可判定处报错,不能静默跳过 |
| ctx.effect() | 登记需要拆卸的副作用,返回 disposer | 监听器、注册表条目、定时器和外部句柄都要有所有者 |
| typed event | 通过声明合并扩展事件映射,再由 emit/waterfall/parallel/serial 派发 | Waterfall 监听器必须调用 next() 才会委托给后续实现 |
示意:插件的服务 + 可逆注册
class ExamplePlugin {
static inject = ['session']
apply(ctx) {
ctx.provide(ExampleService, new ExampleService(ctx.session))
return ctx.effect(() => ctx.on('agent/status', onStatus))
}
}
1.1 Profile 与 Bundle
Profile(配置档)
存于 Harness 主目录 $DSH_HOME/profiles/<name>。声明其堆叠的 bundle 列表(dsh.profile.bundles)、
持有 cordis.patch.yml。web 与 headless 是随仓模板。
Bundle(包)
Cordis 配置行 + 所挂载代码的分发格式,保证其插入的内容仍可被上层 patch 修改。
dsh-base 是每个 profile 的第一层(模型适配器、工具、持久化、沙箱、审批、设置)。
1.2 层的应用顺序
CLI 启动时所有层都被 patch 到一个空 entry 列表上,顺序如下(apps/cli/src/profile-boot.ts:131-170):
dsh.profile.bundles 顺序(dsh-base 必为首层)cordis.patch.yml$DSH_HOME/cordis.patch.yml,机器本地偏好,对每个 profile 都生效--patch 覆盖命令行参数顺序patch 通过 id 定位一行并整体替换其 config,或插入新行。要查看本机实际启动的树:
$ 命令
dsh --profile web --dump-config # 组合可导出的配置层,输出带来源注释的 YAML,不 boot、不求值 !!js
1.2.1 逻辑层与实际应用步骤
原理上可以把组合分成“核心能力、模式能力、用户覆盖”三类;源码执行时还会把覆盖拆成多个有序步骤,并附加 launcher 派生信息。 因此下面的五步是运行时应用步骤,不是五种互相独立的架构层:
| 运行时步骤 | 对应内容 | 理解方式 |
|---|---|---|
| 1 · Bundle | dsh-base 及 profile 声明的 bundle | 核心能力与模式能力的物理装载 |
| 2 · profile patch | profile 自己的 cordis.patch.yml | 当前 profile 的默认覆盖 |
| 3 · home patch | $DSH_HOME/cordis.patch.yml | 机器级偏好,对所有 profile 生效 |
4 · --patch | 命令行传入的最后覆盖 | 本次启动的临时覆盖 |
| 5 · launcher 派生 | 配置型 agent 身份与遥测开关 | 启动器根据会话事实补出的运行时信息 |
1.2.2 组合算法与最终启动层
composeEntries()(packages/boot/app-boot/src/profile.ts:405-420)只负责把传入的 patch 列表折叠到空 entry 列表。
完整的 boot 层组合位于 apps/cli/src/profile-boot.ts:131-170;--dump-config 与 boot() 共用空根和 applyEntryPatches 算法,
但 dump 当前不会加入 launcher 派生的 agent-presets shipped-root 与遥测 overlay,因此两者的最终配置不完全相同。
healProfilesModuleFallback 修复的是 profile 依赖闭包的模块回退路径,保证这些插件解析到同一安装树、同一 Cordis 包实例。
运行时 root Context 则由 boot() 统一创建并挂载整棵 Loader tree;若各层各自创建 Context,服务注册表、事件监听器和 scope 路由会被拆成互不相见的树。
1.3 boot() 启动序列
boot()(packages/boot/app-boot/src/index.ts:757-802)是核心驱动:
ctx.baseUrl 为配置目录ctx.provide() 注入环境、命令行参数等事实;若需要固定配置型 agent 身份,也可提供 CONFIGURED_AGENT_IDENTITIES_KEY。当前 CLI prepare 实际提供 environment/cmdline,并非 boot 自动注入身份。cordis.yml(字面值 [])上挂载一个 pinned 的 cordis:include entryCONFIGURED_AGENT_IDENTITIES_KEY 可在 prepare(ctx) 阶段提供,固定配置型 agent 的
sessionId/resumeSessionId。这样即使后续 overlay 改写了该行的模型路由,
也不会丢失其 session 身份(agent-loop/src/index.ts:327)。身份与路由的关注点分离。
2底层完成 · 循环驱动状态机
ReactLoopAgent(packages/core/agent-loop/src/agent.ts)是默认驱动器,实现 Agent 接口。
它把一个 session 推过 turn 与 step 边界。每个请求都从会话日志派生。
2.1 三态状态机(Phase)
状态转换通过 setPhase() 提交,并在 idle ⇄ running 时发布 agent/status 事件(agent.ts:104-111)。
maintenance 不改变对外 status(仍是 idle)——它是"闲时的独占任务"。
2.2 Turn 与 Step 的关系
2.3 完整 Turn 流程(含事件)
docs/architecture.md:67-82 + agent.ts:246-330
turn/start durable
claim next-step 输入 + 一个排队消息
组装 prompt sections + tool schemas
→ agent/pre-step live·waterfall reject | enter(messages)
reject,或首个 enter 被改写为空 → 关闭 turn(不消耗 step)
step/start durable
把进入的消息追加为 user/message
从日志派生模型历史 deriveMessages()
agent/request → llm/stream → assistant/chunk* → assistant/message
tool/call* → tools/pre-execute → tools/execute → tools/post-execute → tool/result*
step/end durable
工具欠另一个请求,或 next-step 输入到达 → claim → 下一个 step
→ agent/turn-stopping live·serial
turn/end durable
2.4 关键循环方法
| 方法 | 职责 | 位置 |
|---|---|---|
wakeDriver() | 启动一个驱动,或把唤醒锁存到 maintenance/已中止的活动后 | agent.ts:172 |
kick() | while 循环调用 turn();失败在驱动边界被收容 | agent.ts:210 |
turn() | 开启 turn 边界,循环 preStep → step 直到停止条件 | agent.ts:246 |
preStep() | claim 消息 + 组装 prompt + 运行 agent/pre-step 瀑布 | agent.ts:225 |
step() | 构建请求、流式消费、组装 assistant 消息、执行工具 | agent.ts:332 |
buildRequest() | 解析路由、跑 agent/request 瀑布、冻结请求 | agent.ts:407 |
2.5 turn 的结束原因(TurnEndReason)
注意 max-tokens 是粘性的(sticky):一旦某个 step 撞到上限,后续正常完成的 step 不得降级 turn 结果(agent.ts:287-290)。
interrupted 是循环从不实时发出的原因——只有持久化重载路径产生它(见 §8)。
followup()→next-turn(开新轮)、steer()→next-step(最近 step 边界,会唤醒)、
inject()→next-step(不唤醒,仅注入上下文)。某些消息立即唤醒;注入的上下文在 inbox 中等待,直到另一条消息唤醒。
3底层完成 · 工具执行管线
executeToolCalls()(packages/core/agent-loop/src/tool-calls.ts)调度一个 assistant step 的工具调用。
互斥调用形成屏障;并行调用使用有界滚动池;在启动前重新分类。 调度可重叠,但策略、结果、结果上下文保持模型序。
3.1 调度器四阶段(TOOL_RUNTIME_SCHEDULER)
符号键 [TOOL_RUNTIME_SCHEDULER](tools/src/index.ts:796)向循环暴露分阶段管线。
拆分是为了让 pre/post 策略保持有序,而 dispatch 可重叠:
tools/pre-execute 门禁与单调守卫。
final-result(折叠调用/无效参数/取消)绕过 post-execute。tools/execute + 工具体。包装器只能替换 exec.signal,注册表随后将其与调用者信号熔合(fuse),确保取消无法脱离。finalize 跑 post-execute + 内容终结;finish 仅定义拥有的内容终结,不跑 post-execute。3.1.1 执行关卡与持久结果
工具执行的安全性不是一个 try/catch,而是从模型提出调用到持久结果回到模型的一串有序关卡:
tools/result 是进程内的 live 观察事件;tool/result 才是写入 session 日志的 durable 事件。两者名称相近,但恢复职责不同。
| 调度阶段 | 对应关卡 | 是否可重叠 |
|---|---|---|
prepare | 快照参数、pre-execute、审批与单调守卫 | 按调用顺序完成 |
dispatch | 执行 around-dispatch 和工具 provider | 并行池可重叠,互斥调用形成屏障 |
finalize | post-execute + 内容终结 | 按模型序收束 |
finish | 仅内容终结,不再运行 post-execute | 用于已经确定的 final-result |
3.2 三个 Waterfall 事件
| 事件 | 模式 | 作用 |
|---|---|---|
tools/pre-execute | waterfall | dispatch 前的 allow / deny / ask;缺失审批降级为 deny |
tools/execute | waterfall | around-dispatch,用于超时/重试/指标;next() 返回归一化结果 |
tools/post-execute | waterfall | accept / replace / block 一个归一化结果;block 把成功转为 isError |
3.3 并发分类与屏障
executionMode(exec)(index.ts:1276):只有工具 isConcurrencySafe(args) 精确返回 true 才是 {kind:'parallel'};
未知/隐藏/未声明/抛错的分类器全部 {kind:'exclusive'}——fail-closed。互斥调用形成排序屏障。
3.4 中止语义(关键)
TOOL_ABORTED_BEFORE_DISPATCH),然后 turn 才中止。
这保证回放仍然有效——日志里每个 tool/call 都有配对的 result。
但调度器内部失败不同:它排空已启动的 dispatch,保留已记录的 tool/call 事件,不伪造结果。
3.5 restrict() 与作用域交集
restrict(filter)(index.ts:1071)是仅作用域的(需要 agent.ctx):allow/deny 作用于全局工具名。
作用域链上多个 restriction 取交集:一个工具只有当链上每一层都 admits 时才可见。
3.6 审批与 loop-hygiene
审批 fail-closed
请求 ask 时没有可用的 approval 服务、审批返回异常或超时,都转成拒绝结果;工具不应因为交互层失效而越过策略。
循环卫生
timeout-policy 约束单个请求的最长等待;repeat-tool-reminder 识别连续相同的规范化调用并注入建议提醒,不阻止或改写调用。
真正单调的 guard(取消、超时、权限或策略拒绝)只能收紧执行结果,不会在已经拒绝后重新放行。
repeat-tool-reminder 是建议性插件:它识别连续相同的规范化调用并注入提醒,不阻止或改写调用,也不改变可执行集合。
工具执行失败仍必须回到统一的 tool/result durable 语义,保证日志配对和下一次请求可重建。
4上下文组装 · 全链路
这是文档要求的重点之一:从会话日志 + 已注册的 provider → 组装的提示 → 冻结的模型请求。
核心入口是 SystemPrompt.assemble()(packages/core/system-prompt/src/index.ts:467)。
4.1 数据流总览
4.2 SystemPrompt 注册的四种贡献
| 注册方法 | 产物 | 排序约定 |
|---|---|---|
section() | PromptSection → system prompt 的一段 | order 升序:-100=harness 身份,0=persona,100–199=工具指引 |
context() | PromptContext → 动态运行时上下文(user-role 快照) | order 升序 |
tools() | tool schema provider → 模型可见工具 | 由 toolOrder 配置或字典序 |
variable() | {{name}} 变量 provider | 作用域遮蔽,最近者胜 |
4.3 assemble() 内部步骤(system-prompt/index.ts:467-542)
ScopedLayers.merge:作用域 section 在稳定排序前遮蔽同名全局<unlisted-tools>4.4 renderPrompt 插值(严格 {{variable}})
renderPrompt()(index.ts:212)对每个 section 插值,丢弃空段,用 \n\n 连接。
插值是严格的:畸形、未知、undefined 的引用都抛错;孤立的 {{(无后续 }})当作字面散文,被替换的值不再扫描。
4.5 RuntimeContextProjection(去重的动态快照)
packages/core/agent-loop/src/runtime-context.ts 是把组装出的运行时上下文快照转成至多一条 UserMessage 的去重适配器。
它从不拥有提交(循环的 preStep 决定是否追加)。
project(current, sections) 的三种返回
undefined(无变化):从未投影且当前为空,或当前文本与上次保留的相同 → 不追加消息,省 tokenCLEARED标记:当前为空但之前有保留 → 发"Current runtime context: none. Earlier snapshots no longer apply."- 正常快照消息:携带
form:'snapshot'+sections以保留归因
它订阅 session/event:新的自有消息更新 retained;一个覆盖其 seq 的替换事件(如压缩)则清空为 null。
4.6 deriveMessages(surface 投影的模型历史)
Session.deriveMessages()(session/index.ts:726)从日志派生模型可见的历史。只有三种事件类型是 surface-eligible
(packages/core/session/src/surface.ts:15-28):user/message、assistant/message、tool/result。
| 事件 | 投影规则 |
|---|---|
user/message | 事件 data 原样(框架由生产者负责) |
assistant/message | event.data.message;空内容(max-tokens 仅含 usage)返回 null 跳过 |
tool/result | event.data.message |
| 其它(chunk/边界/header…) | null——这些是回放/追踪数据,不是历史 |
surfaceOp:{op:'replace', start, end} 的检查点,遮蔽之前的 surface 节点。
deriveMessages 会把被遮蔽的范围从派生中剔除。这就是 §8 压缩复用的同一套机制。
4.7 agent/pre-step 瀑布(拒绝 / 重写点)
preStep()(agent.ts:225-243)在组装后运行瀑布。默认 next() 把投影的运行时上下文并入 claimed 消息。
agent.ts:225-243
private async preStep(target, { turn, step }) {
const claimed = this.inbox.claim(target, turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
const decision = await this.dispatch.waterfall(
'agent/pre-step', { messages: claimed, ...position, signal },
(): Promise<PreStepDecision> => Promise.resolve({ kind: 'enter', messages: context === undefined ? claimed : [...claimed, context] }),
)
return decision.kind === 'reject' ? decision : { ...decision, assembly }
}
4.8 冻结的模型请求(链路终点)
agent.ts:486-494
const request = markAgentLoopRequest(deepFreeze({
...header.config,
messages: boundaryMessages, // = session.deriveMessages()
...header.system !== undefined ? { system: header.system } : {}, // = renderPrompt(assembly)
...header.tools !== undefined ? { tools: header.tools } : {}, // = assembly.tools
sessionId: this.session.id,
signal,
}))
markAgentLoopRequest 用 WeakSet 标记该请求由循环构建;deepFreeze 深冻结但跳过 AbortSignal(那是活的取消通道,冻结会破坏中止)。
因为请求内容是日志的纯函数,监听器只读不改——这是"可重建性"不变量。
5上下文组装 · Scope 作用域基座
packages/core/scope 是让作用域提示/工具/变量注册生效的基座。它铸造一个带不透明身份标签的 Cordis context,
并为该身份构建仅用于路由的事件载体。
5.1 一个父关系,两个方向
同一个 parent 关系同时驱动两个方向(scope/index.ts:33-39):
注册视图向下继承
子作用域能看到祖先的层(ScopedLayers.chainLayers)。一个 agent 能继承全局 + 父预设的工具/提示。
事件准入向上延伸
一个标签为祖先的监听器,能收到派发给后代 key 的事件。事件沿链向上流,从不向下。
5.2 scopeTarget 准入规则
scopeTarget(base, key)(scope/index.ts:170-185)构建一个不透明载体,其 [Context.filter] 准入:
- 未标签的监听器——全局可见(
tag === undefined → true) - 匹配 key 或其任一祖先的标签监听器(从 key 沿父链走)
dispatch key 之下的标签仍被排除——这就是为什么一个 standing composition 能观察到组合在其下的每个 agent。
5.3 ScopedLayers 的"最近者胜"遮蔽
chainLayers(scope) 返回最远祖先优先、最近作用域最后的现有 overlay。
merge() 先物化全局条目,再让链遮蔽,所以同名条目最近者胜。
这是 §4.3 中"作用域 section 遮蔽全局 persona"的底层机制。
packages/preset/persona 是仅作用域插件。
它以同名 PERSONA_SECTION = 'deployment:persona' 在 agent.ctx 作用域注册一个 section,
于是遮蔽该 agent 的全局 persona,而其它 agent 的 persona 不受影响。两边引用同一常量名,所以是替换而非重复。
complete:true 让该 section 成为唯一提示段(assemble 在瀑布后恢复它)。
5.4 Preset 是 standing mount
Preset 不是每次创建 agent 时重新组合整棵插件树,而是一个进程级的 standing mount:组合完成后常驻, 每个 agent 只在自己的 scope 中绑定 preset 选择。这样可以复用共享 provider,同时保留 per-agent 的提示、工具和变量隔离。
agent-preset/selected这是记录所选 preset 的 durable session 事件;scope binding 负责 live 路由,scope 下的 persona、tools 和 sections 由它继承。5.5 initiator 与因果归因
initiator 用异步上下文保存“这次嵌套工作由哪个 agent/session 发起”。工具、子 agent、遥测和日志观察者可以读取这条因果关系,
从而使用正确的 scope 与 session 标识;它不依赖全局可变的“当前 agent”变量,也不会把并发请求错误归到同一个 owner。
6失败恢复 · 会话日志与持久化
这是文档要求的重点之二。统一契约:会话事件日志是唯一持久真相,只追加,seq 连续。 每条恢复路径都从这条日志重建。存活内存状态(inbox、派生消息、头部折叠、AbortController)是投影,靠回放 splice/chunk/header 重建。
6.0 事件信封与来源链
会话事件不是“随手塞进数组的对象”,而是带统一信封的有序事实。声明合并扩展的 SessionEventMap 提供类型映射,
运行时再由 append() 统一盖章;持久化读侧另有生成的 KNOWN_SESSION_EVENT_TYPES catalog 判断当前构建是否认识事件类型。
| 字段 | 作用 |
|---|---|
seq / time | 连续序号与事件时间;由 session 提交点生成,不能由调用者自选 |
标签化 data | 事件类型对应的 payload,如 turn/start、assistant/message、tool/result |
ignorable? | 事件是否允许不了解它的旧读者跳过;默认要求读者认识事件,避免未知事实静默丢失 |
surfaceOp? | 事件如何改变模型可见 surface:追加或 replace 某段历史 |
sourceEventSeqs? | surface 节点引用的来源事件序号,用于归因、替换和回放 |
assistant/chunk 用于流式恢复,assistant/message 才是最终模型历史。
6.1 Session.append(单一提交点)
Session.append()(session/index.ts:604-655)是唯一提交点,恢复契约在此强制:
entry.appending:一个 append 在发布中不能重入6.2 持久化三态:load / inspect / prepare
包 @deepseek-ai/dsh-session-persistence(packages/session/session-persistence/)。PersistenceCoordinator 是共享写路径编排;JSONL 后端是文件实现。
| 方法 | 语义 | 何时用 |
|---|---|---|
load(id) | 提交冷恢复,返回平衡的不可变视图;活会话则 flush 后返回快照 | 冷读取;活 turn 开启时拒绝 |
inspect(id) | 读取但不提交恢复、不发布;中断 turn 的合成 closer 仅在内存 | 预览;可借用活会话视图 |
prepare(id) | resume 入口:提交持久修复后预留一个未发布的 Session | 恢复一个会话 |
load() 在活 turn 开启时拒绝
(coordinator.ts:981-983:"cannot load while its live turn is open")。
6.3 prepareCore 的重建(coordinator.ts:892-931)
SESSION_FORMAT_VERSION,否则硬拒绝(无迁移代码)SESSION_FORMAT_VERSION = 0(types.ts:56),盖入 header,恢复时校验。无迁移代码——版本不符即硬拒
(SessionFormatUnsupportedError)。Pre-release 立场:后端拒绝旧格式。
6.3.1 持久化双版本与读侧三道门
会话格式版本
SESSION_FORMAT_VERSION = 0 属于跨后端的事件格式,写入 session header。恢复时不匹配就拒绝,没有隐式迁移。
SQLite schema 版本
SQLite 后端另有单调递增的 SCHEMA_VERSION = 15,只描述数据库表结构;不能用它替代事件格式版本。
读侧按三道门接纳数据:
- 版本门:header/session format 与后端 schema 都必须在支持范围内。
- 事件门:事件类型必须在当前构建生成的
KNOWN_SESSION_EVENT_TYPEScatalog 中;未知事件只有明确标记ignorable才能跳过。 - 必读门:默认事件是 required-on-read;构建不知道其类型时拒绝日志,只有结构化的可忽略事件可以携带
ignorable:true。
6.4 写后(write-behind)与失败保留
SessionWriteBehind(write-behind.ts)是每会话的批量写,带固定 deadline。失败时(write-behind.ts:138-157):
批被保留在内存(pending = batch.concat(this.pending))、定时器取消、automaticPaused 置位并上报;失败路径不会主动丢弃当前批,
但尚未持久化的事件不能抵抗进程崩溃。
6.5 Inbox 恢复(增量投影)
Inbox 是对 durable agent/inbox/spliced 事件的增量投影(packages/core/agent/src/inbox.ts:24-39)。构造时回放 seedLength 之后的所有 splice 重建状态。
每次 mutate() 在变更存活投影前先提交 durable 事件——所以同步 session/event 观察者看到的是 pre-splice 列表,能从归一化坐标重建被移除消息。
next-turn 队列
等待各自 turn 的提示。claim('next-turn') 弹出恰好一条。
next-step 队列
最近 step 边界的转向/上下文。claim() 总是先清空整个 next-step。
6.6 Fork(分叉活会话)
fork(source, boundary?, childSessionId?)(index.ts:1081-1138)切片活源会话的稳定前缀。_forkSeed 校验:
boundary 是安全整数、存在、连续,且所选切片不得结束在开放 turn 内(否则 OPEN_TURN 错)。子会话带 meta.parentSession 与 meta.seedLength 血统。
6.7 投影缓存的三档读取
日志恢复之后,内存投影按成本和可信度分成三档:snapshot 是当前可直接使用的快照,
checkpoint 是经过事件序号标记的中间投影,restore 则从 seed 与后续事件重新回放。任何缓存都不能成为新的事实来源;
实际 cache 以 projection stateVersion、checkpoint seq 和 session identity 判断可复用程度。日志缩短、身份不匹配或版本不匹配时丢弃缓存并从更早序号或完整日志 restore。
surfaceOp 与 fork 作为普通事件进入日志,由 projection 回放处理;不要把 generation 写成 projection cache 的通用失效条件。
7失败恢复 · 取消与终止语义
7.1 四种取消原因
| AgentCancelCause | 来源 | 是否锁存唤醒 |
|---|---|---|
'user' | 用户主动停止 | 是(待办输入存活并重启) |
'parent' | 父 agent 终止子任务 | 是 |
'hook' | 停止钩子(带 reason) | 是 |
'disposed' | agent 被销毁/拆卸 | 从不——拆卸不等任何模型 turn |
7.2 cancel() 行为(agent.ts:134-140)
agent.ts:134-140
cancel(cause: AgentCancelCause, options: CancelOptions = {}) {
if (!options.keepInbox) {
this.inbox.clear() // 持久 canceled splice
if (this.phase.kind !== 'idle') this.phase.wakeRequested = false
}
if (this.phase.kind !== 'idle') this.phase.abort.abort(cause) // cause 成为 AbortSignal.reason
}
turn 读取 signal.reason as AgentCancelCause,发射 turn/end{kind:'aborted', reason}。
7.3 keepInbox:两种停止的区别
不带 keepInbox(用户取消)
"全停,丢弃我排队的输入"。持久记录 canceled splice;待办工作被丢弃。
带 keepInbox
"停这一轮,保留我的排队工作"。活动 turn 仍中止,但未启动/待办工作存活供后续 turn,不记录 canceled splice。
7.4 aborted 后的唤醒输入(微妙之处)
在 abort 之后提交的唤醒输入,被分类为 wakingAfterAbort(在插入前捕获,agent.ts:116),
路由到 next-turn,所以它存活下来并启动下一轮。唤醒本身被锁存(wakeRequested=true,agent.ts:179)——
仅当 cause 非 disposed 且驱动处于 maintenance 或已中止时;收敛时若 inbox.hasPending,唤醒回放。
wakeDriver 用 reason?.kind !== 'disposed' 门控锁存。
所以用户/父/hook 取消会锁存一个唤醒(待办输入存活并重启),但销毁从不锁存——拆卸在收敛时不等待任何模型 turn。
这让 agent 销毁变得有界。
7.5 AgentLoop 生命周期:prepare / dispose 反向拆卸
AgentLoop.prepare()(agent-loop/index.ts:459)在任何资源存在前就注册反向 teardown。三个所有者的 abort 被熔合:
调用者取消信号、owner fiber 卸载、factory teardown。dispose 是记忆化的(disposing ??=),所以每个竞速的 owner 等待同一个静止点。
agent-loop/index.ts:497-520 (dispose 反向 teardown)
const dispose = (ownerTriggered = false) => (disposing ??= (async () => {
abort.abort(new Error(`agent "${id}" lifecycle disposed`))
// ...移除监听...
try {
if (machine === undefined) await machineReady.promise
if (machine !== undefined) {
machine.cancel({ kind: 'disposed' }) // disposed cause — 不锁存
await machine.whenIdle() // 等待静止
await machine.scope.dispose() // 解开作用域
}
} finally {
detachAgent()?.(); detachSession()?.() // 离开注册表
untrack()
if (!ownerTriggered) await unfollowOwner()
}
})())
8失败恢复 · 崩溃修复与压缩
8.1 崩溃修复 interruptedTurnClosers(repair.ts:27)
修复的是被中断的最后一轮——崩溃让日志留下开放 turn/start 而无配对 turn/end。
它不修复任意损坏;它只合成缺失的边界(在已完整写入的事件前缀之后)。单次前向扫描,跟踪 openTurn / openStep / pendingCalls,
在平衡尾返回 [];否则合成(全部复用最后真实事件的 time,seq 从 last.seq+1 续):
TOOL_NOT_STARTED:调用在 assistant 消息里但无 tool/call 跟随 →"中断前未记录为已启动;如仍需要请重试"。TOOL_OUTCOME_UNKNOWN:tool/call 已记录但无 tool/result →"结果未知;仅当只读/幂等时重试,否则先核对外部状态"。
关键重置语义:每个 turn/start / turn/end / step/end 都 pendingCalls.clear()——更早轮的调用不会泄漏到尾修复。
8.2 撕裂尾检测(JSONL)
SessionLogScanner(session-persistence-jsonl/src/format.ts):撕裂尾是最后一条无结尾换行的记录,或一个部分 Zstandard 帧。
consumeEventLine 只在每个完整换行终止的记录之后累加 committedBytes,所以撕裂尾的字节被排除出前缀。
JsonlTornMarker 带 truncateTo(字节偏移)+ recoveredEvents(从部分 Zstd 帧抢救的明文)。
this.issue,
若其后任何解码事件是 turn/end,则抛错(format.ts:357-359)。seq 间隙同理抛错。
8.3 commitRepair(持久化修复)
commitPrepared(coordinator.ts:934-963):若 tornMarker !== undefined || closers.length > 0,调用
backend.commitRepair(JSONL:truncate 到 truncateTo + fsync,再 append recoveredEvents + closers)使修复持久,
然后返回 undefined 强制重载(修复改变了持久 revision)。两步 fsync,接缝不要求原子性。
8.4 上下文压缩(compaction)
packages/compaction。CompactionEngine 是 Service Definition,provider 子类化。两种触发:'pressure'(正常阈值)与
'context-overflow'(provider 确认溢出,可能在阈值下强制一次有用的平衡缩减)。
surface-replace 机制(压缩不删事件)
日志只追加。压缩追加一条 user/message 检查点,带 surfaceOp:{op:'replace', start, end},遮蔽之前一段 surface 节点。
deriveMessages() 把被遮蔽的范围从派生中剔除。所以模型看到压缩后的历史,而完整日志保留供回放/审计。
toolPairingBalancedBefore/After 维护增量 balance 缓存:assistant 工具调用块 +1,tool/result −1。
切割平衡当 inProgressToolCalls === 0。缓存在外 surface replaceGeneration 变化后重建(即压缩后)。
| 失败场景 | 存活什么 | 如何重建 |
|---|---|---|
| turn 进行中崩溃 | 崩溃前已 fsync 的每个事件 | load → interruptedTurnClosers 合成 + commitRepair 截断 |
| 撕裂的最后写入 | 所有完整换行终止的记录 | scanner 排除未终止尾;truncateTo 截断;可抢救明文入 recoveredEvents |
| 持久写失败 | 该批(保留在 pending) | write-behind 重新排队,暂停自动 flush,上报 |
| turn 取消 | 日志到 abort;queued 输入(若 keepInbox 或 abort 后提交) | turn/end{aborted};post-abort 唤醒锁存回放 |
| agent 销毁 | 日志;无锁存唤醒 | disposed cause 不锁存;拆卸收敛不等待模型 turn |
| 上下文溢出 | 完整日志(只追加) | compactRegion 追加 replace 检查点;deriveMessages 排除遮蔽节点 |
9附录 · 事件扩展点速查
事件是扩展点,选对域是大多数改动的第一个决策。Cordis 四种 dispatch 模式:
| 模式 | 是否 await | 顺序 | 返回值 |
|---|---|---|---|
emit | 否 | 注册序 | 否(观察) |
waterfall | 否 | 注册序 | 是(around-middleware) |
parallel | 是 | 全部并行 | 否 |
serial | 是 | 注册序 | 是(首个 bail 值) |
next() 委托:监听器收到 (...args, next)。调用 next() 把(可能被包装的)结果委托给下一个服务;
不调用 next 就返回则短路。值通过 next() 的返回值传播。协作监听器通常修改共享对象后委托。
三大事件域
Session 事件 durable
追加进日志并经 session/event 广播的持久事实。当事实必须存活重载时使用。
Agent 事件 live
携带 live Agent:inbox、step、status、request、validation、continuation。观察或拦截在途工作。
Capability 事件 live
把策略和适配器挂到一个接缝(fs/*、tools/*、telemetry/*),无需导入循环。
关键 Waterfall 事件清单
| 事件 | 域 | 用途 |
|---|---|---|
system-prompt/assemble | context | 在组装出的 sections/contexts/tools/variables 上跑专家中间件 |
agent/pre-step | agent | 拒绝或重写进入 step 的消息 |
agent/request | agent | 替换冻结的调用配置(不可改消息) |
agent/request-error | agent | 失败请求前重试或关闭 step(返回 {kind:'retry'}) |
agent/turn-stopping serial | agent | turn 即将关闭;反对者可 steer() 再跑一步 |
llm/stream | llm | 中间件可短路产自己的 chunk,或 next() 到适配器 |
tools/pre-execute / execute / post-execute | capability | 工具的三段管线 |
能力接缝:三角色必须成套
| 角色 | 典型问题 | 源码例子 |
|---|---|---|
| Definition | 接口、请求/规格、错误与生命周期是什么? | dsh-shell / dsh-llm Service Definition |
| Provider | 本地、远程、测试实现如何替换? | local shell、DeepSeek provider、JSONL/SQLite backend |
| Consumer | 循环、工具或命令如何使用而不绑定实现? | tool Consumer、agent-loop、session coordinator |
类型模式与工程门禁
Map → 派生联合
以事件 map、工具 map、配置 map 作为唯一目录,再派生 tag union;避免手写列表与注册表漂移。
品牌化 ID
session、agent、scope 等跨边界标识使用 branded ID,避免把任意 string 误当成另一类身份。
边界处信任
同进程、同类型边界信任 TypeScript;模型 JSON、持久化文件、worker/process、wire 输入必须在边界验证。
显式 resolve,失败即早报错
把默认值放在 resolve(request),不要藏进 run();缺少自包含依赖就在加载期失败,不能静默跳过。
| 改动表面 | 至少核对 |
|---|---|
| 事件 / session | pnpm run typecheck、相关 package tests、session snapshot 与恢复路径 |
| 模型可见输出 | 真实 runnable example 的 keyless snapshot,不以 mock-only fixture 替代 |
| 文档 / website | pnpm run doc-sync;若修改 website projection,再运行 pnpm run website:build |
| 发布路径 | build / hygiene 与使用构建产物的 smoke;只报告实际运行过的命令 |
扩展点选择
| 目标 | 优先挂点 | 不要做什么 |
|---|---|---|
| 增加模型后端 | LLM Service Definition + Provider | 在 agent-loop 里写 provider 分支 |
| 增加工具或工具策略 | tool registry / pre-execute / execute / post-execute | 绕开 tool/call 和 tool/result 直接改 messages |
| 改变每个 agent 的人格/提示 | agent scope + preset persona | 改全局 section 让所有 agent 一起变化 |
| 保存 durable 状态 | SessionEventMap + session.append() | 只写内存变量或偷偷把字段塞进 request |
| 拦截中止 / 重试 | agent/request-error、agent/turn-stopping、工具 waterfall | 在循环内部增加不可组合的旁路状态机 |
关键源码索引
读源码时建议按“接口 → 组合 → 提交 → 投影 → 驱动”的顺序跳转:
| 主题 | 入口 | 阅读目的 |
|---|---|---|
| 启动与组合 | apps/cli/src/profile-boot.ts、packages/boot/app-boot/src/profile.ts、packages/boot/app-boot/src/index.ts | 看 CLI 如何组合 entries、通用 helper 如何应用 patch、prepare 如何注入宿主事实、Loader 如何确认激活 |
| 循环状态机 | packages/core/agent-loop/src/agent.ts | 看 phase、turn、step、request 和取消的所有者 |
| 模型可见不变量 | packages/core/agent-loop/src/invariant.ts | 看独立派生与 request header 的比较 |
| 工具调度 | packages/core/tools/src/index.ts、packages/core/agent-loop/src/tool-calls.ts | 看四阶段调度、执行关卡、并发分类和 durable 结果配对 |
| 日志与 surface | packages/core/session/src/index.ts、packages/core/session/src/types.ts、packages/core/session/src/surface.ts | 看事件信封、append、deriveMessages 与 replace |
| 恢复与持久化 | packages/session/session-persistence/src/coordinator.ts、packages/session/session-persistence-jsonl/、packages/session/session-persistence-sqlite/ | 看 prepare、torn tail、repair、双版本和 write-behind |
| 提示与作用域 | packages/core/system-prompt/src/index.ts、packages/core/scope/src/store.ts、packages/core/scope/src/index.ts | 看 sections/context/tools 的合并与 scopeTarget 准入 |
| Preset 与 agent mount | packages/preset/、packages/core/agent/src/index.ts | 看 standing mount、selection、composition generation 和 initiator |
| 能力接缝 | packages/shell/、packages/llm/、packages/session/ | 对照 Definition / Provider / Consumer 的分工 |
10附录 · 术语表
| 术语 | 释义 |
|---|---|
| Step | 一次模型请求 + 它调用的工具 |
| Turn | 零或多个 step;首个输入被领取前开启,不再欠东西时关闭 |
| Surface | 日志的有序、模型可见视图;仅 user/assistant/tool-result 投影为消息 |
| Inbox | 待办输入的增量投影;next-turn 与 next-step 两个队列 |
| 能力接缝(Seam) | 可替换能力,三角色:Service Definition(声明接口)/ Provider(实现)/ Consumer(使用,常为模型工具) |
| Profile / Bundle | 组合格式:profile 列 bundle,bundle 携带 patch + 代码;层 patch 到空 entry 列表 |
| ScopeKey | 不透明对象身份;作用域隔离的基座 |
| standing mount | 进程级常驻的 preset 组合;agent 只在自己的 scope 中选择和继承 |
| initiator | 异步上下文中的发起者身份,用于把嵌套工作归因到正确的 agent/session |
| surfaceOp / sourceEventSeqs | surface 替换操作及其来源事件序号;压缩不删日志,而是改变模型可见投影 |
| ignorable | 允许不了解该事件的读者跳过的显式标记;默认事件必须被读者认识 |
| SESSION_FORMAT_VERSION / SCHEMA_VERSION | 分别表示事件格式版本与 SQLite 表结构版本,两个版本独立递增和校验 |
| PreparedLlmCall | 一次性句柄,跨 header 日志与 dispatch 绑定一个适配器注册,防 HMR 混搭 |
| BlockAssembler | 把流式 chunk 累积成 assistant 消息 + usage + finish kind 的单一组装器 |
| LlmError | 携带稳定 machine code(区别于人类 message)+ 冻结 failure 记录;按 code 路由,勿解析 message |
| concludesTurn | 工具结果标志;提交该批后 agent 循环停止 turn |
| seedLength | session header 中区分 seed 历史(回放/分叉/resume)与本生命周期自写的边界 |