Main Chain B · Step 08

preStep:决定模型看到什么的四个动作

agent.ts:266-285。每一步开始前,preStep 依次做四件事:claim 输入 → assemble 提示词 → project 运行时上下文 → 过 agent/pre-step waterfall。最后一步是全 harness 最重要的扩展点之一——它决定模型看到什么。

05 turn:315 preStep(target, {turn, step}) → agent.ts:266 preStep → (分叉) 12 systemPrompt.assemble · 07 inbox.claim → (下一步) 09 buildRequest

示例本次示例:step 1 的 preStep 四动作实况

示例轨迹 08-1 · 四动作的输入输出
# ① 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 上下文] }
来源:08 页行级解读;AGENTS.md 注入路径见 23 页
示例轨迹 08-2 · 如果插件 reject 会发生什么
# 某插件返回 { kind:'reject' }(不调 next)→ 短路
# → turn() 316-318 行:turnEnds={kind:'blocked'},turn 关闭,无 step 事件
# 注意:claimed 的消息已经离开 inbox(claim 在 waterfall 之前)!
# 日志里没有它的 user/message——被拒的消息就丢弃(05 页的语义)
来源:08 页 §2 + 05 页 316-319 行

§1完整代码与行级解读

packages/core/agent-loop/src/agent.tspreStep 全文240-259
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  }
267

防御性前置:preStep 只在 running phase 合法。注释标了 v8 ignore——私有调用者(turn)已经建立了 running phase。

270

① claim(07 页):纯删除 splice,清空 next-step(+ 一条 next-turn)。注意它的位置在 assemble 之前——消息先离开 inbox 再组装。即使后面的 pre-step 被插件 reject,这些消息也已经移除、不进入 step,日志里不会有它们的 user/message。这是有意设计:被拒的消息就丢弃。

271

② assemble(12 页详述):收集所有插件贡献的提示词片段 + 工具 schema,assembleContextFor(this, signal) 把 agent 与取消信号放进组装上下文。整个组装的产物 assembly 会一路传到 step 里渲染成最终 prompt。

272, 282

两处 throwIfAborted():assemble 之后一次、waterfall 之后一次——组装或监听器里都可能发生取消。

273-274

③ project(§3):把动态运行时上下文(如工作目录、平台信息)快照成候选 user/message。注意 sections 既被 join 成完整文本(作为候选消息的 content),也原样传入(作为结构化元数据)。

275-281

④ waterfall 扩展点:payload 是 { messages: claimed, turn, step, signal }。默认值(fallback 回调)是 enter + claimed + 投影出的 context。任何插件可以改写 messages 或直接 reject。返回值的语义见 §2。

283-284

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:两个投影器

packages/core/agent-loop/src/runtime-context.tsRuntimeContextProjection(节选)109-158
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}
116

retained 三态:undefined = 从未有过快照;null = 当前无保留值;{seq, text} = 有保留值(seq 指向日志里承载它的 user/message)。三态区分「从未」与「已清空」——这是 runtime-context.ts:153 判断「该不该发新消息」的依据:从没发过且现在也没有 → 静默;发过但现在清空了 → 要发一条 CLEARED 消息。

118-127

构造时重建:从最新事件往回扫,第一条属于自己的、且仍在 surface 上的 user/message 就是 retained。用 Set(session.surface.nodes) 做 O(1) 存活判定——被 replace 遮蔽的旧快照不算数。

129-138

之后跟随权威事件流:自己的消息进入 → 更新 retained;自己那条被某个 replacement 事件(sourceEventSeqs 命中)遮蔽 → 置 null。所以投影状态在 compaction 之后仍然正确,且完全可以从日志重建。

147-157

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——遮蔽而非删除,日志仍保留全部历史版本。