第四章 核心子系统详解
本章导读
第二章勾勒了 dsh 的架构地图,本章则下潜到地图内部,逐个拆解驱动一次智能体交互的核心子系统。阅读顺序就是一次消息的生命周期:
前置知识:建议先读完第二章(Cordis 四分发模式、能力接缝)与第三章(配置)。本章所有代码片段均为提炼后的示意,字段与源码保持一致但不逐字照抄;完整签名以 packages/core/session/src/types.ts 等源码文件与仓库内 docs/subsystems/ 下的子文档为准。
4.1 会话子系统:事件溯源的日志
4.1.1 核心思想:日志即真源
dsh 的会话(Session)不是一个「消息数组」,而是一份仅追加(append-only)的类型化 SessionEvent 日志。这个看似简单的结构决定了整个系统的三条基本性质:
- 唯一真源:智能体完整的交互历史——用户输入、模型输出、工具调用与结果、轮次边界、请求快照——全部且仅以事件的形式存在于日志中。任何「派生态」(模型消息历史、请求头、待办列表、surface 投影)都可以从同一组事件重新计算出来。
- 回放即重放事件:恢复(resume)、fork(分叉)、遥测、UI 重建,走的都是同一条路——按 seq 顺序重新读取事件并派生。回放不需要额外的「快照」通道。
- 派生历史不落盘:LLM 消息历史从日志派生而来,从不单独存储。deriveMessages() 的输出是日志的投影:每次调用都是新鲜数组,但其引用的消息对象是共享的、深冻结的。
日志的持久化(持久化接缝、后端、崩溃恢复)是独立关注点,见仓库文档 docs/subsystems/persistence.zh.md;本章只讨论内存模型与消费方依赖的约定。
一条事件长这样(示意,去掉了注释):
type SessionEvent<T extends SessionEventType = SessionEventType> = {
type: T // 可辨识联合的判别字段
seq: number // 日志内单调递增位置,恒等于追加前的 log.length
time: number // Unix epoch 毫秒
data: SessionEventMap[T] // 该类型的 payload
ignorable?: true // 可选:读者可安全跳过;缺省 = 必需
} & (T extends SurfaceEventType
? { sourceEventSeqs?: number[]; surfaceOp?: SurfaceOp } // 仅 surface 事件携带
: object)
这里有两个值得注意的设计决策:
4.1.2 事件词汇表:SessionEventMap
SessionEventMap 是事件类型与其 payload 的映射,也是可通过声明合并扩展的入口:插件(或核心包)通过 declare module 追加自己的事件类型。目前已知的外部贡献者包括:
- 压缩(compaction)接缝添加了 compaction/start / compaction/summary / compaction/end——一个带开闭括号的独立生命周期;
- 钩子桥接(@deepseek-ai/dsh-hook-protocol)添加了仅日志的 hook/invoked / hook/result 记录对,通过 handlerId 关联。
这些外部事件与核心事件一样,默认都不是 SurfaceEventType——没有 surfaceOp,不参与派生历史。
核心词汇表按用途分为四组:
边界与流事件
| turn/start | { turn } | 在循环认领排队输入或执行 pre-step 之前打开轮次 turn。拒绝、空输入、取消或失败都可以在没有任何步骤的情况下关闭它。 |
| turn/end | { turn, reason } | 以 TurnEndReason(4.1.6 节)关闭轮次。循环不在轮次边界等待刷盘:持久化检查点由 dsh-session-checkpoint-policy 按请求拥有;直接读存储的消费方在 whenIdle() 后自行 flush。 |
| step/start | { turn, step } | 打开轮次 turn 的步骤 step——一次模型调用加上它请求的工具执行。 |
| step/end | { turn, step } | 关闭该步骤。 |
| assistant/chunk | { turn, step, chunk } | 原始流式分片(StreamChunk),token 级回放保真。派生历史跳过它,组装后的消息才是权威。 |
消息与工具事件(Surface 事件)
| user/message | UserMessage(共享的 user-role 消息表示) | 模型可见 surface 上的一条用户侧消息。三类来源共用它:人工直接提示(本轮回填的排队消息)、合成的 agent.inject() 上下文(文件变更通知、子目录 AGENTS.md、技能内容、cron 通知等)、进入的 goal 延续轮。三者的 content 都原样投影,source 字段区分生产方。 |
| assistant/message | { turn, step, message, usage?, interrupted? } | 一个步骤组装完成的助手消息,派生历史使用它。适配器报告了 token 计量时携带该步骤的 usage——模型输出与其记账一起旅行,没有独立的 usage 记录。中途取消的轮次把已交付的文本/推理前缀最终化为一条 interrupted: true 的事件(未分发的工具调用不存在于其中);若流没有发出过任何可见内容,则根本没有这条事件。 |
| tool/call | { turn, step, callId, name, arguments } | 模型请求的一次工具调用:arguments 是模型产出的原始 JSON 字符串(未解析)。callId 把调用与其 tool/result 配对。 |
| tool/result | { turn, step, message, error?, meta? } | 已完成调用的面向模型结果,附可选的内部失败身份 error 与工具私有 meta 展示载荷。meta 对核心不透明(生产它的工具拥有其形状,并在 presentResult 中读回,例如 dsh-tool-fs 在此携带结果时刻的上下文 diff),但必须可 JSON 序列化:Session.append 会对所有事件数据做运行时校验,不可序列化的 meta 在源头被拒绝,持久日志才能在回放时复现同一张卡片。 |
UserMessage 本身是普通提示词、注入上下文、steering(中途引导)与实时收件箱事件共享的、带标识且冻结的 user-role 值;事件包装层只增加事件本地的位置或结果事实,条目待处理期间循环只额外附加驱动器自有的路由状态。
状态快照与请求事件
| todo/write | { todos: TodoItem[] } | 待办列表的全量快照,回放时最后一次写入生效。这是仅日志的 UI 状态,从不进入派生历史。 |
| request/header | { header, reason } | 下一个请求的完整请求头(EpochHeader),在其所属步骤内、分派前追加。见 4.1.5 节。 |
| request/context | RequestContext | 下一个请求的路由元数据,仅在提供方、模型或容量与上一条记录变化时追加。 |
| session/end-seed | {}(空) | 构造函数种子的结束边界。见 4.1.7 节。 |
待办项:TodoItem
interface TodoItem {
content: string // 一行祈使句,UI 显示用
status: 'pending' | 'in_progress' | 'completed' // 三态生命周期
}
它有意保持精简:没有 id、优先级或 activeForm——因为列表在每次写入时整体替换(最后写入生效),条目无需稳定标识。三态覆盖了模型与 UI 消费方所需的全部可移植生命周期。
插件贡献事件的附加约定
如果同一个插件事件族的多条事件要组装成一个 Web Client Conversation Node,该族中每条 start/update/result/resource/interruption 事件都必须携带或独立推导出同一个稳定业务 id,使 Client 无需根据相邻关系猜测归属。这条要求只约束需要关联的 Node 事件族,不要求每条会话事件都有业务 id。
4.1.3 派生历史:deriveMessages()
模型看到的对话不是存储的,而是投影出来的。Session.deriveMessages() 把日志投影为 Message[](消息类型定义见 4.4.1 节),投影规则如下:
| user/message(人工提示) | 一条携带确切 content 的 user 消息;可选 envelope 仅作为日志中的展示元数据保留。 |
| user/message(注入上下文,非 user 来源) | 按时间顺序在相应位置生成一条 user-role 消息,原样承载其 content;类型化 source 标明生产方。 |
| assistant/message | 一条 assistant 消息,记录生成它的提供方和模型(以及可选的适配器私有回放状态)。内容为空的 assistant/message 也跳过:因 max-tokens 截断而无内容的步骤仍会记录该事件以保存用量、提供方和模型,但无内容的助手轮次不得进入提供方文本记录。 |
| tool/result | 一条携带 tool-result 块的 user 消息。 |
| 其余全部事件(turn/*、step/*、assistant/chunk、request/*、todo/write、插件事件等) | 结构信息,不投影为消息。 |
实现上的三个关键点:
- 缓存:每个 surface 节点在首次出现时投影恰好一次,deriveMessages() 的代价是 O(新增节点);surface 被重写(一次 replace,见 4.1.4 节)时缓存整体重建。
- 冻结:每次调用返回一个新鲜数组,但其中的 Message 对象是共享且深冻结的——它们直接复用日志中已冻结的事件数据,因此「通过投影修改已记录的历史」在类型上不可表达。
- 逐节点纯函数公开:deriveEventMessage(event) 是折叠所应用的单节点投影函数,作为纯导出公开,供外部重建器和开发不变式检查用完全相同的规则投影日志前缀,不会与内部缓存产生分歧。
Token 记账不走消息投影:它读取每个步骤的 assistant/chunk { type: 'usage' } 记录;若没有用量分片,则以 assistant/message.usage 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息,因此其用量分片本身成为持久化的记账记录。
4.1.4 Surface 类型与 SurfaceOp
「派生历史」需要一个精确定义:有序 surface 是日志中产生消息的事件按模型可见顺序构成的序列。三类产生消息的事件类型构成 SurfaceEventType:
type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result'
只有这三类事件携带 surface 元数据,编译器在 Session.append() 调用点强制执行。
SurfaceOp:事件如何进入 surface
type SurfaceOp = 'append' | { op: 'replace'; start: number; end: number }
- 'append':追加到尾部——user/assistant/tool 消息的正常路径。
- { op: 'replace', start, end }:把 surface 上从 start 到 end(含两端)的节点遮蔽,并在原位置插入新事件。两端都必须是当前 surface 上的有效节点;start === end 时只替换单个节点。新事件的 sourceEventSeqs 必须覆盖每一个被遮蔽的 surface 节点。压缩(compaction)是主要使用者,任何 surface 替换型生产方都可以使用它。
SurfaceIntent:append() 的参数
interface SurfaceIntent {
surfaceOp: SurfaceOp
sourceEventSeqs?: number[]
}
append(type, data, …opts) 的第三个参数按事件类型条件存在:
append<T extends SessionEventType>(
type: T,
data: SessionEventMap[T],
…opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
): SessionEvent<T>
对 SurfaceEventType 事件必填——每个产生消息的事件都必须声明它如何加入 surface,因为这是派生模型历史的唯一来源;非 surface 类型(turn/start、assistant/chunk 等)在编译期直接拒绝该参数。sourceEventSeqs 语义是「本事件引用的、更早的源事件 seq 全集」:assistant/message 可以携带一个存在但为空的数组,表示提供方流已知且完整地为空;字段缺失则意味着该事件没有记录消息的来源。除 assistant/message 外,其他 surface 事件只要携带该字段就必须非空。
SessionSurface 与完整回放
-
Session.surface 返回会话稳定的只读视图:
interface SessionSurface {
readonly nodes: readonly number[] // 当前 surface 的事件 seq,模型可见顺序
readonly replaceGeneration: number // 已提交的位置替换次数(单调递增)
}同一个增量管理器在提交前校验追加候选事件,并根据已提交事件推进该投影;调用方可以观察成员关系与替换代次,但不能触发校验。replaceGeneration 让增量消费方区分「纯尾部增长」与「发生重写」。
-
foldSurface(events) 返回独立完整的回放结果:当前 nodes 加每次声明的替换实际遮蔽的 seq(SurfaceFoldReplacement { seq, start, end, shadowedSeqs })。实时管理器复用同一套状态转换,但不保留替换历史。SurfaceManager(log, baseSeq?) 还可以折叠一个连续的已加载窗口(第一个事件绝对序号为 baseSeq);若替换跨过窗口头部,由于声明的范围不存在,该替换会失败。
为什么需要「遮蔽」? 因为面向人类的 transcript(文本记录)是另一个投影:它读取日志中所有追加来源的事件。surface 会有意遮蔽被替换所概括的范围(比如压缩后的大段历史),所以两个投影故意不一致——这是设计,不是缺陷。
4.1.5 请求头事件:request/header 与 request/context
「每个对话请求都是日志的纯函数」——dsh 的可重建性不变量要求:给定日志前缀,就能重建出下一步将发送的请求。因此请求信封也写入日志。
EpochHeader:请求信封
interface EpochHeader {
config: LlmCallConfig // 调用配置:provider、model、reasoningEffort、采样标量
adapterDefaults?: LlmCallConfigAdapterDefaults // 由精确适配器物化而非调用方提议的字段
system?: string // 渲染后的系统提示词;无系统提示的请求缺省
tools?: ToolSchema[] // 组装后的工具 schema;无工具请求缺省
}
规范形式:空系统提示词和空工具列表都表示为字段缺失,与请求构建方式一致。
request/header 事件携带完整 EpochHeader 快照加一个 reason:
- reason 'initial' 或 'resume':记录每个 agent loop 实例的边界;
- reason 'change':此后请求发生变化时,记录另一份完整快照(没有 delta 事件)。
session.requestHeader() 增量折叠出「下一个请求将与之比较的 header」——即日志最后一个 header 事件之后的生效值,首条 request/header 之前为 undefined。
包含旧版 request/header-delta 事件或原因为 fallback 的完整快照的旧版 v0 日志,会在 seed、append 和持久化加载边界被拒绝,而不是以不完整方式回放。
RequestContext:路由容量
interface RequestContext {
provider: string
model: string
contextWindow?: number // 提供方公布时的请求+响应合计上下文容量
}
它与 EpochHeader 刻意分开:EpochHeader 是 headerEquals 逐字段比较的重建约定,而容量描述的是路由而非请求输入——把它折叠进去会让一次容量变化被登记为请求信封的 change,并把适配器元数据拉进循环的重建不变式。request/context 在同一步骤内紧随 request/header 追加,仅在提供方、模型或容量与上一条记录不同时追加;session.requestContext() 增量归并出最新一条。适配器不公布容量的路由以缺失 contextWindow 记录,因此新记录可以清除较早路由的容量。
4.1.6 轮次结束原因:TurnEndReasonMap
turn/start 没有 trigger 字段——轮次为什么开始由已记录的输入解释:进入的 user/message 批次记录进入每个步骤的内容,llm/retry(插件贡献)记录请求恢复,idle 注入则保持待处理直到唤醒交付抵达后续 pre-step。turn/end 的 reason 回答轮次为什么结束:
interface TurnEndReasonMap {
completed: { kind: 'completed' }
aborted: { kind: 'aborted'; reason: TurnEndCancelCause }
blocked: { kind: 'blocked' }
error: { kind: 'error'; error: LlmFailure }
'max-tokens': { kind: 'max-tokens' }
interrupted: { kind: 'interrupted' }
}
| completed | 正常完成:模型不再欠响应(无待执行工具调用、无新鲜 steering)。 |
| aborted | 取消请求中断了进行中的轮次。实时轮次保留停止驱动器的类型化 AgentCancelCause;持久化只在导入的受支持粗粒度取消记录未保存调用方时使用额外的 { kind: 'legacy' } 原因。 |
| blocked | 被阻止(如审批路径的停止语义;具体生产方见审批子系统文档 [待核实:blocked 的全部产生场景])。 |
| error | 轮次失败。error 永远是结构化失败:LlmError 事实原样,或由任意其他错误压平为 { message: errorChain(error), code: 'UNKNOWN' }。 |
| max-tokens | 至少一个步骤达到输出 token 上限——即使插件后来继续了轮次。截断事实优先于 completed,让消费方能区分正常停止与截断停止。 |
| interrupted | 持久化后端在重载时关闭了一个崩溃孤立的轮次。循环从不发出它,崩溃前记录的事件保持完好。 |
该 map 可通过声明合并扩展;max-tokens 与模型调用中同名的 FinishReason 对应,取消与错误始终是独立的结局。
4.1.7 执行封闭与独立事件
轮次包围的是一次模型循环执行,不是整个日志。几条容易误解的封闭规则:
4.1.8 种子结束边界:session/end-seed
带种子的会话(恢复、fork 或回放)在构造种子之后,把这个空 payload 的仅日志事件作为自己的第一次实时写入追加。它回答一个问题:「日志里哪些事件不是本生命周期写的?」
- 它之前的事件 seq 更小,来自构造种子;
- 它是 Session.firstLiveSeq(本进程内首个实时追加的 seq,等于构造种子的长度)的持久投影:该字段服务持有对象的消费方,该事件服务只持有存储字节的消费方;
- Session 的构造函数是唯一合法写入方(invariant 配套插件刻意不在这里约束,因为一个插件乱写一个边界会静默地把之前的实时工作重新分类为种子历史)。
三条实用规则:
另外:按「真人活动」排序会话的消费方应排除该边界——接手一个会话不算工作,按日志尾部排序会把每个打开过的会话顶到最前。
4.1.9 插件贡献的仅日志事件
插件通过声明合并向 SessionEventMap 添加事件。这些是仅日志事件:不是 SurfaceEventType(不携带 surfaceOp,不参与派生历史),事件所有方决定它们属于一个开放的执行轮次还是可以独立位于轮次之间,并在自己的 invariant 配套插件中强制所需关系。
以钩子桥接为例:hook/invoked / hook/result 通过 handlerId 关联。UserPromptSubmit、PreToolUse、PostToolUse、Stop 在循环已打开的轮次内触发,记录天然位于轮次之内;SessionStart 不产生记录,因为它在轮次 1 之前运行——其上下文在收件箱中保持待处理,直到唤醒交付打开一个轮次。
生成的持久化日志事件目录(docs/persistence-catalog.zh.md)列出每个核心或插件贡献的事件、其 payload、surface 标记与声明位置,是插件作者查阅事件全集的入口。
4.1.10 会话 fork API
ctx.sessions.create(id, { seed, meta }) 是底层的回放/fork 原语:seed 事件会以深克隆的方式填充新会话。对于普通的活跃会话 fork,SessionStore 暴露一个策略 API:
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
- source 是活跃 Session 对象或活跃 SessionId;
- 选取到 boundary(含)为止的源事件,缺省为源会话当前最后一个事件;
- 要求所选前缀结束时没有开放轮次——API 拒绝结束于开放轮次内的前缀,而不是静默截断;
- 子会话携带子会话元数据(parentSession、seedLength 及继承的 cwd),进入 live registry。
显式 boundary 允许从任意稳定的轮次间位置 fork——包括之前的 turn/end 或更晚的独立纯日志事件——即使源会话有更新的事件或正在进行的轮次。更广的执行关系健全性检查留在既有的 dsh-invariants 插件与持久化修复路径中,不在 fork() 中重复。
一个特例:dsh-subagent-fork-in-process 保留它自己「已完成前缀」的截断逻辑,因为工具调用时刻的子智能体分叉通常在父轮次仍然打开时启动;普通的会话分支应显式指定请求的 boundary。
4.1.11 持久性约定
持久化后端依赖的约定只有三条,但都足够硬:
该格式有意不提供兼容性承诺:seed/load 校验会拒绝缺少提供方/模型的请求头和 assistant 消息,而不是猜测历史数据应走的提供方路由。
4.1.12 Session 与 SessionStore 公共 API 速览
Session 是一个普通类(不是 Service):live 实例通过 ctx.sessions.create() 创建,脱离态(detached)实例通过 Session.create(id, seed?, header?) 创建;Session.fromRestore(id, seed, header) 则是持久化恢复路径——转移所有权、全量校验后冻结。
class Session {
get surface(): SessionSurface
readonly header: SessionHeader // 深冻结的创建元数据(版本、cwd、lineage、seed 边界),不在事件日志内
get id(): SessionId
readonly firstLiveSeq: number // 本进程内首个实时追加的 seq
get events(): readonly SessionEvent[] // 不可变快照,下次追加前复用同一数组
get seq(): number // 下一条事件的 seq = 日志长度
append(type, data, …opts): SessionEvent // 见 4.1.4 节
requestHeader(): EpochHeader | undefined
requestContext(): RequestContext | undefined
deriveMessages(): Message[]
deriveEventMessage(event): Message | null
}
SessionStore(ctx.sessions)是内存会话存储。持久化刻意不在这里实现——持久化插件订阅 session/event 并在 session/flush / dispose 时刷盘:
create(id?, options?) // 创建并进入 store;fiber 处置时移除
prepare(id?, options?) // 只构建不进入 store(与 enter+announce 配对)
enter(session): () => void // 进入 store,返回 detach disposer;不发出 session/created
announce(session): void // 恰好一次发出 session/created(回滚安全)
flush(session): Promise<boolean> // 分发 session/flush 持久检查点——唯一的 flush 入口
get(id) / list() // 查找 live 会话
fork(source, boundary?, childSessionId?) // 见 4.1.10 节
prepare/enter 分离的原因:agent 工厂需要把会话生命周期折叠进自己的单个 effect,使 fiber 卸载时「会话 + 智能体」作为一条有序链拆除——若作为两个平行的 effect 竞争,发布钩子会在驱动器的收尾事件提交之前被移除,事件即丢失。enter 会复查 id 是否重复(prepare 与 enter 是公开的跨包原语,调用方可能在其间插入任意工作),防止陈旧的 prepared 会话覆盖同 id 的 live 条目。
会话域事件(均支持 scope 过滤分发):
| session/created | emit | 创建通告(发布期)。同步抛出可否决并回滚;返回的 Promise 拒绝只记录、不能事后否决。 |
| session/disposed | emit | 已通告的会话离开 store 时恰好一次(含发布回滚);从未开始通告的条目不发出。 |
| session/event | emit | 提交后的「发射后不管」追加流:监听器快照在日志推送前解析,回调在其后运行;观察方失败被隔离,不影响已提交的追加。 |
| session/flush | parallel | 等待式并行持久检查点:所有监听器都运行且被 await,无级联否决。 |
4.2 智能体子系统
4.2.1 Agent 接口
会话是被动的日志;真正「驱动」它的是智能体。Agent 是公开 live 句柄,dsh-agent-loop(harness 中唯一包含具体循环逻辑的包)负责其实现:
interface Agent {
readonly id: SessionId // 与 session 共享的单一身份
readonly options: AgentOptions // 提供方路由与模型
readonly session: Session // 被驱动的 live 会话;其日志是持久真源
readonly inbox: Inbox // 智能体拥有的持久待办工作投影
readonly status: AgentStatus // 'idle' | 'running',每次迁移都发 agent/status
readonly ctx: Context // 智能体作用域上下文;贡献在 dispose 时回卷
cancel(cause, options?) // 清空排队/steering 工作(除非 keepInbox)并中止活动轮次
whenIdle(): Promise<void> // 当前整体活动到达静止后 resolve
runMaintenance(task) // 从真正的 idle 相位运行一次非轮次维护任务
send(message, target, wakeup) // 把带标识输入路由到收件箱边界,可选唤醒驱动器
followup(message) // 排队一个普通后续轮次并唤醒(独享一个轮次)
steer(message) // 向最近步骤提交 steering;idle 时启动轮次
inject(message) // 排队面向模型的上下文,不唤醒驱动器
}
followup/steer/inject 的差别是调度语义而非内容语义:
- followup:新消息独占一个轮次;
- steer:在最近的步骤边界消费——运行中的驱动器在下一步边界拾取,被拒绝的步骤让它停泊在收件箱直到下次唤醒;
- inject:排队待下次 pre-step 认领,不唤醒驱动器,且可能错过 pre-step 已认领批次的请求。
4.2.2 live registry:ctx.agents
AgentRegistry(ctx.agents)是一个 Service,跟踪 live 智能体,并携带「发起者(initiating agent)」——进程本地异步驱动链上的因果归因,不是存活证明或授权。
关键分工:registry 本身不提供创建。创建由实现了 AgentFactory 的插件提供(即 dsh-agent-loop,经 setFactory 注册),这样 ctx.agents 的消费者可以编程而不依赖具体循环包:
interface AgentFactory {
createAgent(ownerCtx, options): Promise<AgentHandle>
resume(ownerCtx, options): Promise<AgentHandle>
}
创建序列是回滚覆盖的事务:等待未发布的 setup → 调用可选的同步 commit → 依次插入会话与智能体 → 按序发出 session/created、agent/created、agent/session-start → 才启动循环。任何一步失败都回滚,且已开始通告的每个创建都由 agent/disposed / session/disposed 配对。
AgentHandle = { agent, dispose() }:disposer 是一种能力——只有持有者能拆除该智能体;dispose() 停止循环、等待其退出、注销智能体、从 store 移除会话、最后回卷其作用域世界。ctx.agents.get(id) 返回的是裸 Agent——句柄只暴露给创建它的消费方。
4.2.3 agent 域事件
agent 域事件分三类:
生命周期(emit)
| agent/created | 配置完整的智能体与 live 会话已发布。同步监听器失败否决发布;返回的 Promise 拒绝只报告。 |
| agent/disposed | 智能体离开 registry——AgentLoop 在驱动器静止、作用域注册回卷之后、会话 detach 之前发出。 |
| agent/status | idle ⇄ running 迁移。唤醒交付在预留取消后同步进入 running;idle 表示没有驱动器处于调度或活动状态。处置(disposal)不是第三个可观察状态。 |
| agent/inbox/inserted / claimed / discarded | 消息进入 / 在开放轮次内被认领 / 被丢弃。若提议的步骤被拒绝,被认领的消息到此为止——既不丢弃也不重发为 user/message,轮次无步骤关闭。 |
| agent/session-start | 会话生命周期开始(首个轮次前一次)。source 取值 startup / resume / clear / compact。这是通知而非否决;此时用 agent.inject() 播种面向模型的上下文。 |
| agent/error | 步骤或轮次出错。即使错误在轮次内没有持久记录的位置,机器也在这里报告。 |
循环的扩展点
| agent/pre-step | waterfall | 拒绝提议的步骤,或替换进入步骤的消息;next() 保留当前消息。 |
| agent/request | waterfall | 替换冻结的调用配置(首请求来自 agent options,之后来自已记录 header)。模型可见内容必须走已记录通道——这个 waterfall 不能改写消息。 |
| agent/request-error | waterfall | 处理一次失败的模型请求尝试:监听器返回 { kind: 'retry' }(不调用 next())表示它接管恢复,next() 委托,默认 undefined 让失败成为终态。payload 携带 LlmFailure 事实与失败请求所对应注册的重试策略。 |
| agent/turn-stopping | serial | 轮次即将关闭(模型不再欠响应):在边界提交前等待;有意见的监听器 steer(),机器重读收件箱——新鲜 steering 再跑一个步骤,否则关闭。数据决定结果,监听器顺序无法改变它。反向控制(提前停止工具循环)同样是数据:携带 concludesTurn 的工具结果在其步骤结束轮次。 |
4.3 工具管道:带守卫的执行
4.3.1 ToolDefinition:一个已注册的工具
interface ToolDefinition extends ToolSchema { // name / description / parameters(面向模型)
readonly output: ToolOutputDefinition // 必需的规范输出声明
execute(args: unknown, exec: ToolRunContext): Promise<unknown>
finalizeContent?(exec, result): ContentBlock[] | undefined // 同步的最终内容变换
timeoutMs?: number // 协作式超时预算;永不发给模型
isConcurrencySafe?(args): boolean // 纯同步分类器;只有精确 true 才加入并行组
presentCall?(args): ToolCallView | undefined // 待执行状态的 UI 渲染意图
presentResult?(args, result): ToolResultView | undefined
}
要点:
- output 是规范契约:schema(对每个成功值强制的原始 JSON Schema)+ render(从已校验参数和值到面向模型内容的纯投影)+ 可选的 presentationMeta。
- execute 返回规范值(output.schema 声明的无损 JSON 值),异步工作必须观察 exec.signal。注册表通过环绕分派的 signal 替换保留调用方取消,但不能硬杀同进程代码。
- 面向模型的投影是显式允许列表:schemas() 只输出 name/description/parameters;output、execute、finalizeContent、timeoutMs、isConcurrencySafe、present* 绝不能泄漏到模型请求中。
- 第一方工具用 defineTool({ name, description, parameters, output, execute, … }):它代为校验并收窄参数类型、按 output.schema 推导返回类型,参数不匹配抛 ToolArgsError(INVALID_ARGS)、输出无效抛 ToolOutputError(INVALID_TOOL_OUTPUT),两者都走常规工具错误路径。
4.3.2 执行流水线:瀑布 + 单调守卫
ctx.tools.execute() 接受调用方拥有且含必需 readonly signal 的 ToolExecutionInput,把解析后的 JSON 参数一次性物化为流水线拥有的 ToolExecution(深度冻结),然后依次经过:
tools/pre-execute(waterfall:allow / deny / ask)
→ 已注册的单调 guard(ToolGuard)
→ tools/execute(waterfall:环绕分派包装层——超时、重试、指标)
→ 工具函数体 execute(args, exec)
→ tools/post-execute(waterfall:接受 / 替换 / 附加上下文 / 阻止)
→ finalizeContent(定义拥有,恰好调用一次)
→ tools/result(emit:不可变的权威结果,深度冻结快照)
各级决策语义:
- PreToolDecision:allow 执行;deny { reason } 物化一个错误结果;ask { reason? } 只在审批服务返回 allowed-once 时执行,否则拒绝。输入不可改写——参数已经被记录、呈现,历史/审计/UI 必须一致。
- ToolGuard 是单调的:在全部 pre-execute 监听器之后、工具体之前求值;返回 reason 即拒绝,返回 undefined 保持不变。它的返回类型刻意不含 allow——监听器顺序无法把一次拒绝变回许可。普通上下文注册的 guard 全局生效,经 agent.ctx 注册的只对那个智能体生效。
- PostToolDecision:accept(可替换 content 或 value,不可同时)、block { feedback }(移除值,转为携带纠正反馈的 isError)。内容替换是展示策略而非保密策略——需要隐藏程序化值的监听器必须阻止或替换该值。
- 结果类型:ToolExecutionSuccess(isError: false,携带执行本地的 value 与面向模型的 content,可选 meta、additionalContexts、concludesTurn)与 ToolExecutionFailure(isError: true,error 为人可读消息 + 可选内部 ToolErrorInfo,失败永不携带成功值)。
- 规范值不持久:循环只持久化 content、error、meta;value 仅存在于执行期间。回放可以重现展示,却无法重建规范的中间值。
- 失败不终止轮次:未知工具映射为 UNKNOWN_TOOL 结构化错误,抛异常的工具物化为错误结果——调用失败但当前轮次继续。
ToolRunContext(工具体收到的运行时上下文)提供两个控制手段:deferContext(userMessage) 把上下文附着到本次执行自己的结果上(组合工具转运嵌套分派上下文、叶子工具铸造插件来源指令),循环只在 tool/result 之后追加;concludeTurn() 标记本次成功结果为轮次终结。
调度模式由 executionMode(exec) 给出:parallel 可与兄弟调用重叠,exclusive 单独运行并形成排序屏障。只有精确 true 的 isConcurrencySafe 分类算数——未知、隐藏、未声明、无效或抛异常的分类器一律 exclusive(失败关闭)。
4.3.3 作用域与 ToolRestriction
工具可见性是作用域敏感的:作用域注册遮蔽全局同名工具,restrict({ allow?, deny? }) 对该作用域继承的工具(部署全局层加链上每个祖先作用域)施加实时过滤——多个限制取交集,且不影响该作用域自身的注册(因此被委派的子智能体保留其回报所依赖的工具)。仅 deny 的过滤器放行未列出的继承工具,allow 列表则排除它们。tools/change 事件是不过滤的注册表通告:全局变更关系到每个智能体的下一次组装。
4.4 LLM 流:packages/llm
4.4.1 消息与内容块
对话由 Message 组成;一条消息是类型化内容块的数组:
interface ContentBlockMap {
'text': TextBlock // { text }
'reasoning': ReasoningBlock // thinking,区别于可见文本
'image': ImageBlock // 一个持久的图片附件
'tool-call': ToolCallBlock // { id: CallId, name, arguments: 原始 JSON 字符串 }
'tool-result': ToolResultBlock // { toolCallId, content: ContentBlock[], isError? }
}
interface Message {
readonly id: MessageId
readonly role: 'system' | 'user' | 'assistant'
readonly content: ContentBlock[]
readonly source: MessageSource // 可合并扩展:user / plugin / model / tool
}
MessageSource 的两个轴彼此独立:kind 回答「由谁产生」,可选的 form 回答「这是什么类型的信息」(instructions / catalog / snapshot / notice / relay / recall),消费方决定如何呈现;未声明或无法识别的 form 按不透明内容呈现。模型生成的 assistant 消息在来源中记录生成它的提供方、模型,以及可选的适配器私有 replayState(LlmRuntime 仅在目标适配器当前拥有该历史提供方与目标提供方时才会传递它)。
ToolSchema({ name, description, parameters })声明在 dsh-llm 而非 dsh-tools,正因为它是 GenerateOptions 的一部分——循环每一步组装请求都要携带它。
4.4.2 StreamChunk:原始流协议
type StreamChunk =
| { type: 'block-start'; index; blockType }
| { type: 'text-delta'; index; text }
| { type: 'reasoning-delta'; index; text }
| { type: 'tool-call-delta'; index; id; name?; argumentsDelta }
| { type: 'block-end'; index; block } // 携带完整组装好的块
| { type: 'usage'; usage: TokenUsage }
| { type: 'finish'; reason: FinishReason; replayState? }
index 把交错的 delta 关联到所属块;block-end 携带完整组装好的 ContentBlock,消费方无需自行重组 delta。这是一个封闭的可辨识联合——对 type 的 switch 以 assertNever 结尾(与会话事件相反,因为它是核心封闭词汇)。
TokenUsage 的各计数互不重叠:inputTokens 只含未缓存输入;缓存输入单独报告(cacheReadTokens / cacheWriteTokens),计费输入是三者之和;reasoningTokens 是信息性细节,已含在 outputTokens 中。
4.4.3 适配器约定
每个适配器必须遵守、每个消费方可以依赖的规则(提炼):
FinishReason 是可合并扩展的:stop / tool-calls / max-tokens / aborted(携带 LlmFailure)/ error(携带 LlmFailure)。
4.4.4 BlockAssembler 与请求信封
BlockAssembler 是唯一共享的 StreamChunk → ContentBlock 折叠实现。智能体循环在记录原始分片(assistant/chunk)的同时把同一批分片喂给 assembler,流结束后读取 blocks() / message() / usage / finish,取消截流时读取 interruptedBlocks()(已闭合与开放的、含非空白内容的文本/推理块;工具调用被省略,因为中断先于分派,保留它们需要伪造结果)。max-tokens 结束丢弃每个工具调用——被截断的调用不能安全执行——同一决定同步裁剪回放数据的逐块条目。
模型请求是完整组装的 GenerateOptions:
interface GenerateOptions {
provider: string // 选择已注册适配器
model: string
reasoningEffort?: ReasoningEffortId
messages: Message[] // 循环构建的请求 = 派生历史
system?: string // 渲染后的系统提示词
tools?: ToolSchema[]
temperature?; maxTokens?; stop?; signal?
sessionId? // 循环盖上的会话身份,用于请求路由
purpose?: 'compaction' | 'session-title' // 辅助调用的分类
}
请求信封与记录的 header 直接挂钩:agent/request 瀑布接收冻结的调用配置种子并可返回替代值(切换提供方、模型、推理强度或采样参数);瀑布开始前循环会移除标记为适配器默认值的值,使精确模型准备过程填入所选路由的当前值;结束后在轮次信号控制下拒绝不受支持的显式推理强度(不自动调整),并记录生效配置。协议层面,循环构建的请求先读 system 槽(渲染后的提示词组装),再读派生历史;已记录的请求快照以最新的 user/message(轮次首步)或上一步的工具结果(后续步骤)结尾——开发不变式针对每个循环构建的请求精确重算此等式。
4.4.5 llm/stream 瀑布
'llm/stream'(options: GenerateOptions, next: () => AsyncIterable<StreamChunk>)
: AsyncIterable<StreamChunk>
环绕每一次流式模型调用的瀑布(重试、回放、路由)。调用 next() 到达已解析适配器的流,或产出自己的分片短路。循环构建的请求到达时是深度冻结的(修改即抛异常)——其内容是会话日志的纯函数,监听器只读不改;手工构建的调用不带循环标记。适配器的最终选择发生在瀑布的终端 continuation,监听器可以在查找前短路调用,或路由一个可变的一次性请求。
注册侧的 LlmRuntime(ctx.llm)提供 registerAdapter(全有或全无,重复路由原子失败)、listProviders / listModels(仅供参考的目录,不是请求白名单)、resolveModelInfo(精确路由的上下文容量与推理元数据)、prepareCall(模型解析、请求头持久记录与分派全程持有同一项适配器注册,防止热替换把两个适配器的能力结果拼在一起)与 stream。
4.5 系统提示词组装
4.5.1 注册模型
SystemPrompt(ctx.systemPrompt)管理一次组装调用中交换的数据,贡献者按作用域注册(作用域条目遮蔽同名的全局条目):
| section(section) | PromptSection | 提示词段落。name 唯一(重复注册抛异常)、order 升序拼接(约定:-100 是 harness 身份,0 是部署人格,工具指引用 100–199)、text 可以是静态字符串或每次组装求值的函数,可引用 {{variable}}(由渲染阶段插值)。complete: true 的贡献被视为完整系统提示词:组装仍跑协作瀑布解析工具、上下文与变量,然后把这个段恢复为唯一段落;出现多个有效 complete 段则组装失败。 |
| context(context) | PromptContext | 动态上下文——与段落对应的缓存安全结构。agent loop 仅在完整当前快照变化或被压缩移除时,才将其记录在保留的模型历史之后。 |
| tools(provider) | (ctx) => ToolProviderResult | 工具 schema 提供方。schemas 是当前组装中对模型可见的 schema 集合;knownNames 是限制前的名称全集,用于区分「配置名拼写错误」与「已知工具在此作用域中被有意隐藏」。 |
| variable(name, provider) | 变量 | 作用域值遮蔽全局;provider 可返回 undefined,但渲染引用它的段落时失败。 |
| suppressRuntimeContext() | 抑制器 | 在调用作用域内抑制所有动态运行时上下文贡献,不改变拥有/强制这些事实的服务。 |
AssembleContext 标识一次组装解析的作用域层(scope?),并可携带该请求的显式控制信号(signal?);dsh-agent 还添加了可选的 agent 字段。
4.5.2 组装流程
assemble(context?):
1. 收集全局与作用域贡献(作用域遮蔽全局同名)
2. 解析动态 text(按本次组装的 AssembleContext 求值)
3. 组装工具 schema(提供方 + 作用域过滤)
4. 施加规范排序
5. 运行 system-prompt/assemble 瀑布(专家可改写整个 assembly;返回值为权威)
6. 若存在有效的 complete 段,在瀑布之后恢复为唯一段落
→ PromptAssembly(随后由 renderPrompt 插值变量,得到最终 system 文本)
system-prompt/assemble 是瀑布:作用域过滤分发,监听器收到可变的 assembly 与本次组装的 context;传入的 signal 只控制这一次显式组装请求,不得保留以控制后续轮次。由于 complete 段在瀑布之后恢复,监听器无法为拥有 complete 段的作用域追加或替换系统提示词。注册与释放都发出 system-prompt/change(不过滤的全局通告:全局变更影响每个作用域)。
最终产出的 system 文本与 tools schema 进入 EpochHeader,作为 request/header 快照写入会话日志——这就是 4.1.5 节「请求是日志的纯函数」的最后一块拼图:系统提示词的变化同样留下持久痕迹,可被 headerEquals 比较并触发新的快照。
4.6 一次轮次的全景
把本章的子系统串起来,一个正常轮次的时间线是:
agent.followup(消息)
→ session: turn/start {turn: N}
→ agent/pre-step(waterfall:拒绝则轮次无步骤关闭;否则 enter 消息批次)
→ session: user/message(surface append;批次中每条)
→ agent/request(waterfall:调用配置)
→ systemPrompt.assemble → request/header(快照)→ request/context(如变化)
→ session: step/start {turn: N, step: M}
→ llm/stream(waterfall)→ 适配器流
session: assistant/chunk × k(原始分片)
BlockAssembler 折叠
→ session: assistant/message(surface append,sourceEventSeqs = 分片 seqs)
→ 若有工具调用:session: tool/call × n(原始 arguments 字符串)
每个调用走 4.3.2 的守卫执行流水线
→ session: tool/result(surface append;meta 可选)
→ 下一步 step(回到 step/start)直到模型不再欠响应
→ agent/turn-stopping(serial:监听器可 steer)
→ session: step/end, turn/end {reason: completed}
取消发生在流中途时,已交付的文本/推理前缀最终化为 interrupted: true 的 assistant/message,轮次以 aborted 关闭;崩溃发生在轮次内时,重载时持久化层以 interrupted 关闭孤立轮次——两种「非正常结束」在日志上都能区分。
4.7 本章小结
| 会话(dsh-session) | 仅追加的类型化事件日志是唯一真源;模型历史、请求头、UI 状态都是它的投影。 |
| 派生历史(deriveMessages) | surface 节点 → Message[],缓存、冻结、纯函数可复算。 |
| Surface(SurfaceOp) | append 是正常路径,replace 是压缩等重写的受控入口,遮蔽必须被 sourceEventSeqs 完整覆盖。 |
| fork | 从任意轮次间稳定位置分叉;拒绝开放轮次前缀;子会话继承 lineage 元数据。 |
| TurnEndReasonMap | 六种结局区分完成、取消、阻止、错误、截断与崩溃恢复。 |
| session/end-seed | 种子与实时工作的持久分界;定位最后一条,它是 firstLiveSeq 的存储投影。 |
| 智能体(Agent / ctx.agents) | 会话的驱动器:收件箱、idle/running 状态、followup/steer/inject 三种调度语义。 |
| 工具管道 | pre-execute → guard → execute → post-execute → finalize → result;拒绝单调、内容替换不保密、失败不终止轮次。 |
| LLM 流 | 封闭的 StreamChunk 协议 + 适配器约定 + BlockAssembler;一次调用一次提供方尝试。 |
| 系统提示词 | 段落/上下文/变量/工具的协作组装 + 专家瀑布;结果随 request/header 落日志。 |
延伸阅读:docs/subsystems/session.zh.md(会话完整类型)、docs/subsystems/persistence.zh.md(持久化后端与崩溃恢复)、docs/subsystems/tools.zh.md(工具类型全集)、docs/subsystems/llm-streaming.zh.md(流协议与适配器)、docs/subsystems/system-prompt.zh.md(组装行为)、docs/architecture.zh.md(轮次流程与能力接缝)。


