本文基于 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 | 用户输入 |
这里先记住一个边界:一个 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、上下文类型或无关事件,应该用来理解数据形状,不是可以独立复制运行的完整文件。
下面的流程图只描述一次任务如何运行,不涉及插件如何启动:
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 | user input |
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:
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
SessionEventlog 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 | profile(命名组合,如 web / headless) |
dsh --profile web --dump-config 能把最终拼出来的整棵插件树(也就是"一份能跑的产品")打印出来——任何一行都可以用你自己的 patch 行替换。这跟传统"改主程序"的开发方式完全不同:你不 fork 分支,你叠加 patch。
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 | profile (named composition, e.g. web / headless) |
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.
Cordis 微内核:事件是给人看的契约
要读懂 dsh,先读懂它的底座 Cordis。五个核心概念:
- 插件是一个实现 Service 的对象——一个带
inject+apply(ctx)的函数,或一个Service子类; - 上下文(Context)是服务的仓库——
ctx.tools、ctx.llm、ctx.sessions都是稳定键,其他插件按 key 找服务,而不是import具体实现; inject声明服务依赖——插件声明需要哪些服务,加载顺序就由"服务是否已存在"决定,而不是手工排启动顺序;- 类型化事件(Typed Events)——服务通过 TS declaration merging 声明事件名,然后用
emit/waterfall/parallel/serial四种模式派发; - 注册是可逆副作用——提示词小节、工具 schema、适配器、监听器全部经
ctx.effect()/ctx.on()安装,重载和卸载时成对撤销。
The Cordis microkernel: events are public contracts
To understand dsh, first understand its base, Cordis. Five core ideas:
- A plugin is an object that implements Service — a function with
inject+apply(ctx), or aServicesubclass; - A context is a repository of services —
ctx.tools,ctx.llm,ctx.sessionsare stable keys; other plugins find a service by key instead of importing a concrete implementation; injectdeclares service dependencies — a plugin names what it needs; load order follows “does the service exist yet”, not hand-ordered boot sequences;- Typed Events — services declare event names through TS declaration merging, then dispatch them as
emit/waterfall/parallel/serial; - 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 | // 一个规范的 waterfall 监听器 |
这个"忘调 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 | // A canonical waterfall listener |
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 defaultnext()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 callingnext()delegates the default (= terminate with failure);agent/turn-stopping, on the other hand, isserial, not waterfall — it is the final negotiation point and needs “data to decide” rather than “nesting to wrap”; a listener’s only move issteer()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 | // packages/core/scope/src/index.ts |
两条链方向(注释里点破):
- 注册视图向下继承:子 scope 能"看到"祖先层的注册(
ScopedLayersmerge 时沿 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 | // packages/core/scope/src/index.ts |
Two directions of the chain (as the comments spell out):
- Registration views inherit DOWN: a child scope sees its ancestors’ layers (when
ScopedLayersmerges, 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 | ctx.effect(async () => { |
对 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 | ctx.effect(async () => { |
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/message、assistant/chunk、tool/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
Sessionis an append-only log of typedSessionEvents — 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 | {"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"{{cwd}}","delegationDepth":0} |
请注意几个细节:
agent/inbox/spliced先于user/message:输入还没有变成模型消息之前,它的入队/出队就已被持久化。连 inbox 都是 durable 的——重启后 inbox 能重建。assistant/message携带sourceEventSeqs:它引用了组成它的 54 个 chunk 的 seq(8..61)——一条消息可被精确定位到它由哪些 token 增量拼成。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 一节)。tool/result的role是user:工具结果不是独立角色,而是 user 角色消息里嵌一个tool-resultblock。
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
Sessionis an append-only log of typedSessionEvents — 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 | {"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"{{cwd}}","delegationDepth":0} |
Notice several details:
agent/inbox/splicedprecedesuser/message— before the input becomes a model message, its enqueue/dequeue is already persisted. Even the inbox is durable — a restart can rebuild it.assistant/messagecarriessourceEventSeqs— 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.reasoning-chunks/tool-call-chunksare packed rows —seq 8is the reasoning block-start; the reasoning packed row begins atseq0=9and covers 17 deltas atseq 9..25. The tool-call packed row begins atseq0=27and covers 31 deltas atseq 27..57(details in the chunk-rows section).tool/result’sroleisuser— a tool result is not its own role; it rides inside a user-role message as atool-resultblock.
SessionEventMap:可被插件扩展的"事件宪法"
事件全集是一个接口 + declaration merging,所以第三方插件也能往会话日志里加自己的事件类型(compaction、hook、approval、goal 都是这么干的):
1 | // packages/core/session/src/types.ts(核心族节选) |
每个事件外面再包一层 SessionEvent<T> 信封,长得像这样(注意 ignorable 这个字段的设计):
1 | type SessionEvent<T> = { type: T; seq: number; time: number; data: SessionEventMap[T]; |
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 | // packages/core/session/src/types.ts (core families, excerpt) |
Each event is wrapped in a SessionEvent<T> envelope that looks like this (note the design of the ignorable field):
1 | type SessionEvent<T> = { type: T; seq: number; time: number; data: SessionEventMap[T]; |
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 | export function deriveEventMessage(event: SessionEvent): Message | null { |
三个"看不出但必须知道"的点:
assistant/chunk永远不进历史——重放/UI 用它,模型看到的永远是组装好的assistant/message;- 空内容的
assistant/message被跳过——max-tokens 截断时仍会记录一条只承载 usage 的消息,但"内容为空的 assistant 回合不能进入 provider transcript"; - tool result 以 user 角色进入历史——这正是上一篇博客(LLM 层)里"为什么没有
role: 'tool'"的答案:harness 词汇里只有三种角色(system/user/assistant),工具结果是谁产生的由独立的source轴表达,到 wire 序列化时才拆成role: 'tool'。
Session.deriveMessages() 再使用一个 O(1) 增量缓存:SurfaceManager 用 replaceGeneration 单调递增,只有在出现 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 | export function deriveEventMessage(event: SessionEvent): Message | null { |
Three points you can’t see at a glance but must know:
assistant/chunknever enters history — replay/UI use it; the model only ever sees the assembledassistant/message;- Empty-content
assistant/messageis skipped — a max-tokens truncation still records a usage-only message, but “a content-less assistant turn must not enter the provider transcript”; - 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 separatesourceaxis, and only at wire serialization is it split intorole: '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/stream 上 prepend 一个全局检查(prepend 是为了防止某个 replay 监听器短路时跳过检查):
1 | ctx.on('llm/stream', (options, next) => { |
对 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 | ctx.on('llm/stream', (options, next) => { |
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.”
两个新字段:sourceEventSeqs 与 surfaceOp
每条 surface 事件(user/message、assistant/message、tool/result)在信封上多了两个顶层字段:
sourceEventSeqs?: number[]:这条消息由哪些更早的事件构成(assistant/message引用它的 chunk seqs,tool/result引用它的tool/callseq)。对于 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 | session.append('user/message', { |
关键约束(surface manager 强制校验):
start/end必须在当前表面上、且start <= end;sourceEventSeqs必须完整覆盖每个被遮蔽的节点,但不要求只能包含这些节点——compaction 还可以把自己的 start/summary trace event 一起记录进去;- 只允许
tool/result做"替换另一个 tool/result"这种特例,且只能改content字段,其它字段必须深相等。
而 compaction/start、compaction/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/messagecites its chunk seqs,tool/resultcites itstool/callseq). For a replace, the array must cover every shadowed surface node, but it may also include compaction trace events. Onlyassistant/messagemay 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 | session.append('user/message', { |
The key constraints (enforced by the surface manager):
start/endmust be on the current surface, andstart <= end;sourceEventSeqsmust 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/resultreplacing anothertool/result, and only thecontentfield 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,
foldSurfacere-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.tssynthesizes closers withsurfaceOp: 'append'and correctsourceEventSeqs, 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 | // packages/core/session/src/chunk-rows.ts —— 结构示意,字段类型完整 |
我实测的数据
为了验证,我对真实 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 | // packages/core/session/src/chunk-rows.ts — structural example with complete field types |
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/chunkrow 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"状态机
日志是"真相",那谁在日志上执行?就是 ReactLoopAgent(packages/core/agent-loop/src/agent.ts,约 500 行)。它叫 React 是因为它就是个响应式驱动:输入到达 inbox → 唤醒 driver → 跑一圈 → 回到 idle。
它的顶层相位是三态:
1 | type Phase = |
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 | async kick() { |
turn() 的内层循环会在工具执行后写入 step/end,然后为带着 tool/result 的下一次模型请求重新打开 step/start。step() 自己的 while (true) 主要用于 agent/request-error 的重试:失败且策略决定 retry 时,它在同一个 turn 和 step 内重新构造请求;正常的工具结果不会在旧 step 里继续发请求。
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 | type Phase = |
idle/maintenanceboth show asidleexternally (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 | async kick() { |
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.
几个"不读代码绝对想不到"的决策
(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 | const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted |
唤醒型输入不能加入一个已取消的活动,所以它被强制投递到 next-turn(下一轮)。而且这个判定要在 inbox.splice 插入之前捕获——防止内联 splice 观察者里发生重入 cancel 时把这条消息重新分类。锁存后,driver 收敛回 idle 时重放;有三种精细的角落语义(见研究笔记:disposed 取消永不锁存、锁存重放只在队列不再持有 wake 时抑制等)。
(4)turn() 的空首步
被移除的唤醒消息、或被 pre-step 重写成空的 enter 决策,仍然消费初始 turn 边界但不花模型调用——日志里会有一条没发请求的 turn/start…turn/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 | const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted |
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/start…turn/end(completed) that issued no request.
preStep:claim 先于组装
preStep() 的顺序设计值得注意:
1 | const claimed = this.inbox.claim(target, position.turn) // 1️⃣ 先 claim |
先移出 inbox,再花时间组装 prompt。这样组装期间到来的新输入不会污染本次 step 的批量;而 claim 的语义也暗藏深意(agent/src/inbox.ts):
1 | claim(target: InboxTarget, turn: number): UserMessage[] { |
所有 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 | const claimed = this.inbox.claim(target, position.turn) // 1️⃣ claim first |
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 | claim(target: InboxTarget, turn: number): UserMessage[] { |
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 | executionMode(exec): ToolExecutionMode { |
fail-closed:只有工具注册了 isConcurrencySafe(args) 且返回值是严格 === true 才算可并行;未注册、隐藏、未声明、非法、抛异常的分类器,一律 exclusive。并发上限默认 10(DEFAULT_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 | while (committed < group.length) { |
调度器用一个 Promise.race(inFlight) 等最快完成的 slot,完成后如果能连续推进就 commit,再补池。结果、结果上下文永远按模型序写日志,即使底层 dispatch 乱序完成。
取消时绝不放弃已启动的 body
有两个规范错误码:
1 | export const TOOL_ABORTED = 'ABORTED' // 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 收尾。
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 | executionMode(exec): ToolExecutionMode { |
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
exclusivecalls form a barrier: a single group, mutually exclusive with other calls — no concurrency before or after it;parallelcalls: enter a bounded rolling pool, at mostmaxParallelToolCallsin 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 | while (committed < group.length) { |
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 | export const TOOL_ABORTED = 'ABORTED' // cancelled after 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.
LLM 层:一个 provider-neutral 的流协议
主循环消费的模型流,被收敛成一个当前核心包含 7 种形状的 provider-neutral 流协议(StreamChunk)。chunk 的核心 discriminant 如下;其中 content block 和 finish reason 的词汇仍允许通过类型扩展增加,消费者需要保留未知扩展的 fallback:
1 | type StreamChunk = |
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 | assistant。ToolResultMessage 的定义:
1 | interface ToolResultMessage extends Message { |
harness 词汇里工具结果就是 user 角色消息里的一个 tool-result block;"谁产生的"由独立的 source 轴表达。到了 wire 序列化(发给 DeepSeek API)时,serializeMessages 再把它拆分成 role: 'tool' 消息,并且空输出补 '(no output)'(有些网关拒绝空 content):
1 | // serialize.ts —— harness 词汇里 user 角色 → wire 上拆成 role:'tool' |
这样设计的收益: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 | export function mapUsage(usage: WireUsage): TokenUsage { |
为什么 adapter 要 fuse signal
DeepSeekAdapter.stream() 第一件事:
1 | const consumer = new AbortController() |
AbortSignal.any 把调用方的取消和 adapter 自己的 consumer controller 融成一个稳定信号,finally 里 consumer.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 | type StreamChunk = |
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-endis 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 | interface ToolResultMessage extends Message { |
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 | // serialize.ts — user role in the harness vocabulary → split into role:'tool' on the wire |
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
argumentsstay a raw JSON string throughout —argumentsDeltaconcatenates increments,block-endcarries 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’sfinish_reasonis 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]→ throwsSTREAM_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_tokensincludes cache hits;mapUsagesubtracts them out:
1 | export function mapUsage(usage: WireUsage): TokenUsage { |
Why the adapter fuses the signal
The first thing DeepSeekAdapter.stream() does:
1 | const consumer = new AbortController() |
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.ts 的 interruptedTurnClosers() 做的事(packages/core/session/src/repair.ts):
- 每个未配对的工具调用,先得到合成错误结果:
- 调用已记录开始(有
tool/call):TOOL_OUTCOME_UNKNOWN,“结果未耐久记录,是否重试取决于工具是否只读/幂等,可能产生副作用就先验证外部状态”; - 从未记录开始(无
tool/call):TOOL_NOT_STARTED;
- 调用已记录开始(有
- 补
step/end(若 step 开着——turn 结束时空开着 step 是 invariant 违规); - 补
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/result带sourceEventSeqs: [callSeq],保持 provenance;message id 用确定模式interrupted-tool-result-${callId}-${seq}。
注意恢复只对冷加载(进程重启后的持久化恢复)生效;live 会话崩溃恢复时若是"拖尾 turn",只截断 torn tail 而不关闭 turn——因为 live Session 仍是权威,它之后可能自己追加真正的 step/turn end。
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:
- 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;
- If the call was recorded as started (has a
- Append
step/end(if the step was open — ending a turn with an open step violates an invariant); - 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:
seqcontinues fromlast.seq + 1, and the time reuses the last real event’s time — deterministic, and never fabricates a future time;- The synthesized
tool/resultcarriessourceEventSeqs: [callSeq], preserving provenance; the message id uses the deterministic patterninterrupted-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.
持久化:两个后端,一套契约
事件溯源最后一步是落盘。持久化是一条能力接缝:抽象服务 + 契约 PersistenceBackend + 编排器,两个可互换后端:SQLite 和 JSONL。
| SQLite | JSONL | |
|---|---|---|
| 每会话工件 | 一行 sessions 表 + 多行 events 表 |
一文件 session.jsonl[.zstd] |
| schema 版本 | SCHEMA_VERSION = 15(PRAGMA 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/flush(parallel 事件)是耐久性屏障——checkpoint-policy 在llm/stream、tools/execute、agent/pre-step前 flush,确保"请求发出前,之前的日志已落盘"; - torn tail 判定很精细:最后一个
turn/end之前的解析失败/seq 缺口 = 已提交区损坏,抛错;之后的 = 崩溃孤儿尾,容忍并给出删除点。
一个"双版本"体系:SESSION_FORMAT_VERSION 与 SCHEMA_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); ifuser_versiondoesn’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), notrename— the comment calls it out: “link fails with EEXIST … rename() would silently overwrite”, preventing concurrent double-write clobbering; file mode0o600, directory0o700; - Write-behind buffering: enqueue on (sync)
session/event, a fixedmaxDelayMs(default 200ms) batch window;session/flush(a parallel event) is the durability barrier — the checkpoint policy flushes beforellm/stream,tools/execute, andagent/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’signorablecovers 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/call与tool/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/callpaired withtool/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
interruptedthat “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.


