Main Chain B · Step 11
step:调用、落盘与三条分支
agent.ts:380-526。一个 step = 一次模型调用 + 它请求的工具执行。这里把 09/10 页的请求与流串起来:逐 chunk 落盘 → 组装完成锚点 → 三条分支(max-tokens / 完成 / 有工具调用)。0.1.3-alpha.2 起新增了「取消时把已流出的文本前缀固化为 interrupted 锚点」。
示例本次示例:step 1 的完整落盘序列
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 行
# 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 锚点——用户看到的前半句进了模型历史
§1完整代码与行级解读
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 }
接收整个 decision(不再是分离的 assembly + 标志位),一次渲染 system 文本(12 页组装的片段在此变成最终 prompt)。
0.1.5 新增 firstAttempt:只有首次尝试注入 user/system 消息;重试循环的后续迭代不再重复注入(此前每轮都重复 append user/message,导致重试时日志里出现重复消息)。
拆分 1:prepareRequest(agent.ts:529)只做「解析 config + 绑定 adapter」——把 09 页原先塞在 buildRequest 前半段的逻辑独立出来,因为 system prompt 的投影决策需要知道 preparedCall.systemPromptUpdate(该路由能否读 in-history prompt)。
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)。
落盘为 system/message 事件:system prompt 不再是 request/header.system 字符串,而是 sequence 上的一个 surface 节点。模型历史由 deriveMessages() 投影,system 节点自然进入(06 页 §1)。
首次尝试注入 claimed 的 user 消息;随后清 firstAttempt 标志。
拆分 2:buildRequest(agent.ts:581)变成纯函数——接收已解析的 config/preparedCall/tools,自己 derive session.deriveMessages()(agent.ts:631),组装并冻结请求。不再接收 system/boundaryMessages 参数——两者都从 surface 派生。
AssistantStreamAttempt 与流消费(0.1.3 机制保留):start/push,落盘推迟到 settle。
catch 结算路径(三态 settle/abandon/ended),与前一版一致。
失败分支:agent/request-error waterfall 仲裁;retry → continue(新 Attempt 新序号,但 firstAttempt 已 false,不再重复注入)。
完成锚点:live.blocks() → settle('assistant/message'),内嵌 stream。
分支一:max-tokens;分支二:无工具调用 → completed。
分支三:executeToolCalls(13 页),additionalContexts 插回 next-step inbox。
外层 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'} | 模型没要工具,或工具结果宣告 concludesTurn | turnEnds 记录,nextStep 空则关 turn |
{kind:'max-tokens'} | 触顶 | turnEnds 记录为 max-tokens(sticky,后续不降级) |
null | 还有欠账:模型请求了工具且结果不 concludesTurn | turnEnds 保持 null,target 转 next-step,再来一步(模型要回复工具结果) |
这就是「一个轮次包含零个或多个步骤」的行级实现:欠账驱动步进,结论驱动闭合。
§40.1.3-alpha.2:assistant-stream 机制(chunk 事件退役)
升级前,每个 chunk 都单独落一条 assistant/chunk 事件——回放保真但日志膨胀。0.1.3 起整个机制重构:
assistant/chunk事件退役(session/types.ts:299 从 SessionEventMap 移除)。assistant/attempt新事件(types.ts:313):一条事件内嵌stream: AssistantStreamRecord[]——compact 编码的完整原始流。assistant/message现在也内嵌stream字段(types.ts:299)。- 持久化语义:每个尝试一次结算——settle 是尝试的持久终态(assistant/message 或 assistant/attempt 恰好一条);abandon 是放弃(帧流中止、不落盘)。原始流只存一次,UI 回放从内嵌流重建。
- 格式版本:
SESSION_FORMAT_VERSION0 → 2(types.ts:86)。恢复路径用expandAssistantStream解压内嵌流校验(session/index.ts:295),并拒绝旧 request/header-delta 格式。 - 取消固化的机制不变:interrupted 锚点仍在(387-400 行),只是现在 message 事件自带 stream。
- live 事件:
agent/assistant-stream(runtime-types.ts:315)供 UI 增量渲染;帧修订号由尝试对象的 revision 回调驱动。
这也解释了为什么 catch 必须无条件 rethrow(416 行):结算只是「给失败的尝试留下持久账目」,不是「处理失败」。turn 的 aborted 结局、agent/error 通知、kick 的收敛全都依赖这个 error 继续向上传播。