本文基于 deepseek-harness 仓库(0.1.0-rc.5,约 1.2 万次提交)源码逐行解读,并实际运行了其单元测试与回放 fixture 验证结论。

DeepSeek 官方开源了一个 agent harness:everything is a plugin——连模型适配器、工具注册表、会话日志、甚至 agent 主循环本身都是插件,任何一个都可以从配置里被替换。

这不只是营销话术。把它拆开之后,我发现这个框架藏着大量"不看代码绝对发现不了"的细节:为什么工具调用结果要伪装成 user 角色?为什么模型请求必须是会话日志的"纯函数"?为什么崩溃后日志里会出现一个任何主循环都永远不会主动发出turn/end?并发工具调用怎么在"并发执行"和"严格保序"之间同时成立?

这篇文章用中英双语,从架构骨架到内核细节逐层拆解。

This article is based on a line-by-line reading of the deepseek-harness repository (0.1.0-rc.5, ~12K commits), and the conclusions were verified by actually running its unit tests and replay fixtures.

DeepSeek has open-sourced an agent harness where everything is a plugin — the model adapters, the tool registry, the session log, and even the agent loop itself are all plugins, and any of them can be replaced from configuration.

That is not just marketing. Once you open it up, you find a pile of details you can only discover by reading the code: why do tool results disguise themselves as user messages? Why must a model request be a “pure function” of the session log? Why does a crash leave behind a turn/end that no loop ever emits on its own? And how do concurrent tool calls manage to be both “concurrent” and “strictly ordered” at the same time?

This article goes from the architectural skeleton down to the kernel-level details, in both Chinese and English.

先认识这套框架

先说明阅读方式:本文不是前端教程,也不要求你先学会 React 或网页开发。这里的 Agent 不是一个页面按钮,而是一个会反复推进任务的程序:接收用户输入,向模型发请求,执行模型要求的工具,再把工具结果交回模型,直到得到最终回答。

可以先把 dsh 想成四个对象的协作:

  • Agent loop:负责推进流程,决定什么时候开始一轮任务、什么时候请求模型、什么时候结束;
  • LLM adapter:把 dsh 内部统一的请求格式转换成 DeepSeek API 能理解的 HTTP/SSE 格式;
  • Tool registry:保存模型可以调用的工具,例如 bash、文件系统和网页工具,并负责执行它们;
  • Session log:按顺序记录发生过的事实,供界面展示、重启恢复和下一次模型请求使用。

先看一个最小任务:用户要求“执行 echo hello,然后告诉我输出”。

1
2
3
4
5
6
7
8
9
用户输入
-> inbox(等待处理的输入队列)
-> turn(这一轮任务)
-> step 1(一次模型请求)
-> 模型返回 tool-call
-> 工具执行并写入 tool-result
-> step 1 结束,带着 tool-result 开始 step 2
-> 模型返回最终文本
-> turn 结束

这里先记住一个边界:一个 step 是一次模型请求和它触发的工具执行;工具结果之后的下一次模型请求属于新的 step,而不是同一个 step 的第三次请求。 后文会用真实日志逐行解释这条流程。

读懂后文只需要先认识这些词

  • event:日志中的一条事实,例如 tool/call 表示“准备调用某个工具”;
  • message:模型历史中的一条消息,例如 user、assistant 或工具结果消息;
  • chunk:流式输出的一小段增量,模型说一句话时可能产生很多 chunk;
  • block:一条消息里的内容块,例如文本、思考内容或工具调用;
  • turn / step:turn 是一轮任务,step 是其中一次模型请求及其工具执行;
  • log / surface / wire:log 是内部事件账本,surface 是当前给模型看的消息视图,wire 是发给 DeepSeek API 的最终格式。

代码阅读提示:文中标为 ts 的代码块优先展示真实源码结构;带“节选”或“结构示意”说明的片段省略了 import、上下文类型或无关事件,应该用来理解数据形状,不是可以独立复制运行的完整文件。

下面的流程图只描述一次任务如何运行,不涉及插件如何启动:

工具调用

最终文本

用户输入

inbox
等待处理

turn
一轮任务

step 1
一次模型请求

模型返回文本或工具调用

执行工具

写入 tool/result

step 2
带着工具结果再次请求

turn/end

dsh(DeepSeek Harness)不是一个"又一个 Agent 玩具",它是一套面向生产场景设计、但仍在快速演化的运行时:

  • 底座是 vendored 的 Cordis 插件框架——一个"微内核 + 可逆副作用"的组合系统。这里的 vendored 指源码被复制并维护在本仓库的 vendor/ 目录中;
  • 当前仓库把大量 @deepseek-ai/dsh-* package manifest 按"能力接缝"(capability seam)组织。能力接缝可以先理解成一组可替换的服务:定义接口的服务、提供实现的 provider,以及使用它的 consumer;
  • 会话是纯事件溯源的:一个 append-only 的 SessionEvent 日志就是唯一的真相源,模型消息历史、标题、token 用量全部从日志派生,从不单独存储;
  • 这是一个面向生产场景设计、但仍处于 developer preview 的项目:当前版本是 0.1.0-rc.5,README 明确写着"THERE WILL BE COMPATIBILITY-BREAKING CHANGES"。本文的代码观察以当前检出的源码为准,不把这些内部结构当成稳定 API。

Getting to know the framework

First, a reading promise: this is not a frontend tutorial, and you do not need to know React or web development first. Here, an Agent is not a button on a page. It is a program that keeps moving a task forward: receive user input, call the model, run tools requested by the model, feed the results back, and stop when a final answer is ready.

Think of dsh as four cooperating objects:

  • Agent loop: drives the process, deciding when a task turn starts, when to call the model, and when to stop;
  • LLM adapter: translates dsh’s provider-neutral request into the HTTP/SSE format understood by the DeepSeek API;
  • Tool registry: stores tools the model may call, such as bash, filesystem, and web tools, and executes them;
  • Session log: records facts in order for UI rendering, restart recovery, and the next model request.

Start with one small task: the user asks, “Run echo hello, then tell me its output.”

1
2
3
4
5
6
7
8
9
user input
-> inbox (queue of input waiting to be processed)
-> turn (this task attempt)
-> step 1 (one model request)
-> the model returns a tool-call
-> the tool runs and writes a tool-result
-> step 1 ends, and step 2 starts with that tool-result
-> the model returns final text
-> the turn ends

Keep one boundary in mind: a step is one model request plus the tool executions it triggers; the model request after a tool result belongs to a new step, not a third request inside the old step. The real log later walks through this sequence line by line.

Words needed for the rest of the article

  • event: one fact in the log, such as tool/call, meaning “a tool is about to be called”;
  • message: one message in the model history, such as a user, assistant, or tool-result message;
  • chunk: a small streaming delta; one model sentence may produce many chunks;
  • block: a content unit inside a message, such as text, reasoning, or a tool call;
  • turn / step: a turn is one task attempt; a step is one model request and its tool execution;
  • log / surface / wire: the log is the internal event ledger, the surface is the current model-visible message view, and wire is the final format sent to the DeepSeek API.

Code-reading note: code blocks marked ts primarily show real source structure. Blocks labeled as an excerpt or structural example omit imports, surrounding types, or unrelated events; use them to understand data shapes, not as complete standalone files to copy and run.

The following diagram describes one task at runtime. It does not describe plugin startup:

tool call

final text

User input

inbox
waiting to be processed

turn
one task attempt

step 1
one model request

Model returns text or a tool call

Run the tool

Write tool/result

step 2
request again with the tool result

turn/end

dsh (DeepSeek Harness) is not “yet another agent toy” — it is a runtime designed for production scenarios but still evolving quickly:

  • The base is the vendored Cordis plugin framework — a “microkernel + reversible side effects” composition system. Vendored here means that the source is copied into and maintained under the repository’s vendor/ directory;
  • The current repository organizes a large set of @deepseek-ai/dsh-* package manifests around capability seams. A seam can be understood as a swappable service: a definition, one or more providers, and consumers that use it;
  • Sessions are purely event-sourced: an append-only SessionEvent log is the single source of truth; model message history, titles, and token usage are all derived from the log, never stored separately;
  • This is a project designed for production scenarios but still in developer preview: the current version is 0.1.0-rc.5, and the README explicitly says “THERE WILL BE COMPATIBILITY-BREAKING CHANGES”. The observations in this article apply to the checked-out source and should not be treated as a stable public API.

先看启动时的组合结构。这里的“插件树”不是一次请求的执行流程,而是一份启动时要加载哪些能力的清单profile 是场景预设,bundle 是一组可安装的插件配置,patch 是按 id 替换或插入配置行的覆盖层。

1
2
3
profile(命名组合,如 web / headless)
└─ 依次叠加:bundle 列表 → profile cordis.patch.yml → home-level patch → --patch overlay
每一层的每一条 entry 都是一个可以单独替换的插件行

dsh --profile web --dump-config 能把最终拼出来的整棵插件树(也就是"一份能跑的产品")打印出来——任何一行都可以用你自己的 patch 行替换。这跟传统"改主程序"的开发方式完全不同:你不 fork 分支,你叠加 patch。

一个 Profile(如 web / headless)

dsh-base
模型适配器/工具/持久化/沙箱/审批

dsh-web-app 或 dsh-headless
浏览器应用 / 一次性运行器

profile cordis.patch.yml

home-level patch

--patch overlay

mountRootInclude
将各层展平为一个 patch 列表
逐条 apply 到空 entry 列表

最终插件树
--dump-config 可打印每一行

agent-loop 插件

session / 持久化插件

llm-deepseek 适配器

tools / bash / fs 工具

任何第三方 dsh-plugin

Let’s start with startup composition. This “plugin tree” is not the execution flow of one request. It is a list of capabilities to load at startup: a profile is a scenario preset, a bundle is a group of installable plugin configurations, and a patch replaces or inserts configuration rows by id.

1
2
3
profile (named composition, e.g. web / headless)
└─ stacked in order: bundle list → profile cordis.patch.yml → home-level patch → --patch overlay
Every entry in every layer is a plugin row that can be replaced individually

dsh --profile web --dump-config prints the entire final plugin tree (i.e., “one runnable product”) — any row can be replaced by a patch of your own. This is completely different from “modify the main program”: you don’t fork, you overlay patches.

A Profile (e.g. web / headless)

dsh-base
model adapters / tools / persistence / sandbox / approval

dsh-web-app or dsh-headless
browser app / one-shot runner

profile cordis.patch.yml

home-level patch

--patch overlay

mountRootInclude
flatten all layers into one patch list
apply each row onto an empty entry list

Final plugin tree
--dump-config can print every row

agent-loop plugin

session / persistence plugins

llm-deepseek adapter

tools / bash / fs tools

any third-party dsh-plugin

Cordis 微内核:事件是给人看的契约

要读懂 dsh,先读懂它的底座 Cordis。五个核心概念:

  1. 插件是一个实现 Service 的对象——一个带 inject + apply(ctx) 的函数,或一个 Service 子类;
  2. 上下文(Context)是服务的仓库——ctx.toolsctx.llmctx.sessions 都是稳定键,其他插件按 key 找服务,而不是 import 具体实现;
  3. inject 声明服务依赖——插件声明需要哪些服务,加载顺序就由"服务是否已存在"决定,而不是手工排启动顺序;
  4. 类型化事件(Typed Events)——服务通过 TS declaration merging 声明事件名,然后用 emit / waterfall / parallel / serial 四种模式派发;
  5. 注册是可逆副作用——提示词小节、工具 schema、适配器、监听器全部经 ctx.effect() / ctx.on() 安装,重载和卸载时成对撤销。

The Cordis microkernel: events are public contracts

To understand dsh, first understand its base, Cordis. Five core ideas:

  1. A plugin is an object that implements Service — a function with inject + apply(ctx), or a Service subclass;
  2. A context is a repository of servicesctx.tools, ctx.llm, ctx.sessions are stable keys; other plugins find a service by key instead of importing a concrete implementation;
  3. inject declares service dependencies — a plugin names what it needs; load order follows “does the service exist yet”, not hand-ordered boot sequences;
  4. Typed Events — services declare event names through TS declaration merging, then dispatch them as emit / waterfall / parallel / serial;
  5. Registrations are reversible effects — prompt sections, tool schemas, adapters, providers, and listeners are installed via ctx.effect() / ctx.on(), so reload and teardown unwind them in pairs.

四种派发模式,四种不同的契约

cordis-primer.md 里那张表是整个框架的"宪法":

模式 是否 await 顺序 返回值
emit 按注册顺序同步广播
waterfall 按注册顺序嵌套包裹
parallel 全部并行
serial 按注册顺序,首个返回值短路
bail 按注册顺序,首个同步非空返回值短路

本文重点讲 dsh 最常用的四种模式;Cordis 核心还提供 bail,用于同步地拿到第一个有效返回值。另一个关键事实是:@mode 标注是事件公开契约的一部分,生成器会校验声明与派发现场一致(缺失 @mode 即 violation,有 next 参数的事件必须声明为 waterfall)。这意味着"事件以什么方式派发"本身是类型系统强制的一部分。

表格中 waterfall 的“否”只表示 dispatcher 不像 parallel 那样自动等待并聚合所有监听器;监听器仍然可以返回 Promise,调用方可以显式 await ctx.waterfall(...)。agent-loop 就是这样使用它的。

waterfall 的"茅山道士"陷阱

ctx.waterfall 是 around-middleware:监听器收到 (...args, next)调用 next() 才会把值传下去;不调用就是短路(short-circuit)。

1
2
3
4
5
// 一个规范的 waterfall 监听器
ctx.on('agent/pre-step', async (payload, next) => {
// 观察/改写后必须 delegate,否则下游监听器和默认行为全部失效
return next()
})

这个"忘调 next() = 静默吞掉整条链"的坑,在官方教程里被反复强调为 standing rule。而这是设计如此——“single-decision events, short-circuiting is the design”:一个策略监听器决定自己拍板时,直接 return 不调 next() 就是"裁决";而一个只做注释/观察的监听器则必须 delegate。

在 agent-loop 里,这个机制被用得极其精妙:

  • agent/pre-step:默认 next() 保留 claimed 的消息;返回 { kind: 'reject' }{ kind: 'enter', messages } 就是短路决策——这是请求派生之前唯一一个串行决策链;
  • agent/request-error:短路返回 { kind: 'retry' } 就拥有恢复权,调 next() 则委托默认(= 终结失败);
  • agent/turn-stopping 反而是 serial 而不是 waterfall——因为它是最后一个协商点,需要"数据决定"而不是"嵌套包裹",监听器唯一的动作是 steer() 补充下一步输入,结果由 inbox 数据决定。

Four dispatch modes, four different contracts

The table in cordis-primer.md is the “constitution” of the whole framework:

Mode Awaited? Order Return value
emit No synchronous broadcast in registration order No
waterfall No nested wrapping in registration order Yes
parallel Yes all listeners in parallel No
serial Yes in registration order; first returned value short-circuits Yes
bail No in registration order; first synchronous non-empty value short-circuits Yes

This article focuses on the four modes dsh uses most often; Cordis also provides bail, which returns the first synchronous non-empty value. Another critical fact is that the @mode tag is part of an event’s public contract, and a generator validates declarations against real dispatch sites (missing @mode is a violation; an event with a next parameter must declare waterfall). So “how an event dispatches” is itself enforced by the type system.

The “No” in the waterfall row only means that the dispatcher does not automatically await and aggregate every listener as parallel does. A listener may still return a Promise, and the caller can explicitly await ctx.waterfall(...); agent-loop does exactly that.

The waterfall “sorcerer’s apprentice” trap

ctx.waterfall is around-middleware: listeners receive (...args, next); only calling next() passes the value down; returning without it short-circuits.

1
2
3
4
5
// A canonical waterfall listener
ctx.on('agent/pre-step', async (payload, next) => {
// observe/rewrite, then MUST delegate, or the whole downstream chain dies
return next()
})

This trap — “forgetting next() silently swallows the whole chain” — is hammered in the official tutorial as a standing rule. And that is by design — “single-decision events, short-circuiting is the design”: a policy listener that decides returns without next() as its ruling; a listener that only annotates or observes must delegate.

In agent-loop this mechanism is used with remarkable elegance:

  • agent/pre-step: the default next() preserves claimed messages; returning { kind: 'reject' } or { kind: 'enter', messages } is a short-circuit ruling — this is the only serial decision chain before request derivation;
  • agent/request-error: short-circuiting with { kind: 'retry' } owns the recovery, while calling next() delegates the default (= terminate with failure);
  • agent/turn-stopping, on the other hand, is serial, not waterfall — it is the final negotiation point and needs “data to decide” rather than “nesting to wrap”; a listener’s only move is steer() to append next-step input, and the result is decided by inbox data.

作用域(scope):每个 agent 一个"私有世界"

这一节是实现细节。如果你只想先看懂“输入如何到达模型、工具如何执行、日志如何恢复”,可以先跳到 事件溯源;回头再看 scope。它解决的问题很具体:如何让不同 agent 拥有不同的工具、提示词和监听器,而不会互相污染。

dsh 里每个活着的 agent 都拥有一个 scope,所有经 agent.ctx 的注册只对这个 agent 可见,且随 agent 生命周期一起销毁。

作用域的实现值得细看——它用 WeakMap 存 parent 链,只允许一次 bind

1
2
3
4
5
6
7
8
9
10
// packages/core/scope/src/index.ts
const scopeParents = new WeakMap<ScopeKey, ScopeKey>()

export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding {
if (scopeParents.has(key)) {
throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind')
}
linkScopeParent(key, parent) // 带环检测
return { rebind(next) { linkScopeParent(key, next) } }
}

两条链方向(注释里点破):

  • 注册视图向下继承:子 scope 能"看到"祖先层的注册(ScopedLayers merge 时沿 parent 链走);
  • 事件准入向上延伸:带祖先 key 的监听器也能收 descendant 事件(scopeTarget 的 carrier filter)。

一个 agent scope 的世界 = 全局注册 + 自己这一层的注册,同名时最近者胜(shadowing)——这是"per-agent persona"和"per-agent 工具变体"的底层机制。subagent 的子 scope 不会向下继承(flat 两级),子树行为用 lineage 数据表达而不是 scope 结构。

scope carrier 还有个隐蔽的坑:主题不符的监听器会静默漏收事件scope-invariant 插件用 internal/dispatch 强制 carrier 必须存在、且其 key 与事件载荷里的 subject 是同一个对象——所以"漏收"在开发期就会被 invariant 抓到,而不是等上线后灵异复现。

Scope: a “private world” per agent

This is an implementation-detail section. If you only want the main story first — how input reaches the model, how tools run, and how logs recover — skip to Event sourcing and return here later. Scope solves one concrete problem: how different agents get different tools, prompts, and listeners without contaminating one another.

Every live agent in dsh owns a scope; all registrations through agent.ctx are visible only to that agent and die with it.

The implementation is worth a close look — it stores the parent chain in a WeakMap, and each key can be bound only once:

1
2
3
4
5
6
7
8
9
10
// packages/core/scope/src/index.ts
const scopeParents = new WeakMap<ScopeKey, ScopeKey>()

export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding {
if (scopeParents.has(key)) {
throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind')
}
linkScopeParent(key, parent) // cycle-checked
return { rebind(next) { linkScopeParent(key, next) } }
}

Two directions of the chain (as the comments spell out):

  • Registration views inherit DOWN: a child scope sees its ancestors’ layers (when ScopedLayers merges, it walks the parent chain);
  • Event admission extends UP: a listener tagged with an ancestor key also receives events dispatched to a descendant key (the carrier filter built by scopeTarget).

An agent scope’s world = global registrations + its own layer, with the same-name most-specific-wins shadowing — this is the underlying mechanism behind per-agent personas and per-agent tool variants. A subagent’s child scope does not inherit down (flat, two levels); subtree behavior is expressed with lineage data, never scope structure.

The scope carrier has one more subtle trap: listeners whose subject doesn’t match silently miss events. The scope-invariant plugin uses internal/dispatch to force the carrier to exist and its key to be the same object as the subject in the event payload — so “missed events” get caught by an invariant in development, instead of haunting you in production.

ctx.effect():注册是效果,卸载是可逆的

"每个注册必须有一个 disposer"是硬规则。ctx.effect(fn) 接受一个返回 disposer(函数 / 可迭代 / Promise)的回调,卸载时倒序执行。

一个容易踩的坑藏在文档里:多个 async disposer 是并发执行的——如果要保证顺序,必须把相关工作塞进同一个 disposer 里:

1
2
3
4
5
ctx.effect(async () => {
await teardownA()
await teardownB() // 保持顺序:放进同一个 effect
})
// 而不是注册两个并行 effect,它们的 cleanup 并发跑,顺序无法保证

对 agent-loop 这样的状态机来说,dispose 顺序意味着"先停 driver、再拆注册、再关 store"的既定序列,乱一步就可能复现"幽灵注册"(卸载后事件仍被某处处理)。

ctx.effect(): registration is an effect, unload is reversible

“Every registration must have a disposer” is a hard rule. ctx.effect(fn) accepts a callback returning a disposer (function / iterable / Promise), and unwinds them in reverse order on unload.

One easy-to-step-on trap hides in the docs: multiple async disposers run concurrently — if you need ordering, push the related work into one disposer:

1
2
3
4
5
ctx.effect(async () => {
await teardownA()
await teardownB() // preserve order: keep them in one effect
})
// instead of two parallel effects whose cleanups race with no ordering

For a state machine like agent-loop, dispose order means a fixed sequence of “stop the driver, then tear down registrations, then shut the store”; one wrong step and you get “ghost registrations” (events still handled by something after unload).

事件溯源:Session 日志即真相

这是整个框架的灵魂,也是我花最多时间验证的部分。

核心命题:模型的历史不是"存下来的数组",而是"从日志里重新算出来的函数"。 日志存的是原子事件(user/messageassistant/chunktool/result…)。其中,Session.deriveMessages() 专门负责模型历史;token 用量、todo 和标题也由日志支撑,但分别由 projection、todo/write 快照和 session-title 插件维护。它们共享“日志可重建”的原则,不是同一个函数,也不是同一种投影。

把三层关系记成一条链:SessionEvent[] -> surface seq[] -> Message[] -> DeepSeek wire messages。后文先讲这条主链,再讲其它日志投影。

早期的架构笔记(2026-06-11-event-sourced-sessions.md)把这个决定写得很直白:

“A Session is an append-only log of typed SessionEvents — the single source of truth. The LLM message history is derived from the log (deriveMessages()).”

它对比过备选方案:一个"可变消息数组 + 事件通知"显然更简单——但状态和日志会漂移。事件溯源让"日志就是状态",发散在结构上不可能发生。

一个真实会话长什么样

这是仓库里一个真实的测试 fixture(examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl),我把关键的 seq 标注出来。一个"运行 echo 命令并把 stdout 回给模型"的 turn,落盘后是这样的(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"agent/inbox/spliced","seq":0,"time":1785498589606,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this exact command with your bash tool..."}],"source":{"kind":"user"},"role":"user","id":"8ef0..."}]}}
{"type":"turn/start","seq":1,"time":1785821460035,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":2,"time":1785498589606,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1785097395908,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1785498589630,"data":{"content":[...],"source":{"kind":"user"},"role":"user","id":"8ef0..."},"surfaceOp":"append"}
{"type":"request/header","seq":6,"time":1785498589632,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":7,"time":1785498589630,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":8,"time":1785097396657,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":9,"time0":1785097396679,"data":{"turn":1,"step":1,"index":0,"dt":[...],"texts":["The"," user"," wants"," me"," to"," run"," ..."]}}
{"type":"assistant/chunk","seq":26,"time":1785097396857,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":27,"time0":1785097396857,"data":{"turn":1,"step":1,"index":1,"dt":[...30 gaps...],"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," d","sh","-s","dk","-proof","-","739","1","\"",", ","\"","description","\"",": ","\"","Run"," the"," echo"," command"," as"," requested","\"","}"]}}
{"type":"assistant/chunk","seq":58,... "chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific bash command..."}}}
{"type":"assistant/chunk","seq":61,"time":1785730506499,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":62,"time":1785730506500,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning",...},{"type":"tool-call","id":"call_00_...","name":"bash","arguments":"{...}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"..."},"usage":{...},"sourceEventSeqs":[...8..61],"surfaceOp":"append"}
{"type":"tool/call","seq":63,"time":1785730506500,"data":{"turn":1,"step":1,"callId":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{...}"}}
{"type":"tool/result","seq":64,"time":1785730506517,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_..."},"content":[{"type":"tool-result","toolCallId":"call_00_...","content":[{"type":"text","text":"dsh-sdk-proof-7391\n"}],"isError":false}],"role":"user","id":"..."}},"sourceEventSeqs":[63],"surfaceOp":"append"}
{"type":"step/end","seq":65,"time":1785730506517,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":66,...} // step 2: 模型看到 tool/result 后继续
...
{"type":"turn/end","seq":98,"time":1785730506531,"data":{"turn":1,"reason":{"kind":"completed"}}}

请注意几个细节:

  1. agent/inbox/spliced 先于 user/message:输入还没有变成模型消息之前,它的入队/出队就已被持久化。连 inbox 都是 durable 的——重启后 inbox 能重建。
  2. assistant/message 携带 sourceEventSeqs:它引用了组成它的 54 个 chunk 的 seq(8..61)——一条消息可被精确定位到它由哪些 token 增量拼成。
  3. reasoning-chunks / tool-call-chunks 是打包行seq 8 是 reasoning block 的开始,真正的 reasoning packed row 从 seq0=9 开始,覆盖 seq 9..25 的 17 个增量;tool-call packed row 从 seq0=27 开始,覆盖 seq 27..57 的 31 个增量(详见 chunk-rows 一节)。
  4. tool/resultroleuser:工具结果不是独立角色,而是 user 角色消息里嵌一个 tool-result block。

Event sourcing: the session log is the truth

This is the soul of the whole framework, and the part I spent the most time verifying.

Core proposition: the model’s history is not a “stored array”, it is a function recomputed from a log. The log stores atomic events (user/message, assistant/chunk, tool/result…). Session.deriveMessages() specifically owns model history; token usage, todos, and titles are also log-backed, but are maintained by projections, todo/write snapshots, and the session-title plugin respectively. They share the rule that state can be rebuilt from the log, but they are not one function or one kind of projection.

Keep the main chain in mind: SessionEvent[] -> surface seq[] -> Message[] -> DeepSeek wire messages. The article follows this chain first, then returns to the other log projections.

An early architecture note (2026-06-11-event-sourced-sessions.md) puts the decision bluntly:

“A Session is an append-only log of typed SessionEvents — the single source of truth. The LLM message history is derived from the log (deriveMessages()).”

It considered the alternative — a “mutable message array + event notifications” is clearly simpler — but then state and log can diverge. With event sourcing, the log IS the state, so divergence is structurally impossible.

What a real session looks like

This is a real test fixture from the repo (examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl), with the key seqs annotated. A turn that runs echo via bash and feeds the stdout back to the model looks like this on disk (excerpt):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"agent/inbox/spliced","seq":0,"time":1785498589606,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this exact command with your bash tool..."}],"source":{"kind":"user"},"role":"user","id":"8ef0..."}]}}
{"type":"turn/start","seq":1,"time":1785821460035,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":2,"time":1785498589606,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1785097395908,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1785498589630,"data":{"content":[...],"source":{"kind":"user"},"role":"user","id":"8ef0..."},"surfaceOp":"append"}
{"type":"request/header","seq":6,"time":1785498589632,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":7,"time":1785498589630,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":8,"time":1785097396657,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":9,"time0":1785097396679,"data":{"turn":1,"step":1,"index":0,"dt":[...],"texts":["The"," user"," wants"," me"," to"," run"," ..."]}}
{"type":"assistant/chunk","seq":26,"time":1785097396857,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":27,"time0":1785097396857,"data":{"turn":1,"step":1,"index":1,"dt":[...30 gaps...],"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," d","sh","-s","dk","-proof","-","739","1","\"",", ","\"","description","\"",": ","\"","Run"," the"," echo"," command"," as"," requested","\"","}"]}}
{"type":"assistant/chunk","seq":58,... "chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific bash command..."}}}
{"type":"assistant/chunk","seq":61,"time":1785730506499,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":62,"time":1785730506500,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning",...},{"type":"tool-call","id":"call_00_...","name":"bash","arguments":"{...}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"..."},"usage":{...},"sourceEventSeqs":[...8..61],"surfaceOp":"append"}
{"type":"tool/call","seq":63,"time":1785730506500,"data":{"turn":1,"step":1,"callId":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{...}"}}
{"type":"tool/result","seq":64,"time":1785730506517,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_..."},"content":[{"type":"tool-result","toolCallId":"call_00_...","content":[{"type":"text","text":"dsh-sdk-proof-7391\n"}],"isError":false}],"role":"user","id":"..."}},"sourceEventSeqs":[63],"surfaceOp":"append"}
{"type":"step/end","seq":65,"time":1785730506517,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":66,...} // step 2: after seeing tool/result, model continues
...
{"type":"turn/end","seq":98,"time":1785730506531,"data":{"turn":1,"reason":{"kind":"completed"}}}

Notice several details:

  1. agent/inbox/spliced precedes user/message — before the input becomes a model message, its enqueue/dequeue is already persisted. Even the inbox is durable — a restart can rebuild it.
  2. assistant/message carries sourceEventSeqs — it cites the 54 chunk seqs it was built from (8..61), so a message can be located precisely to the token deltas that composed it.
  3. reasoning-chunks / tool-call-chunks are packed rowsseq 8 is the reasoning block-start; the reasoning packed row begins at seq0=9 and covers 17 deltas at seq 9..25. The tool-call packed row begins at seq0=27 and covers 31 deltas at seq 27..57 (details in the chunk-rows section).
  4. tool/result’s role is user — a tool result is not its own role; it rides inside a user-role message as a tool-result block.

SessionEventMap:可被插件扩展的"事件宪法"

事件全集是一个接口 + declaration merging,所以第三方插件也能往会话日志里加自己的事件类型(compaction、hook、approval、goal 都是这么干的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// packages/core/session/src/types.ts(核心族节选)
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; usage?: TokenUsage }
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
'tool/result': { turn: number; step: number; message: ToolResultMessage; error?: { name: string; code: string }; meta?: JsonValue }
'todo/write': { todos: TodoItem[] }
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
'request/context': RequestContext
'session/end-seed': Record<string, never>
}

每个事件外面再包一层 SessionEvent<T> 信封,长得像这样(注意 ignorable 这个字段的设计):

1
2
3
4
5
type SessionEvent<T> = { type: T; seq: number; time: number; data: SessionEventMap[T];
ignorable?: true // 未知事件跳过标记
// 仅 surface 消息携带:
sourceEventSeqs?: number[]; surfaceOp?: SurfaceOp
}

ignorable 的设计哲学是最精彩的细节:未知事件类型默认"不可忽略"——一个没有 ignorable: true 的未知事件,读方必须拒绝重建整个会话,而不是静默丢弃。为什么?因为忘标 ignorable 只会过度拒绝(不便);而漏标"required"则会静默恢复一个被掏空的会话(危险)。这是"安全方向上的固执"。

SessionEventMap: the expandable “event constitution”

The full event set is an interface + declaration merging, so third-party plugins can add their own event types to the session log (compaction, hooks, approvals, goals all do this):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// packages/core/session/src/types.ts (core families, excerpt)
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; usage?: TokenUsage }
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
'tool/result': { turn: number; step: number; message: ToolResultMessage; error?: { name: string; code: string }; meta?: JsonValue }
'todo/write': { todos: TodoItem[] }
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
'request/context': RequestContext
'session/end-seed': Record<string, never>
}

Each event is wrapped in a SessionEvent<T> envelope that looks like this (note the design of the ignorable field):

1
2
3
4
5
type SessionEvent<T> = { type: T; seq: number; time: number; data: SessionEventMap[T];
ignorable?: true // marker for unknown events
// surface messages only:
sourceEventSeqs?: number[]; surfaceOp?: SurfaceOp
}

The design philosophy of ignorable is the most delightful detail: unknown event types are required by default — an unknown event without ignorable: true must make the reader refuse to rebuild the whole session, never silently drop it. Why? Because forgetting to mark ignorable only causes over-rejection (inconvenient); but forgetting to mark required would silently restore a hollowed-out session (dangerous). This is stubbornness in the safe direction.

deriveEventMessage():单个事件如何变成消息

这里要区分两个函数。deriveEventMessage() 位于 packages/core/session/src/surface.ts,只回答“这一条 event 是否产生一条 Message”;Session.deriveMessages() 位于 packages/core/session/src/index.ts,负责遍历当前 surface 的事件序列并缓存完整的模型历史。下面展示的是前者,不能把两个函数混称。

单个事件的投影规则只有三行,而且同一份规则既用于内存投影,也用于持久化重建

1
2
3
4
5
6
7
8
9
10
export function deriveEventMessage(event: SessionEvent): Message | null {
switch (event.type) {
case 'user/message': return event.data // 原样投射
case 'assistant/message': // 空内容直接 null —— 只承载 usage 的 max-tokens step 不进 transcript
if (event.data.message.content.length === 0) return null
return event.data.message
case 'tool/result': return event.data.message // ToolResultMessage 本身就是 user 角色
default: return null // 边界/chunk/日志事件无消息
}
}

三个"看不出但必须知道"的点:

  1. assistant/chunk 永远不进历史——重放/UI 用它,模型看到的永远是组装好的 assistant/message
  2. 空内容的 assistant/message 被跳过——max-tokens 截断时仍会记录一条只承载 usage 的消息,但"内容为空的 assistant 回合不能进入 provider transcript";
  3. tool result 以 user 角色进入历史——这正是上一篇博客(LLM 层)里"为什么没有 role: 'tool'"的答案:harness 词汇里只有三种角色(system/user/assistant),工具结果是谁产生的由独立的 source 轴表达,到 wire 序列化时才拆成 role: 'tool'

Session.deriveMessages() 再使用一个 O(1) 增量缓存:SurfaceManagerreplaceGeneration 单调递增,只有在出现 replace(压缩重写)时才重建缓存,普通 append 只折叠增量。

deriveEventMessage(): how one event becomes one message

Two functions should be kept separate. deriveEventMessage() lives in packages/core/session/src/surface.ts and answers “does this one event produce a Message?” Session.deriveMessages() lives in packages/core/session/src/index.ts and walks the current surface sequence to build and cache the complete model history. The code below is the former; the two names should not be conflated.

The projection rule for one event is only three lines, and the same rule is used for the in-memory projection and persistence rebuild:

1
2
3
4
5
6
7
8
9
10
export function deriveEventMessage(event: SessionEvent): Message | null {
switch (event.type) {
case 'user/message': return event.data // projected as-is
case 'assistant/message': // empty content -> null — a usage-only max-tokens step never enters the transcript
if (event.data.message.content.length === 0) return null
return event.data.message
case 'tool/result': return event.data.message // ToolResultMessage is already a user-role message
default: return null // boundary/chunk/log events carry no message
}
}

Three points you can’t see at a glance but must know:

  1. assistant/chunk never enters history — replay/UI use it; the model only ever sees the assembled assistant/message;
  2. Empty-content assistant/message is skipped — a max-tokens truncation still records a usage-only message, but “a content-less assistant turn must not enter the provider transcript”;
  3. Tool results enter history as user role — this is the answer to "why is there no role: 'tool'" from the LLM-layer post: the harness vocabulary has only three roles (system/user/assistant); who produced a tool result is expressed by a separate source axis, and only at wire serialization is it split into role: 'tool'.

Session.deriveMessages() then uses an O(1) incremental cache: SurfaceManager uses a monotonically increasing replaceGeneration, rebuilding the cache only when a replace (compaction rewrite) occurs; ordinary appends only fold the delta.

invariant:给"日志即真相"上保险

光声明"日志是真相"是不够的。agent-loop/src/invariant.ts 注册了一个 Cordis 伴生插件,在 llm/streamprepend 一个全局检查(prepend 是为了防止某个 replay 监听器短路时跳过检查):

1
2
3
4
5
6
7
8
9
10
ctx.on('llm/stream', (options, next) => {
if (!isAgentLoopRequest(options)) return next()
// ... 校验 session 存在、messages 被冻结 ...
const expected = session.deriveMessages()
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
fail(`llm request for session ... diverges from the dispatch-time durable derivation (log-reconstruction desync)`)
}
// 校验 request header 各字段与日志折叠一致
return next()
}, { global: true, prepend: true })

对 agent-loop 构造并标记的请求,分派瞬间会把消息的 JSON 表示与"从日志重新投影出的消息"比较。它还会检查冻结状态、session、model、system、temperature、maxTokens、stop 和 tools 等关键字段;它不是所有 ctx.llm.stream() 调用的通用检查器,而且需要 invariant companion 插件被加载。

这条 invariant 把"事件溯源"从一句哲学变成了一个 dispatch-time 的运行时闭环:模型请求 ≡ 日志的纯函数,任何偏离都是 bug,而不是优雅降级。这也是整个 harness 可重放、可恢复、可审计的根基。

invariant: insurance on “the log is the truth”

Merely declaring “the log is the truth” is not enough. agent-loop/src/invariant.ts registers a Cordis companion plugin that prepends a global check on llm/stream (prepend ensures the check runs even if some replay listener short-circuits):

1
2
3
4
5
6
7
8
9
10
ctx.on('llm/stream', (options, next) => {
if (!isAgentLoopRequest(options)) return next()
// ... validates session exists, messages are frozen ...
const expected = session.deriveMessages()
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
fail(`llm request for session ... diverges from the dispatch-time durable derivation (log-reconstruction desync)`)
}
// validates request header fields match the folded log
return next()
}, { global: true, prepend: true })

For requests constructed and marked by agent-loop, dispatch compares the JSON representation of their messages with the messages re-projected from the log. It also checks frozen state, session, model, system, temperature, maxTokens, stop, and tools. It is not a universal check for every ctx.llm.stream() call, and it exists only when the invariant companion is loaded.

This invariant turns “event sourcing” from a philosophy into a dispatch-time runtime closed loop: the model request ≡ a pure function of the log; any divergence is a bug, not graceful degradation. This is also the very foundation of the harness’s replayability, recoverability, and auditability.

Surface:历史是日志的"视图",可以被重写但不被篡改

到这里有个问题浮出来:如果历史完全由日志决定,那 compaction(上下文压缩)怎么办?长会话总会超出上下文窗口,压缩必须要"把旧消息换成摘要"——但"日志即真相"又不允许删改。

surface(表面)机制就是为这道题发明的。它的设计笔记(2026-06-18-session-surface.md)开场就问:

“The event log is authoritative, but history manipulation had no durable shared mechanism.”

两个新字段:sourceEventSeqssurfaceOp

每条 surface 事件(user/messageassistant/messagetool/result)在信封上多了两个顶层字段:

  • sourceEventSeqs?: number[]:这条消息由哪些更早的事件构成(assistant/message 引用它的 chunk seqs,tool/result 引用它的 tool/call seq)。对于 replace,数组必须至少覆盖所有被遮蔽的 surface 节点,也可以额外记录 compaction 的 trace event。只允许 assistant/message 携带合法的空数组 [](记录"已知的空 provider 流");
  • surfaceOp?: SurfaceOp:这条事件如何进入表面——'append'(尾部追加)或 { op: 'replace'; start; end }(覆盖 [start, end] 闭区间的表面节点)。

SurfaceManager 维护一个有序 seq 数组,就是"当前模型可见的事件序列表"。replace 的语义是:被覆盖的旧事件仍在日志里(永不删除),只是不再位于表面上

compaction 就是一次 surface replace

压缩插件(dsh-compaction-basic)把"旧对话+摘要"用一条 user/message(装着摘要)替换掉表面上的一段区间:

1
2
3
4
5
6
7
8
session.append('user/message', {
content: [{ type: 'text', text: summary }],
source: { kind: 'plugin' },
role: 'user',
}, {
surfaceOp: { op: 'replace', start, end }, // start/end 是当前表面上的 seq
sourceEventSeqs: [...被覆盖的所有 seq], // 必须覆盖每个被遮蔽的节点
})

关键约束(surface manager 强制校验):

  • start / end 必须在当前表面上、且 start <= end
  • sourceEventSeqs 必须完整覆盖每个被遮蔽的节点,但不要求只能包含这些节点——compaction 还可以把自己的 start/summary trace event 一起记录进去;
  • 只允许 tool/result 做"替换另一个 tool/result"这种特例,且只能改 content 字段,其它字段必须深相等。

compaction/startcompaction/end 这类"trace 事件"不进表面——它们只是日志里的说明文字,不影响模型看得见的历史。

为什么这套设计"妙"

  • 重放确定性:重放时从日志重跑 surface 折叠(foldSurface),replace 决策作为事件字段保存下来,结论确定;
  • 人类 transcript 与模型面分离:人看到的对话应该从 append-origin 事件读取;模型看到的表面"故意遮蔽"被替换的区间——同一个日志,两种视图,互不污染
  • 崩溃安全repair.ts 合成 closers 时带上 surfaceOp: 'append' + 正确的 sourceEventSeqs,重水合后的 surface 依然合法;
  • 不破坏 invariant:因为历史还是"日志的纯函数",只是多了一层 replace 视图变换——deriveMessages() 依然可重算、可校验。

history 操作从"改代码"变成"发事件",这正是事件溯源能支撑 compaction 而不破坏"日志即真相"的原因。

Surface: history is a view of the log, rewritable but never tampered

A problem surfaces here: if history is entirely determined by the log, what about compaction? Long sessions always outgrow the context window; compaction must “replace old messages with a summary” — but “the log is the truth” forbids deletion.

The surface mechanism was invented for exactly this. Its design note (2026-06-18-session-surface.md) opens with the question:

“The event log is authoritative, but history manipulation had no durable shared mechanism.”

Two new fields: sourceEventSeqs and surfaceOp

Each surface event (user/message, assistant/message, tool/result) gains two top-level fields on its envelope:

  • sourceEventSeqs?: number[]: which earlier events this message was built from (assistant/message cites its chunk seqs, tool/result cites its tool/call seq). For a replace, the array must cover every shadowed surface node, but it may also include compaction trace events. Only assistant/message may carry a legal empty array [] (recording a “known-empty provider stream”);
  • surfaceOp?: SurfaceOp: how this event entered the surface — 'append' (tail append) or { op: 'replace'; start; end } (shadowing the closed [start, end] range of surface nodes).

SurfaceManager keeps an ordered seq array — the “currently model-visible event order”. The semantics of replace: the shadowed old events remain in the log forever (never deleted), they just leave the surface.

Compaction is just a surface replace

The compaction plugin (dsh-compaction-basic) replaces a surface range with one user/message (carrying the summary):

1
2
3
4
5
6
7
8
session.append('user/message', {
content: [{ type: 'text', text: summary }],
source: { kind: 'plugin' },
role: 'user',
}, {
surfaceOp: { op: 'replace', start, end }, // start/end are seqs on the current surface
sourceEventSeqs: [...all shadowed seqs], // must cover every shadowed node
})

The key constraints (enforced by the surface manager):

  • start / end must be on the current surface, and start <= end;
  • sourceEventSeqs must completely cover every shadowed node, but it need not contain only those nodes — compaction may also cite its start and summary trace events;
  • The one special case is a tool/result replacing another tool/result, and only the content field may change — every other field must be deep-equal.

Whereas compaction/start, compaction/end, and other “trace events” never enter the surface — they are mere annotation in the log, invisible to the model’s history.

Why this design is “clever”

  • Replay determinism: on replay, foldSurface re-runs the surface fold from the log; replace decisions are persisted as event fields, so the outcome is deterministic;
  • Human transcript vs model surface are separate: humans read the dialogue from append-origin events; the model’s surface deliberately shadows replaced ranges — one log, two views, never polluting each other;
  • Crash safety: repair.ts synthesizes closers with surfaceOp: 'append' and correct sourceEventSeqs, so the rehydrated surface stays valid;
  • Invariant intact: history is still a pure function of the log, just with one more view transform (replace) — deriveMessages() remains recomputable and verifiable.

History manipulation goes from “changing code” to “emitting events” — this is exactly why event sourcing can support compaction without breaking “the log is the truth”.

chunk-rows:把 56 倍浪费压回去(附实测)

这是存储层的优化细节,不是 Agent 必须理解的运行步骤。如果你想先沿着“请求模型 → 执行工具 → 进入下一 step”的主线阅读,可以先跳过这一节,读完持久化后再回来。

事件溯源有个实际代价:provider 流式输出 token 级 delta,一个会话会存几百条几乎相同的 assistant/chunk。chunk-rows.ts 的模块注释记录过一次真实 DeepSeek 会话的测量值:

“a log stores hundreds of near-identical event lines whose JSON envelopes dwarf their payloads (~56× measured on a real DeepSeek session)”

也就是:在那次测量里,事件信封(JSON 结构)约是实际 token 数据的 56 倍。这不是所有会话都固定拥有的比例。

打包规则:只压缩"同类的连续 run"

  • 只对三种 delta kind 打包:text-delta / reasoning-delta / tool-call-delta;block 边界、usage、finish 永远一行一事件;
  • MIN_RUN = 3:连续同 kind、同 block index、seq 连续、turn/step 相同才可能打包;tool-call 还必须保持相同的 id,并且 name 字段的存在性和值一致;
  • 时间用 gap(dt) 数组编码(成员 k 的时间 = time0 + 前 k 个 gap),gap 可以为负(墙钟回拨);
  • 白名单精确形状classify() 逐字段核对;任何不认识的东西原样存储——“未知字段或未来 chunk 变体失去压缩,绝不丢数据”(lose compression, never data)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// packages/core/session/src/chunk-rows.ts —— 结构示意,字段类型完整
interface RunLocation {
turn: number
step: number
index: number
dt: number[]
}

interface TextRunData extends RunLocation {
texts: string[]
}

interface ToolCallRunData extends RunLocation {
id: string
name?: string
args: string[]
}

export type ChunkRow =
| { type: 'text-chunks'; seq0: number; time0: number; data: TextRunData }
| { type: 'reasoning-chunks'; seq0: number; time0: number; data: TextRunData }
| { type: 'tool-call-chunks'; seq0: number; time0: number; data: ToolCallRunData }

我实测的数据

为了验证,我对真实 fixture 做了简单统计(示例 33 行的 bash-tool/session.jsonl):

  • 33 行文件里,4 个打包行覆盖了 71 个原始 seq 事件
  • 若不打包,估算体积约 16 KB;打包后 7.4 KB——节省约 54%
  • 单个未压缩的 assistant/chunk 行平均约 122 字节(对一条只有几个 token 的 delta 来说,确实很贵)。
总行数: 33      打包行: 4      被打包事件(seq): 71
若不打包估计: 16026 B    打包后: 7364 B    节省: 54.0%

chunk-rows: squeezing out the 56× waste (with my own measurement)

This is a storage optimization, not a runtime step the Agent must understand. If you want to follow the main path first — “call the model → run a tool → enter the next step” — skip this section and return after the persistence section.

Event sourcing has a practical cost: providers stream token-sized deltas, so a session stores hundreds of near-identical assistant/chunk rows. The chunk-rows.ts module comment gives a shocking number:

“a log stores hundreds of near-identical event lines whose JSON envelopes dwarf their payloads (~56× measured on a real DeepSeek session)”

That is: in that measurement, the event envelope (JSON structure) was about 56× the actual token data. It is not a fixed ratio for every session.

Packing rules: only “runs of the same kind” compress

  • Only three delta kinds pack: text-delta / reasoning-delta / tool-call-delta; block boundaries, usage, and finish always stay one event per line;
  • MIN_RUN = 3: consecutive same-kind, same block index, contiguous seq, and same turn/step are required; tool-call runs must also keep the same id and the same name-field presence and value;
  • Time is encoded as gap (dt) arrays (member k’s time = time0 + first k gaps), and a gap may be negative (wall clock stepped backwards);
  • Exact-shape whitelist: classify() checks field by field; anything it does not fully recognize is stored verbatim — “unknown fields or future chunk variants lose compression, never data”.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// packages/core/session/src/chunk-rows.ts — structural example with complete field types
interface RunLocation {
turn: number
step: number
index: number
dt: number[]
}

interface TextRunData extends RunLocation {
texts: string[]
}

interface ToolCallRunData extends RunLocation {
id: string
name?: string
args: string[]
}

export type ChunkRow =
| { type: 'text-chunks'; seq0: number; time0: number; data: TextRunData }
| { type: 'reasoning-chunks'; seq0: number; time0: number; data: TextRunData }
| { type: 'tool-call-chunks'; seq0: number; time0: number; data: ToolCallRunData }

My own measurement

To verify, I ran a quick stat over a real fixture (the 33-line bash-tool/session.jsonl):

  • The 4 packed rows cover 71 original seq events in the 33-line file;
  • Unpacked estimate ~16 KB; packed 7.4 KB — about 54% savings;
  • A single uncompressed assistant/chunk row averages ~122 bytes (for a delta of only a few tokens, that is genuinely expensive).
total lines: 33      packed rows: 4      packed events (seq): 71
unpacked estimate: 16026 B    packed: 7364 B    savings: 54.0%

Agent 主循环:一个"双层 while"状态机

日志是"真相",那谁在日志上执行?就是 ReactLoopAgentpackages/core/agent-loop/src/agent.ts,约 500 行)。它叫 React 是因为它就是个响应式驱动:输入到达 inbox → 唤醒 driver → 跑一圈 → 回到 idle。

它的顶层相位是三态:

1
2
3
4
type Phase =
| { kind: 'idle'; lastTurn: number }
| { kind: 'maintenance'; abort: AbortController; lastTurn: number; wakeRequested: boolean }
| { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }
  • idle / maintenance 对外都显示 idle 状态(maintenance 是非 turn 的后台任务,如 checkpoint);
  • 每个活动持有一个 AbortController每开新轮换新控制器
  • 4 个输入入口:send()(统一入口)、followup()(下一轮 + 唤醒)、steer()(步内转向 + 唤醒)、inject()(步内注入、不唤醒)。

turn 与 step:两个概念,两层循环

  • turn(轮):一次用户输入驱动的完整 model-loop 执行(turn/start … turn/end);
  • step(步):一次模型请求 + 它引发的所有工具执行(step/start … step/end)。工具结果之后的下一次模型请求会打开新的 step。

主循环是双层 while

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
async kick() {
while (await this.turn()) {} // 外层:还有 pending 就再开一轮
}

async turn(): Promise<boolean> {
// 内层 while:一次 turn 里的 step 循环
while (true) {
const decision = await this.preStep(target, { turn, step })
if (decision.kind === 'reject') return false // 拒绝:轮以 blocked 结束
// 空首步(被移除的唤醒消息 / 重写成空):消费 turn 边界但不花模型调用
if (phase.step === 0 && decision.messages.length === 0) return false

session.append('step/start', { turn, step })
const stepEnd = await this.step(decision.assembly) // 一次模型请求 + 它触发的工具执行
session.append('step/end', { turn, step })

if (turnEnds && this.inbox.nextStep.length === 0) {
await this.dispatch.serial('agent/turn-stopping', { turn, signal }) // 最后协商点
}
if (turnEnds && this.inbox.nextStep.length === 0) break // 关轮
}
...
return this.inbox.hasPending // 还有工作 → 开新轮
}

turn() 的内层循环会在工具执行后写入 step/end,然后为带着 tool/result 的下一次模型请求重新打开 step/startstep() 自己的 while (true) 主要用于 agent/request-error 的重试:失败且策略决定 retry 时,它在同一个 turn 和 step 内重新构造请求;正常的工具结果不会在旧 step 里继续发请求。

reject

空首步

enter

有工具调用

无工具调用

还有 pending

排空

输入到达 inbox

send / followup / steer / inject

wakeDriver 唤醒 driver

Kick 外层循环
while (await this.turn())

preStep
claim inbox + 组装 prompt + agent/pre-step 决策

turn/end blocked
return false

turn/end completed
不花模型调用

step/start 落盘

buildRequest + llm/stream
逐 chunk 落盘 assistant/chunk

assistant/message + tool-call?

executeToolCalls
barrier / rolling pool

tool/result 按模型序落盘

step/end 落盘

next-step 还有输入?

新的 preStep
为下一步 claim 输入

agent/turn-stopping 串行协商

next-step 空了?

turn/end + return true/false

idle

The agent loop: a “double-while” state machine

The log is the “truth”, but who executes on the log? That’s ReactLoopAgent (packages/core/agent-loop/src/agent.ts, ~500 lines). It’s called React because it’s a reactive driver: input arrives at the inbox → wakes the driver → runs a lap → back to idle.

Its top-level phase is three-state:

1
2
3
4
type Phase =
| { kind: 'idle'; lastTurn: number }
| { kind: 'maintenance'; abort: AbortController; lastTurn: number; wakeRequested: boolean }
| { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }
  • idle / maintenance both show as idle externally (maintenance is non-turn background work like checkpoints);
  • each activity holds an AbortController, and a fresh controller is minted per new turn;
  • four input entries: send() (the unified entry), followup() (next-turn + wake), steer() (in-step steering + wake), inject() (in-step injection, no wake).

turn vs step: two concepts, two nested loops

  • turn: one model-loop execution driven by a user input (turn/start … turn/end);
  • step: one model request plus all the tool executions it caused (step/start … step/end). The next model request after a tool result opens a new step.

The main loop is a double while:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
async kick() {
while (await this.turn()) {} // outer: as long as there's pending work, open another turn
}

async turn(): Promise<boolean> {
// inner while: the step loop within one turn
while (true) {
const decision = await this.preStep(target, { turn, step })
if (decision.kind === 'reject') return false // rejection: the turn ends as blocked
// empty first step (removed wake message / rewritten-to-empty): spends the turn boundary but no model call
if (phase.step === 0 && decision.messages.length === 0) return false

session.append('step/start', { turn, step })
const stepEnd = await this.step(decision.assembly) // one model request + its tool executions
session.append('step/end', { turn, step })

if (turnEnds && this.inbox.nextStep.length === 0) {
await this.dispatch.serial('agent/turn-stopping', { turn, signal }) // final negotiation point
}
if (turnEnds && this.inbox.nextStep.length === 0) break // close the turn
}
...
return this.inbox.hasPending // more work → open another turn
}

After tool execution, the inner turn() loop appends step/end, then opens a new step/start for the next model request carrying the tool/result. The while (true) inside step() is mainly for agent/request-error retries: when policy returns retry, the request is rebuilt within the same turn and step; ordinary tool results do not cause another request inside the old step.

reject

empty first step

enter

has tool calls

no tool calls

yes

no

no

yes

still pending

drained

Input arrives at inbox

send / followup / steer / inject

wakeDriver wakes the driver

Kick outer loop
while (await this.turn())

preStep
claim inbox + assemble prompt + agent/pre-step decision

turn/end blocked
return false

turn/end completed
no model call

step/start appended

buildRequest + llm/stream
append assistant/chunk per chunk

assistant/message + tool-call?

executeToolCalls
barrier / rolling pool

tool/result appended in model order

step/end appended

next-step still has input?

new preStep
claim input for the next step

agent/turn-stopping serial negotiation

next-step empty now?

turn/end + return true/false

idle

几个"不读代码绝对想不到"的决策

(1)max-tokens 具有"粘性"

一次 turn 里如果某个 step 撞了输出上限(max-tokens),即使后面的 step 正常完成,turn/end 的结局也不会降级成 completed

1
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd

(2)agent/turn-stopping 是关轮前的"最后发言权"

它是 serial 事件(数据决定结果)。监听器可以 steer() 补充 next-step 输入;补了之后重新读 inbox——有新输入就继续开 step,没有才真正关轮。这正是"模型可以决定再补一步"的钩子。

(3)取消的收敛锁存(wake latch)

send() 的第一行有一个微妙的判定:

1
2
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target

唤醒型输入不能加入一个已取消的活动,所以它被强制投递到 next-turn(下一轮)。而且这个判定要在 inbox.splice 插入之前捕获——防止内联 splice 观察者里发生重入 cancel 时把这条消息重新分类。锁存后,driver 收敛回 idle 时重放;有三种精细的角落语义(见研究笔记:disposed 取消永不锁存、锁存重放只在队列不再持有 wake 时抑制等)。

(4)turn() 的空首步

被移除的唤醒消息、或被 pre-step 重写成空的 enter 决策,仍然消费初始 turn 边界但不花模型调用——日志里会有一条没发请求的 turn/startturn/end(completed)

Decisions you can’t guess without reading the code

(1) max-tokens is "sticky"

If one step in a turn hits the output cap (max-tokens), later steps completing normally still do not downgrade the turn/end reason to completed:

1
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd

(2) agent/turn-stopping is the “last word” before closing a turn

It is a serial event (data decides the outcome). Listeners can steer() to append next-step input; after that the inbox is re-read — if new input arrived, open another step; only when it’s empty does the turn actually close. This is the hook for “the model may add one more step”.

(3) The cancellation convergence wake latch

The first line of send() has a subtle judgment:

1
2
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target

A waking input cannot join an aborted activity, so it is force-routed to next-turn (the next turn). And this judgment is captured before the inbox.splice insertion — to prevent a reentrant cancel inside an inline splice observer from reclassifying the message. After latching, the driver replays when it converges back to idle; there are three subtle corner semantics (per the study notes: disposed cancellation never latches; latched replay is suppressed only when the queue no longer holds the wake, etc.).

(4) The empty first step of turn()

A removed wake message, or an enter decision rewritten-to-empty by pre-step, still spends the initial turn boundary but no model call — the log gets a turn/startturn/end(completed) that issued no request.

preStep:claim 先于组装

preStep() 的顺序设计值得注意:

1
2
3
4
5
6
7
8
9
const claimed = this.inbox.claim(target, position.turn)          // 1️⃣ 先 claim
const assembly = await this.loopCtx.systemPrompt.assemble(...) // 2️⃣ 再组装(耗时)
const decision = await this.dispatch.waterfall('agent/pre-step',
{ messages: claimed, ...position, signal },
() => Promise.resolve({
kind: 'enter',
messages: context === undefined ? claimed : [...claimed, context],
}),
)

先移出 inbox,再花时间组装 prompt。这样组装期间到来的新输入不会污染本次 step 的批量;而 claim 的语义也暗藏深意(agent/src/inbox.ts):

1
2
3
4
5
6
7
claim(target: InboxTarget, turn: number): UserMessage[] {
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false) // 全清 next-step
if (target === 'next-turn') {
claimed.push(...this.mutate('next-turn', 0, 1, [], false)) // 轮边界才消费 1 条 next-turn
}
return claimed
}

所有 pending 的 next-step 转向/注入输入都会进入下一批(这正是"下一步边界"的含义);只有轮到边界时才额外消费 1 条 next-turn 消息。而这整个 mutate 是先 session.append('agent/inbox/spliced') 再改内存投影——durable 事件先于 live 投影,所以重启后 inbox 可重建。

preStep: claim before assembly

The ordering of preStep() is worth noting:

1
2
3
4
5
6
7
8
9
const claimed = this.inbox.claim(target, position.turn)          // 1️⃣ claim first
const assembly = await this.loopCtx.systemPrompt.assemble(...) // 2️⃣ assemble (slow)
const decision = await this.dispatch.waterfall('agent/pre-step',
{ messages: claimed, ...position, signal },
() => Promise.resolve({
kind: 'enter',
messages: context === undefined ? claimed : [...claimed, context],
}),
)

Claim from the inbox first, then spend time assembling the prompt. New input arriving during assembly cannot pollute this step’s batch. And the claim semantics themselves hide depth (agent/src/inbox.ts):

1
2
3
4
5
6
7
claim(target: InboxTarget, turn: number): UserMessage[] {
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false) // clear ALL next-step
if (target === 'next-turn') {
claimed.push(...this.mutate('next-turn', 0, 1, [], false)) // only at a turn boundary consume 1 next-turn
}
return claimed
}

All pending next-step steering/injection inputs enter the next batch (that’s the real meaning of “next-step boundary”); only at a turn boundary is 1 next-turn message additionally consumed. And this entire mutate first session.append('agent/inbox/spliced') and only then mutates the in-memory projection — durable events precede live projection, so the inbox can be rebuilt after a restart.

工具调度:并发执行,但结果严格保序

当一个模型响应里有多个工具调用时,怎么调度?这是全框架最"拧巴"、也最精彩的部分。tool-calls.ts 模块头注释就是精华:

“Exclusive calls form barriers; parallel calls use a bounded rolling pool and are reclassified before start. Dispatch may overlap, while policy, results, and result context remain model-ordered. Abort or an internal scheduler failure stops replenishment and drains started calls.”

执行可以重叠(并发),策略、结果、结果上下文永远按模型序。 这句话是整套调度的宪法。

fail-closed 的分类器

工具能否并发,由一个显式声明决定(tools/src/index.ts):

1
2
3
4
5
6
7
8
9
10
executionMode(exec): ToolExecutionMode {
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
try {
const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments)
return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' }
} catch {
return { kind: 'exclusive' }
}
}

fail-closed:只有工具注册了 isConcurrencySafe(args) 且返回值是严格 === true 才算可并行;未注册、隐藏、未声明、非法、抛异常的分类器,一律 exclusive。并发上限默认 10DEFAULT_MAX_PARALLEL_TOOL_CALLS),且这个值通过 getter 动态读取——配置改动只影响下一个 group,不打断在飞的一组

barrier 与 rolling pool

  • exclusive 调用 = barrier(屏障):单独成一个 group,与其它调用互斥——它前面和后面都没有并发;
  • parallel 调用:进入一个有界滚动池(bounded rolling pool),最多 maxParallelToolCalls 个在飞;
  • 重新分类:每次 group 开始和每次补池时都会重新调用 executionMode()——注册表的变化会影响未开始的调用。如果一个 parallel 组里后续某个调用在开始前被重分类为 exclusive,pool 立即停止补充,该调用留待下一个 barrier。

结果按模型序 commit(关键不变式)

并发执行时结果完成的先后顺序是乱的,但落盘必须按模型顺次commitReady() 只跨"连续模型序"推进:

1
2
3
4
5
6
while (committed < group.length) {
const slot = slots[committed]
if (slot === undefined) break // 前面的还没完成,卡住
// ...appendToolResult(...) 按模型序落盘
committed++
}

调度器用一个 Promise.race(inFlight) 等最快完成的 slot,完成后如果能连续推进就 commit,再补池。结果、结果上下文永远按模型序写日志,即使底层 dispatch 乱序完成。

取消时绝不放弃已启动的 body

有两个规范错误码:

1
2
export const TOOL_ABORTED = 'ABORTED'                        // body 已启动后取消
export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH' // body 启动前取消

cancellationResult(exec, prior)bodyInvoked 状态选其一——取消绝不放弃已启动的 body:"a started promise reaches quiescence before its outcome becomes ABORTED"。而未启动的调用会逐条记录 tool/call + 合成的错误 tool/result'tool call aborted before dispatch'),所以正常完成和取消路径都能保持日志可重放。这个保证不适用于所有内部 scheduler 故障:故障路径会保留已经记录的 tool/call,不伪造结果,未配对的尾部交给 crash repair 以 TOOL_OUTCOME_UNKNOWN 收尾。

session 日志工具 C (exclusive)工具 B (parallel)工具 A (parallel)ctx.tools 注册表调度器 runGroupagent-loop step()session 日志工具 C (exclusive)工具 B (parallel)工具 A (parallel)ctx.tools 注册表调度器 runGroupagent-loop step()par[并发执行]无论底层谁先完成,commit 必须按模型序 A→BexecuteToolCalls(3 个调用, 模型序 [A,B,C])1分类: A/B=parallel, C=exclusive2group1=[A,B] rolling pool, maxParallel=103append tool/call(A) ← 先落日志再执行4append tool/call(B)5prepare(A) → tools/pre-execute → dispatch6prepare(B) → tools/pre-execute → dispatch7execute(A) ← 可能 A 先完成或 B 先完成8execute(B)9append tool/result(A) ← 模型序第一位先落盘10append tool/result(B)11group2=[C] exclusive barrier12append tool/call(C)13execute(C) ← 与 A/B 完全不重叠14append tool/result(C)15concluded16

Tool scheduling: concurrent execution, but strictly ordered results

When one model response contains multiple tool calls, how do you schedule them? This is the most “twisted” and most brilliant part of the whole framework. The module header comment of tool-calls.ts is the essence:

“Exclusive calls form barriers; parallel calls use a bounded rolling pool and are reclassified before start. Dispatch may overlap, while policy, results, and result context remain model-ordered. Abort or an internal scheduler failure stops replenishment and drains started calls.”

Execution may overlap (concurrency), while policy, results, and result context stay model-ordered. That sentence is the constitution of the whole scheduler.

A fail-closed classifier

Whether a tool can run concurrently is decided by an explicit declaration (tools/src/index.ts):

1
2
3
4
5
6
7
8
9
10
executionMode(exec): ToolExecutionMode {
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
try {
const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments)
return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' }
} catch {
return { kind: 'exclusive' }
}
}

Fail-closed: only a tool that registers isConcurrencySafe(args) AND returns strictly === true is parallel; unregistered, hidden, undeclared, invalid, or throwing classifiers are all exclusive. The concurrency cap defaults to 10 (DEFAULT_MAX_PARALLEL_TOOL_CALLS), and it’s read through a getter — config changes only affect the next group, without disturbing the one in flight.

Barriers and the rolling pool

  • exclusive calls form a barrier: a single group, mutually exclusive with other calls — no concurrency before or after it;
  • parallel calls: enter a bounded rolling pool, at most maxParallelToolCalls in flight;
  • Reclassification: executionMode() is re-invoked at every group start and every pool refill — registry changes affect unstarted calls. If a later call in a parallel group is reclassified as exclusive before it starts, the pool stops replenishing immediately and that call waits for the next barrier.

Results commit in model order (the critical invariant)

Under concurrency, results finish in arbitrary order, but they must be logged in model order. commitReady() advances only across contiguous model-order slots:

1
2
3
4
5
6
while (committed < group.length) {
const slot = slots[committed]
if (slot === undefined) break // the predecessor isn't done yet; stall
// ...appendToolResult(...) in model order
committed++
}

The scheduler uses Promise.race(inFlight) to wait for the fastest-completing slot, commits when it can advance contiguously, then refills. Results and result contexts always hit the log in model order, even though the underlying dispatch finishes out of order.

Cancellation never abandons a started body

There are two canonical error codes:

1
2
export const TOOL_ABORTED = 'ABORTED'                        // cancelled after the body started
export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH' // cancelled before the body started

cancellationResult(exec, prior) picks one based on the bodyInvoked state — cancellation never abandons a started body: "a started promise reaches quiescence before its outcome becomes ABORTED". Unstarted calls receive a synthesized error tool/result (one tool/call each, 'tool call aborted before dispatch'), so ordinary completion and cancellation preserve replay validity. This is not a promise that every internal scheduler failure fabricates a result: the failure path preserves already-recorded tool/call events and leaves an unpaired tail for crash repair to close with TOOL_OUTCOME_UNKNOWN.

session logTool C (exclusive)Tool B (parallel)Tool A (parallel)ctx.tools registryScheduler runGroupagent-loop step()session logTool C (exclusive)Tool B (parallel)Tool A (parallel)ctx.tools registryScheduler runGroupagent-loop step()par[concurrent execution]whichever finishes first underneath, commit must be in model order A→BexecuteToolCalls(3 calls, model order [A,B,C])1classify: A/B=parallel, C=exclusive2group1=[A,B] rolling pool, maxParallel=103append tool/call(A) ← log first, then execute4append tool/call(B)5prepare(A) → tools/pre-execute → dispatch6prepare(B) → tools/pre-execute → dispatch7execute(A) ← A or B may finish first8execute(B)9append tool/result(A) ← the model-order first one commits first10append tool/result(B)11group2=[C] exclusive barrier12append tool/call(C)13execute(C) ← never overlaps with A/B14append tool/result(C)15concluded16

LLM 层:一个 provider-neutral 的流协议

主循环消费的模型流,被收敛成一个当前核心包含 7 种形状的 provider-neutral 流协议(StreamChunk)。chunk 的核心 discriminant 如下;其中 content block 和 finish reason 的词汇仍允许通过类型扩展增加,消费者需要保留未知扩展的 fallback:

1
2
3
4
5
6
7
8
type StreamChunk =
| { type: 'block-start'; index: number; blockType: ContentBlockType } // 块开始(thinking/text/tool-call)
| { type: 'text-delta'; index: number; text: string } // 文本增量
| { type: 'reasoning-delta'; index: number; text: string } // 思考增量
| { type: 'tool-call-delta'; index: number; id: string; name?: string; argumentsDelta: string } // 工具调用参数增量
| { type: 'block-end'; index: number; block: ContentBlock } // 块结束,携带完整块
| { type: 'usage'; usage: TokenUsage } // token 用量
| { type: 'finish'; reason: FinishReason; replayState?: unknown } // 终态

index 把交错到达的 thinking/text/多个 tool-call 归到各自的块上——这是处理流式工具调用的关键。消费者 BlockAssembler 累加增量,有几个健壮性决策很聪明:

  • 容忍 delta-only 协议(没有 block-start/end):ensure() 会给未知 index 隐式开块;
  • 容忍乱序/重复:已被 block-end 关闭的 index 再来 delta 一律忽略——“a misbehaving adapter cannot grow memory or corrupt a completed block”;
  • max-tokens 截断时丢弃 tool-call 块:半截的工具调用不能安全执行。

为什么工具结果伪装成 user 角色

Message.role 只有三种:system | user | assistantToolResultMessage 的定义:

1
2
3
4
5
interface ToolResultMessage extends Message {
readonly role: 'user' // ← role 是 user!
readonly content: [ToolResultBlock] // 恰好一个 tool-result block
readonly source: ToolMessageSource // { kind: 'tool', callId }
}

harness 词汇里工具结果就是 user 角色消息里的一个 tool-result block;"谁产生的"由独立的 source 轴表达。到了 wire 序列化(发给 DeepSeek API)时,serializeMessages 再把它拆分role: 'tool' 消息,并且空输出补 '(no output)'(有些网关拒绝空 content):

1
2
3
4
5
6
7
8
9
10
// serialize.ts —— harness 词汇里 user 角色 → wire 上拆成 role:'tool'
const toolResults = message.content.filter(block => block.type === 'tool-result')
const text = flattenText(message.content)
if (text.length > 0 || toolResults.length === 0) {
wire.push({ role: 'user', content: text })
}
for (const result of toolResults) {
wire.push({ role: 'tool', tool_call_id: result.toolCallId,
content: flattenText(result.content) || '(no output)' })
}

这样设计的收益:session 日志与具体 provider 无关——换任何模型厂商,历史不变;只有"最外层"的 wire 序列化需要知道 Anthropic 还是 OpenAI 格式。

DeepSeek 适配器:几个只有抓包才发现的细节

厂商适配器(llm-deepseek)里藏着不少知识:

  • 工具调用的 arguments 始终是原始 JSON 字符串——argumentsDelta 增量拼接,block-end 携带完整 raw string,harness 从不帮模型"翻译"参数;
  • [DONE] 之前什么都不收尾:block-end、usage、finish 全部延迟到 [DONE] 才一次性发射——因为 DeepSeek 的 finish_reason 只在终止 chunk 非 null,usage 可能挂在 finish chunk 或独立 trailing chunk 上,必须等流结束才知道最终值;
  • thinking 模式第一个 chunk 的空字符串要跳过"The FIRST chunk carries an empty string (must not open a reasoning block)"
  • EOF 而没有 [DONE] → 抛 STREAM_CLOSED:“truncated response — the model call cannot be trusted”;
  • 空完成(stop 但无任何内容)→ EMPTY_RESPONSE 可重试错误(这是 2026-07-24 才引入的新语义,之前是静默成功);
  • cache hit 计费减法:DeepSeek 的 prompt_tokens 包含缓存命中,mapUsage 里要减出来:
1
2
3
4
5
6
7
8
9
10
export function mapUsage(usage: WireUsage): TokenUsage {
const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens
const reasoning = usage.completion_tokens_details?.reasoning_tokens
return {
inputTokens: usage.prompt_tokens - (cacheRead ?? 0), // disjoint:input 只算未命中
outputTokens: usage.completion_tokens,
...cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {},
...reasoning !== undefined ? { reasoningTokens: reasoning } : {},
}
}

为什么 adapter 要 fuse signal

DeepSeekAdapter.stream() 第一件事:

1
2
3
4
const consumer = new AbortController()
const upstream = options.signal === undefined
? consumer.signal
: AbortSignal.any([options.signal, consumer.signal]) // fuse caller + consumer

AbortSignal.any调用方的取消adapter 自己的 consumer controller 融成一个稳定信号,finallyconsumer.abort() 保证任何退出路径都干净收尾。不用 options.signal 直接 fetch 的原因:adapter 需要自己也能中止(比如 idle watchdog 超时、内部清理),单一外部信号不够。出错的优先级也很有意思:idle 超时 > caller aborted > 未分类 transport

而"一个 adapter call = 一次 provider attempt"是另一条契约——适配器不在一次 provider attempt 内自行重试。agent-loop 在当前 turn 的 agent/request-error 扩展点决定是否 retry;返回 { kind: 'retry' } 后,step() 内部会在同一个 turn 和 step里重新构造请求。dsh-llm-retry 还把重试计数做成 durable session 事件,跨进程重启也能恢复计数。

The LLM layer: a provider-neutral stream protocol

The model stream the loop consumes is normalized into a provider-neutral protocol with 7 core chunk shapes (StreamChunk). The core discriminants are shown below; content-block and finish-reason vocabularies remain declaration-merge extensible, so consumers must keep a fallback for unknown extensions:

1
2
3
4
5
6
7
8
type StreamChunk =
| { type: 'block-start'; index: number; blockType: ContentBlockType } // block start (thinking/text/tool-call)
| { type: 'text-delta'; index: number; text: string } // text delta
| { type: 'reasoning-delta'; index: number; text: string } // reasoning delta
| { type: 'tool-call-delta'; index: number; id: string; name?: string; argumentsDelta: string } // tool-call argument delta
| { type: 'block-end'; index: number; block: ContentBlock } // block end, carries the complete block
| { type: 'usage'; usage: TokenUsage } // token usage
| { type: 'finish'; reason: FinishReason; replayState?: unknown } // terminal state

index attributes interleaved thinking/text/multiple tool-calls to their respective blocks — this is the key to streaming tool calls. The consumer BlockAssembler accumulates deltas and makes several clever robustness decisions:

  • Tolerates delta-only protocols (no block-start/end): ensure() implicitly opens a block for an unknown index;
  • Tolerates out-of-order/duplicates: a delta for an index already closed by block-end is ignored — “a misbehaving adapter cannot grow memory or corrupt a completed block”;
  • Drops tool-call blocks on max-tokens truncation: a half-built tool call cannot be safely executed.

Why tool results disguise themselves as user

Message.role has only three values: system | user | assistant. The ToolResultMessage definition:

1
2
3
4
5
interface ToolResultMessage extends Message {
readonly role: 'user' // ← the role IS user!
readonly content: [ToolResultBlock] // exactly one tool-result block
readonly source: ToolMessageSource // { kind: 'tool', callId }
}

In the harness vocabulary, a tool result is a tool-result block inside a user-role message; who produced it is expressed by the separate source axis. At wire serialization (when sending to the DeepSeek API), serializeMessages splits it back into a role: 'tool' message, padding empty output with '(no output)' (some gateways reject empty content):

1
2
3
4
5
6
7
8
9
10
// serialize.ts — user role in the harness vocabulary → split into role:'tool' on the wire
const toolResults = message.content.filter(block => block.type === 'tool-result')
const text = flattenText(message.content)
if (text.length > 0 || toolResults.length === 0) {
wire.push({ role: 'user', content: text })
}
for (const result of toolResults) {
wire.push({ role: 'tool', tool_call_id: result.toolCallId,
content: flattenText(result.content) || '(no output)' })
}

The payoff: the session log is provider-independent — switch model vendors and the history never changes; only the outermost wire serialization needs to know Anthropic vs OpenAI formats.

The DeepSeek adapter: details you’d only find by packet capture

The vendor adapter (llm-deepseek) hides plenty of knowledge:

  • Tool-call arguments stay a raw JSON string throughoutargumentsDelta concatenates increments, block-end carries the complete raw string; the harness never “translates” arguments for the model;
  • Nothing is finalized before [DONE]: block-end, usage, and finish are all deferred and emitted in one burst at [DONE] — because DeepSeek’s finish_reason is non-null only on the terminal chunk, and usage may ride on the finish chunk or a separate trailing usage-only chunk, so you must wait for the stream end to know the final values;
  • The first thinking-mode chunk’s empty string must be skipped: “The FIRST chunk carries an empty string (must not open a reasoning block)”;
  • EOF without [DONE] → throws STREAM_CLOSED: “truncated response — the model call cannot be trusted”;
  • Empty completion (stop but no content) → EMPTY_RESPONSE, a retryable error (new semantics introduced 2026-07-24; previously it was silent success);
  • Cache-hit billing subtraction: DeepSeek’s prompt_tokens includes cache hits; mapUsage subtracts them out:
1
2
3
4
5
6
7
8
9
10
export function mapUsage(usage: WireUsage): TokenUsage {
const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens
const reasoning = usage.completion_tokens_details?.reasoning_tokens
return {
inputTokens: usage.prompt_tokens - (cacheRead ?? 0), // disjoint: input counts only misses
outputTokens: usage.completion_tokens,
...cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {},
...reasoning !== undefined ? { reasoningTokens: reasoning } : {},
}
}

Why the adapter fuses the signal

The first thing DeepSeekAdapter.stream() does:

1
2
3
4
const consumer = new AbortController()
const upstream = options.signal === undefined
? consumer.signal
: AbortSignal.any([options.signal, consumer.signal]) // fuse caller + consumer

AbortSignal.any fuses the caller’s cancellation and the adapter’s own consumer controller into one stable signal, and consumer.abort() in finally guarantees a clean teardown on every exit path. Why not pass options.signal directly to fetch? Because the adapter needs to abort itself too (idle watchdog timeout, internal cleanup) — a single external signal is not enough. The error priority is also neat: idle timeout > caller aborted > unclassified transport.

And “one adapter call = one provider attempt” is another contract — the adapter does not retry inside one provider attempt. At the agent/request-error extension point, agent-loop decides whether to retry; when it returns { kind: 'retry' }, step() rebuilds the request within the same turn and step. The dsh-llm-retry executor also makes the retry counter a durable session event, so it can be recovered across process restarts.

崩溃恢复:一个"任何主循环都不会发出"的 turn/end

这是全文我最喜欢的一个细节。

进程崩溃时,日志可能停在未闭合的 turn 中间——某个 tool/call 已经落盘,但对应的 tool/result 还没写;或 step/start 之后没有 step/end。恢复策略是绝不截断(单 turn 可能巨大:多 step + 大工具输出,而且这些事件崩溃前已耐久落盘),而是保留完整的中断 turn,只合成缺失的关边界

repair.tsinterruptedTurnClosers() 做的事(packages/core/session/src/repair.ts):

  1. 每个未配对的工具调用,先得到合成错误结果
    • 调用已记录开始(有 tool/call):TOOL_OUTCOME_UNKNOWN,“结果未耐久记录,是否重试取决于工具是否只读/幂等,可能产生副作用就先验证外部状态”;
    • 从未记录开始(无 tool/call):TOOL_NOT_STARTED
  2. step/end(若 step 开着——turn 结束时空开着 step 是 invariant 违规);
  3. turn/end { reason: { kind: 'interrupted' } }

注意最后一条的注释(文档原话):

“interrupted is the one reason no loop emits”

TurnEndReason 有六种:completed / aborted / blocked / error / max-tokens——这五种都是主循环自己写的;唯独 interrupted 是任何主循环都永远不会主动发出的——它只会由崩溃恢复器合成。所以检查日志时,看到 turn/end: interrupted 就能确定:这台进程之前崩溃过

合成的 closers 还有两个细节:

  • seq 接续 last.seq + 1,时间复用最后一条真实事件的时间——确定性,且不虚构未来时间;
  • 合成的 tool/resultsourceEventSeqs: [callSeq],保持 provenance;message id 用确定模式 interrupted-tool-result-${callId}-${seq}

注意恢复只对冷加载(进程重启后的持久化恢复)生效;live 会话崩溃恢复时若是"拖尾 turn",只截断 torn tail 而不关闭 turn——因为 live Session 仍是权威,它之后可能自己追加真正的 step/turn end。

进程崩溃时日志停在 turn 中间

冷加载 scanRows / scanLog

有未闭合的 step 或未配对 tool/call?

为每个孤儿 tool/call 合成错误 tool/result
TOOL_OUTCOME_UNKNOWN / TOOL_NOT_STARTED

补 step/end(若 step 开着)

补 turn/end reason=interrupted
← 任何主循环都不会主动发出的 reason

commitRepair 落盘
torn tail 物理截断

日志自然闭合,无需恢复

Session.fromRestore 重建
surface 合法可重放

Crash recovery: a turn/end that “no loop ever emits”

This is my favorite detail in the whole article.

When a process crashes, the log can stop in the middle of an unterminated turn — a tool/call already on disk with no matching tool/result, or a step/start with no step/end. The recovery strategy is never to truncate (a single turn can be huge: many steps + large tool outputs, all durably written before the crash); instead, keep the full interrupted turn and only synthesize the missing closing boundaries.

What interruptedTurnClosers() in packages/core/session/src/repair.ts does:

  1. Every unpaired tool call first gets a synthesized error result:
    • If the call was recorded as started (has a tool/call): TOOL_OUTCOME_UNKNOWN — “result was not durably recorded; whether to retry depends on whether the tool is read-only/idempotent; if side effects are possible, verify external state first”;
    • If it was never recorded as started (no tool/call): TOOL_NOT_STARTED;
  2. Append step/end (if the step was open — ending a turn with an open step violates an invariant);
  3. Append turn/end { reason: { kind: 'interrupted' } }.

About that last one, the docs say (verbatim):

“interrupted is the one reason no loop emits”

TurnEndReason has six kinds: completed / aborted / blocked / error / max-tokens — all five are written by the loop itself; only interrupted is never emitted by any loop — it appears solely from the crash-recovery synthesizer. So when examining a log, seeing turn/end: interrupted tells you for certain: this process crashed before.

The synthesized closers also have two details:

  • seq continues from last.seq + 1, and the time reuses the last real event’s time — deterministic, and never fabricates a future time;
  • The synthesized tool/result carries sourceEventSeqs: [callSeq], preserving provenance; the message id uses the deterministic pattern interrupted-tool-result-${callId}-${seq}.

Note that recovery only applies to cold loads (persistence restore after a process restart); during live-session crash recovery, a trailing turn is only torn-tail-truncated and not closed — because the live Session is still authoritative and may itself append real step/turn ends later.

yes

no

Process crashes with the log mid-turn

cold load scanRows / scanLog

open step or unpaired tool/call?

synthesize error tool/result per orphan call
TOOL_OUTCOME_UNKNOWN / TOOL_NOT_STARTED

append step/end (if step open)

append turn/end reason=interrupted
← a reason no loop ever emits

commitRepair to disk
torn tail physically truncated

log is naturally closed; nothing to repair

Session.fromRestore rebuilds
surface valid and replayable

持久化:两个后端,一套契约

事件溯源最后一步是落盘。持久化是一条能力接缝:抽象服务 + 契约 PersistenceBackend + 编排器,两个可互换后端:SQLiteJSONL

SQLite JSONL
每会话工件 一行 sessions 表 + 多行 events 一文件 session.jsonl[.zstd]
schema 版本 SCHEMA_VERSION = 15PRAGMA user_version + application_id 头行 version: SESSION_FORMAT_VERSION
修订 token storeId:incarnation:revision(每次变更 +1) stat 五元组 dev:ino:size:mtimeNs:ctimeNs
撕裂检测 scanRows(找最后一个 turn/end scanLog/scanZstdFrames
seek 读 loadStoredFrom SQL seq >= ? 不支持(顺序介质)

几个要点:

  • SQLite 用 SCHEMA_VERSION 校验:打开时 PRAGMA application_id = 0x44534850(防误写无关库),user_version 不等于版本号就拒绝(不做原地迁移——pre-release 阶段宁可拒绝也不迁移);
  • JSONL 的原子物化不用 rename,而用 link(tmp, final) + unlink(tmp)——注释点破:“link fails with EEXIST … rename() would silently overwrite”,防止并发双写覆盖;文件模式 0o600、目录 0o700
  • write-behind 缓冲session/event(同步)里入队,固定 maxDelayMs(默认 200ms)批窗口;session/flushparallel 事件)是耐久性屏障——checkpoint-policy 在 llm/streamtools/executeagent/pre-step 前 flush,确保"请求发出前,之前的日志已落盘";
  • torn tail 判定很精细:最后一个 turn/end 之前的解析失败/seq 缺口 = 已提交区损坏,抛错之后的 = 崩溃孤儿尾,容忍并给出删除点

一个"双版本"体系:SESSION_FORMAT_VERSIONSCHEMA_VERSION

  • SESSION_FORMAT_VERSION = 0事件词汇的版本,预发布阶段永久钳在 0,不承诺兼容;只有结构性改动才 bump(header 形状、事件信封、surface 机制),新增普通事件类型不 bump——靠每条事件的 ignorable 兜底"词汇增长";
  • SCHEMA_VERSION = 15磁盘 schema 的版本,与事件词汇版本正交(注释明确:“version 会话自身的 version(版本化事件词汇,存于 sessions 行)”)。

这套"正交双版本"让词汇演进(加事件)与物理布局演进(改表结构)各管各的,互不牵连。

Persistence: two backends, one contract

The last step of event sourcing is going to disk. Persistence is a capability seam: an abstract service + the PersistenceBackend contract + a coordinator, with two swappable backends: SQLite and JSONL.

SQLite JSONL
per-session artifact one sessions row + many events rows one file session.jsonl[.zstd]
schema version SCHEMA_VERSION = 15 (PRAGMA user_version + application_id) header line version: SESSION_FORMAT_VERSION
revision token storeId:incarnation:revision (+1 per change) stat quintuple dev:ino:size:mtimeNs:ctimeNs
torn detection scanRows (find last turn/end) scanLog/scanZstdFrames
seek read loadStoredFrom SQL seq >= ? unsupported (sequential medium)

A few points:

  • SQLite validates via SCHEMA_VERSION: on open, PRAGMA application_id = 0x44534850 (prevents writing into unrelated DBs); if user_version doesn’t match the version, it refuses (no in-place migration — in pre-release, better refuse than migrate);
  • JSONL’s atomic materialization uses link(tmp, final) + unlink(tmp), not rename — the comment calls it out: “link fails with EEXIST … rename() would silently overwrite”, preventing concurrent double-write clobbering; file mode 0o600, directory 0o700;
  • Write-behind buffering: enqueue on (sync) session/event, a fixed maxDelayMs (default 200ms) batch window; session/flush (a parallel event) is the durability barrier — the checkpoint policy flushes before llm/stream, tools/execute, and agent/pre-step, ensuring “before the request leaves, the prior log is on disk”;
  • Torn-tail classification is fine-grained: parse failures/seq gaps before the last turn/end = corruption in the committed region, throw; after it = crash orphan tail, tolerated and given a deletion point.

A “two-version” system: SESSION_FORMAT_VERSION and SCHEMA_VERSION

  • SESSION_FORMAT_VERSION = 0: the event vocabulary version, pinned at 0 during pre-release with no compatibility promise; only structural changes bump it (header shape, event envelope, surface mechanics); adding ordinary event types does NOT bump — each event’s ignorable covers the “vocabulary growth”;
  • SCHEMA_VERSION = 15: the on-disk schema version, orthogonal to the vocabulary version (doc comments: “version the session’s own version (versioned event vocabulary, stored in the sessions row)”).

This “orthogonal two-version” regime lets vocabulary evolution (adding events) and physical-layout evolution (changing table structure) proceed independently without entangling.

把散点串起来:这套架构的三条主线

读完全文,三个互相咬合的设计值得最后用一条线串起来:

主线一:模型请求是日志的纯函数。 事件溯源 保证历史可重建;invariant 小节在分派瞬间强制"请求 ≡ 日志投影 ± surface 视图变换";工具调度 保证 tool/result 按模型序落盘。三者合起来:任何一步都能被重放、被审计、被 fork

主线二:把副作用包在可审计的边界里。 "日志即真相"不能冻结所有 IO——工具要跑、文件要写、模型要调。答案是把可变行为放在日志事件之间:工具执行是"从日志读入参数 → 发生外部副作用 → 把结果写回日志"的一段受控区间;它的并发由 isConcurrencySafe fail-closed 管控;它的取消用 tool/result 合成结果保重放合法;它的崩溃用 interrupted 合成边界收尾。**日志可以重建工具调用的边界、参数、结果和恢复状态,但不能自动重放真实外部副作用。**遇到未知结果时,是否能安全重试仍取决于工具是否只读或幂等。

主线三:能力接缝 = 可替换的产品单位。 Cordis 的插件模型 + 作用域(scope) + 能力接缝(capability seam)让"换 provider"不是改代码而是换插件:换 LLM 适配器不影响日志格式;换 bash provider 不影响工具语义;甚至主循环本身都是插件(ctx.agentLoop)。这正是"everything is a plugin"的实际含义——没有需要打补丁的"核心",只有可以叠加的 patch 层

结语

把 1.2 万次提交压缩成一句话,deepseek-harness 最值得学的不是某个具体功能,而是它那种把正确性做成结构的偏执:

  • “model-visible ⟺ logged” 被写成运行时 invariant,而不是开发纪律;
  • 正常完成和取消路径会尽量保持 tool/calltool/result 配对;scheduler 故障或进程崩溃留下的未配对调用,则由 repair 生成未知结果收尾;
  • 崩溃不靠 try/catch 兜底,而靠 interrupted 这种"主循环永远不会发出"的哨兵事件标识;
  • 未知事件宁可拒绝整个会话,也绝不静默掏空它。

如果你要写一个生产级 agent,这套"日志即真相 + 可逆插件 + fail-closed 调度"的组合拳,是值得抄的作业。

Tying it together: three threads through this architecture

Having read everything, three interlocking design ideas deserve to be threaded together:

Thread 1: the model request is a pure function of the log. Event sourcing guarantees history is rebuildable; the invariant section enforces at dispatch time that “request ≡ log projection ± surface view transform”; Tool scheduling guarantees tool/result lands in model order. Together: every step can be replayed, audited, and forked.

Thread 2: put side effects inside auditable boundaries. “The log is the truth” cannot freeze all I/O — tools run, files get written, models get called. The answer is to keep mutable behavior between log events: a tool execution is a bounded interval of “read args from the log → perform an external side effect → write the result back to the log”; its concurrency is gated by fail-closed isConcurrencySafe; its cancellation preserves replay validity via synthesized tool/results; its crashes are closed by the synthesized interrupted boundary. The log can reconstruct tool-call boundaries, arguments, results, and recovery state, but it cannot automatically replay real external side effects. Whether an unknown result can be safely retried still depends on whether the tool is read-only or idempotent.

Thread 3: capability seams = replaceable product units. Cordis’s plugin model + Scope + capability seams make “swapping a provider” a config change, not a code change: swap the LLM adapter without touching the log format; swap the bash provider without touching tool semantics; even the main loop itself is a plugin (ctx.agentLoop). This is what “everything is a plugin” actually means — there is no “core” to patch, only patch layers to stack.

Conclusion

Compressing 12K commits into one sentence: the most valuable thing about deepseek-harness isn’t any single feature, it’s that stubbornness of turning correctness into structure:

  • “model-visible ⟺ logged” is written as a runtime invariant, not a development discipline;
  • normal completion and cancellation try to keep tool/call paired with tool/result; a scheduler failure or process crash may leave an unpaired call for repair to close as an unknown outcome;
  • crashes aren’t handled by try/catch, but by a sentinel event like interrupted that “no loop ever emits”;
  • an unknown event would rather reject the whole session than silently hollow it out.

If you’re going to write a production-grade agent, this combination — “log as truth + reversible plugins + fail-closed scheduling” — is homework worth copying.