DeepSeek Harness 完整原理学习文档

DeepSeek Harness 是一个构建在 vendored Cordis 之上的插件式 Agent 框架——"一切皆插件"。 本文档基于当前 master(47f943859b)源码,按“设计公理 → Cordis 与组合 → 事件日志与不变量 → 循环与工具 → 上下文与作用域 → 持久化与恢复 → 接缝与扩展点”的路径组织。所有机制均标注 文件:行 引用以便核对。

Cordis 插件树 append-only 会话日志 作用域隔离 失败即回滚 能力接缝三角色

0全局心智模型

一句话总结:DeepSeek Harness 把一次 Agent 对话建模为一条 只追加(append-only)的会话事件日志。模型看到的每一样东西都能从这条日志重建, 日志本身又是崩溃、恢复、压缩、回放、遥测的唯一权威来源。整个系统的其余部分——循环、工具、提示、UI—— 都是对这条日志的投影(projection)。

三层视角

dsh 运行实例 // = 一棵 Cordis 插件树,启动时组合 Plugin Tree(插件树) // session / systemPrompt / tools / agent / agentLoop / llm ... Session 事件日志 durable // 唯一持久真相,seq 连续递增 turn/start → step/start → user/message → assistant/chunk* → assistant/message → tool/call → tool/result → step/end → turn/end Live 投影层 in-memory Inbox(待办队列)/ Surface(模型可见视图)/ 头部折叠 驱动层 ReactLoopAgent 状态机:idle ↔ running ↔ maintenance

五条设计公理

一切皆插件

循环、模型、工具、持久化、提示和交互都挂在 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 方法
贯穿全文的关键词:durable(持久)事件会被写进日志并广播 session/event; live(存活)事件只是进程内扩展点(如 agent/*、tools/*),不落盘。 分清这两者是理解恢复机制的前提。

不变量的三层强制

compile-time类型签名先约束带 surfaceOp 的模型可见事件必须在类型层提供表面选项;声明合并扩展 SessionEventMap,让源码获得类型事件。持久化读侧再由生成的 KNOWN_SESSION_EVENT_TYPES catalog 判断当前构建是否认识该类型。
append-time提交点再校验Session.append() 快照 JSON、校验 surface 元数据、深冻结事件,再推进连续 seq;失败时日志不变。
dispatch-time请求前独立复核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() 才会委托给后续实现
最小阅读模型:先由 Loader 激活插件,再由插件在自己的 fiber 中注册服务和事件;服务等待依赖,效果绑定生命周期,事件选择明确的派发模式。 这也是“插件树”比“全局单例集合”更容易恢复和替换的原因。
示意:插件的服务 + 可逆注册
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):

layer 1Bundle 层按 dsh.profile.bundles 顺序(dsh-base 必为首层)
layer 2profile 自身 patchcordis.patch.yml
layer 3home 级用户层$DSH_HOME/cordis.patch.yml,机器本地偏好,对每个 profile 都生效
layer 4--patch 覆盖命令行参数顺序
layer 5launcher 派生层agent-presets shipped-root 覆盖 + 遥测开关

patch 通过 id 定位一行并整体替换其 config,或插入新行。要查看本机实际启动的树:

$ 命令
dsh --profile web --dump-config   # 组合可导出的配置层,输出带来源注释的 YAML,不 boot、不求值 !!js

1.2.1 逻辑层与实际应用步骤

原理上可以把组合分成“核心能力、模式能力、用户覆盖”三类;源码执行时还会把覆盖拆成多个有序步骤,并附加 launcher 派生信息。 因此下面的五步是运行时应用步骤,不是五种互相独立的架构层:

运行时步骤对应内容理解方式
1 · Bundledsh-base 及 profile 声明的 bundle核心能力与模式能力的物理装载
2 · profile patchprofile 自己的 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)是核心驱动:

step 1new Context()设置 ctx.baseUrl 为配置目录
step 2ctx.plugin(Loader)装载 Cordis Loader 服务——插件树管理者
step 3prepare(ctx) — 宿主设置(关键时序)在任何 Loader entry 挂载之前运行。launcher 可在此通过 ctx.provide() 注入环境、命令行参数等事实;若需要固定配置型 agent 身份,也可提供 CONFIGURED_AGENT_IDENTITIES_KEY。当前 CLI prepare 实际提供 environment/cmdline,并非 boot 自动注入身份。
step 4mountRootInclude()在 root cordis.yml(字面值 [])上挂载一个 pinned 的 cordis:include entry
step 5loader.await()等待整棵树稳定
step 6assertEntriesActivated()审计每个 entry:失败的 fiber 报原因,pending 的报缺失服务,active 的通过
若 launcher 提供配置型 agent 身份: CONFIGURED_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)

type Phase = idle // 无驱动;lastTurn 记录上一轮 maintenance // 运行非 turn 维护任务(如压缩),对外仍 idle running // turn/step 进行中,携带 AbortController + wakeRequested 锁存

状态转换通过 setPhase() 提交,并在 idle ⇄ running 时发布 agent/status 事件(agent.ts:104-111)。 maintenance 不改变对外 status(仍是 idle)——它是"闲时的独占任务"。

2.2 Turn 与 Step 的关系

定义:一个 step = 一次模型请求 + 它调用的工具。 一个 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)

completed blocked max-tokens aborted error interrupted

注意 max-tokens 是粘性的(sticky):一旦某个 step 撞到上限,后续正常完成的 step 不得降级 turn 结果(agent.ts:287-290)。 interrupted 是循环从不实时发出的原因——只有持久化重载路径产生它(见 §8)。

三种输入进入驱动器的唯一通道:Inbox。 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 可重叠:

prepareprepare(exec) → {dispatch | post-result | final-result} 物化输入(无损 JSON 快照 + 深冻结参数)、运行 tools/pre-execute 门禁与单调守卫。 final-result(折叠调用/无效参数/取消)绕过 post-execute。
dispatchdispatch(exec) → {post-result | final-result} 运行 around-dispatch tools/execute + 工具体。包装器只能替换 exec.signal,注册表随后将其与调用者信号熔合(fuse),确保取消无法脱离。
finalize / finishfinalize(exec, result) 或 finish(exec, result) finalize 跑 post-execute + 内容终结;finish 仅定义拥有的内容终结,不跑 post-execute。

3.1.1 执行关卡与持久结果

工具执行的安全性不是一个 try/catch,而是从模型提出调用到持久结果回到模型的一串有序关卡:

tool/call 已记录 // 先留下可回放的意图 → tools/pre-execute // allow / deny / ask,审批缺失即 deny → monotonic guards // cancel、timeout、策略拒绝等只收紧执行 → tools/execute // around-dispatch 与具体 provider → tools/post-execute // 归一化、替换或阻断结果 → finalizeContent // 终结内容并形成模型可见结果 → tools/result // live 的不可变通知 → tool/result 已记录 // durable 事件,结果上下文按模型序返回

tools/result 是进程内的 live 观察事件;tool/result 才是写入 session 日志的 durable 事件。两者名称相近,但恢复职责不同。

调度阶段对应关卡是否可重叠
prepare快照参数、pre-execute、审批与单调守卫按调用顺序完成
dispatch执行 around-dispatch 和工具 provider并行池可重叠,互斥调用形成屏障
finalizepost-execute + 内容终结按模型序收束
finish仅内容终结,不再运行 post-execute用于已经确定的 final-result

3.2 三个 Waterfall 事件

事件模式作用
tools/pre-executewaterfalldispatch 前的 allow / deny / ask;缺失审批降级为 deny
tools/executewaterfallaround-dispatch,用于超时/重试/指标;next() 返回归一化结果
tools/post-executewaterfallaccept / replace / block 一个归一化结果;block 把成功转为 isError

3.3 并发分类与屏障

executionMode(exec)(index.ts:1276):只有工具 isConcurrencySafe(args) 精确返回 true 才是 {kind:'parallel'}; 未知/隐藏/未声明/抛错的分类器全部 {kind:'exclusive'}——fail-closed。互斥调用形成排序屏障。

3.4 中止语义(关键)

中止时:已启动的调用与已接受的上下文先结算;每个剩余的模型调用随后按序收到合成错误结果 ("tool call aborted before dispatch",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 时才可见。

非显然的豁免:restriction 只过滤一个作用域继承来的(全局 + 每个祖先层)工具, 从不过滤它自己层注册的工具(index.ts:1178-1183)。这保证子 agent 自带的报告/结构化输出工具不会被自己的能力过滤器剥掉。

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 数据流总览

session 日志 + 已注册 provider systemPrompt.assemble(assembleContextFor(agent)) → PromptAssembly { sections, contexts, tools, variables } renderContextSections(assembly) → sections joinContextSections(sections) → 运行时快照字符串 RuntimeContextProjection.project(snapshot) → UserMessage? // 去重 agent/pre-step 瀑布 → PreStepDecision {reject | enter} session.append('user/message', …) // 每条进入消息 renderPrompt(assembly) → system 字符串 session.deriveMessages() → Message[] // surface 投影的历史 buildRequest(...) → deepFreeze(请求) // 冻结

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)

step 1收集变量先全局,再沿作用域链(最远优先,最近覆盖同名)
step 2合并 sections / contextsScopedLayers.merge:作用域 section 在稳定排序前遮蔽同名全局
step 3收集 tool schemas每个 provider 求值 → 脱离参数(structuredClone)→ 校验保留名 <unlisted-tools>
step 4检测 complete section多于一个 effective complete section 则报错
step 5system-prompt/assemble 瀑布协作式中间件;返回值权威——除非存在 complete section,则在瀑布后恢复为唯一 section
step 6运行时上下文抑制检查全局层或任一作用域层有 suppressor → contexts 置空

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(无变化):从未投影且当前为空,或当前文本与上次保留的相同 → 不追加消息,省 token
  • CLEARED 标记:当前为空但之前有保留 → 发"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/messageevent.data.message;空内容(max-tokens 仅含 usage)返回 null 跳过
tool/resultevent.data.message
其它(chunk/边界/header…)null——这些是回放/追踪数据,不是历史
替换(replace)surfaceOp:压缩不删除事件(日志只追加),而是追加一条带 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 }
}
reject → turn 以 blocked 结束 enter + 改写 messages → 删/换/增 claimed 消息 next() → 保留运行时上下文

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] 准入:

  1. 未标签的监听器——全局可见(tag === undefined → true)
  2. 匹配 key 或其任一祖先的标签监听器(从 key 沿父链走)

dispatch key 之下的标签仍被排除——这就是为什么一个 standing composition 能观察到组合在其下的每个 agent。

5.3 ScopedLayers 的"最近者胜"遮蔽

chainLayers(scope) 返回最远祖先优先、最近作用域最后的现有 overlay。 merge() 先物化全局条目,再让链遮蔽,所以同名条目最近者胜。 这是 §4.3 中"作用域 section 遮蔽全局 persona"的底层机制。

Preset 如何遮蔽部署 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 的提示、工具和变量隔离。

mount一次组合,常驻进程preset bundle 连接到共享 Cordis context;不为每个 agent 重建一套全局服务,agent 通过 scope parent 接入。
selectagent-preset/selected这是记录所选 preset 的 durable session 事件;scope binding 负责 live 路由,scope 下的 persona、tools 和 sections 由它继承。
generation组合文件代次generation 由 composition 文件 stamp 驱动,不是每次选择或 recompose 都递增;recompose 只是重新连接 parent,调用方必须先保证 session 仍为空,已有 agent 保留旧 mount。
何时允许重组?只有空 session 的配置阶段可以重新组合 preset。运行中的 session 必须保持既有作用域与服务身份; 若需要隔离一组临时能力,应创建新的 isolate realm,而不是把全局 preset 改成可变单例。

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 节点引用的来源事件序号,用于归因、替换和回放
读日志的基本问法:先看事件信封和类型目录,再看它是否进入 surface;不要把所有 durable 事件都当成模型消息。 例如 assistant/chunk 用于流式恢复,assistant/message 才是最终模型历史。

6.1 Session.append(单一提交点)

Session.append()(session/index.ts:604-655)是唯一提交点,恢复契约在此强制:

check 1snapshotJsonValue一遍递归既读又校验又拷贝每个嵌套值——有状态 getter 无法在校验和存储间供不同值。非 JSON 值在日志变更前拒绝。
check 2surfaceManager.validateNext校验 surface 契约(marker 形状、唯一 source-seq 引用、位置 replace 有效性、完整遮蔽覆盖)
check 3重入守卫entry.appending:一个 append 在发布中不能重入
commitseq = log.length连续性契约;事件 deepFreeze 后 push;eventsSnapshot 失效

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)

1backend.loadStored(id)→ { meta, events, revision, tornMarker }
2adoptStoredEvents + 断言 version重盖章 id;header version 必须 == SESSION_FORMAT_VERSION,否则硬拒绝(无迁移代码)
3interruptedTurnClosers(events)合成缺失的工具结果 + step/end + turn/end{interrupted}
4balanced = [...stored, ...closers]→ ctx.sessions.prepare(id, { seed: balanced, meta }) → Session.fromRestore

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,只描述数据库表结构;不能用它替代事件格式版本。

读侧按三道门接纳数据:

  1. 版本门:header/session format 与后端 schema 都必须在支持范围内。
  2. 事件门:事件类型必须在当前构建生成的 KNOWN_SESSION_EVENT_TYPES catalog 中;未知事件只有明确标记 ignorable 才能跳过。
  3. 必读门:默认事件是 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,唤醒回放。

disposed 的特殊性: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 续):

未配对工具调用合成错误结果,两种 code TOOL_NOT_STARTED:调用在 assistant 消息里但无 tool/call 跟随 →"中断前未记录为已启动;如仍需要请重试"。
TOOL_OUTCOME_UNKNOWN:tool/call 已记录但无 tool/result →"结果未知;仅当只读/幂等时重试,否则先核对外部状态"。
开放 step合成 step/endturn/end 时仍有开放 step 是不变量违反,必须先合 step/end
开放 turn合成 turn/end{interrupted}循环从不实时发出的 reason kind,仅持久化重载产生

关键重置语义:每个 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() 把被遮蔽的范围从派生中剔除。所以模型看到压缩后的历史,而完整日志保留供回放/审计。

工具配对安全:压缩改变 surface 位置,所以安全切割基于内容而非 step 标记。 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 值)
Waterfall 的 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/assemblecontext在组装出的 sections/contexts/tools/variables 上跑专家中间件
agent/pre-stepagent拒绝或重写进入 step 的消息
agent/requestagent替换冻结的调用配置(不可改消息)
agent/request-erroragent失败请求前重试或关闭 step(返回 {kind:'retry'})
agent/turn-stopping serialagentturn 即将关闭;反对者可 steer() 再跑一步
llm/streamllm中间件可短路产自己的 chunk,或 next() 到适配器
tools/pre-execute / execute / post-executecapability工具的三段管线

能力接缝:三角色必须成套

Service Definition 声明能力与类型;Provider 在某个环境实现它; Consumer 通过服务或工具使用它。新增能力时先找接缝,再决定事件挂点;不要让 consumer 直接 import 某个 provider。
角色典型问题源码例子
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();缺少自包含依赖就在加载期失败,不能静默跳过。

改动表面至少核对
事件 / sessionpnpm run typecheck、相关 package tests、session snapshot 与恢复路径
模型可见输出真实 runnable example 的 keyless snapshot,不以 mock-only fixture 替代
文档 / websitepnpm 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 结果配对
日志与 surfacepackages/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 mountpackages/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 / sourceEventSeqssurface 替换操作及其来源事件序号;压缩不删日志,而是改变模型可见投影
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
seedLengthsession header 中区分 seed 历史(回放/分叉/resume)与本生命周期自写的边界
← 返回内容索引