Main Chain B · Step 08
preStep:决定模型看到什么的四个动作
agent.ts:266-285。每一步开始前,preStep 依次做四件事:claim 输入 → assemble 提示词 → project 运行时上下文 → 过 agent/pre-step waterfall。最后一步是全 harness 最重要的扩展点之一——它决定模型看到什么。
示例本次示例:step 1 的 preStep 四动作实况
# ① claim(270 行):next-step=[],next-turn=['帮我列一下当前目录']
# → claimed = ['帮我列一下当前目录']
# ② assemble(271 行):systemPrompt.assemble(assembleContextFor(agent, signal))
# → assembly = { sections:[harness 身份, persona, 工具指引...],
# tools:[bash, read, write, ... 的 schema],
# contexts:[...] } ← 12 页详述
# ③ project(274 行):runtimeContext 无变化(首步)→ context = undefined
# ④ waterfall agent/pre-step(275 行):
# fallback 返回 { kind:'enter',
# messages: context===undefined ? claimed : [...claimed, context] }
# = { kind:'enter', messages:['帮我列一下当前目录'] }
# 假设某个插件(如 agent-instructions)在链上插了一条 AGENTS.md 上下文:
# → 最终 { kind:'enter', messages:[原消息, AGENTS.md 上下文] }
# 某插件返回 { kind:'reject' }(不调 next)→ 短路
# → turn() 316-318 行:turnEnds={kind:'blocked'},turn 关闭,无 step 事件
# 注意:claimed 的消息已经离开 inbox(claim 在 waterfall 之前)!
# 日志里没有它的 user/message——被拒的消息就丢弃(05 页的语义)
§1完整代码与行级解读
266 private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreparedStep> {
268 if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)
269 const signal = this.phase.abort.signal
270 const claimed = this.inbox.claim(target, position.turn) // ①
271 const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)) // ②
272 signal.throwIfAborted()
273 const sections = renderContextSections(assembly)
274 const context = this.runtimeContext.project(joinContextSections(sections), sections) // ③
275 const decision = await this.dispatch.waterfall( // ④
276 'agent/pre-step', { messages: claimed, ...position, signal },
277 (): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({
278 kind: 'enter',
279 messages: context === undefined ? claimed : [...claimed, context],
280 }),
281 )
282 signal.throwIfAborted()
283 if (decision.kind === 'reject') return decision
284 return { ...decision, assembly }
285 }
防御性前置:preStep 只在 running phase 合法。注释标了 v8 ignore——私有调用者(turn)已经建立了 running phase。
① claim(07 页):纯删除 splice,清空 next-step(+ 一条 next-turn)。注意它的位置在 assemble 之前——消息先离开 inbox 再组装。即使后面的 pre-step 被插件 reject,这些消息也已经移除、不进入 step,日志里不会有它们的 user/message。这是有意设计:被拒的消息就丢弃。
② assemble(12 页详述):收集所有插件贡献的提示词片段 + 工具 schema,assembleContextFor(this, signal) 把 agent 与取消信号放进组装上下文。整个组装的产物 assembly 会一路传到 step 里渲染成最终 prompt。
两处 throwIfAborted():assemble 之后一次、waterfall 之后一次——组装或监听器里都可能发生取消。
③ project(§3):把动态运行时上下文(如工作目录、平台信息)快照成候选 user/message。注意 sections 既被 join 成完整文本(作为候选消息的 content),也原样传入(作为结构化元数据)。
④ waterfall 扩展点:payload 是 { messages: claimed, turn, step, signal }。默认值(fallback 回调)是 enter + claimed + 投影出的 context。任何插件可以改写 messages 或直接 reject。返回值的语义见 §2。
assembly 只随 enter 返回:reject 的 decision 不携带 assembly(反正不进 step)。PreparedStep 的形态:{kind:'reject'} 或 {kind:'enter', messages, assembly, startsRequestSeries?}——startsRequestSeries 宣告一个显式区分的新消息系列(09 页 §4 的 request/header series 机制;0.1.5 起它还驱动 system prompt 的系列判定,见 11 页 363-368 行)。
§2扩展点语义:waterfall 的四个返回形态
agent/pre-step 的声明在 packages/core/agent/src/runtime-types.ts:320(PreStepDecision 定义在 112-119 行)。它的 waterfall 返回 PreStepDecision:
| 监听器行为 | 结果 |
|---|---|
调 next() | 保留当前消息,继续委托给后续监听器(或 fallback) |
返回 {kind:'enter', messages: 改写后}(不调 next) | 用改写后的消息进入 step——短路,后面的监听器不跑 |
返回 {kind:'reject'}(不调 next) | 拒绝该 step → turn 以 blocked 关闭(05 页 316-318 行) |
| 调 next 后改写其结果再返回 | 在默认/后续结果基础上再改写——最常见的「包裹」形态 |
waterfall 语义(全库通用,之后 09/10/14 页还会出现):不调 next() 就返回 = 短路整条链。忘调 next 的监听器会静默吃掉所有后续监听器——这是 Cordis waterfall 最容易犯的错。AGENTS.md 原文:「Waterfall listeners MUST call next() to delegate; returning without it short-circuits the chain」。
§3runtime-context.ts:两个投影器
114export class RuntimeContextProjection {
116 private retained: { seq: SessionSeq; text: string | undefined } | null | undefined三态:undefined | null | {seq,text}
123 constructor(ctx: Context, session: Session) {
124 const surface = new Set(session.surface.nodes)
125 for (const event of eventsNewestFirst(session)) { // 从最新往回扫,重建 retained
126 if (event.type !== 'user/message' || !isOwned(event.data)) continue
134 ctx.on('session/event', (subject, event) => { // 之后跟随权威事件流
138 } else if (this.retained
139 && isReplacementSurfaceEvent(event)
140 && event.sourceEventSeqs?.includes(this.retained.seq) === true) {
141 this.retained = null // 被 replace 遮蔽 → 清空
152 project(current: string, sections: readonly ContextSnapshotSection[]): UserMessage | undefined {
153 if (this.retained === undefined && current.length === 0) return
154 const snapshot = current.length === 0 ? CLEARED : current
155 if (this.retained?.text === snapshot) return // 值没变 → 不发新消息
156 return createUserMessage({ ... })
163 }
164}
retained 三态:undefined = 从未有过快照;null = 当前无保留值;{seq, text} = 有保留值(seq 指向日志里承载它的 user/message)。三态区分「从未」与「已清空」——这是 runtime-context.ts:153 判断「该不该发新消息」的依据:从没发过且现在也没有 → 静默;发过但现在清空了 → 要发一条 CLEARED 消息。
构造时重建:从最新事件往回扫,第一条属于自己的、且仍在 surface 上的 user/message 就是 retained。用 Set(session.surface.nodes) 做 O(1) 存活判定——被 replace 遮蔽的旧快照不算数。
之后跟随权威事件流:自己的消息进入 → 更新 retained;自己那条被某个 replacement 事件(sourceEventSeqs 命中)遮蔽 → 置 null。所以投影状态在 compaction 之后仍然正确,且完全可以从日志重建。
project 只在值变化时返回候选消息,且不拥有落盘——落盘权在 step() 的 agent.ts:374-376。这正是「投影只提议、日志才裁决」的范例。
同文件 0.1.5 新增:SystemPromptProjection(60-106 行)——system prompt 的对应物。project(rendered, input)(第 83 行)比较 surface 上现存的 system/message 节点与本次渲染结果:
- 没有 head 节点(86-88 行)→ append 一条,这是正常首步路径。
!inHistory || startsSeries || rendered 为空(90-95 行)→ 把 head 之外的旧节点全部 replace 成空串,再 replace head 为新文本。inHistory= 适配器是否把 system prompt 读作历史消息(0.1.5 引入的能力位,见 11 页 365 行)。- 否则(96-97 行)→ 文本变了就 append 一条新
system/message。这就是「prompt 在历史里累积」的路径:模型能把每次变化当作一条对话内消息看到。
注意 replace(100-105 行)构造的 intent:surfaceOp: {op:'replace', startSeq, endSeq} 加 sourceEventSeqs——遮蔽而非删除,日志仍保留全部历史版本。