Main Chain B · Step 11

step:调用、落盘与三条分支

agent.ts:380-526。一个 step = 一次模型调用 + 它请求的工具执行。这里把 09/10 页的请求与流串起来:逐 chunk 落盘 → 组装完成锚点 → 三条分支(max-tokens / 完成 / 有工具调用)。0.1.3-alpha.2 起新增了「取消时把已流出的文本前缀固化为 interrupted 锚点」。

05 turn:311 step(decision) → agent.ts:380 step → (分叉) 09 prepareRequest/buildRequest · 10 stream · AssistantStreamAttempt · 13 executeToolCalls → (终点) 返回 completed / max-tokens / null

示例本次示例:step 1 的完整落盘序列

示例轨迹 11-1 · seq 连续的完整日志(假设从 turn/start=0 起)
seq 0  turn/start        {turn:1}                          ← 05 页 255 行
seq 1  step/start         {turn:1,step:1}                   ← 05 页 279 行
seq 2  user/message       「帮我列一下当前目录」(surfaceOp:append) ← 282 行
seq 3  request/header     {header,reason:'initial'}         ← 09 页 485 行
seq 4  assistant/attempt 「我来」流片段                  ← 本页 380 行
seq 5  assistant/attempt 「帮你」流片段                  ← 同上
seq 6  assistant/attempt 「列目录」+ tool-call 块         ← 同上
seq 7  assistant/message  完成锚点 sourceEventSeqs:[4,5,6]   ← 本页 400-408 行
seq 8  tool/call          {callId:'c1',name:'bash',arguments:'{"command":"ls -la"}'} ← 13 页
seq 9  tool/result        {callId:'c1',content:[...],sourceEventSeqs:[8]} ← 13 页
seq 10 step/end           {turn:1,step:1}                   ← 05 页 292 行
# → 模型欠工具结果一个回复 → step 返回 null → turn 循环继续(418 行)
seq 11 step/start         {turn:1,step:2}
seq 12 request/header?    无——headerEquals 相同(09 页示例 09-2)
seq 13 assistant/attempt   最终回答...
seq 14 assistant/message  回答锚点
seq 15 step/end           {turn:1,step:2}
seq 16 turn/end           {turn:1,reason:'completed'}       ← 05 页 319 行
来源:序列与行号全部来自源码;信封格式与 fixture 一致。注意 12 号位置没有 request/header——09 页示例 09-2 的原因
示例轨迹 11-2 · 如果用户在「我来帮你」时取消了
# seq 5 之后用户取消 → signal.aborted
# chunk 循环 throwIfAborted(349 行)抛 → catch(354 行)
# → assembler.interruptedBlocks() = ['我来','帮你'](非空白 text 前缀)
# → 落一条 assistant/message {interrupted:true}(358-367 行)
# → rethrow(388 行)→ turn 以 aborted 关闭
# 结果:seq 6 的位置是一条 interrupted 锚点——用户看到的前半句进了模型历史
来源:11 页 §4 + 0.1.3-alpha.2 的 interrupted 机制

§1完整代码与行级解读

packages/core/agent-loop/src/agent.tsstep 全文(0.1.5-rc.2)380-526
380  private async step(decision: Extract<PreparedStep, { kind: 'enter' }>): Promise<StepEndReason | null> {
384    const { assembly } = decision
387    const renderedPrompt = renderPrompt(assembly)
388    let firstAttempt = true      // 0.1.5:首次尝试才注入 user/system 消息重试不再重复注入
389    while (true) {
390      const { config, preparedCall } = await this.prepareRequest(turn, step, signal)拆分 1:解析配置 + 绑定 adapter
391      const startsRequestSeries = firstAttempt && decision.startsRequestSeries === true
392      const commits = this.systemPrompt.project(renderedPrompt, {   // system prompt 投影取代 header.system 赋值
393        inHistory: preparedCall?.systemPromptUpdate === 'in-history',
394        startsSeries: startsRequestSeries
395          || this.requestSurfaceGeneration !== this.session.surface.replaceGeneration
396          || this.toolsChanged(assembly.tools),
397      })
398      for (const { message, intent } of commits) {
399        this.session.append('system/message', { turn, step, message }, intent)落盘为 surface 事件
400      }
401      if (firstAttempt) {
402        for (const message of decision.messages) {
403          this.session.append('user/message', message, { surfaceOp: 'append' })
404        }
405      }
406      firstAttempt = false
407      const request = this.buildRequest(config, preparedCall, assembly.tools, startsRequestSeries, signal)拆分 2:组装冻结请求
408      const live = new AssistantStreamAttempt(
409        this.session.id,
410        ++this.assistantAttemptCounter,
411        () => ++this.assistantStreamRevision,
412        turn, step,
414        (frame) => { this.dispatch.emit('agent/assistant-stream', { frame }) },
415      )
416      let started = false
417      try {
418        const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
420        live.start()
421        started = true
422        for await (const chunk of stream) {
424          live.push(chunk)
425        }
427      } catch (error: unknown) {
428        if (!started) throw error
433              live.settle('assistant/message', () => this.session.append('assistant/message', {
451        throw error
452      }
453      try {
454        const finish = live.finish
455        if (finish.kind === 'error' || finish.kind === 'aborted') {
456          live.settle('assistant/attempt', () => this.session.append('assistant/attempt', {
457            turn, step, stream: live.stream,
458          }).seq)
460          const action = await this.dispatch.waterfall(
461            'agent/request-error', { turn, step, provider: request.provider,
462              failure: finish.failure, retryPolicy: preparedCall?.retryPolicy, signal },
469            () => Promise.resolve<RequestErrorAction>(undefined),
470          )
472          if (action?.kind !== 'retry') {
473            throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)
474          }
475          continue
476        }
478        const message = createAssistantMessage({
479          content: live.blocks(),
480          source: { provider: request.provider, model: request.model,
483            ...live.replayState !== undefined ? { replayState: live.replayState } : {} },
484        })
486        live.settle('assistant/message', () => this.session.append('assistant/message', {
487          turn, step, message,
489          ...live.usage === undefined ? {} : { usage: live.usage },
490          stream: live.stream,
491        }, { surfaceOp: 'append' }).seq)
492        if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }
494        const toolCalls = message.content.filter(block => block.type === 'tool-call')
495        if (toolCalls.length === 0) return { kind: 'completed' }
516        const { concluded } = await executeToolCalls(
517          this.loopCtx, turn, step, toolCalls, signal,
518          context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),
519        )
520        return concluded ? { kind: 'completed' } : null
522        if (!live.ended) live.abandon()
523        throw error
524      }
525    }
526  }
356-359

接收整个 decision(不再是分离的 assembly + 标志位),一次渲染 system 文本(12 页组装的片段在此变成最终 prompt)。

388

0.1.5 新增 firstAttempt:只有首次尝试注入 user/system 消息;重试循环的后续迭代不再重复注入(此前每轮都重复 append user/message,导致重试时日志里出现重复消息)。

390

拆分 1:prepareRequest(agent.ts:529)只做「解析 config + 绑定 adapter」——把 09 页原先塞在 buildRequest 前半段的逻辑独立出来,因为 system prompt 的投影决策需要知道 preparedCall.systemPromptUpdate(该路由能否读 in-history prompt)。

391-397

system prompt 投影(0.1.5 核心变更):SystemPromptProjection.project()(runtime-context.ts,159 行)决定要提交哪些 system 节点。三个触发新系列的条件:显式 startsRequestSeries、surface 被 replace 过(generation 变了)、或 toolsChanged(262 行新方法:工具 schema 与已记录 header 不同——工具集变了必须重发 prompt)。返回 commits[],每条带 message 与 intent(surfaceOp)。

398-400

落盘为 system/message 事件:system prompt 不再是 request/header.system 字符串,而是 sequence 上的一个 surface 节点。模型历史由 deriveMessages() 投影,system 节点自然进入(06 页 §1)。

373-378

首次尝试注入 claimed 的 user 消息;随后清 firstAttempt 标志。

379

拆分 2:buildRequest(agent.ts:581)变成纯函数——接收已解析的 config/preparedCall/tools,自己 derive session.deriveMessages()(agent.ts:631),组装并冻结请求。不再接收 system/boundaryMessages 参数——两者都从 surface 派生。

380-399

AssistantStreamAttempt 与流消费(0.1.3 机制保留):start/push,落盘推迟到 settle。

399-423

catch 结算路径(三态 settle/abandon/ended),与前一版一致。

426-448

失败分支:agent/request-error waterfall 仲裁;retry → continue(新 Attempt 新序号,但 firstAttempt 已 false,不再重复注入)。

450-463

完成锚点:live.blocks() → settle('assistant/message'),内嵌 stream。

464-467

分支一:max-tokens;分支二:无工具调用 → completed。

488-492

分支三:executeToolCalls(13 页),additionalContexts 插回 next-step inbox。

494-495

外层 catch:live.abandon() + rethrow。

§2重试循环的边界

两个 while(true) 不要混(05 页也强调过):step 的内层 while(true)(361 行)是 request-error→retry 重试循环;turn 的外层 while(true)(05 页 286 行)是 step 循环;kick 的 while(05 页 224 行)是 turn 循环。三层嵌套,各管一层。

为什么重试要重新 buildRequest?① 每次重试前的 deriveMessages() 可能已不同(失败尝试本身可能改变日志);② agent/request waterfall 的监听器可能依赖每步最新状态;③ prepareCall 是一次性的——上次的 preparedCall 已经 dispatch 或失败,必须重新 prepare。

§3返回值的三态语义

返回含义turn 循环的反应(05 页)
{kind:'completed'}模型没要工具,或工具结果宣告 concludesTurnturnEnds 记录,nextStep 空则关 turn
{kind:'max-tokens'}触顶turnEnds 记录为 max-tokens(sticky,后续不降级)
null还有欠账:模型请求了工具且结果不 concludesTurnturnEnds 保持 null,target 转 next-step,再来一步(模型要回复工具结果)

这就是「一个轮次包含零个或多个步骤」的行级实现:欠账驱动步进,结论驱动闭合。

§40.1.3-alpha.2:assistant-stream 机制(chunk 事件退役)

升级前,每个 chunk 都单独落一条 assistant/chunk 事件——回放保真但日志膨胀。0.1.3 起整个机制重构:

  1. assistant/chunk 事件退役(session/types.ts:299 从 SessionEventMap 移除)。assistant/attempt 新事件(types.ts:313):一条事件内嵌 stream: AssistantStreamRecord[]——compact 编码的完整原始流。assistant/message 现在也内嵌 stream 字段(types.ts:299)。
  2. 持久化语义:每个尝试一次结算——settle 是尝试的持久终态(assistant/message 或 assistant/attempt 恰好一条);abandon 是放弃(帧流中止、不落盘)。原始流只存一次,UI 回放从内嵌流重建。
  3. 格式版本:SESSION_FORMAT_VERSION 0 → 2(types.ts:86)。恢复路径用 expandAssistantStream 解压内嵌流校验(session/index.ts:295),并拒绝旧 request/header-delta 格式。
  4. 取消固化的机制不变:interrupted 锚点仍在(387-400 行),只是现在 message 事件自带 stream。
  5. live 事件:agent/assistant-stream(runtime-types.ts:315)供 UI 增量渲染;帧修订号由尝试对象的 revision 回调驱动。

这也解释了为什么 catch 必须无条件 rethrow(416 行):结算只是「给失败的尝试留下持久账目」,不是「处理失败」。turn 的 aborted 结局、agent/error 通知、kick 的收敛全都依赖这个 error 继续向上传播。